diff --git a/internal-api/src/jmh/java/datadog/trace/api/telemetry/LogCollectorBenchmark.java b/internal-api/src/jmh/java/datadog/trace/api/telemetry/LogCollectorBenchmark.java
index d7c836b8621..0899c8fecf6 100644
--- a/internal-api/src/jmh/java/datadog/trace/api/telemetry/LogCollectorBenchmark.java
+++ b/internal-api/src/jmh/java/datadog/trace/api/telemetry/LogCollectorBenchmark.java
@@ -2,7 +2,11 @@
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
@@ -11,15 +15,25 @@
@Measurement(iterations = 5)
@Threads(8)
public class LogCollectorBenchmark {
+ @State(Scope.Benchmark)
+ public static class CollectorState {
+ final LogCollector collector = new LogCollector(4);
+
+ @Setup(Level.Trial)
+ public void setup() {
+ collector.addLogMessage("error", "ugh!", null);
+ }
+ }
+
@Benchmark
- public void noException_before() {
- LogCollector.get().addLogMessage("error", "ugh!", null);
+ public void duplicateWithoutException(CollectorState state) {
+ state.collector.addLogMessage("error", "ugh!", null);
}
static final Object NULL = null;
@Benchmark
- public void nullPointerException() {
+ public void nullPointerException(CollectorState state) {
// Represents the fast throw case where the JVM switches to using
// a single Exception instance to handle a hot throw location
// of NullPointerException, ArrayIndexOutOfBoundsException, etc.
@@ -27,18 +41,18 @@ public void nullPointerException() {
try {
NULL.hashCode();
} catch (Throwable t) {
- LogCollector.get().addLogMessage("error", "npe", t);
+ state.collector.addLogMessage("error", "npe", t);
}
}
@Benchmark
- public void unsupportedOperationException() {
+ public void unsupportedOperationException(CollectorState state) {
// Represents the common case where stack trace is preserved
// despite hot throw
try {
unsupportedOperation();
} catch (Throwable t) {
- LogCollector.get().addLogMessage("error", "unsupported", t);
+ state.collector.addLogMessage("error", "unsupported", t);
}
}
diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java
deleted file mode 100644
index 63ccb734e9c..00000000000
--- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java
+++ /dev/null
@@ -1,293 +0,0 @@
-package datadog.trace.util;
-
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentSkipListMap;
-import java.util.function.Supplier;
-import org.openjdk.jmh.annotations.Benchmark;
-import org.openjdk.jmh.annotations.Fork;
-import org.openjdk.jmh.annotations.Measurement;
-import org.openjdk.jmh.annotations.Scope;
-import org.openjdk.jmh.annotations.State;
-import org.openjdk.jmh.annotations.Threads;
-import org.openjdk.jmh.annotations.Warmup;
-
-/**
- *
- * Benchmark comparing different approaches to filling and reading a Map in a multi-thread
- * context.
- * - ConcurrentMap - only when there are simultaneously readers & writers in multiple threads
- *
- HashMap via volatile - preferred for background thread updates
- *
- synchronized HashMap - when simultaneous readers & writers are uncommon (e.g. tags)
- *
- FlatHashtable - lock-free reads (no lock, no volatile; benign-race) of a fixed, once-built
- * keyed set; a find-or-create table, not a general concurrent Map (no arbitrary put/remove)
- *
- *
- *
- *
- *
In most situations in dd-java-agent, ConcurrentMaps are not necessarily needed and incur
- * additional overhead. ConcurrentMaps make sense when concurrent writers are likely.
- *
- *
If a Map can be created atomically in one thread and then stored into a volatile, that is the
- * preferred solution. For example, requesting an update from agent / API and then exposing to the
- * rest of the tracer via a global.
- *
- *
If a Map needs to be written in a thread-safe manner, but is primarily accessed from one
- * thread at a time, then a synchronized HashMap is usually the best option.
- * MacBook M1 with 1 thread (Java 21)
- *
- * Benchmark Mode Cnt Score Error Units
- * ThreadSafeMapBenchmark.create_concHashMap thrpt 6 8081979.153 ± 261559.222 ops/s
- * ThreadSafeMapBenchmark.create_concSkipListMap thrpt 6 2998832.124 ± 103708.038 ops/s
- * ThreadSafeMapBenchmark.create_hashMap thrpt 6 24938311.610 ± 673725.902 ops/s
- * ThreadSafeMapBenchmark.create_hashMap_synchronized thrpt 6 7971740.607 ± 121986.296 ops/s
- *
- * ThreadSafeMapBenchmark.get_concHashMap thrpt 6 173942565.340 ± 12003493.448 ops/s
- * ThreadSafeMapBenchmark.get_concSkipListMap thrpt 6 79230298.061 ± 13007895.765 ops/s
- * ThreadSafeMapBenchmark.get_hashMap_synchronized thrpt 6 98056657.832 ± 3413815.061 ops/s
- * ThreadSafeMapBenchmark.get_hashMap_volatile thrpt 6 210511753.596 ± 5017502.317 ops/s
- *
- * MacBook M1 with 8 threads (Java 21)
- *
- * Benchmark Mode Cnt Score Error Units
- * ThreadSafeMapBenchmark.create_concHashMap thrpt 6 58015351.219 ± 6201384.867 ops/s
- * ThreadSafeMapBenchmark.create_concSkipListMap thrpt 6 19296105.790 ± 4516587.751 ops/s
- * ThreadSafeMapBenchmark.create_hashMap thrpt 6 147917381.815 ± 22901897.589 ops/s
- * ThreadSafeMapBenchmark.create_hashMap_synchronized thrpt 6 56466354.962 ± 13202034.783 ops/s
- *
- * ThreadSafeMapBenchmark.get_concHashMap thrpt 6 849986442.797 ± 14499355.893 ops/s
- * ThreadSafeMapBenchmark.get_concSkipListMap thrpt 6 26828246.629 ± 2772377.532 ops/s
- * ThreadSafeMapBenchmark.get_hashMap_synchronized thrpt 6 20123419.604 ± 4858466.787 ops/s
- * ThreadSafeMapBenchmark.get_hashMap_volatile thrpt 6 286024211.995 ± 114449056.603 ops/s
- *
- */
-@Fork(2)
-@Warmup(iterations = 2)
-@Measurement(iterations = 3)
-@Threads(8)
-@State(Scope.Thread)
-public class ThreadSafeMapBenchmark {
- static final String[] INSERTION_KEYS = {
- "foo", "bar", "baz", "quux", "foobar", "foobaz", "key0", "key1", "key2", "key3"
- };
-
- static final String[] EQUAL_KEYS =
- init(
- () -> {
- String[] keys = new String[INSERTION_KEYS.length];
- for (int i = 0; i < INSERTION_KEYS.length; ++i) {
- keys[i] = new String(INSERTION_KEYS[i]);
- }
- return keys;
- });
-
- static T init(Supplier supplier) {
- return supplier.get();
- }
-
- // Per-thread (@State(Scope.Thread)) so cycling the lookup key doesn't contend a shared counter.
- // The maps below stay static/shared (the point — concurrent reads of one map); only the index is
- // per-thread. A shared counter's cache-line ping-pong would otherwise floor the fastest reads
- // (e.g. FlatHashtable's lock-free probe), hiding exactly the differences this benchmark compares.
- int lookupIndex = 0;
-
- String nextLookupKey() {
- return nextLookupKey(EQUAL_KEYS);
- }
-
- String nextLookupKey(String[] keys) {
- int localIndex = ++lookupIndex;
- if (localIndex >= keys.length) {
- lookupIndex = localIndex = 0;
- }
- return keys[localIndex];
- }
-
- static void fill(Map map) {
- for (int i = 0; i < INSERTION_KEYS.length; ++i) {
- map.put(INSERTION_KEYS[i], i);
- }
- }
-
- // FlatHashtable's contribution here is the lock-free concurrent read: get() is a plain array
- // probe
- // with no lock and no volatile — safe under concurrency because the table is published once (a
- // final static field) and each entry's identity fields are final. (Fixture mirrors the one in
- // SingleThreadedMapBenchmark; the benchmarks are self-contained.)
- static final class IntEntry {
- final String key;
- final int value;
-
- IntEntry(String key, int value) {
- this.key = key;
- this.value = value;
- }
- }
-
- static final class IntEntryKeyStrategy extends FlatHashtable.EntryStrategy {
- static final IntEntryKeyStrategy INSTANCE = new IntEntryKeyStrategy();
-
- private IntEntryKeyStrategy() {}
-
- @Override
- public boolean matches(IntEntry entry, String key) {
- return key.equals(entry.key);
- }
-
- @Override
- public long hashOf(IntEntry entry) {
- return entry.key.hashCode(); // consistent with the default hashKey
- }
- }
-
- // --- CHA-defeat decoys ---------------------------------------------------------------------
- // These are never used to build a table; they exist only to be *loaded* (see CHA_DEFEAT), so
- // MatchingStrategy.matches and .hashKey each have >=2 concrete implementors. That denies C2 the
- // single-implementor CHA devirtualization of matchStrat.hashKey/matches inside get(). If the
- // strategy calls still inline afterward, the win is structural (the constant INSTANCE's exact
- // type propagated through the inlined get), not a CHA bet that would deopt on a second subclass.
-
- // Second matches impl -> MatchingStrategy.matches is polymorphic.
- static final class DecoyMatchStrategy extends FlatHashtable.EntryStrategy {
- static final DecoyMatchStrategy INSTANCE = new DecoyMatchStrategy();
-
- private DecoyMatchStrategy() {}
-
- @Override
- public boolean matches(IntEntry entry, String key) {
- return key == entry.key; // deliberately different body from IntEntryKeyStrategy
- }
-
- @Override
- public long hashOf(IntEntry entry) {
- return entry.key.hashCode();
- }
- }
-
- // Overrides hashKey -> MatchingStrategy.hashKey is polymorphic too (default + this override).
- static final class DecoyHashKeyStrategy extends FlatHashtable.EntryStrategy {
- static final DecoyHashKeyStrategy INSTANCE = new DecoyHashKeyStrategy();
-
- private DecoyHashKeyStrategy() {}
-
- @Override
- public long hashKey(String key) {
- return key.length();
- }
-
- @Override
- public boolean matches(IntEntry entry, String key) {
- return key.equals(entry.key);
- }
-
- @Override
- public long hashOf(IntEntry entry) {
- return entry.key.length();
- }
- }
-
- // Referenced only so these three concrete implementors load at benchmark class-init, before the
- // hot method compiles — see the CHA-defeat note above.
- @SuppressWarnings("unused")
- static final Object[] CHA_DEFEAT = {
- IntEntryKeyStrategy.INSTANCE, DecoyMatchStrategy.INSTANCE, DecoyHashKeyStrategy.INSTANCE
- };
-
- static IntEntry[] _create_flat() {
- // Sized to the key count (FlatHashtable is fixed-capacity, no resize): load factor <= 0.5.
- IntEntry[] table = FlatHashtable.create(IntEntry.class, INSERTION_KEYS.length);
- for (int i = 0; i < INSERTION_KEYS.length; ++i) {
- FlatHashtable.insert(table, new IntEntry(INSERTION_KEYS[i], i), IntEntryKeyStrategy.INSTANCE);
- }
- return table;
- }
-
- static final HashMap _create_hashMap() {
- HashMap map = new HashMap<>();
- fill(map);
- return map;
- }
-
- @Benchmark
- public Map create_hashMap() {
- return _create_hashMap();
- }
-
- static volatile HashMap VOLATILE_HASH_MAP = _create_hashMap();
-
- @Benchmark
- public Integer get_hashMap_volatile() {
- Map map = VOLATILE_HASH_MAP;
- return map.get(nextLookupKey());
- }
-
- static final Map _create_hashMap_synchronized() {
- Map map = Collections.synchronizedMap(new HashMap<>());
- fill(map);
- return map;
- }
-
- @Benchmark
- public Map create_hashMap_synchronized() {
- return _create_hashMap_synchronized();
- }
-
- static final Map SYNC_HASH_MAP = _create_hashMap_synchronized();
-
- @Benchmark
- public Integer get_hashMap_synchronized() {
- return SYNC_HASH_MAP.get(nextLookupKey());
- }
-
- static ConcurrentHashMap _create_concHashMap() {
- ConcurrentHashMap map = new ConcurrentHashMap<>();
- fill(map);
- return map;
- }
-
- @Benchmark
- public ConcurrentHashMap create_concHashMap() {
- return _create_concHashMap();
- }
-
- static final ConcurrentHashMap CONC_HASH_MAP = _create_concHashMap();
-
- @Benchmark
- public Integer get_concHashMap() {
- return CONC_HASH_MAP.get(nextLookupKey());
- }
-
- static ConcurrentSkipListMap _create_concSkipListMap() {
- ConcurrentSkipListMap map = new ConcurrentSkipListMap<>();
- fill(map);
- return map;
- }
-
- @Benchmark
- public ConcurrentSkipListMap create_concSkipListMap() {
- return _create_concSkipListMap();
- }
-
- static final ConcurrentSkipListMap CONC_SKIP_LIST_MAP =
- _create_concSkipListMap();
-
- @Benchmark
- public Integer get_concSkipListMap() {
- return CONC_SKIP_LIST_MAP.get(nextLookupKey());
- }
-
- @Benchmark
- public IntEntry[] create_flatHashtable() {
- return _create_flat();
- }
-
- static final IntEntry[] FLAT_TABLE = _create_flat();
-
- @Benchmark
- public IntEntry get_flatHashtable() {
- // Lock-free concurrent read of the shared, once-published table.
- return FlatHashtable.get(FLAT_TABLE, nextLookupKey(), IntEntryKeyStrategy.INSTANCE);
- }
-}
diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java
new file mode 100644
index 00000000000..bc8bc07b9f5
--- /dev/null
+++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java
@@ -0,0 +1,140 @@
+package datadog.trace.util;
+
+import static java.util.concurrent.TimeUnit.MICROSECONDS;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicLongFieldUpdater;
+import java.util.concurrent.atomic.LongAdder;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Measures lookup followed by an atomic counter increment in a shared, pre-populated table. Models
+ * per-class or per-method hit counters in the tracer.
+ *
+ * The {@link ConcurrentHashtable.D1} case embeds a {@code volatile long} in each entry. {@link
+ * AtomicLongFieldUpdater} updates that field atomically without allocating an {@link AtomicLong}
+ * per key. The map baselines store a separate {@link AtomicLong} or {@link LongAdder}; {@code
+ * LongAdder} spreads contention across internal cells at the cost of more memory and a more
+ * expensive read.
+ *
+ *
Lookups reuse the key instances installed during setup. {@code Objects.equals} therefore
+ * returns on its identity check without dispatching to {@code equals}, so this measures the
+ * interned-key pattern used by the tracer rather than distinct-but-equal keys.
+ *
+ *
Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys):
+ *
+ *
{@code
+ * Benchmark Score Units
+ * increment_longAdder 79 ops/us
+ * increment_atomicLong 71 ops/us
+ * increment_concurrentHashtable 69 ops/us
+ * }
+ *
+ * Key findings:
+ *
+ *
+ * - All three strategies are within 15% of each other under 8 threads — the {@code
+ * ConcurrentHashMap} lookup, not the counter increment, dominates the cost in all baselines.
+ *
- {@code LongAdder} is marginally faster (79 vs 71 ops/us) because it shards the counter
+ * across cells to reduce CAS contention; the advantage grows with thread count.
+ *
- {@code ConcurrentHashtable} matches {@code AtomicLong} throughput (69 vs 71 ops/us) while
+ * embedding the counter directly in the entry — one object instead of two, with no throughput
+ * penalty.
+ *
+ */
+@Fork(2)
+@Warmup(iterations = 2)
+@Measurement(iterations = 3)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(MICROSECONDS)
+@Threads(8)
+public class ThreadSafeMapCounterBenchmark {
+
+ static final int N_KEYS = 64;
+ static final int CAPACITY = 128;
+
+ static final String[] KEYS = new String[N_KEYS];
+
+ static {
+ for (int i = 0; i < N_KEYS; ++i) {
+ KEYS[i] = "key-" + i;
+ }
+ }
+
+ static final class CounterEntry extends ConcurrentHashtable.D1.Entry {
+ private static final AtomicLongFieldUpdater COUNT =
+ AtomicLongFieldUpdater.newUpdater(CounterEntry.class, "count");
+
+ volatile long count;
+
+ CounterEntry(String key) {
+ super(key);
+ }
+
+ long increment() {
+ return COUNT.incrementAndGet(this);
+ }
+ }
+
+ /**
+ * Shared state ({@link Scope#Benchmark}): one instance of each map across all threads, modelling
+ * a shared instrumentation counter table.
+ */
+ @State(Scope.Benchmark)
+ public static class SharedState {
+ ConcurrentHashtable.D1 table;
+ ConcurrentHashMap atomicLongMap;
+ ConcurrentHashMap longAdderMap;
+
+ @Setup(Level.Iteration)
+ public void setUp() {
+ table = ConcurrentHashtable.D1.createBounded(CounterEntry.class, CAPACITY);
+ atomicLongMap = new ConcurrentHashMap<>(CAPACITY);
+ longAdderMap = new ConcurrentHashMap<>(CAPACITY);
+ for (int i = 0; i < N_KEYS; ++i) {
+ table.tryGetOrCreateOrNull(KEYS[i], CounterEntry::new);
+ atomicLongMap.put(KEYS[i], new AtomicLong());
+ longAdderMap.put(KEYS[i], new LongAdder());
+ }
+ }
+ }
+
+ /** Per-thread cursor so each thread cycles through keys independently. */
+ @State(Scope.Thread)
+ public static class ThreadState {
+ int cursor;
+
+ int next() {
+ int i = cursor;
+ cursor = (i + 1) & (N_KEYS - 1);
+ return i;
+ }
+ }
+
+ @Benchmark
+ public long increment_concurrentHashtable(SharedState s, ThreadState t) {
+ return s.table.get(KEYS[t.next()]).increment();
+ }
+
+ @Benchmark
+ public long increment_atomicLong(SharedState s, ThreadState t) {
+ return s.atomicLongMap.get(KEYS[t.next()]).incrementAndGet();
+ }
+
+ @Benchmark
+ public void increment_longAdder(SharedState s, ThreadState t) {
+ s.longAdderMap.get(KEYS[t.next()]).increment();
+ }
+}
diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java
new file mode 100644
index 00000000000..8fd07544264
--- /dev/null
+++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java
@@ -0,0 +1,187 @@
+package datadog.trace.util;
+
+import static java.util.concurrent.TimeUnit.MICROSECONDS;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListMap;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Measures steady-state single-key lookups in a shared, pre-populated table.
+ *
+ * Compares {@link ConcurrentHashtable.D1}, {@link ConcurrentHashMap}, {@link
+ * ConcurrentSkipListMap}, and a synchronized {@link HashMap}. The table is shared across all
+ * threads ({@link Scope#Benchmark}) and pre-populated before the measurement iteration — modelling
+ * the steady-state read-mostly pattern that the tracer uses (a per-class or per-method
+ * instrumentation cache consulted on every invocation). The {@code getOrCreate} methods exercise
+ * their hit paths because setup installs every key.
+ *
+ *
Lookups reuse the key instances installed during setup. {@code Objects.equals} therefore
+ * returns on identity before invoking {@code equals}; the benchmark does not include the cost of
+ * comparing distinct-but-equal keys ({@code ImmutableMapBenchmark} covers that path explicitly via
+ * its {@code _sameKey} vs default variants). See {@link ThreadSafeMapD2Benchmark} for composite
+ * keys.
+ *
+ *
Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys):
+ *
+ *
{@code
+ * Benchmark Score Units
+ * get_concurrentHashtable 1583 ops/us
+ * get_concurrentHashMap 1145 ops/us
+ * get_concurrentSkipListMap 170 ops/us
+ * get_synchronizedHashMap 33 ops/us
+ *
+ * getOrCreate_concurrentHashtable 1450 ops/us
+ * getOrCreate_concurrentHashMap 1125 ops/us
+ * getOrCreate_synchronizedHashMap 31 ops/us
+ * }
+ *
+ * Key findings:
+ *
+ *
+ * - {@code ConcurrentHashtable} is ~38% faster than {@code ConcurrentHashMap} on {@code get}
+ * (1583 vs 1145 ops/us); avoids the hash-to-segment translation CHM pays even on its fast
+ * path.
+ *
- {@code ConcurrentSkipListMap} is ~9× slower than {@code ConcurrentHashMap} — tree traversal
+ * cost is high even under lock-free CAS.
+ *
- Synchronized {@code HashMap} is ~47× slower than {@code ConcurrentHashtable}; the global
+ * lock serializes all 8 threads.
+ *
- {@code getOrCreate} is near-identical to {@code get} because all keys are pre-populated —
+ * the lock branch is never taken during measurement.
+ *
+ */
+@Fork(2)
+@Warmup(iterations = 2)
+@Measurement(iterations = 3)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(MICROSECONDS)
+@Threads(8)
+public class ThreadSafeMapD1Benchmark {
+
+ static final int N_KEYS = 64;
+ static final int CAPACITY = 128;
+
+ static final String[] KEYS = new String[N_KEYS];
+
+ static {
+ for (int i = 0; i < N_KEYS; ++i) {
+ KEYS[i] = "key-" + i;
+ }
+ }
+
+ static final class D1Entry extends ConcurrentHashtable.D1.Entry {
+ final long value;
+
+ D1Entry(String key) {
+ super(key);
+ this.value = 1L;
+ }
+ }
+
+ /**
+ * Shared state ({@link Scope#Benchmark}): one instance of each map across all threads, modelling
+ * a shared instrumentation cache.
+ */
+ @State(Scope.Benchmark)
+ public static class SharedState {
+ ConcurrentHashtable.D1 table;
+ ConcurrentHashMap concurrentHashMap;
+ ConcurrentSkipListMap skipListMap;
+ Map synchronizedHashMap;
+
+ @Setup(Level.Iteration)
+ public void setUp() {
+ table = ConcurrentHashtable.D1.createBounded(D1Entry.class, CAPACITY);
+ concurrentHashMap = new ConcurrentHashMap<>(CAPACITY);
+ skipListMap = new ConcurrentSkipListMap<>();
+ synchronizedHashMap = Collections.synchronizedMap(new HashMap<>(CAPACITY));
+ for (int i = 0; i < N_KEYS; ++i) {
+ table.tryGetOrCreateOrNull(KEYS[i], D1Entry::new);
+ concurrentHashMap.put(KEYS[i], (long) i);
+ skipListMap.put(KEYS[i], (long) i);
+ synchronizedHashMap.put(KEYS[i], (long) i);
+ }
+ }
+ }
+
+ /** Per-thread cursor so each thread cycles through keys independently. */
+ @State(Scope.Thread)
+ public static class ThreadState {
+ int cursor;
+
+ int next() {
+ int i = cursor;
+ cursor = (i + 1) & (N_KEYS - 1);
+ return i;
+ }
+ }
+
+ @Benchmark
+ public D1Entry get_concurrentHashtable(SharedState s, ThreadState t) {
+ return s.table.get(KEYS[t.next()]);
+ }
+
+ @Benchmark
+ public Long get_concurrentHashMap(SharedState s, ThreadState t) {
+ return s.concurrentHashMap.get(KEYS[t.next()]);
+ }
+
+ @Benchmark
+ public Long get_concurrentSkipListMap(SharedState s, ThreadState t) {
+ return s.skipListMap.get(KEYS[t.next()]);
+ }
+
+ @Benchmark
+ public Long get_synchronizedHashMap(SharedState s, ThreadState t) {
+ return s.synchronizedHashMap.get(KEYS[t.next()]);
+ }
+
+ @Benchmark
+ public D1Entry getOrCreate_concurrentHashtable(SharedState s, ThreadState t) {
+ return s.table.tryGetOrCreateOrNull(KEYS[t.next()], D1Entry::new);
+ }
+
+ /**
+ * get-first pattern for CHM — the idiomatic equivalent of D1.getOrCreate on a mostly-populated
+ * table.
+ */
+ @Benchmark
+ public Long getOrCreate_concurrentHashMap(SharedState s, ThreadState t) {
+ String key = KEYS[t.next()];
+ Long existing = s.concurrentHashMap.get(key);
+ if (existing != null) {
+ return existing;
+ }
+ return s.concurrentHashMap.computeIfAbsent(key, k -> 0L);
+ }
+
+ /**
+ * get-first pattern for synchronized HashMap. On hit: one lock acquire/release for get. On miss:
+ * a second synchronized block for the double-checked put.
+ */
+ @Benchmark
+ public Long getOrCreate_synchronizedHashMap(SharedState s, ThreadState t) {
+ String key = KEYS[t.next()];
+ Long existing = s.synchronizedHashMap.get(key);
+ if (existing != null) {
+ return existing;
+ }
+ synchronized (s.synchronizedHashMap) {
+ return s.synchronizedHashMap.computeIfAbsent(key, k -> 0L);
+ }
+ }
+}
diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java
new file mode 100644
index 00000000000..aff30dd0a33
--- /dev/null
+++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java
@@ -0,0 +1,336 @@
+package datadog.trace.util;
+
+import static java.util.concurrent.TimeUnit.MICROSECONDS;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListMap;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Measures steady-state composite-key lookups in a shared, pre-populated table.
+ *
+ * Compares {@link ConcurrentHashtable.D2}, a custom {@link ConcurrentHashtable.Entry} with a
+ * primitive {@code int} key part, {@link ConcurrentHashMap}, {@link ConcurrentSkipListMap}, and a
+ * synchronized {@link HashMap}. The table is shared across all threads ({@link Scope#Benchmark})
+ * and pre-populated before the measurement iteration — modelling the steady-state read-mostly
+ * pattern that the tracer uses (a per-class or per-method instrumentation cache consulted on every
+ * invocation).
+ *
+ *
The map cases create a {@link Key2} for each lookup. HotSpot may remove that allocation only
+ * when inlining and escape analysis prove that the wrapper is not retained; a miss that inserts the
+ * key makes it escape. The concurrent hashtable passes key parts directly, and the custom entry
+ * also avoids boxing the {@code int}, so neither optimization depends on escape analysis.
+ *
+ *
Lookups reuse the key-part instances installed during setup, taking the identity fast path for
+ * their object comparisons. See {@link ThreadSafeMapD1Benchmark} for single-key lookups.
+ *
+ *
Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys):
+ *
+ *
{@code
+ * Benchmark Score Units
+ * get_concurrentHashtable 1452 ops/us
+ * get_support 1450 ops/us
+ * get_concurrentHashMap 777 ops/us
+ * get_concurrentSkipListMap 146 ops/us
+ * get_synchronizedHashMap 27 ops/us
+ *
+ * getOrCreate_support 1379 ops/us
+ * getOrCreate_concurrentHashtable 1119 ops/us
+ * getOrCreate_concurrentHashMap 769 ops/us
+ * getOrCreate_concurrentSkipListMap 151 ops/us
+ * getOrCreate_synchronizedHashMap 28 ops/us
+ * }
+ *
+ * Key findings:
+ *
+ *
+ * - {@code ConcurrentHashtable} and {@code Support} are neck-and-neck on {@code get} (1452 vs
+ * 1450 ops/us); both avoid the {@link Key2} wrapper allocation that {@code ConcurrentHashMap}
+ * requires on every lookup.
+ *
- {@code ConcurrentHashMap} is ~2× slower than {@code ConcurrentHashtable} on {@code get}
+ * (777 vs 1452 ops/us) — the {@link Key2} allocation plus two-level hash lookup adds up.
+ *
- {@code Support} shows slightly higher {@code getOrCreate} throughput than {@code D2} (1379
+ * vs 1119 ops/us) because its primitive {@code int} K2 field avoids boxing inside the entry
+ * match on the write-path re-check.
+ *
- {@code ConcurrentSkipListMap} is ~5× slower than {@code ConcurrentHashMap} due to tree
+ * traversal; the two-traversal {@code getOrCreate} pattern adds further overhead on misses.
+ *
- Synchronized {@code HashMap} is ~50× slower than {@code ConcurrentHashtable}.
+ *
+ */
+@Fork(2)
+@Warmup(iterations = 2)
+@Measurement(iterations = 3)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(MICROSECONDS)
+@Threads(8)
+public class ThreadSafeMapD2Benchmark {
+
+ static final int N_KEYS = 64;
+ static final int CAPACITY = 128;
+
+ static final String[] SOURCE_K1 = new String[N_KEYS];
+ static final Integer[] SOURCE_K2 = new Integer[N_KEYS];
+ static final int[] SOURCE_K2_INT = new int[N_KEYS];
+
+ static {
+ for (int i = 0; i < N_KEYS; ++i) {
+ SOURCE_K1[i] = "key-" + i;
+ SOURCE_K2_INT[i] = i * 31 + 17;
+ SOURCE_K2[i] = SOURCE_K2_INT[i];
+ }
+ }
+
+ static final class D2Entry extends ConcurrentHashtable.D2.Entry {
+ final long value;
+
+ D2Entry(String k1, Integer k2) {
+ super(k1, k2);
+ this.value = 1L;
+ }
+ }
+
+ /**
+ * Entry used with the static helpers. Its primitive second key keeps storage and lookup unboxed,
+ * independently of {@link Integer} caching or JVM escape analysis.
+ */
+ static final class SupportEntry extends ConcurrentHashtable.Entry {
+ final String k1;
+ final int k2;
+ final long value;
+
+ SupportEntry(String k1, int k2) {
+ super(hash(k1, k2));
+ this.k1 = k1;
+ this.k2 = k2;
+ this.value = 1L;
+ }
+
+ static long hash(String k1, int k2) {
+ return LongHashingUtils.hash(k1.hashCode(), Integer.hashCode(k2));
+ }
+
+ boolean matches(String k1, int k2) {
+ return this.k2 == k2 && this.k1.equals(k1);
+ }
+ }
+
+ /** Composite key for map-based baselines. */
+ static final class Key2 implements Comparable {
+ final String k1;
+ final Integer k2;
+ final int hash;
+
+ Key2(String k1, Integer k2) {
+ this.k1 = k1;
+ this.k2 = k2;
+ // Varargs-free hash: Objects.hash(k1, k2) would allocate an Object[] per key, penalizing the
+ // map baselines with an allocation the wrapper itself doesn't need and overstating the
+ // ConcurrentHashtable advantage this benchmark measures.
+ this.hash = 31 * k1.hashCode() + k2.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Key2)) {
+ return false;
+ }
+ Key2 other = (Key2) o;
+ return Objects.equals(k1, other.k1) && Objects.equals(k2, other.k2);
+ }
+
+ @Override
+ public int hashCode() {
+ return hash;
+ }
+
+ @Override
+ public int compareTo(Key2 other) {
+ int c = k1.compareTo(other.k1);
+ return c != 0 ? c : k2.compareTo(other.k2);
+ }
+ }
+
+ /**
+ * Shared state ({@link Scope#Benchmark}): one instance of each map across all threads, modelling
+ * a shared instrumentation cache.
+ */
+ @State(Scope.Benchmark)
+ public static class SharedState {
+ ConcurrentHashtable.D2 table;
+ java.util.concurrent.atomic.AtomicReferenceArray supportBuckets;
+ ConcurrentHashMap concurrentHashMap;
+ ConcurrentSkipListMap skipListMap;
+ Map synchronizedHashMap;
+
+ @Setup(Level.Iteration)
+ public void setUp() {
+ table = ConcurrentHashtable.D2.createBounded(D2Entry.class, CAPACITY);
+ supportBuckets = ConcurrentHashtable.createFixedBuckets(SupportEntry.class, CAPACITY);
+ concurrentHashMap = new ConcurrentHashMap<>(CAPACITY);
+ skipListMap = new ConcurrentSkipListMap<>();
+ synchronizedHashMap = Collections.synchronizedMap(new HashMap<>(CAPACITY));
+ for (int i = 0; i < N_KEYS; ++i) {
+ int k2 = SOURCE_K2[i];
+ table.tryGetOrCreateOrNull(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new);
+ // populate support table
+ SupportEntry se = new SupportEntry(SOURCE_K1[i], k2);
+ synchronized (ConcurrentHashtable.getWriteLock(supportBuckets, se.keyHash)) {
+ ConcurrentHashtable.insertHeadEntryFor(supportBuckets, se.keyHash, se);
+ }
+ Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]);
+ concurrentHashMap.put(key, (long) i);
+ skipListMap.put(key, (long) i);
+ synchronizedHashMap.put(key, (long) i);
+ }
+ }
+ }
+
+ /** Per-thread cursor so each thread cycles through keys independently. */
+ @State(Scope.Thread)
+ public static class ThreadState {
+ int cursor;
+
+ int next() {
+ int i = cursor;
+ cursor = (i + 1) & (N_KEYS - 1);
+ return i;
+ }
+ }
+
+ @Benchmark
+ public D2Entry get_concurrentHashtable(SharedState s, ThreadState t) {
+ int i = t.next();
+ return s.table.get(SOURCE_K1[i], SOURCE_K2[i]);
+ }
+
+ @Benchmark
+ public SupportEntry get_support(SharedState s, ThreadState t) {
+ int i = t.next();
+ String k1 = SOURCE_K1[i];
+ int k2 = SOURCE_K2_INT[i];
+ long keyHash = SupportEntry.hash(k1, k2);
+ for (SupportEntry e = ConcurrentHashtable.bucketFor(s.supportBuckets, keyHash);
+ e != null;
+ e = e.next()) {
+ if (e.keyHash == keyHash && e.matches(k1, k2)) {
+ return e;
+ }
+ }
+ return null;
+ }
+
+ @Benchmark
+ public Long get_concurrentHashMap(SharedState s, ThreadState t) {
+ int i = t.next();
+ return s.concurrentHashMap.get(new Key2(SOURCE_K1[i], SOURCE_K2[i]));
+ }
+
+ @Benchmark
+ public Long get_concurrentSkipListMap(SharedState s, ThreadState t) {
+ int i = t.next();
+ return s.skipListMap.get(new Key2(SOURCE_K1[i], SOURCE_K2[i]));
+ }
+
+ @Benchmark
+ public Long get_synchronizedHashMap(SharedState s, ThreadState t) {
+ int i = t.next();
+ return s.synchronizedHashMap.get(new Key2(SOURCE_K1[i], SOURCE_K2[i]));
+ }
+
+ @Benchmark
+ public D2Entry getOrCreate_concurrentHashtable(SharedState s, ThreadState t) {
+ int i = t.next();
+ return s.table.tryGetOrCreateOrNull(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new);
+ }
+
+ @Benchmark
+ public SupportEntry getOrCreate_support(SharedState s, ThreadState t) {
+ int i = t.next();
+ String k1 = SOURCE_K1[i];
+ int k2 = SOURCE_K2_INT[i];
+ long keyHash = SupportEntry.hash(k1, k2);
+ int index = ConcurrentHashtable.bucketIndex(s.supportBuckets, keyHash);
+ for (SupportEntry e = ConcurrentHashtable.bucketAt(s.supportBuckets, index);
+ e != null;
+ e = e.next()) {
+ if (e.keyHash == keyHash && e.matches(k1, k2)) {
+ return e;
+ }
+ }
+ synchronized (ConcurrentHashtable.getWriteLockAt(s.supportBuckets, index)) {
+ for (SupportEntry e = ConcurrentHashtable.bucketAt(s.supportBuckets, index);
+ e != null;
+ e = e.next()) {
+ if (e.keyHash == keyHash && e.matches(k1, k2)) {
+ return e;
+ }
+ }
+ SupportEntry newEntry = new SupportEntry(k1, k2);
+ ConcurrentHashtable.insertHeadEntryAt(s.supportBuckets, index, newEntry);
+ return newEntry;
+ }
+ }
+
+ /**
+ * get-first pattern for CHM to avoid capturing-lambda allocation on hits — the idiomatic
+ * equivalent of D2.getOrCreate on a mostly-populated table.
+ */
+ @Benchmark
+ public Long getOrCreate_concurrentHashMap(SharedState s, ThreadState t) {
+ int i = t.next();
+ Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]);
+ Long existing = s.concurrentHashMap.get(key);
+ if (existing != null) {
+ return existing;
+ }
+ return s.concurrentHashMap.computeIfAbsent(key, k -> 0L);
+ }
+
+ /**
+ * get-first pattern for ConcurrentSkipListMap — manual get-then-putIfAbsent since CSLM has no
+ * computeIfAbsent. Two traversals on miss; one on hit.
+ */
+ @Benchmark
+ public Long getOrCreate_concurrentSkipListMap(SharedState s, ThreadState t) {
+ int i = t.next();
+ Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]);
+ Long existing = s.skipListMap.get(key);
+ if (existing != null) {
+ return existing;
+ }
+ Long prev = s.skipListMap.putIfAbsent(key, 0L);
+ return prev != null ? prev : 0L;
+ }
+
+ /**
+ * get-first pattern for synchronized HashMap. On hit: one lock acquire/release for get. On miss:
+ * a second synchronized block for the double-checked put.
+ */
+ @Benchmark
+ public Long getOrCreate_synchronizedHashMap(SharedState s, ThreadState t) {
+ int i = t.next();
+ Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]);
+ Long existing = s.synchronizedHashMap.get(key);
+ if (existing != null) {
+ return existing;
+ }
+ synchronized (s.synchronizedHashMap) {
+ return s.synchronizedHashMap.computeIfAbsent(key, k -> 0L);
+ }
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java
index b7ad3cb0eb0..39d600a421b 100644
--- a/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java
+++ b/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java
@@ -1,16 +1,21 @@
package datadog.trace.api.telemetry;
-import datadog.trace.util.HashingUtils;
+import static datadog.trace.util.ConcurrentHashtable.bucketAt;
+import static datadog.trace.util.ConcurrentHashtable.bucketIndex;
+import static datadog.trace.util.ConcurrentHashtable.estimateSize;
+import static datadog.trace.util.ConcurrentHashtable.getTableWriteLock;
+import static datadog.trace.util.ConcurrentHashtable.insertReserved;
+import static datadog.trace.util.ConcurrentHashtable.isFull;
+import static datadog.trace.util.LongHashingUtils.hash;
+
+import datadog.trace.util.ConcurrentHashtable;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
-import java.util.Iterator;
import java.util.List;
-import java.util.Map;
import java.util.Objects;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
import javax.annotation.Nullable;
import org.slf4j.Marker;
import org.slf4j.MarkerFactory;
@@ -20,8 +25,7 @@ public class LogCollector {
public static final Marker EXCLUDE_TELEMETRY = MarkerFactory.getMarker("EXCLUDE_TELEMETRY");
private static final int DEFAULT_MAX_CAPACITY = 10;
private static final LogCollector INSTANCE = new LogCollector();
- private final Map rawLogMessages;
- private final int maxCapacity;
+ private final ConcurrentHashtable.State rawLogMessages;
public static LogCollector get() {
return INSTANCE;
@@ -35,8 +39,7 @@ private LogCollector() {
value = "SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR",
justification = "Usage in tests")
LogCollector(int maxCapacity) {
- this.maxCapacity = maxCapacity;
- this.rawLogMessages = new ConcurrentHashMap<>(maxCapacity);
+ this.rawLogMessages = ConcurrentHashtable.State.createBounded(RawLogMessage.class, maxCapacity);
}
public void addLogMessage(String logLevel, String message, @Nullable Throwable throwable) {
@@ -54,41 +57,88 @@ public void addLogMessage(String logLevel, String message, @Nullable Throwable t
*/
public void addLogMessage(
String logLevel, String message, @Nullable Throwable throwable, @Nullable String tags) {
- if (rawLogMessages.size() >= maxCapacity) {
+ if (isFull(rawLogMessages)) {
// TODO: We could emit a metric for dropped logs.
return;
}
- RawLogMessage rawLogMessage =
- new RawLogMessage(logLevel, message, throwable, tags, System.currentTimeMillis() / 1000);
- AtomicInteger count = rawLogMessages.computeIfAbsent(rawLogMessage, k -> new AtomicInteger());
- count.incrementAndGet();
+
+ long keyHash = RawLogMessage.computeHash(logLevel, message, throwable);
+ int index = bucketIndex(rawLogMessages.buckets, keyHash);
+ RawLogMessage rawLogMessage = find(index, keyHash, logLevel, message, throwable);
+ if (rawLogMessage != null) {
+ rawLogMessage.increment();
+ return;
+ }
+
+ synchronized (getTableWriteLock(rawLogMessages)) {
+ rawLogMessage = find(index, keyHash, logLevel, message, throwable);
+ if (rawLogMessage != null) {
+ rawLogMessage.increment();
+ return;
+ }
+ if (isFull(rawLogMessages)) {
+ return;
+ }
+
+ rawLogMessage =
+ new RawLogMessage(logLevel, message, throwable, tags, System.currentTimeMillis() / 1000);
+ if (rawLogMessages.sizeManager.tryReserve()) {
+ insertReserved(rawLogMessages, keyHash, rawLogMessage);
+ }
+ }
}
public Collection drain() {
- if (rawLogMessages.isEmpty()) {
+ int size = estimateSize(rawLogMessages);
+ if (size == 0) {
return Collections.emptyList();
}
- List list = new ArrayList<>(rawLogMessages.size());
- Iterator> iterator =
- rawLogMessages.entrySet().iterator();
-
- while (iterator.hasNext()) {
- Map.Entry entry = iterator.next();
- RawLogMessage logMessage = entry.getKey();
- // XXX: There might be lost writers to the counters under concurrency if another thread
- // increments it
- // while we are reading it here. At the moment, we are not overdoing this to prevent some
- // counter losses.
- logMessage.count = entry.getValue().get();
- iterator.remove();
- list.add(logMessage);
- }
-
+ List list = new ArrayList<>(size);
+ ConcurrentHashtable.drain(
+ rawLogMessages,
+ list,
+ (drained, logMessage) -> {
+ // A writer that found this entry before drain detached it can still increment too late.
+ logMessage.snapshotCount();
+ drained.add(logMessage);
+ });
return list;
}
- public static final class RawLogMessage {
+ @Nullable
+ private RawLogMessage find(
+ int index, long keyHash, String logLevel, String message, @Nullable Throwable throwable) {
+ StackTraceElement[] stackTrace = null;
+ for (RawLogMessage entry = bucketAt(rawLogMessages, index);
+ entry != null;
+ entry = entry.next()) {
+ if (entry.keyHash != keyHash
+ || !Objects.equals(logLevel, entry.logLevel)
+ || !Objects.equals(message, entry.message)) {
+ continue;
+ }
+ if (throwable == entry.throwable) {
+ return entry;
+ }
+ if (throwable != null
+ && entry.throwable != null
+ && throwable.getClass().equals(entry.throwable.getClass())) {
+ if (stackTrace == null) {
+ stackTrace = throwable.getStackTrace();
+ }
+ if (Objects.deepEquals(stackTrace, entry.stackTrace())) {
+ return entry;
+ }
+ }
+ }
+ return null;
+ }
+
+ public static final class RawLogMessage extends ConcurrentHashtable.Entry {
+ private static final AtomicIntegerFieldUpdater DEDUP_COUNT =
+ AtomicIntegerFieldUpdater.newUpdater(RawLogMessage.class, "dedupCount");
+
public final String message;
public final String logLevel;
public final Throwable throwable;
@@ -96,10 +146,12 @@ public static final class RawLogMessage {
public final long timestamp;
public int count;
+ private volatile int dedupCount = 1;
private StackTraceElement[] cachedStackTrace = null;
public RawLogMessage(
String logLevel, String message, Throwable throwable, String tags, long timestamp) {
+ super(computeHash(logLevel, message, throwable));
this.logLevel = logLevel;
this.message = message;
this.throwable = throwable;
@@ -122,6 +174,14 @@ public StackTraceElement[] stackTrace() {
return stackTrace;
}
+ private void increment() {
+ DEDUP_COUNT.incrementAndGet(this);
+ }
+
+ private void snapshotCount() {
+ count = DEDUP_COUNT.get(this);
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
@@ -149,7 +209,12 @@ public boolean equals(Object o) {
@Override
public int hashCode() {
- return HashingUtils.hash(logLevel, message, throwable == null ? null : throwable.getClass());
+ return (int) keyHash;
+ }
+
+ private static long computeHash(
+ String logLevel, String message, @Nullable Throwable throwable) {
+ return hash(logLevel, message, throwable == null ? null : throwable.getClass());
}
}
}
diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java
new file mode 100644
index 00000000000..f07ae879280
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java
@@ -0,0 +1,1316 @@
+package datadog.trace.util;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReferenceArray;
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * Fixed-capacity concurrent hash tables with lock-free reads and serialized writes.
+ *
+ * {@link D1} accepts one key. {@link D2} accepts two key parts directly. Both store
+ * caller-defined {@link Entry} objects in separate-chained buckets and never resize.
+ *
+ *
Bucket heads live in an {@link AtomicReferenceArray}. Its volatile {@code set}/{@code get}
+ * semantics publish an inserted entry and its initialized fields to lock-free readers. The volatile
+ * {@code next} links likewise make chain splices visible. A read racing a removal may observe
+ * either state; removed entries retain their link so a reader already on the chain can still finish
+ * traversing it.
+ *
+ *
{@link D2} structurally avoids a temporary composite key. With a conventional concurrent map,
+ * that wrapper may be retained on insertion, so HotSpot cannot reliably scalar-replace it through
+ * escape analysis. Avoiding the wrapper matters on the tracer's hot lookup paths.
+ *
+ *
{@link D1} and {@link D2} manage locking and capacity internally. For primitive or
+ * higher-arity keys, subclass {@link Entry} and use the static helpers. {@link #bucketFor}, {@link
+ * #bucketAt}, and {@link #forEach} are lock-free. {@link #removeIf}, {@link #drain}, and {@link
+ * #clear} acquire the table write lock internally. Follow each mutation helper's locking contract
+ * and treat the monitor returned by the lock helpers as opaque.
+ */
+public final class ConcurrentHashtable {
+ private ConcurrentHashtable() {}
+
+ /**
+ * Internal base class for concurrent entries. Stores the precomputed 64-bit keyHash and a {@code
+ * volatile} chain-next pointer used to link colliding entries within a single bucket.
+ *
+ *
The {@code next} pointer is {@code volatile} (unlike {@link Hashtable.Entry}) so that chain
+ * splices performed by {@link D1#remove}/{@link D2#remove} are visible to lock-free readers.
+ *
+ *
Subclasses add the key field(s) and a {@code matches(...)} method tailored to their key
+ * arity. See {@link D1.Entry} and {@link D2.Entry}; for higher arities, or for primitive key
+ * components, subclass this directly and drive the table with the static building blocks on
+ * {@link ConcurrentHashtable}.
+ */
+ public abstract static class Entry {
+ public final long keyHash;
+ private volatile Entry next = null;
+
+ protected Entry(long keyHash) {
+ this.keyHash = keyHash;
+ }
+
+ // Package-private: the only writers are the static insert/remove building blocks
+ // (insertHeadEntry, unlink) on the enclosing class, which reach it via the Entry bound. Custom
+ // tables mutate chains through those helpers, never by touching next directly.
+ final void setNext(TEntry next) {
+ this.next = next;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Nullable
+ public final TEntry next() {
+ return (TEntry) this.next;
+ }
+ }
+
+ /**
+ * Single-key concurrent hash table. Lock-free on hit; locked on miss/mutation.
+ *
+ * @param the key type
+ * @param the user's {@link D1.Entry D1.Entry<K>} subclass
+ */
+ @ThreadSafe
+ public static final class D1> {
+
+ /**
+ * Abstract base for {@link D1} entries. Subclass to add value fields you wish to mutate in
+ * place after retrieving the entry via {@link D1#get}.
+ *
+ * @param the key type
+ */
+ public abstract static class Entry extends ConcurrentHashtable.Entry {
+ final K key;
+
+ protected Entry(@Nullable K key) {
+ super(hash(key));
+ this.key = key;
+ }
+
+ /** The key this entry was created with. */
+ @Nullable
+ public K key() {
+ return this.key;
+ }
+
+ public boolean matches(@Nullable Object key) {
+ // equals() on the lookup param, not the field, so the JIT can devirtualize it once
+ // matches() inlines into get/getOrCreate (the caller's key type is known there).
+ return Objects.equals(key, this.key);
+ }
+
+ /**
+ * Returns the 64-bit lookup hash for {@code key}. Null keys map to {@link Long#MIN_VALUE} so
+ * they don't collide with a real key that hashes to 0; real-key collisions in chains are
+ * resolved by {@link #matches(Object)}.
+ */
+ public static long hash(@Nullable Object key) {
+ return (key == null) ? Long.MIN_VALUE : key.hashCode();
+ }
+ }
+
+ private final State state;
+
+ private D1(State state) {
+ this.state = state;
+ }
+
+ /**
+ * Creates a fixed-size table holding at most {@code maxCapacity} entries. {@code entryClass} is
+ * used only to infer the concrete entry type; entries are created by the functions passed to
+ * the insertion methods. The table does not resize.
+ */
+ @Nonnull
+ public static > D1 createBounded(
+ @Nonnull Class entryClass, int maxCapacity) {
+ return new D1<>(State.createBounded(entryClass, maxCapacity));
+ }
+
+ public int size() {
+ return state.sizeManager.estimateSize();
+ }
+
+ public boolean isFull() {
+ return state.sizeManager.isFull();
+ }
+
+ @Nullable
+ public TEntry get(@Nullable K key) {
+ long keyHash = D1.Entry.hash(key);
+ for (TEntry curEntry = bucketFor(state, keyHash);
+ curEntry != null;
+ curEntry = curEntry.next()) {
+ if (curEntry.keyHash == keyHash && curEntry.matches(key)) {
+ return curEntry;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the entry for {@code key}, creating one via {@code creator} if absent and the table
+ * is under capacity. Lock-free on hit; acquires a table-level lock on miss. Wraps {@link
+ * #tryGetOrCreateOrNull} — see that method for the refusal and ordering details.
+ */
+ @Nonnull
+ public Maybe tryGetOrCreate(
+ @Nullable K key, @Nonnull Function super K, ? extends TEntry> creator) {
+ return Maybe.of(tryGetOrCreateOrNull(key, creator));
+ }
+
+ /**
+ * Escape hatch for {@link #tryGetOrCreate} for callers that want the nullable entry directly
+ * rather than a {@link Maybe} wrapper. Returns {@code null} when the table is at capacity and
+ * {@code key} was not already present. Re-checks under the lock to avoid duplicate entries
+ * under concurrent misses.
+ */
+ @Nullable
+ public TEntry tryGetOrCreateOrNull(
+ @Nullable K key, @Nonnull Function super K, ? extends TEntry> creator) {
+ long keyHash = D1.Entry.hash(key);
+ int index = bucketIndex(state.buckets, keyHash);
+ for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) {
+ if (curEntry.keyHash == keyHash && curEntry.matches(key)) {
+ return curEntry;
+ }
+ }
+ synchronized (getTableWriteLock(state)) {
+ for (TEntry curEntry = bucketAt(state, index);
+ curEntry != null;
+ curEntry = curEntry.next()) {
+ if (curEntry.keyHash == keyHash && curEntry.matches(key)) {
+ return curEntry;
+ }
+ }
+ // isFull() is checked before creating the entry, not before reserving a slot for it:
+ // creator.apply() can throw, and if we'd already reserved (incremented) the slot, a
+ // throwing creator would leak that reservation forever. So we accept the entry only after
+ // creator succeeds, then increment.
+ if (state.sizeManager.isFull()) {
+ return null;
+ }
+ TEntry newEntry = creator.apply(key);
+ insertHeadEntryAt(state, index, newEntry);
+ state.sizeManager.increment();
+ return newEntry;
+ }
+ }
+
+ /**
+ * {@link #tryGetOrCreate}, but when the table is full, evicts one entry matching {@code
+ * evictable} to make room instead of refusing the insert. Refuses only when the table is full
+ * and nothing matches {@code evictable} — see {@link #tryGetOrCreateOrEvictOrNull} for
+ * the null-returning form and the eviction/creation ordering.
+ */
+ @Nonnull
+ public Maybe tryGetOrCreateOrEvict(
+ @Nullable K key,
+ @Nonnull Function super K, ? extends TEntry> creator,
+ @Nonnull Predicate super TEntry> evictable) {
+ return Maybe.of(tryGetOrCreateOrEvictOrNull(key, creator, evictable));
+ }
+
+ /**
+ * Escape hatch for {@link #tryGetOrCreateOrEvict} for callers that want the nullable entry
+ * directly. Eviction runs before {@code creator}, not after: {@code creator} may throw, so
+ * freeing a slot and only then attempting the fallible create keeps a thrown exception from
+ * ever leaving a slot double-booked. A creator that throws after a successful eviction simply
+ * leaves the table one entry smaller — no corruption, just a wasted eviction.
+ */
+ @Nullable
+ public TEntry tryGetOrCreateOrEvictOrNull(
+ @Nullable K key,
+ @Nonnull Function super K, ? extends TEntry> creator,
+ @Nonnull Predicate super TEntry> evictable) {
+ long keyHash = D1.Entry.hash(key);
+ int index = bucketIndex(state.buckets, keyHash);
+ for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) {
+ if (curEntry.keyHash == keyHash && curEntry.matches(key)) {
+ return curEntry;
+ }
+ }
+ synchronized (getTableWriteLock(state)) {
+ for (TEntry curEntry = bucketAt(state, index);
+ curEntry != null;
+ curEntry = curEntry.next()) {
+ if (curEntry.keyHash == keyHash && curEntry.matches(key)) {
+ return curEntry;
+ }
+ }
+ if (state.sizeManager.isFull()
+ && state.sizeManager.evictOne(state.buckets, evictable) == null) {
+ return null;
+ }
+ TEntry newEntry = creator.apply(key);
+ insertHeadEntryAt(state, index, newEntry);
+ state.sizeManager.increment();
+ return newEntry;
+ }
+ }
+
+ /**
+ * Removes and returns the entry for {@code key}, or {@code null} if absent. Acquires the
+ * table-level lock to splice the chain; lock-free readers observe the removal via the volatile
+ * write of the predecessor's {@code next} (or the bucket head).
+ */
+ @Nullable
+ public TEntry remove(@Nullable K key) {
+ long keyHash = D1.Entry.hash(key);
+ int index = bucketIndex(state.buckets, keyHash);
+ synchronized (getTableWriteLock(state)) {
+ TEntry prev = null;
+ for (TEntry curEntry = bucketAt(state, index);
+ curEntry != null;
+ prev = curEntry, curEntry = curEntry.next()) {
+ if (curEntry.keyHash == keyHash && curEntry.matches(key)) {
+ unlink(state, index, prev, curEntry);
+ state.sizeManager.decrement();
+ return curEntry;
+ }
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Removes every entry matching {@code predicate}, returning {@code true} if any were removed.
+ * Holds the table-level lock for the whole sweep, so the predicate sees a stable table and
+ * concurrent writers are excluded; lock-free readers continue throughout.
+ */
+ public boolean removeIf(@Nonnull Predicate super TEntry> predicate) {
+ return ConcurrentHashtable.removeIf(state, predicate);
+ }
+
+ /**
+ * Removes all entries and passes each one to {@code sink} while holding the table write lock.
+ * The sink should be quick and must not throw. If it throws, the partial drain is not rolled
+ * back and the size is not adjusted.
+ *
+ * Use {@link #drain(Object, BiConsumer)} to avoid a capturing lambda.
+ */
+ public void drain(@Nonnull Consumer super TEntry> sink) {
+ ConcurrentHashtable.drain(state, sink);
+ }
+
+ /**
+ * Context-passing {@link #drain(Consumer)}. Pass a non-capturing {@link BiConsumer} (typically
+ * a {@code static final}) plus the accumulator as {@code context} (e.g. the target list or
+ * event builder) to avoid a capturing-lambda allocation.
+ */
+ public void drain(C context, @Nonnull BiConsumer super C, ? super TEntry> sink) {
+ ConcurrentHashtable.drain(state, context, sink);
+ }
+
+ /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */
+ public void clear() {
+ ConcurrentHashtable.clear(state);
+ }
+
+ public void forEach(@Nonnull Consumer super TEntry> consumer) {
+ ConcurrentHashtable.forEach(state, consumer);
+ }
+
+ /**
+ * Context-passing forEach. Avoids a capturing-lambda allocation — pass a non-capturing {@link
+ * BiConsumer} (typically a {@code static final}) plus whatever side-band state it needs.
+ */
+ public void forEach(C context, @Nonnull BiConsumer super C, ? super TEntry> consumer) {
+ ConcurrentHashtable.forEach(state, context, consumer);
+ }
+ }
+
+ /**
+ * Two-key concurrent hash table. Key parts are passed directly to {@link #get} and {@link
+ * #tryGetOrCreate}, avoiding a composite wrapper whose allocation would otherwise rely on HotSpot
+ * escape analysis to disappear. Reads are lock-free; misses and mutations acquire the write lock.
+ *
+ * @param first key type
+ * @param second key type
+ * @param the user's {@link D2.Entry D2.Entry<K1, K2>} subclass
+ */
+ @ThreadSafe
+ public static final class D2> {
+
+ /**
+ * Abstract base for {@link D2} entries. Subclass to add value fields you wish to mutate in
+ * place.
+ *
+ * @param first key type
+ * @param second key type
+ */
+ public abstract static class Entry extends ConcurrentHashtable.Entry {
+ final K1 key1;
+ final K2 key2;
+
+ protected Entry(@Nullable K1 key1, @Nullable K2 key2) {
+ super(hash(key1, key2));
+ this.key1 = key1;
+ this.key2 = key2;
+ }
+
+ /** The first key part this entry was created with. */
+ @Nullable
+ public K1 key1() {
+ return this.key1;
+ }
+
+ /** The second key part this entry was created with. */
+ @Nullable
+ public K2 key2() {
+ return this.key2;
+ }
+
+ public boolean matches(@Nullable K1 key1, @Nullable K2 key2) {
+ // equals() on the lookup params, not the fields, so the JIT can devirtualize them once
+ // matches() inlines into get/getOrCreate (the caller's key types are known there).
+ return Objects.equals(key1, this.key1) && Objects.equals(key2, this.key2);
+ }
+
+ /** Returns the 64-bit lookup hash combining both key parts via {@link LongHashingUtils}. */
+ public static long hash(@Nullable Object key1, @Nullable Object key2) {
+ return LongHashingUtils.hash(key1, key2);
+ }
+ }
+
+ private final State state;
+
+ private D2(State state) {
+ this.state = state;
+ }
+
+ /**
+ * Creates a fixed-size table holding at most {@code maxCapacity} entries. {@code entryClass} is
+ * used only to infer the concrete entry type; entries are created by the functions passed to
+ * the insertion methods. The table does not resize.
+ */
+ @Nonnull
+ public static > D2 createBounded(
+ @Nonnull Class entryClass, int maxCapacity) {
+ return new D2<>(State.createBounded(entryClass, maxCapacity));
+ }
+
+ public int size() {
+ return state.sizeManager.estimateSize();
+ }
+
+ public boolean isFull() {
+ return state.sizeManager.isFull();
+ }
+
+ @Nullable
+ public TEntry get(@Nullable K1 key1, @Nullable K2 key2) {
+ long keyHash = D2.Entry.hash(key1, key2);
+ for (TEntry curEntry = bucketFor(state, keyHash);
+ curEntry != null;
+ curEntry = curEntry.next()) {
+ if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) {
+ return curEntry;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the entry for {@code (key1, key2)}, creating one via {@code creator} if absent and
+ * the table is under capacity. Lock-free on hit; acquires a table-level lock on miss. Wraps
+ * {@link #tryGetOrCreateOrNull} — see that method for the refusal and ordering details.
+ *
+ * The {@code creator} should build an entry whose {@code keyHash} equals {@link
+ * D2.Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}.
+ */
+ @Nonnull
+ public Maybe tryGetOrCreate(
+ @Nullable K1 key1,
+ @Nullable K2 key2,
+ @Nonnull BiFunction super K1, ? super K2, ? extends TEntry> creator) {
+ return Maybe.of(tryGetOrCreateOrNull(key1, key2, creator));
+ }
+
+ /**
+ * Escape hatch for {@link #tryGetOrCreate} for callers that want the nullable entry directly
+ * rather than a {@link Maybe} wrapper. Returns {@code null} when the table is at capacity and
+ * {@code (key1, key2)} was not already present. Re-checks under the lock to avoid duplicate
+ * entries under concurrent misses.
+ */
+ @Nullable
+ public TEntry tryGetOrCreateOrNull(
+ @Nullable K1 key1,
+ @Nullable K2 key2,
+ @Nonnull BiFunction super K1, ? super K2, ? extends TEntry> creator) {
+ long keyHash = D2.Entry.hash(key1, key2);
+ int index = bucketIndex(state.buckets, keyHash);
+ for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) {
+ if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) {
+ return curEntry;
+ }
+ }
+ synchronized (getTableWriteLock(state)) {
+ for (TEntry curEntry = bucketAt(state, index);
+ curEntry != null;
+ curEntry = curEntry.next()) {
+ if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) {
+ return curEntry;
+ }
+ }
+ // isFull() is checked before creating the entry, not before reserving a slot for it:
+ // creator.apply() can throw, and if we'd already reserved (incremented) the slot, a
+ // throwing creator would leak that reservation forever. So we accept the entry only after
+ // creator succeeds, then increment.
+ if (state.sizeManager.isFull()) {
+ return null;
+ }
+ TEntry newEntry = creator.apply(key1, key2);
+ insertHeadEntryAt(state, index, newEntry);
+ state.sizeManager.increment();
+ return newEntry;
+ }
+ }
+
+ /**
+ * {@link #tryGetOrCreate}, but when the table is full, evicts one entry matching {@code
+ * evictable} to make room instead of refusing the insert. Refuses only when the table is full
+ * and nothing matches {@code evictable} — see {@link #tryGetOrCreateOrEvictOrNull} for
+ * the null-returning form and the eviction/creation ordering.
+ */
+ @Nonnull
+ public Maybe tryGetOrCreateOrEvict(
+ @Nullable K1 key1,
+ @Nullable K2 key2,
+ @Nonnull BiFunction super K1, ? super K2, ? extends TEntry> creator,
+ @Nonnull Predicate super TEntry> evictable) {
+ return Maybe.of(tryGetOrCreateOrEvictOrNull(key1, key2, creator, evictable));
+ }
+
+ /**
+ * Escape hatch for {@link #tryGetOrCreateOrEvict} for callers that want the nullable entry
+ * directly. Eviction runs before {@code creator}, not after: {@code creator} may throw, so
+ * freeing a slot and only then attempting the fallible create keeps a thrown exception from
+ * ever leaving a slot double-booked. A creator that throws after a successful eviction simply
+ * leaves the table one entry smaller — no corruption, just a wasted eviction.
+ */
+ @Nullable
+ public TEntry tryGetOrCreateOrEvictOrNull(
+ @Nullable K1 key1,
+ @Nullable K2 key2,
+ @Nonnull BiFunction super K1, ? super K2, ? extends TEntry> creator,
+ @Nonnull Predicate super TEntry> evictable) {
+ long keyHash = D2.Entry.hash(key1, key2);
+ int index = bucketIndex(state.buckets, keyHash);
+ for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) {
+ if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) {
+ return curEntry;
+ }
+ }
+ synchronized (getTableWriteLock(state)) {
+ for (TEntry curEntry = bucketAt(state, index);
+ curEntry != null;
+ curEntry = curEntry.next()) {
+ if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) {
+ return curEntry;
+ }
+ }
+ if (state.sizeManager.isFull()
+ && state.sizeManager.evictOne(state.buckets, evictable) == null) {
+ return null;
+ }
+ TEntry newEntry = creator.apply(key1, key2);
+ insertHeadEntryAt(state, index, newEntry);
+ state.sizeManager.increment();
+ return newEntry;
+ }
+ }
+
+ /**
+ * Removes and returns the entry for {@code (key1, key2)}, or {@code null} if absent. Acquires
+ * the table-level lock to splice the chain; lock-free readers observe the removal via the
+ * volatile write of the predecessor's {@code next} (or the bucket head).
+ */
+ @Nullable
+ public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) {
+ long keyHash = D2.Entry.hash(key1, key2);
+ int index = bucketIndex(state.buckets, keyHash);
+ synchronized (getTableWriteLock(state)) {
+ TEntry prev = null;
+ for (TEntry curEntry = bucketAt(state, index);
+ curEntry != null;
+ prev = curEntry, curEntry = curEntry.next()) {
+ if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) {
+ unlink(state, index, prev, curEntry);
+ state.sizeManager.decrement();
+ return curEntry;
+ }
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Removes every entry matching {@code predicate}, returning {@code true} if any were removed.
+ * Holds the table-level lock for the whole sweep, so the predicate sees a stable table and
+ * concurrent writers are excluded; lock-free readers continue throughout.
+ */
+ public boolean removeIf(@Nonnull Predicate super TEntry> predicate) {
+ return ConcurrentHashtable.removeIf(state, predicate);
+ }
+
+ /**
+ * Removes all entries and passes each one to {@code sink} while holding the table write lock.
+ * The sink should be quick and must not throw. If it throws, the partial drain is not rolled
+ * back and the size is not adjusted.
+ *
+ * Use {@link #drain(Object, BiConsumer)} to avoid a capturing lambda.
+ */
+ public void drain(@Nonnull Consumer super TEntry> sink) {
+ ConcurrentHashtable.drain(state, sink);
+ }
+
+ /**
+ * Context-passing {@link #drain(Consumer)}. Pass a non-capturing {@link BiConsumer} (typically
+ * a {@code static final}) plus the accumulator as {@code context} (e.g. the target list or
+ * event builder) to avoid a capturing-lambda allocation.
+ */
+ public void drain(C context, @Nonnull BiConsumer super C, ? super TEntry> sink) {
+ ConcurrentHashtable.drain(state, context, sink);
+ }
+
+ /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */
+ public void clear() {
+ ConcurrentHashtable.clear(state);
+ }
+
+ public void forEach(@Nonnull Consumer super TEntry> consumer) {
+ ConcurrentHashtable.forEach(state, consumer);
+ }
+
+ /**
+ * Context-passing forEach. Avoids a capturing-lambda allocation — pass a non-capturing {@link
+ * BiConsumer} (typically a {@code static final}) plus whatever side-band state it needs.
+ */
+ public void forEach(C context, @Nonnull BiConsumer super C, ? super TEntry> consumer) {
+ ConcurrentHashtable.forEach(state, context, consumer);
+ }
+ }
+
+ /**
+ * Tracks a capped table's occupancy and eviction position.
+ *
+ * The count includes live entries and outstanding reservations. Its {@link AtomicInteger}
+ * provides volatile visibility and atomic updates, allowing size queries and {@link
+ * #tryReserve()} without the table lock. The plain {@code evictionCursor} is instead protected by
+ * the table write lock; methods annotated with {@link GuardedBy} require that lock.
+ */
+ @ThreadSafe
+ public static final class SizeManager {
+ private final AtomicInteger size = new AtomicInteger();
+ private final int capacity;
+
+ /**
+ * Bucket index the last eviction removed from. The next scan resumes here, so a sustained
+ * eviction stream doesn't repeatedly re-walk the same hot entries clustered near bucket 0.
+ */
+ @GuardedBy("getTableWriteLock(buckets)")
+ private int evictionCursor;
+
+ public SizeManager(int capacity) {
+ this.capacity = capacity;
+ }
+
+ /** Live entries. Safe to call without the write lock. */
+ public int estimateSize() {
+ return size.get();
+ }
+
+ public int capacity() {
+ return capacity;
+ }
+
+ /** {@code true} once {@link #estimateSize()} has reached {@link #capacity()}. */
+ public boolean isFull() {
+ return size.get() >= capacity;
+ }
+
+ /**
+ * Atomically reserves one slot without taking the table write lock. It claims first with {@link
+ * AtomicInteger#incrementAndGet()} and refunds values above capacity, so concurrent callers
+ * cannot both acquire the last slot. Returns {@code false} with the count unchanged when the
+ * table is full.
+ *
+ *
Build the entry before reserving: there is no cancellation operation, so abandoning a
+ * successful reservation permanently consumes capacity.
+ */
+ public boolean tryReserve() {
+ if (size.incrementAndGet() > capacity) {
+ size.decrementAndGet();
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Reserves one slot, evicting an entry matching {@code evictable} when the table is full.
+ * Returns {@code false} without changing the table when no entry can be evicted.
+ *
+ *
The reservation survives concurrent drain and clear operations. The caller must fill it;
+ * an abandoned reservation permanently consumes capacity.
+ */
+ @GuardedBy("getTableWriteLock(buckets)")
+ public boolean tryReserveOrEvict(
+ @Nonnull AtomicReferenceArray buckets,
+ @Nonnull Predicate super TEntry> evictable) {
+ if (tryReserve()) {
+ return true;
+ }
+ if (evictOne(buckets, evictable) == null) {
+ return false;
+ }
+ // evictOne already decremented; the slot it freed is ours.
+ size.incrementAndGet();
+ return true;
+ }
+
+ /** Call after successfully linking a new entry. */
+ public void increment() {
+ size.incrementAndGet();
+ }
+
+ /** Call after successfully unlinking an entry. */
+ public void decrement() {
+ size.decrementAndGet();
+ }
+
+ /**
+ * Releases {@code removed} slots after a sweep and resets the {@code evictionCursor}.
+ * Outstanding reservations remain counted.
+ */
+ @GuardedBy("getTableWriteLock(buckets)")
+ @SuppressFBWarnings(
+ value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE",
+ justification =
+ "evictionCursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs"
+ + " cannot model that dynamic guard")
+ public void release(int removed) {
+ if (removed != 0) {
+ size.addAndGet(-removed);
+ }
+ evictionCursor = 0;
+ }
+
+ /**
+ * Removes and returns the first entry matching {@code evictable}, scanning from the previous
+ * eviction position and wrapping once. Returns {@code null} without changing the count when no
+ * entry matches.
+ *
+ * This operation may inspect every live entry while holding the table write lock, so the
+ * predicate should be quick.
+ */
+ @GuardedBy("getTableWriteLock(buckets)")
+ @Nullable
+ public TEntry evictOne(
+ @Nonnull AtomicReferenceArray buckets,
+ @Nonnull Predicate super TEntry> evictable) {
+ TEntry evicted = evictOneInRange(buckets, evictable, evictionCursor, buckets.length());
+ if (evicted == null && evictionCursor != 0) {
+ evicted = evictOneInRange(buckets, evictable, 0, evictionCursor);
+ }
+ if (evicted != null) {
+ size.decrementAndGet();
+ return evicted;
+ }
+ // Nothing matched anywhere; step the evictionCursor on regardless so repeated refusals don't
+ // all
+ // restart the (wasted) scan from the same bucket.
+ evictionCursor = bucketIndex(buckets, evictionCursor + 1);
+ return null;
+ }
+
+ @GuardedBy("getTableWriteLock(buckets)")
+ @SuppressFBWarnings(
+ value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE",
+ justification =
+ "evictionCursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs"
+ + " cannot model that dynamic guard")
+ @Nullable
+ private TEntry evictOneInRange(
+ @Nonnull AtomicReferenceArray buckets,
+ @Nonnull Predicate super TEntry> evictable,
+ int startBucket,
+ int endBucket) {
+ for (int i = startBucket; i < endBucket; i++) {
+ TEntry prev = null;
+ for (TEntry e = buckets.get(i); e != null; e = e.next()) {
+ if (evictable.test(e)) {
+ unlink(buckets, i, prev, e);
+ evictionCursor = i;
+ return e;
+ }
+ prev = e;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Unlinks every entry matching {@code evictable} in one full pass, decrementing the count for
+ * each, and returns how many were removed. Resets the scan position, since a full pass leaves
+ * nothing later to resume from.
+ */
+ @GuardedBy("getTableWriteLock(buckets)")
+ @SuppressFBWarnings(
+ value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE",
+ justification =
+ "evictionCursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs"
+ + " cannot model that dynamic guard")
+ public int evictAll(
+ @Nonnull AtomicReferenceArray buckets,
+ @Nonnull Predicate super TEntry> evictable) {
+ int count = 0;
+ for (int i = 0; i < buckets.length(); i++) {
+ TEntry prev = null;
+ for (TEntry e = buckets.get(i); e != null; e = e.next()) {
+ if (evictable.test(e)) {
+ unlink(buckets, i, prev, e);
+ size.decrementAndGet();
+ count++;
+ } else {
+ prev = e;
+ }
+ }
+ }
+ evictionCursor = 0;
+ return count;
+ }
+ }
+
+ /**
+ * Bucket array and occupancy manager for a caller-defined capped table. Keep them paired and
+ * prefer the {@code State}-accepting helpers so structural changes update the count consistently.
+ */
+ public static final class State {
+ public final AtomicReferenceArray buckets;
+ public final SizeManager sizeManager;
+
+ private State(AtomicReferenceArray buckets, int maxCapacity) {
+ this.buckets = buckets;
+ this.sizeManager = new SizeManager(maxCapacity);
+ }
+
+ /**
+ * Creates a bucket array for {@code maxCapacity} entries and pairs it with a manager enforcing
+ * that cap. {@code entryClass} is used only to infer {@code TEntry}.
+ */
+ @Nonnull
+ public static State createBounded(
+ @Nonnull Class entryClass, int maxCapacity) {
+ return new State<>(createFixedBuckets(entryClass, maxCapacity), maxCapacity);
+ }
+ }
+
+ /** Live entries in {@code state}; see {@link SizeManager#estimateSize()}. Lock-free. */
+ public static int estimateSize(@Nonnull State> state) {
+ return state.sizeManager.estimateSize();
+ }
+
+ /**
+ * {@code true} once {@code state} is at capacity; see {@link SizeManager#isFull()}. Lock-free.
+ */
+ public static boolean isFull(@Nonnull State> state) {
+ return state.sizeManager.isFull();
+ }
+
+ /**
+ * Reserves one slot in {@code state}, evicting an entry matching {@code evictable} when
+ * necessary. Returns {@code false} if the table is full and nothing can be evicted. This method
+ * acquires the table write lock.
+ *
+ * The reservation survives drain and clear operations. Complete it with {@link
+ * #insertReserved}; abandoning it permanently consumes capacity.
+ */
+ public static boolean tryReserveOrEvict(
+ @Nonnull State state, @Nonnull Predicate super TEntry> evictable) {
+ synchronized (getTableWriteLock(state)) {
+ return state.sizeManager.tryReserveOrEvict(state.buckets, evictable);
+ }
+ }
+
+ /**
+ * Unlinks the first entry in {@code state} matching {@code evictable}, resuming from where the
+ * last eviction looked, and decrements the count. {@code null} if nothing matched anywhere.
+ * Self-locking.
+ */
+ @Nullable
+ public static TEntry evictOne(
+ @Nonnull State state, @Nonnull Predicate super TEntry> evictable) {
+ synchronized (getTableWriteLock(state)) {
+ return state.sizeManager.evictOne(state.buckets, evictable);
+ }
+ }
+
+ /**
+ * Unlinks every entry in {@code state} matching {@code evictable}, decrementing per removal, and
+ * returns how many went. Self-locking.
+ */
+ public static int evictAll(
+ @Nonnull State state, @Nonnull Predicate super TEntry> evictable) {
+ synchronized (getTableWriteLock(state)) {
+ return state.sizeManager.evictAll(state.buckets, evictable);
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Static building blocks over a caller-owned bucket array (formerly the nested Support class).
+ // Use these to assemble a custom table (higher arity, primitive keys, extra value fields) when
+ // D1/D2 don't fit; D1/D2 delegate to them internally. The whole-table mutators (removeIf, drain,
+ // clear) self-lock on the array; the single-slot write primitives (insertHeadEntry, unlink) do
+ // not lock and must be called under the caller's own synchronized (getWriteLock(buckets,
+ // keyHash)) block.
+ // Readers
+ // (bucket walks, forEach) are lock-free.
+ // ---------------------------------------------------------------------------------------------
+
+ /**
+ * Creates a bucket array whose length is {@link #sizeFor(int) sizeFor(capacity)}. Because {@link
+ * AtomicReferenceArray}'s element type is erased at runtime, {@code entryClass} is not used for
+ * reflective allocation or runtime type checks; it only lets the compiler infer {@code TEntry}.
+ */
+ @Nonnull
+ public static AtomicReferenceArray createFixedBuckets(
+ @Nonnull Class entryClass, int capacity) {
+ return new AtomicReferenceArray<>(sizeFor(capacity));
+ }
+
+ /** Upper bound on the bucket-array length returned by {@link #sizeFor(int)}. */
+ static final int MAX_BUCKETS = 1 << 30;
+
+ /**
+ * Returns the bucket-array length to allocate for a table sized to hold {@code requestedSize}
+ * entries: {@code requestedSize} rounded up to the next power of two, capped at {@link
+ * #MAX_BUCKETS}. Throws {@link IllegalArgumentException} for negative inputs or inputs above the
+ * cap.
+ */
+ public static int sizeFor(int requestedSize) {
+ if (requestedSize < 0) {
+ throw new IllegalArgumentException("requestedSize must be non-negative: " + requestedSize);
+ }
+ if (requestedSize > MAX_BUCKETS) {
+ throw new IllegalArgumentException(
+ "requestedSize exceeds maximum bucket count (" + MAX_BUCKETS + "): " + requestedSize);
+ }
+ if (requestedSize <= 1) {
+ return 1;
+ }
+ return Integer.highestOneBit(requestedSize - 1) << 1;
+ }
+
+ /**
+ * Returns the opaque monitor guarding writes to the bucket selected by {@code keyHash}. Use this
+ * monitor for a keyed scan-and-mutate operation; do not depend on its identity or granularity.
+ *
+ * @see #getWriteLockAt(AtomicReferenceArray, int)
+ * @see #getTableWriteLock(AtomicReferenceArray)
+ */
+ @Nonnull
+ public static Object getWriteLock(@Nonnull AtomicReferenceArray> buckets, long keyHash) {
+ return getWriteLockAt(buckets, bucketIndex(buckets, keyHash));
+ }
+
+ /** {@link #getWriteLock(AtomicReferenceArray, long)} over a {@link State}. */
+ @Nonnull
+ public static Object getWriteLock(@Nonnull State> state, long keyHash) {
+ return getWriteLock(state.buckets, keyHash);
+ }
+
+ /**
+ * {@link #getWriteLock(AtomicReferenceArray, long)} for a bucket index that has already been
+ * computed — the shape a {@code getOrCreate} wants when it reuses the index from its lock-free
+ * pre-check. This is the primitive; the {@code keyHash} form maps the hash through {@link
+ * #bucketIndex} and calls it.
+ */
+ @Nonnull
+ public static Object getWriteLockAt(@Nonnull AtomicReferenceArray> buckets, int bucketIndex) {
+ return buckets;
+ }
+
+ /** {@link #getWriteLockAt(AtomicReferenceArray, int)} over a {@link State}. */
+ @Nonnull
+ public static Object getWriteLockAt(@Nonnull State> state, int bucketIndex) {
+ return getWriteLockAt(state.buckets, bucketIndex);
+ }
+
+ /**
+ * Returns the opaque monitor guarding operations that span all buckets. Whole-table helpers such
+ * as {@link #drain}, {@link #clear}, and {@link #removeIf} acquire this monitor internally.
+ */
+ @Nonnull
+ public static Object getTableWriteLock(@Nonnull AtomicReferenceArray> buckets) {
+ return buckets;
+ }
+
+ /** {@link #getTableWriteLock(AtomicReferenceArray)} over a {@link State}. */
+ @Nonnull
+ public static Object getTableWriteLock(@Nonnull State> state) {
+ return getTableWriteLock(state.buckets);
+ }
+
+ public static int bucketIndex(@Nonnull AtomicReferenceArray> buckets, long keyHash) {
+ // Bucket lengths are powers of two, so masking replaces a more expensive modulo operation.
+ return (int) (keyHash & (buckets.length() - 1));
+ }
+
+ /**
+ * Returns the head entry of the bucket that {@code keyHash} maps to. The bucket read is a
+ * volatile read of the slot, so it is safe from any thread without a lock.
+ *
+ * Named distinctly from {@link #bucketAt} (rather than overloaded on {@code long} vs. {@code
+ * int}) deliberately: a caller with a primitive {@code int}-typed key hash that called an
+ * overloaded {@code bucket(buckets, intHash)} would silently bind to the {@code int}-index
+ * overload instead of widening to this one, reading the raw hash as an array index — out-of-range
+ * hashes throw {@link IndexOutOfBoundsException}, in-range-but-wrong ones silently read the wrong
+ * bucket.
+ */
+ @Nullable
+ public static TEntry bucketFor(
+ @Nonnull AtomicReferenceArray buckets, long keyHash) {
+ return buckets.get(bucketIndex(buckets, keyHash));
+ }
+
+ /** {@link #bucketFor(AtomicReferenceArray, long)} over a {@link State}. */
+ @Nullable
+ public static TEntry bucketFor(
+ @Nonnull State state, long keyHash) {
+ return bucketFor(state.buckets, keyHash);
+ }
+
+ /**
+ * Returns the head entry of the bucket at {@code index}. Use when the bucket index is already
+ * computed (e.g. inside {@code getOrCreate} where the same index is reused across the lock
+ * boundary). See {@link #bucketFor} for why this is a distinct name rather than an {@code int}
+ * overload of it.
+ */
+ @Nullable
+ public static TEntry bucketAt(
+ @Nonnull AtomicReferenceArray buckets, int index) {
+ return buckets.get(index);
+ }
+
+ /** {@link #bucketAt(AtomicReferenceArray, int)} over a {@link State}. */
+ @Nullable
+ public static TEntry bucketAt(@Nonnull State state, int index) {
+ return bucketAt(state.buckets, index);
+ }
+
+ /**
+ * Publishes {@code entry} as the head of bucket {@code index}. The helper writes the entry's
+ * {@code next} link before the volatile {@link AtomicReferenceArray#set}; a volatile bucket read
+ * that observes the new head also sees the initialized entry and its link. The caller must hold
+ * {@link #getWriteLockAt}; this method does not acquire a lock or update size accounting.
+ *
+ * The entry must be unlinked and must not be reused after removal. Removal intentionally
+ * retains its {@code next} link for readers already traversing that chain.
+ */
+ @GuardedBy("getWriteLockAt(buckets, index)")
+ public static void insertHeadEntryAt(
+ @Nonnull AtomicReferenceArray buckets, int index, @Nonnull TEntry entry) {
+ assert Thread.holdsLock(getWriteLockAt(buckets, index))
+ : "insertHeadEntryAt called without holding getWriteLockAt(buckets, index)";
+ assert entry.next() == null
+ : "Entry already linked -- inserting the same Entry instance twice corrupts the chain"
+ + " (unlink() deliberately leaves a removed entry's next intact for in-flight"
+ + " readers, so a removed entry must never be reinserted)";
+ entry.setNext(buckets.get(index));
+ buckets.set(index, entry);
+ }
+
+ /** {@link #insertHeadEntryAt(AtomicReferenceArray, int, Entry)} over a {@link State}. */
+ @GuardedBy("getWriteLockAt(state, index)")
+ public static void insertHeadEntryAt(
+ @Nonnull State state, int index, @Nonnull TEntry entry) {
+ insertHeadEntryAt(state.buckets, index, entry);
+ }
+
+ /**
+ * Convenience form of {@link #insertHeadEntryAt} that derives the bucket index from {@code
+ * keyHash}. Prefer {@link #insertHeadEntryAt} when the index is already computed (e.g. a {@code
+ * getOrCreate} that reuses it across the lock-free pre-check).
+ */
+ @GuardedBy("getWriteLock(buckets, keyHash)")
+ public static void insertHeadEntryFor(
+ @Nonnull AtomicReferenceArray buckets, long keyHash, @Nonnull TEntry entry) {
+ insertHeadEntryAt(buckets, bucketIndex(buckets, keyHash), entry);
+ }
+
+ /**
+ * Links an already-built entry after a successful {@link #tryReserveOrEvict} or {@link
+ * SizeManager#tryReserve()} call. This method does not acquire a lock or update the count.
+ *
+ * Complete all fallible work before reserving. There is no cancellation operation, so an
+ * abandoned reservation permanently consumes capacity. Drain and clear do not cancel outstanding
+ * reservations.
+ */
+ @GuardedBy("getTableWriteLock(state)")
+ public static void insertReserved(
+ @Nonnull State state, long keyHash, @Nonnull TEntry entry) {
+ insertHeadEntryFor(state.buckets, keyHash, entry);
+ }
+
+ /**
+ * Splices {@code entry} out of the chain at {@code index}. {@code prev} is the in-chain
+ * predecessor, or {@code null} when {@code entry} is the bucket head. Re-points the predecessor
+ * (or the bucket head slot) past {@code entry} via a volatile write so lock-free readers see the
+ * removal. {@code entry}'s own {@code next} is deliberately left intact so a reader already
+ * positioned on it can still traverse forward. This is a single-slot primitive: it does not lock,
+ * so call it inside the caller's {@code synchronized (getWriteLockAt(buckets, index))} block.
+ * Does not touch size accounting.
+ */
+ @GuardedBy("getWriteLockAt(buckets, index)")
+ public static void unlink(
+ @Nonnull AtomicReferenceArray buckets,
+ int index,
+ @Nullable TEntry prev,
+ @Nonnull TEntry entry) {
+ assert Thread.holdsLock(getWriteLockAt(buckets, index))
+ : "unlink called without holding getWriteLockAt(buckets, index)";
+ TEntry next = entry.next();
+ if (prev == null) {
+ buckets.set(index, next);
+ } else {
+ prev.setNext(next);
+ }
+ }
+
+ /** {@link #unlink(AtomicReferenceArray, int, Entry, Entry)} over a {@link State}. */
+ @GuardedBy("getWriteLockAt(state, index)")
+ public static void unlink(
+ @Nonnull State state, int index, @Nullable TEntry prev, @Nonnull TEntry entry) {
+ unlink(state.buckets, index, prev, entry);
+ }
+
+ /**
+ * Removes every entry matching {@code predicate} from {@code buckets}, decrementing {@code size}
+ * once per removal. Self-locking: synchronizes on {@code buckets} for the whole sweep, so the
+ * predicate sees a stable table and concurrent writers are excluded; lock-free readers continue
+ * throughout.
+ */
+ public static boolean removeIf(
+ @Nonnull AtomicReferenceArray buckets,
+ @Nonnull AtomicInteger size,
+ @Nonnull Predicate super TEntry> predicate) {
+ synchronized (getTableWriteLock(buckets)) {
+ boolean removed = false;
+ for (int i = 0; i < buckets.length(); i++) {
+ TEntry prev = null;
+ for (TEntry e = buckets.get(i); e != null; e = e.next()) {
+ if (predicate.test(e)) {
+ unlink(buckets, i, prev, e);
+ size.decrementAndGet();
+ removed = true;
+ // prev stays put: e is now unlinked, so the last survivor remains the predecessor.
+ } else {
+ prev = e;
+ }
+ }
+ }
+ return removed;
+ }
+ }
+
+ /**
+ * {@link #removeIf(AtomicReferenceArray, AtomicInteger, Predicate)} variant for callers tracking
+ * occupancy with a {@link State} instead of a bare counter — used by {@link D1#removeIf} and
+ * {@link D2#removeIf}.
+ */
+ public static boolean removeIf(
+ @Nonnull State state, @Nonnull Predicate super TEntry> predicate) {
+ AtomicReferenceArray buckets = state.buckets;
+ synchronized (getTableWriteLock(state)) {
+ boolean removed = false;
+ for (int i = 0; i < buckets.length(); i++) {
+ TEntry prev = null;
+ for (TEntry e = buckets.get(i); e != null; e = e.next()) {
+ if (predicate.test(e)) {
+ unlink(buckets, i, prev, e);
+ state.sizeManager.decrement();
+ removed = true;
+ } else {
+ prev = e;
+ }
+ }
+ }
+ return removed;
+ }
+ }
+
+ /**
+ * Removes all entries while holding the table write lock. Each bucket head is cleared with a
+ * volatile write before its detached chain is passed to {@code sink}, so subsequent lock-free
+ * readers observe an empty bucket while readers already on that chain can continue through its
+ * retained {@code next} links. This overload does not update size accounting.
+ *
+ * The sink must not throw. If it does, the partial drain is not rolled back.
+ */
+ public static void drain(
+ @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer super TEntry> sink) {
+ drainCounting(buckets, sink);
+ }
+
+ /**
+ * {@link #drain(AtomicReferenceArray, Consumer)} returning how many entries it handed to {@code
+ * sink}, so a {@link State} form can subtract exactly that from its {@link SizeManager} instead
+ * of zeroing. The count is free here: the sweep already visits every entry.
+ */
+ private static int drainCounting(
+ @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer super TEntry> sink) {
+ int removed = 0;
+ synchronized (getTableWriteLock(buckets)) {
+ for (int i = 0; i < buckets.length(); i++) {
+ TEntry head = buckets.get(i);
+ if (head == null) {
+ continue;
+ }
+ buckets.set(i, null);
+ for (TEntry e = head; e != null; e = e.next()) {
+ removed++;
+ sink.accept(e);
+ }
+ }
+ }
+ return removed;
+ }
+
+ /** Context-passing variant of {@link #drain(AtomicReferenceArray, Consumer)}. Self-locking. */
+ public static void drain(
+ @Nonnull AtomicReferenceArray buckets,
+ C context,
+ @Nonnull BiConsumer super C, ? super TEntry> sink) {
+ drainCounting(buckets, context, sink);
+ }
+
+ /** {@link #drainCounting(AtomicReferenceArray, Consumer)}, context-passing form. */
+ private static int drainCounting(
+ @Nonnull AtomicReferenceArray buckets,
+ C context,
+ @Nonnull BiConsumer super C, ? super TEntry> sink) {
+ int removed = 0;
+ synchronized (getTableWriteLock(buckets)) {
+ for (int i = 0; i < buckets.length(); i++) {
+ TEntry head = buckets.get(i);
+ if (head == null) {
+ continue;
+ }
+ buckets.set(i, null);
+ for (TEntry e = head; e != null; e = e.next()) {
+ removed++;
+ sink.accept(context, e);
+ }
+ }
+ }
+ return removed;
+ }
+
+ /**
+ * {@link #drain(AtomicReferenceArray, Consumer)} plus the matching bookkeeping: empties {@code
+ * state} into {@code sink} and gives its {@link SizeManager} back exactly the slots the sweep
+ * freed. Draining without that leaves the cap permanently consumed, so the two belong in one call
+ * rather than as a pair the caller has to remember.
+ */
+ public static void drain(
+ @Nonnull State state, @Nonnull Consumer super TEntry> sink) {
+ synchronized (getTableWriteLock(state)) {
+ state.sizeManager.release(drainCounting(state.buckets, sink));
+ }
+ }
+
+ /** Context-passing form of {@link #drain(State, Consumer)}. */
+ public static void drain(
+ @Nonnull State state,
+ C context,
+ @Nonnull BiConsumer super C, ? super TEntry> sink) {
+ synchronized (getTableWriteLock(state)) {
+ state.sizeManager.release(drainCounting(state.buckets, context, sink));
+ }
+ }
+
+ /** Nulls every bucket head. Self-locking: synchronizes on {@code buckets}. */
+ public static void clear(@Nonnull AtomicReferenceArray> buckets) {
+ synchronized (getTableWriteLock(buckets)) {
+ for (int i = 0; i < buckets.length(); i++) {
+ buckets.set(i, null);
+ }
+ }
+ }
+
+ /**
+ * {@link #clear(AtomicReferenceArray)} returning how many entries it detached, so a {@link State}
+ * form can subtract exactly that rather than zeroing — see {@link SizeManager#release(int)} for
+ * why that distinction matters. Unlike the plain form this walks the chains, making it O(entries)
+ * rather than O(buckets); clear is a rare, whole-table operation, so the walk is affordable and
+ * keeping the count honest is worth more than the constant.
+ */
+ private static int clearCounting(@Nonnull AtomicReferenceArray extends Entry> buckets) {
+ int removed = 0;
+ synchronized (getTableWriteLock(buckets)) {
+ for (int i = 0; i < buckets.length(); i++) {
+ Entry head = buckets.get(i);
+ if (head == null) {
+ continue;
+ }
+ buckets.set(i, null);
+ for (Entry e = head; e != null; e = e.next()) {
+ removed++;
+ }
+ }
+ }
+ return removed;
+ }
+
+ /**
+ * {@link #clear(AtomicReferenceArray)} over a {@link State}: also resets its {@link SizeManager}.
+ */
+ public static void clear(@Nonnull State> state) {
+ synchronized (getTableWriteLock(state)) {
+ state.sizeManager.release(clearCounting(state.buckets));
+ }
+ }
+
+ public static void forEach(
+ @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer super TEntry> consumer) {
+ for (int i = 0; i < buckets.length(); i++) {
+ for (TEntry curEntry = buckets.get(i); curEntry != null; curEntry = curEntry.next()) {
+ consumer.accept(curEntry);
+ }
+ }
+ }
+
+ public static void forEach(
+ @Nonnull AtomicReferenceArray buckets,
+ C context,
+ @Nonnull BiConsumer super C, ? super TEntry> consumer) {
+ for (int i = 0; i < buckets.length(); i++) {
+ for (TEntry curEntry = buckets.get(i); curEntry != null; curEntry = curEntry.next()) {
+ consumer.accept(context, curEntry);
+ }
+ }
+ }
+
+ /** {@link #forEach(AtomicReferenceArray, Consumer)} over a {@link State}. */
+ public static void forEach(
+ @Nonnull State state, @Nonnull Consumer super TEntry> consumer) {
+ forEach(state.buckets, consumer);
+ }
+
+ /** {@link #forEach(AtomicReferenceArray, Object, BiConsumer)} over a {@link State}. */
+ public static void forEach(
+ @Nonnull State state,
+ C context,
+ @Nonnull BiConsumer super C, ? super TEntry> consumer) {
+ forEach(state.buckets, context, consumer);
+ }
+}
diff --git a/internal-api/src/test/groovy/datadog/trace/api/telemetry/LogCollectorTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/telemetry/LogCollectorTest.groovy
deleted file mode 100644
index 4f798f5bf9f..00000000000
--- a/internal-api/src/test/groovy/datadog/trace/api/telemetry/LogCollectorTest.groovy
+++ /dev/null
@@ -1,66 +0,0 @@
-package datadog.trace.api.telemetry
-
-import datadog.trace.test.util.DDSpecification
-
-class LogCollectorTest extends DDSpecification {
-
- void "tracer time is set"() {
- setup:
- def logCollector = new LogCollector(1)
-
- when:
- logCollector.addLogMessage("ERROR", "Message 1", null)
-
- then:
- def log = logCollector.drain().toList().get(0)
- def ts = log.timestamp
- ts > 0L
- // Check tracer time is not in millis
- ts < 1706529524286L
- }
-
- void "limit log messages in LogCollector"() {
- setup:
- def logCollector = new LogCollector(3)
-
- when:
- logCollector.addLogMessage("ERROR", "Message 1", null)
- logCollector.addLogMessage("ERROR", "Message 2", null)
- logCollector.addLogMessage("ERROR", "Message 3", null)
- logCollector.addLogMessage("ERROR", "Message 4", null)
-
- then:
- logCollector.rawLogMessages.size() == 3
- }
-
- void "grouping messages in LogCollector"() {
- when:
- LogCollector.get().addLogMessage("ERROR", "First Message", null)
- LogCollector.get().addLogMessage("ERROR", "Second Message", null)
- LogCollector.get().addLogMessage("ERROR", "Third Message", null)
- LogCollector.get().addLogMessage("ERROR", "Forth Message", null)
- LogCollector.get().addLogMessage("ERROR", "Second Message", null)
- LogCollector.get().addLogMessage("ERROR", "Third Message", null)
- LogCollector.get().addLogMessage("ERROR", "Forth Message", null)
- LogCollector.get().addLogMessage("ERROR", "Third Message", null)
- LogCollector.get().addLogMessage("ERROR", "Forth Message", null)
- LogCollector.get().addLogMessage("ERROR", "Forth Message", null)
-
- then:
- def list = LogCollector.get().drain()
- list.size() == 4
- listContains(list, 'ERROR', "First Message", null, 1)
- listContains(list, 'ERROR', "Second Message", null, 2)
- listContains(list, 'ERROR', "Third Message", null,3)
- listContains(list, 'ERROR', "Forth Message", null, 4)
- }
-
- boolean listContains(Collection list, String logLevel, String message, Throwable t, int count) {
- for (final def logMsg in list) {
- if (logMsg.logLevel == logLevel && logMsg.message == message && logMsg.throwable == t && logMsg.count == count) {
- return true
- }
- }
- return false
- }
-}
diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java
new file mode 100644
index 00000000000..34e221b254a
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java
@@ -0,0 +1,211 @@
+package datadog.trace.api.telemetry;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Collection;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import org.junit.jupiter.api.Test;
+
+class LogCollectorTest {
+
+ @Test
+ void setsTracerTime() {
+ LogCollector logCollector = new LogCollector(1);
+ long before = System.currentTimeMillis() / 1000;
+
+ logCollector.addLogMessage("ERROR", "Message 1", null);
+
+ long after = System.currentTimeMillis() / 1000;
+ LogCollector.RawLogMessage log = onlyLog(logCollector.drain());
+ assertTrue(log.timestamp >= before);
+ assertTrue(log.timestamp <= after);
+ }
+
+ @Test
+ void limitsLogMessages() {
+ LogCollector logCollector = new LogCollector(3);
+
+ logCollector.addLogMessage("ERROR", "Message 1", null);
+ logCollector.addLogMessage("ERROR", "Message 2", null);
+ logCollector.addLogMessage("ERROR", "Message 3", null);
+ logCollector.addLogMessage("ERROR", "Message 4", null);
+
+ assertEquals(3, logCollector.drain().size());
+ }
+
+ @Test
+ void groupsMessages() {
+ LogCollector logCollector = new LogCollector(10);
+
+ logCollector.addLogMessage("ERROR", "First Message", null);
+ logCollector.addLogMessage("ERROR", "Second Message", null);
+ logCollector.addLogMessage("ERROR", "Third Message", null);
+ logCollector.addLogMessage("ERROR", "Fourth Message", null);
+ logCollector.addLogMessage("ERROR", "Second Message", null);
+ logCollector.addLogMessage("ERROR", "Third Message", null);
+ logCollector.addLogMessage("ERROR", "Fourth Message", null);
+ logCollector.addLogMessage("ERROR", "Third Message", null);
+ logCollector.addLogMessage("ERROR", "Fourth Message", null);
+ logCollector.addLogMessage("ERROR", "Fourth Message", null);
+
+ Collection logs = logCollector.drain();
+ assertEquals(4, logs.size());
+ assertLog(logs, "First Message", 1);
+ assertLog(logs, "Second Message", 2);
+ assertLog(logs, "Third Message", 3);
+ assertLog(logs, "Fourth Message", 4);
+ }
+
+ @Test
+ void dropsDuplicatesWhenFull() {
+ LogCollector logCollector = new LogCollector(1);
+
+ logCollector.addLogMessage("ERROR", "Message", null);
+ logCollector.addLogMessage("ERROR", "Message", null);
+
+ assertEquals(1, onlyLog(logCollector.drain()).count);
+ }
+
+ @Test
+ void reusesCapacityAfterDrain() {
+ LogCollector logCollector = new LogCollector(1);
+
+ logCollector.addLogMessage("ERROR", "First", null);
+ assertEquals("First", onlyLog(logCollector.drain()).message);
+ logCollector.addLogMessage("ERROR", "Second", null);
+
+ assertEquals("Second", onlyLog(logCollector.drain()).message);
+ assertTrue(logCollector.drain().isEmpty());
+ }
+
+ @Test
+ void groupsEquivalentThrowablesAndKeepsFirstMetadata() {
+ LogCollector logCollector = new LogCollector(2);
+ Throwable first = throwableAtLine(10);
+ Throwable second = throwableAtLine(10);
+
+ logCollector.addLogMessage("ERROR", "Message", first, "source:first");
+ logCollector.addLogMessage("ERROR", "Message", second, "source:second");
+
+ LogCollector.RawLogMessage log = onlyLog(logCollector.drain());
+ assertEquals(2, log.count);
+ assertSame(first, log.throwable);
+ assertEquals("source:first", log.tags);
+ }
+
+ @Test
+ void keepsDifferentStackTracesSeparate() {
+ LogCollector logCollector = new LogCollector(2);
+
+ logCollector.addLogMessage("ERROR", "Message", throwableAtLine(10));
+ logCollector.addLogMessage("ERROR", "Message", throwableAtLine(20));
+
+ assertEquals(2, logCollector.drain().size());
+ }
+
+ @Test
+ void rawLogMessageEqualityMatchesDeduplication() {
+ LogCollector.RawLogMessage first =
+ new LogCollector.RawLogMessage("ERROR", "Message", throwableAtLine(10), "first", 1);
+ LogCollector.RawLogMessage equivalent =
+ new LogCollector.RawLogMessage("ERROR", "Message", throwableAtLine(10), "second", 2);
+ LogCollector.RawLogMessage different =
+ new LogCollector.RawLogMessage("ERROR", "Message", throwableAtLine(20), "first", 1);
+
+ assertEquals(first, equivalent);
+ assertEquals(first.hashCode(), equivalent.hashCode());
+ assertNotEquals(first, different);
+ }
+
+ @Test
+ void countsConcurrentDuplicates() throws Exception {
+ int threadCount = 16;
+ int messagesPerThread = 1_000;
+ LogCollector logCollector = new LogCollector(2);
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+ CountDownLatch start = new CountDownLatch(1);
+ Future>[] futures = new Future>[threadCount];
+ try {
+ for (int i = 0; i < threadCount; i++) {
+ futures[i] =
+ executor.submit(
+ () -> {
+ start.await();
+ for (int message = 0; message < messagesPerThread; message++) {
+ logCollector.addLogMessage("ERROR", "Message", null);
+ }
+ return null;
+ });
+ }
+ start.countDown();
+ for (Future> future : futures) {
+ future.get();
+ }
+ } finally {
+ executor.shutdownNow();
+ }
+
+ assertEquals(threadCount * messagesPerThread, onlyLog(logCollector.drain()).count);
+ }
+
+ @Test
+ void capsConcurrentDistinctMessages() throws Exception {
+ int capacity = 3;
+ int threadCount = 16;
+ LogCollector logCollector = new LogCollector(capacity);
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+ CountDownLatch start = new CountDownLatch(1);
+ Future>[] futures = new Future>[threadCount];
+ try {
+ for (int i = 0; i < threadCount; i++) {
+ String message = "Message " + i;
+ futures[i] =
+ executor.submit(
+ () -> {
+ start.await();
+ logCollector.addLogMessage("ERROR", message, null);
+ return null;
+ });
+ }
+ start.countDown();
+ for (Future> future : futures) {
+ future.get();
+ }
+ } finally {
+ executor.shutdownNow();
+ }
+
+ assertEquals(capacity, logCollector.drain().size());
+ }
+
+ private static Throwable throwableAtLine(int lineNumber) {
+ Throwable throwable = new IllegalStateException("ignored by deduplication");
+ throwable.setStackTrace(
+ new StackTraceElement[] {
+ new StackTraceElement("Example", "run", "Example.java", lineNumber)
+ });
+ return throwable;
+ }
+
+ private static LogCollector.RawLogMessage onlyLog(Collection logs) {
+ assertEquals(1, logs.size());
+ return logs.iterator().next();
+ }
+
+ private static void assertLog(
+ Collection logs, String message, int count) {
+ LogCollector.RawLogMessage log =
+ logs.stream()
+ .filter(candidate -> message.equals(candidate.message))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Missing log message: " + message));
+ assertEquals("ERROR", log.logLevel);
+ assertEquals(count, log.count);
+ }
+}
diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java
new file mode 100644
index 00000000000..540eb036c2f
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java
@@ -0,0 +1,506 @@
+package datadog.trace.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.Test;
+
+class ConcurrentHashtableD1Test {
+
+ @Test
+ void getReturnsMappedEntry() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ StringEntry e = table.tryGetOrCreateOrNull("hello", k -> new StringEntry(k, 42));
+ assertSame(e, table.get("hello"));
+ assertNull(table.get("world"));
+ }
+
+ @Test
+ void getOrCreateOnMissBuildsEntry() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ int[] createCount = {0};
+ StringEntry created =
+ table.tryGetOrCreateOrNull(
+ "a",
+ k -> {
+ createCount[0]++;
+ return new StringEntry(k, 1);
+ });
+ assertNotNull(created);
+ assertEquals(1, table.size());
+ assertEquals(1, createCount[0]);
+ assertSame(created, table.get("a"));
+ }
+
+ @Test
+ void getOrCreateOnHitSkipsCreator() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ StringEntry seeded = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 100));
+ int[] createCount = {0};
+ StringEntry got =
+ table.tryGetOrCreateOrNull(
+ "a",
+ k -> {
+ createCount[0]++;
+ return new StringEntry(k, 999);
+ });
+ assertSame(seeded, got);
+ assertEquals(1, table.size());
+ assertEquals(0, createCount[0]);
+ }
+
+ @Test
+ void nullKeyIsSupported() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ StringEntry e = table.tryGetOrCreateOrNull(null, k -> new StringEntry(k, 0));
+ assertNotNull(e);
+ assertSame(e, table.get(null));
+ }
+
+ @Test
+ void forEachVisitsAllEntries() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1));
+ table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2));
+ table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3));
+ Set seen = new HashSet<>();
+ table.forEach(e -> seen.add(e.key));
+ assertEquals(3, seen.size());
+ assertTrue(seen.contains("a"));
+ assertTrue(seen.contains("b"));
+ assertTrue(seen.contains("c"));
+ }
+
+ @Test
+ void forEachWithContextPassesContext() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ table.tryGetOrCreateOrNull("x", k -> new StringEntry(k, 10));
+ table.tryGetOrCreateOrNull("y", k -> new StringEntry(k, 20));
+ Set seen = new HashSet<>();
+ table.forEach(seen, (ctx, e) -> ctx.add(e.key));
+ assertEquals(2, seen.size());
+ assertTrue(seen.contains("x"));
+ assertTrue(seen.contains("y"));
+ }
+
+ @Test
+ void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ int threads = 16;
+ CountDownLatch ready = new CountDownLatch(threads);
+ CountDownLatch go = new CountDownLatch(1);
+ AtomicInteger createCount = new AtomicInteger();
+
+ Thread[] workers = new Thread[threads];
+ for (int i = 0; i < threads; i++) {
+ workers[i] =
+ new Thread(
+ () -> {
+ ready.countDown();
+ try {
+ go.await();
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ table.tryGetOrCreateOrNull(
+ "shared",
+ k -> {
+ createCount.incrementAndGet();
+ return new StringEntry(k, 1);
+ });
+ });
+ workers[i].start();
+ }
+ ready.await();
+ go.countDown();
+ for (Thread w : workers) {
+ w.join();
+ }
+
+ assertEquals(1, table.size());
+ assertEquals(1, createCount.get());
+ }
+
+ @Test
+ void chainedEntriesInSameBucketAreAllReachable() {
+ // All three keys share hash 0, so they land in the same bucket regardless of table size.
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 8);
+ CollidingKey a = new CollidingKey("a", 0);
+ CollidingKey b = new CollidingKey("b", 0); // same bucket as a
+ CollidingKey c = new CollidingKey("c", 0); // same bucket
+ CollidingEntry ea = table.tryGetOrCreateOrNull(a, CollidingEntry::new);
+ CollidingEntry eb = table.tryGetOrCreateOrNull(b, CollidingEntry::new);
+ CollidingEntry ec = table.tryGetOrCreateOrNull(c, CollidingEntry::new);
+ assertEquals(3, table.size());
+ assertSame(ea, table.get(a));
+ assertSame(eb, table.get(b));
+ assertSame(ec, table.get(c));
+ assertNull(table.get(new CollidingKey("d", 0))); // same bucket, different label → miss
+ }
+
+ @Test
+ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException {
+ int threads = 16;
+ String[] keys = new String[threads];
+ for (int i = 0; i < threads; i++) {
+ keys[i] = "key-" + i;
+ }
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, threads * 2);
+ CountDownLatch ready = new CountDownLatch(threads);
+ CountDownLatch go = new CountDownLatch(1);
+
+ Thread[] workers = new Thread[threads];
+ for (int i = 0; i < threads; i++) {
+ final String key = keys[i];
+ workers[i] =
+ new Thread(
+ () -> {
+ ready.countDown();
+ try {
+ go.await();
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ table.tryGetOrCreateOrNull(key, k -> new StringEntry(k, 1));
+ });
+ workers[i].start();
+ }
+ ready.await();
+ go.countDown();
+ for (Thread w : workers) {
+ w.join();
+ }
+
+ assertEquals(threads, table.size());
+ for (String key : keys) {
+ assertNotNull(table.get(key));
+ }
+ }
+
+ @Test
+ void removeReturnsEntryAndShrinks() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ StringEntry a = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1));
+ table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2));
+ assertSame(a, table.remove("a"));
+ assertEquals(1, table.size());
+ assertNull(table.get("a"));
+ assertNotNull(table.get("b"));
+ }
+
+ @Test
+ void removeAbsentKeyReturnsNull() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1));
+ assertNull(table.remove("missing"));
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void removeHeadMiddleAndTailOfSameBucketChain() {
+ // All three keys share hash 0, so a, b, c land in the same bucket and form one chain.
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 8);
+ CollidingKey a = new CollidingKey("a", 0);
+ CollidingKey b = new CollidingKey("b", 0);
+ CollidingKey c = new CollidingKey("c", 0);
+ table.tryGetOrCreateOrNull(a, CollidingEntry::new);
+ table.tryGetOrCreateOrNull(b, CollidingEntry::new);
+ table.tryGetOrCreateOrNull(c, CollidingEntry::new);
+
+ // Remove a middle element; the other two stay reachable.
+ assertNotNull(table.remove(b));
+ assertNull(table.get(b));
+ assertNotNull(table.get(a));
+ assertNotNull(table.get(c));
+ assertEquals(2, table.size());
+
+ // Drain the rest.
+ assertNotNull(table.remove(c));
+ assertNotNull(table.remove(a));
+ assertEquals(0, table.size());
+ assertNull(table.get(a));
+ }
+
+ @Test
+ void removeIfRemovesMatchingEntries() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 16);
+ for (int i = 0; i < 10; i++) {
+ final int v = i;
+ table.tryGetOrCreateOrNull("k" + i, k -> new StringEntry(k, v));
+ }
+ boolean removed = table.removeIf(e -> e.value % 2 == 0); // removes values 0,2,4,6,8
+ assertTrue(removed);
+ assertEquals(5, table.size());
+ Set seen = new HashSet<>();
+ table.forEach(e -> seen.add(e.key));
+ assertEquals(5, seen.size());
+ for (String key : seen) {
+ assertNotNull(table.get(key));
+ }
+ }
+
+ @Test
+ void removeIfReturnsFalseWhenNothingMatches() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1));
+ assertFalse(table.removeIf(e -> false));
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void clearEmptiesTableAndLeavesItUsable() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1));
+ table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2));
+ table.clear();
+ assertEquals(0, table.size());
+ assertNull(table.get("a"));
+ assertNull(table.get("b"));
+ StringEntry c = table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3));
+ assertSame(c, table.get("c"));
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void drainRemovesEveryEntryAndFeedsSink() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1));
+ table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2));
+ table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3));
+
+ Set drained = new HashSet<>();
+ int[] sum = {0};
+ table.drain(
+ e -> {
+ drained.add(e.key);
+ sum[0] += e.value;
+ });
+
+ assertEquals(new HashSet<>(Arrays.asList("a", "b", "c")), drained);
+ assertEquals(6, sum[0]);
+ assertEquals(0, table.size());
+ assertNull(table.get("a"));
+ // table remains usable after drain
+ StringEntry d = table.tryGetOrCreateOrNull("d", k -> new StringEntry(k, 4));
+ assertSame(d, table.get("d"));
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void drainWithContextFeedsSink() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1));
+ table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2));
+
+ Set drained = new HashSet<>();
+ table.drain(drained, (ctx, e) -> ctx.add(e.key));
+
+ assertEquals(new HashSet<>(Arrays.asList("a", "b")), drained);
+ assertEquals(0, table.size());
+ }
+
+ @Test
+ void drainOnEmptyTableInvokesSinkZeroTimes() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ int[] count = {0};
+ table.drain(e -> count[0]++);
+ assertEquals(0, count[0]);
+ assertEquals(0, table.size());
+ }
+
+ /**
+ * Exercises the volatile-{@code next} removal contract: while one key is repeatedly removed and
+ * re-added in a shared collision chain, the other keys in that chain must remain continuously
+ * visible to a concurrent lock-free reader.
+ */
+ @Test
+ void concurrentReadsStaySafeWhileOneChainMemberChurns() throws InterruptedException {
+ // All keys share hash 0, putting every key in one bucket so removal splices a chain the
+ // reader is walking.
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 16);
+ int n = 8;
+ CollidingKey[] keys = new CollidingKey[n];
+ for (int i = 0; i < n; i++) {
+ keys[i] = new CollidingKey("k" + i, 0);
+ table.tryGetOrCreateOrNull(keys[i], CollidingEntry::new);
+ }
+ CollidingKey churn = keys[0]; // keys[1..] are stable and must never vanish
+
+ AtomicBoolean stop = new AtomicBoolean(false);
+ AtomicInteger missed = new AtomicInteger();
+ Thread reader =
+ new Thread(
+ () -> {
+ while (!stop.get()) {
+ for (int i = 1; i < n; i++) {
+ if (table.get(keys[i]) == null) {
+ missed.incrementAndGet();
+ }
+ }
+ }
+ });
+ reader.start();
+ for (int r = 0; r < 100_000; r++) {
+ table.remove(churn);
+ table.tryGetOrCreateOrNull(churn, CollidingEntry::new);
+ }
+ stop.set(true);
+ reader.join();
+
+ assertEquals(0, missed.get(), "stable chain members must never be unreachable during removal");
+ }
+
+ @Test
+ void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 8);
+ Maybe created =
+ table.tryGetOrCreateOrEvict("a", k -> new StringEntry(k, 1), e -> true);
+ assertTrue(created.isPresent());
+ assertEquals(1, table.size());
+ assertSame(created.getOrNull(), table.get("a"));
+ }
+
+ @Test
+ void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 1);
+ StringEntry a = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1));
+ Maybe got =
+ table.tryGetOrCreateOrEvict(
+ "a",
+ k -> {
+ throw new AssertionError("creator must not run on a hit");
+ },
+ e -> {
+ throw new AssertionError("evictable must not run on a hit");
+ });
+ assertSame(a, got.getOrNull());
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void tryGetOrCreateOrEvictEvictsWhenFullAndInsertsNewEntry() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 1);
+ table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1));
+ assertTrue(table.isFull());
+
+ Maybe created =
+ table.tryGetOrCreateOrEvict("new", k -> new StringEntry(k, 2), e -> true);
+ assertTrue(created.isPresent());
+ assertEquals("new", created.getOrNull().key);
+ assertEquals(1, table.size());
+ assertNull(table.get("old"));
+ assertSame(created.getOrNull(), table.get("new"));
+ }
+
+ @Test
+ void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 1);
+ table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1));
+
+ StringEntry result =
+ table.tryGetOrCreateOrEvictOrNull("new", k -> new StringEntry(k, 2), e -> false);
+ assertNull(result);
+ assertEquals(1, table.size());
+ assertNotNull(table.get("old"));
+ assertNull(table.get("new"));
+ }
+
+ @Test
+ void tryGetOrCreateOrEvictOrNullEvictionRunsBeforeThrowingCreator() {
+ ConcurrentHashtable.D1 table =
+ ConcurrentHashtable.D1.createBounded(StringEntry.class, 1);
+ table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1));
+
+ assertThrows(
+ RuntimeException.class,
+ () ->
+ table.tryGetOrCreateOrEvictOrNull(
+ "new",
+ k -> {
+ throw new RuntimeException("boom");
+ },
+ e -> true));
+
+ // Eviction already happened before the creator threw: the table is left one entry smaller,
+ // not corrupted or double-booked.
+ assertEquals(0, table.size());
+ assertNull(table.get("old"));
+ assertNull(table.get("new"));
+ }
+
+ private static final class StringEntry extends ConcurrentHashtable.D1.Entry {
+ final int value;
+
+ StringEntry(String key, int value) {
+ super(key);
+ this.value = value;
+ }
+ }
+
+ /** Key with a fixed hashCode to force deterministic bucket placement. */
+ private static final class CollidingKey {
+ final String label;
+ final int fixedHash;
+
+ CollidingKey(String label, int fixedHash) {
+ this.label = label;
+ this.fixedHash = fixedHash;
+ }
+
+ @Override
+ public int hashCode() {
+ return fixedHash;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof CollidingKey)) {
+ return false;
+ }
+ CollidingKey that = (CollidingKey) o;
+ return fixedHash == that.fixedHash && label.equals(that.label);
+ }
+ }
+
+ private static final class CollidingEntry extends ConcurrentHashtable.D1.Entry {
+ CollidingEntry(CollidingKey key) {
+ super(key);
+ }
+ }
+}
diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java
new file mode 100644
index 00000000000..182cb4c4f25
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java
@@ -0,0 +1,399 @@
+package datadog.trace.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.Test;
+
+class ConcurrentHashtableD2Test {
+
+ @Test
+ void pairKeysParticipateInIdentity() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ PairEntry ab = table.tryGetOrCreateOrNull("a", 1, PairEntry::new);
+ PairEntry ac = table.tryGetOrCreateOrNull("a", 2, PairEntry::new);
+ PairEntry bb = table.tryGetOrCreateOrNull("b", 1, PairEntry::new);
+ assertEquals(3, table.size());
+ assertSame(ab, table.get("a", 1));
+ assertSame(ac, table.get("a", 2));
+ assertSame(bb, table.get("b", 1));
+ assertNull(table.get("a", 3));
+ }
+
+ @Test
+ void getOrCreateOnMissBuildsEntryViaCreator() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ int[] createCount = {0};
+ PairEntry created =
+ table.tryGetOrCreateOrNull(
+ "a",
+ 1,
+ (k1, k2) -> {
+ createCount[0]++;
+ return new PairEntry(k1, k2);
+ });
+ assertNotNull(created);
+ assertEquals("a", created.key1);
+ assertEquals(Integer.valueOf(1), created.key2);
+ assertEquals(1, table.size());
+ assertEquals(1, createCount[0]);
+ assertSame(created, table.get("a", 1));
+ }
+
+ @Test
+ void getOrCreateOnHitSkipsCreator() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ PairEntry seeded = table.tryGetOrCreateOrNull("a", 1, PairEntry::new);
+ int[] createCount = {0};
+ PairEntry got =
+ table.tryGetOrCreateOrNull(
+ "a",
+ 1,
+ (k1, k2) -> {
+ createCount[0]++;
+ return new PairEntry(k1, k2);
+ });
+ assertSame(seeded, got);
+ assertEquals(1, table.size());
+ assertEquals(0, createCount[0]);
+ }
+
+ @Test
+ void forEachVisitsBothPairs() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", 1, PairEntry::new);
+ table.tryGetOrCreateOrNull("b", 2, PairEntry::new);
+ Set seen = new HashSet<>();
+ table.forEach(e -> seen.add(e.key1 + ":" + e.key2));
+ assertEquals(2, seen.size());
+ assertTrue(seen.contains("a:1"));
+ assertTrue(seen.contains("b:2"));
+ }
+
+ @Test
+ void forEachWithContextPassesContextToConsumer() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", 1, PairEntry::new);
+ table.tryGetOrCreateOrNull("b", 2, PairEntry::new);
+ Set seen = new HashSet<>();
+ table.forEach(seen, (ctx, e) -> ctx.add(e.key1 + ":" + e.key2));
+ assertEquals(2, seen.size());
+ assertTrue(seen.contains("a:1"));
+ assertTrue(seen.contains("b:2"));
+ }
+
+ @Test
+ void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ int threads = 16;
+ CountDownLatch ready = new CountDownLatch(threads);
+ CountDownLatch go = new CountDownLatch(1);
+ AtomicInteger createCount = new AtomicInteger();
+
+ Thread[] workers = new Thread[threads];
+ for (int i = 0; i < threads; i++) {
+ workers[i] =
+ new Thread(
+ () -> {
+ ready.countDown();
+ try {
+ go.await();
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ table.tryGetOrCreateOrNull(
+ "shared",
+ 42,
+ (k1, k2) -> {
+ createCount.incrementAndGet();
+ return new PairEntry(k1, k2);
+ });
+ });
+ workers[i].start();
+ }
+ ready.await();
+ go.countDown();
+ for (Thread w : workers) {
+ w.join();
+ }
+
+ assertEquals(1, table.size());
+ assertEquals(1, createCount.get());
+ }
+
+ @Test
+ void chainedEntriesInSameBucketAreAllReachable() {
+ // key2 = -31 * key1.hashCode() zeroes the combined hash, so all four land in bucket 0
+ // regardless of table size.
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ PairEntry e1 = table.tryGetOrCreateOrNull("a", -31 * "a".hashCode(), PairEntry::new);
+ PairEntry e2 = table.tryGetOrCreateOrNull("b", -31 * "b".hashCode(), PairEntry::new);
+ PairEntry e3 = table.tryGetOrCreateOrNull("c", -31 * "c".hashCode(), PairEntry::new);
+ PairEntry e4 = table.tryGetOrCreateOrNull("d", -31 * "d".hashCode(), PairEntry::new);
+ assertEquals(4, table.size());
+ assertSame(e1, table.get("a", -31 * "a".hashCode()));
+ assertSame(e2, table.get("b", -31 * "b".hashCode()));
+ assertSame(e3, table.get("c", -31 * "c".hashCode()));
+ assertSame(e4, table.get("d", -31 * "d".hashCode()));
+ assertNull(table.get("a", 3));
+ }
+
+ @Test
+ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException {
+ int threads = 16;
+ String[] k1s = new String[threads];
+ Integer[] k2s = new Integer[threads];
+ for (int i = 0; i < threads; i++) {
+ k1s[i] = "key-" + i;
+ k2s[i] = i;
+ }
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, threads * 2);
+ CountDownLatch ready = new CountDownLatch(threads);
+ CountDownLatch go = new CountDownLatch(1);
+
+ Thread[] workers = new Thread[threads];
+ for (int i = 0; i < threads; i++) {
+ final String k1 = k1s[i];
+ final Integer k2 = k2s[i];
+ workers[i] =
+ new Thread(
+ () -> {
+ ready.countDown();
+ try {
+ go.await();
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ table.tryGetOrCreateOrNull(k1, k2, PairEntry::new);
+ });
+ workers[i].start();
+ }
+ ready.await();
+ go.countDown();
+ for (Thread w : workers) {
+ w.join();
+ }
+
+ assertEquals(threads, table.size());
+ for (int i = 0; i < threads; i++) {
+ assertNotNull(table.get(k1s[i], k2s[i]));
+ }
+ }
+
+ @Test
+ void removeReturnsEntryAndShrinks() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ PairEntry ab = table.tryGetOrCreateOrNull("a", 1, PairEntry::new);
+ table.tryGetOrCreateOrNull("a", 2, PairEntry::new);
+ assertSame(ab, table.remove("a", 1));
+ assertEquals(1, table.size());
+ assertNull(table.get("a", 1));
+ assertNotNull(table.get("a", 2));
+ }
+
+ @Test
+ void removeAbsentKeyReturnsNull() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", 1, PairEntry::new);
+ assertNull(table.remove("a", 99));
+ assertNull(table.remove("z", 1));
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void removeMiddleOfSameBucketChainKeepsOthersReachable() {
+ // key2 = -31 * key1.hashCode() zeroes the combined hash, so all three land in one bucket
+ // chain regardless of table size.
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", -31 * "a".hashCode(), PairEntry::new);
+ PairEntry mid = table.tryGetOrCreateOrNull("b", -31 * "b".hashCode(), PairEntry::new);
+ table.tryGetOrCreateOrNull("c", -31 * "c".hashCode(), PairEntry::new);
+
+ assertSame(mid, table.remove("b", -31 * "b".hashCode()));
+ assertNull(table.get("b", -31 * "b".hashCode()));
+ assertNotNull(table.get("a", -31 * "a".hashCode()));
+ assertNotNull(table.get("c", -31 * "c".hashCode()));
+ assertEquals(2, table.size());
+ }
+
+ @Test
+ void removeIfRemovesMatchingEntries() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 16);
+ for (int i = 0; i < 10; i++) {
+ table.tryGetOrCreateOrNull("k", i, PairEntry::new);
+ }
+ boolean removed = table.removeIf(e -> e.key2 % 2 == 0); // removes key2 0,2,4,6,8
+ assertTrue(removed);
+ assertEquals(5, table.size());
+ Set seen = new HashSet<>();
+ table.forEach(e -> seen.add(e.key1 + ":" + e.key2));
+ assertEquals(5, seen.size());
+ }
+
+ @Test
+ void removeIfReturnsFalseWhenNothingMatches() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", 1, PairEntry::new);
+ assertFalse(table.removeIf(e -> false));
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void clearEmptiesTableAndLeavesItUsable() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", 1, PairEntry::new);
+ table.tryGetOrCreateOrNull("b", 2, PairEntry::new);
+ table.clear();
+ assertEquals(0, table.size());
+ assertNull(table.get("a", 1));
+ PairEntry c = table.tryGetOrCreateOrNull("c", 3, PairEntry::new);
+ assertSame(c, table.get("c", 3));
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void drainRemovesEveryEntryAndFeedsSink() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", 1, PairEntry::new);
+ table.tryGetOrCreateOrNull("a", 2, PairEntry::new);
+ table.tryGetOrCreateOrNull("b", 1, PairEntry::new);
+
+ Set drained = new HashSet<>();
+ table.drain(e -> drained.add(e.key1 + ":" + e.key2));
+
+ assertEquals(new HashSet<>(Arrays.asList("a:1", "a:2", "b:1")), drained);
+ assertEquals(0, table.size());
+ assertNull(table.get("a", 1));
+ PairEntry c = table.tryGetOrCreateOrNull("c", 3, PairEntry::new);
+ assertSame(c, table.get("c", 3));
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void drainWithContextFeedsSink() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ table.tryGetOrCreateOrNull("a", 1, PairEntry::new);
+ table.tryGetOrCreateOrNull("b", 2, PairEntry::new);
+
+ Set drained = new HashSet<>();
+ table.drain(drained, (ctx, e) -> ctx.add(e.key1 + ":" + e.key2));
+
+ assertEquals(new HashSet<>(Arrays.asList("a:1", "b:2")), drained);
+ assertEquals(0, table.size());
+ }
+
+ @Test
+ void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 8);
+ Maybe created = table.tryGetOrCreateOrEvict("a", 1, PairEntry::new, e -> true);
+ assertTrue(created.isPresent());
+ assertEquals(1, table.size());
+ assertSame(created.getOrNull(), table.get("a", 1));
+ }
+
+ @Test
+ void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 1);
+ PairEntry a = table.tryGetOrCreateOrNull("a", 1, PairEntry::new);
+ Maybe got =
+ table.tryGetOrCreateOrEvict(
+ "a",
+ 1,
+ (k1, k2) -> {
+ throw new AssertionError("creator must not run on a hit");
+ },
+ e -> {
+ throw new AssertionError("evictable must not run on a hit");
+ });
+ assertSame(a, got.getOrNull());
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void tryGetOrCreateOrEvictEvictsWhenFullAndInsertsNewEntry() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 1);
+ table.tryGetOrCreateOrNull("old", 1, PairEntry::new);
+ assertTrue(table.isFull());
+
+ Maybe created = table.tryGetOrCreateOrEvict("new", 2, PairEntry::new, e -> true);
+ assertTrue(created.isPresent());
+ assertEquals("new", created.getOrNull().key1);
+ assertEquals(1, table.size());
+ assertNull(table.get("old", 1));
+ assertSame(created.getOrNull(), table.get("new", 2));
+ }
+
+ @Test
+ void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 1);
+ table.tryGetOrCreateOrNull("old", 1, PairEntry::new);
+
+ PairEntry result = table.tryGetOrCreateOrEvictOrNull("new", 2, PairEntry::new, e -> false);
+ assertNull(result);
+ assertEquals(1, table.size());
+ assertNotNull(table.get("old", 1));
+ assertNull(table.get("new", 2));
+ }
+
+ @Test
+ void tryGetOrCreateOrEvictOrNullEvictionRunsBeforeThrowingCreator() {
+ ConcurrentHashtable.D2 table =
+ ConcurrentHashtable.D2.createBounded(PairEntry.class, 1);
+ table.tryGetOrCreateOrNull("old", 1, PairEntry::new);
+
+ assertThrows(
+ RuntimeException.class,
+ () ->
+ table.tryGetOrCreateOrEvictOrNull(
+ "new",
+ 2,
+ (k1, k2) -> {
+ throw new RuntimeException("boom");
+ },
+ e -> true));
+
+ // Eviction already happened before the creator threw: the table is left one entry smaller,
+ // not corrupted or double-booked.
+ assertEquals(0, table.size());
+ assertNull(table.get("old", 1));
+ assertNull(table.get("new", 2));
+ }
+
+ private static final class PairEntry extends ConcurrentHashtable.D2.Entry {
+ PairEntry(String key1, Integer key2) {
+ super(key1, key2);
+ }
+ }
+}
diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java
new file mode 100644
index 00000000000..24c581b2380
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java
@@ -0,0 +1,431 @@
+package datadog.trace.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import datadog.trace.test.util.PollingConditions;
+import java.util.function.Predicate;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Exercises {@link ConcurrentHashtable.SizeManager} and {@link ConcurrentHashtable.State} against a
+ * {@link ConcurrentHashtable.State}, the same shape a custom table driving the static building
+ * blocks would use. {@link ConcurrentHashtableD1Test} and {@link ConcurrentHashtableD2Test} cover
+ * the eviction-aware {@code tryGetOrCreateOrEvict} methods built on top of this.
+ */
+class ConcurrentHashtableSizeManagerTest {
+
+ @Test
+ void tryReserveSucceedsUnderCapacityAndFailsWhenFull() {
+ ConcurrentHashtable.SizeManager sizeManager = new ConcurrentHashtable.SizeManager(2);
+ assertEquals(0, sizeManager.estimateSize());
+ assertFalse(sizeManager.isFull());
+
+ assertTrue(sizeManager.tryReserve());
+ assertEquals(1, sizeManager.estimateSize());
+ assertFalse(sizeManager.isFull());
+
+ assertTrue(sizeManager.tryReserve());
+ assertEquals(2, sizeManager.estimateSize());
+ assertTrue(sizeManager.isFull());
+
+ assertFalse(sizeManager.tryReserve());
+ assertEquals(2, sizeManager.estimateSize());
+ }
+
+ @Test
+ void tryReserveOrEvictReservesDirectlyWhenUnderCapacity() {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 2);
+
+ boolean reserved = tryReserveOrEvict(state, e -> true);
+ assertTrue(reserved);
+ assertEquals(1, state.sizeManager.estimateSize());
+ assertNull(state.buckets.get(0)); // nothing was evicted
+ }
+
+ @Test
+ void tryReserveOrEvictEvictsWhenFullAndSomethingMatches() {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 1);
+ TestEntry existing = insertAt(state, 0, "existing");
+ assertTrue(state.sizeManager.tryReserve());
+ assertTrue(state.sizeManager.isFull());
+
+ boolean reserved = tryReserveOrEvict(state, e -> true);
+ assertTrue(reserved);
+ assertEquals(1, state.sizeManager.estimateSize()); // one evicted, one reserved: net unchanged
+ assertNull(state.buckets.get(0)); // existing was unlinked
+ }
+
+ @Test
+ void tryReserveOrEvictFailsAndLeavesTableUntouchedWhenNothingEvictable() {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 1);
+ TestEntry existing = insertAt(state, 0, "existing");
+ assertTrue(state.sizeManager.tryReserve());
+
+ boolean reserved = tryReserveOrEvict(state, e -> false);
+ assertFalse(reserved);
+ assertEquals(1, state.sizeManager.estimateSize());
+ assertSame(existing, state.buckets.get(0));
+ }
+
+ @Test
+ void insertReservedSplicesWithoutTouchingTheCountAfterATryReserve() {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 2);
+ assertTrue(state.sizeManager.tryReserve());
+ assertEquals(1, state.sizeManager.estimateSize());
+
+ TestEntry entry = new TestEntry(0, "reserved");
+ synchronized (ConcurrentHashtable.getTableWriteLock(state)) {
+ ConcurrentHashtable.insertReserved(state, entry.keyHash, entry);
+ }
+
+ assertSame(entry, state.buckets.get(0));
+ // Count reflects only the earlier tryReserve() -- insertReserved must not increment again.
+ assertEquals(1, state.sizeManager.estimateSize());
+ }
+
+ @Test
+ void evictOneReturnsNullAndLeavesCountUnchangedWhenNothingMatches() {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 4);
+ insertAt(state, 0, "a");
+ state.sizeManager.increment();
+
+ TestEntry evicted = evictOne(state, e -> false);
+ assertNull(evicted);
+ assertEquals(1, state.sizeManager.estimateSize());
+ }
+
+ @Test
+ void evictOneUnlinksMatchAndDecrementsCount() {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 4);
+ TestEntry a = insertAt(state, 0, "a");
+ TestEntry b = insertAt(state, 1, "b");
+ state.sizeManager.increment();
+ state.sizeManager.increment();
+
+ TestEntry evicted = evictOne(state, e -> e.label.equals("a"));
+ assertSame(a, evicted);
+ assertNull(state.buckets.get(0));
+ assertSame(b, state.buckets.get(1)); // untouched
+ assertEquals(1, state.sizeManager.estimateSize());
+ }
+
+ /**
+ * Verifies the cursor-resume contract from {@link ConcurrentHashtable.SizeManager#evictOne}: each
+ * scan resumes where the previous eviction left off, so among several equally-matching candidates
+ * the one nearest (forward from the cursor, wrapping) is picked first -- not always the lowest
+ * bucket index.
+ */
+ @Test
+ void evictOneResumesFromLastEvictedBucketAndWrapsAround() {
+ // Bucket-array length 4: keyHash i lands in bucket i.
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 4);
+ TestEntry e0 = insertAt(state, 0, "e0");
+ insertAt(state, 2, "e2");
+ TestEntry e3 = insertAt(state, 3, "e3");
+ state.sizeManager.increment();
+ state.sizeManager.increment();
+ state.sizeManager.increment();
+
+ // First eviction: scan starts at cursor 0, finds bucket 2 first among evictable entries
+ // (only e2 matches here) -- sets the cursor to 2.
+ TestEntry firstEvicted = evictOne(state, e -> e.label.equals("e2"));
+ assertEquals("e2", firstEvicted.label);
+
+ // Second eviction: both e0 (bucket 0) and e3 (bucket 3) match. Scanning resumes at the
+ // cursor (2) and goes forward before wrapping, so bucket 3 (e3) is found before bucket 0.
+ TestEntry secondEvicted = evictOne(state, e -> true);
+ assertSame(e3, secondEvicted);
+ assertSame(e0, state.buckets.get(0)); // e0 not yet touched
+
+ // Third eviction: only e0 remains. The cursor is now past bucket 3, so the scan must wrap
+ // around to bucket 0 to find it.
+ TestEntry thirdEvicted = evictOne(state, e -> true);
+ assertSame(e0, thirdEvicted);
+ assertEquals(0, state.sizeManager.estimateSize());
+ }
+
+ @Test
+ void evictAllRemovesEveryMatchAndReturnsCount() {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 8);
+ for (int i = 0; i < 6; i++) {
+ insertAt(state, i, "e" + i);
+ state.sizeManager.increment();
+ }
+ // Evict everything except bucket 1 and bucket 4.
+ int count = evictAll(state, e -> !e.label.equals("e1") && !e.label.equals("e4"));
+
+ assertEquals(4, count);
+ assertEquals(2, state.sizeManager.estimateSize());
+ assertNotNullLabel(state, 1, "e1");
+ assertNotNullLabel(state, 4, "e4");
+ for (int i : new int[] {0, 2, 3, 5}) {
+ assertNull(state.buckets.get(i));
+ }
+ }
+
+ @Test
+ void evictAllResetsCursorSoSubsequentEvictOneScansFromBucketZero() {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 4);
+ insertAt(state, 2, "a");
+ state.sizeManager.increment();
+ // Advance the cursor away from 0 via a successful eviction at bucket 2.
+ evictOne(state, e -> true);
+
+ // A full pass that removes nothing still resets the scan position (per evictAll's contract).
+ int count = evictAll(state, e -> false);
+ assertEquals(0, count);
+
+ TestEntry e0 = insertAt(state, 0, "e0");
+ TestEntry e3 = insertAt(state, 3, "e3");
+ state.sizeManager.increment();
+ state.sizeManager.increment();
+
+ // With the cursor reset to 0, the forward scan reaches bucket 0 before bucket 3.
+ TestEntry evicted = evictOne(state, e -> true);
+ assertSame(e0, evicted);
+ assertSame(e3, state.buckets.get(3));
+ }
+
+ @Test
+ void releaseGivesBackRemovedSlotsAndRestartsScan() {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 4);
+ insertAt(state, 2, "a");
+ state.sizeManager.increment();
+ evictOne(state, e -> true); // advances the cursor to 2, count back to 0
+ state.sizeManager.increment(); // pretend a fresh entry was inserted
+
+ // One entry is live and counted; releasing that one slot brings the count back to zero and
+ // restarts the scan -- unlike a blanket zeroing, this only gives back what was removed.
+ state.sizeManager.release(1);
+ assertEquals(0, state.sizeManager.estimateSize());
+
+ TestEntry e0 = insertAt(state, 0, "e0");
+ TestEntry e3 = insertAt(state, 3, "e3");
+ state.sizeManager.increment();
+ state.sizeManager.increment();
+ TestEntry evicted = evictOne(state, e -> true);
+ assertSame(e0, evicted); // scan restarted from bucket 0, per release()
+ assertSame(e3, state.buckets.get(3));
+ }
+
+ @Test
+ void stateCreateCappedBundlesBucketsAndSizeManager() {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 3);
+ assertEquals(0, state.sizeManager.estimateSize());
+ assertEquals(3, state.sizeManager.capacity());
+ assertTrue(state.buckets.length() >= 3);
+ }
+
+ @Test
+ void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 1);
+ synchronized (ConcurrentHashtable.getWriteLockAt(state, 0)) {
+ ConcurrentHashtable.insertHeadEntryAt(state, 0, new TestEntry(0, "a"));
+ }
+ state.sizeManager.increment();
+ assertTrue(ConcurrentHashtable.isFull(state));
+
+ // Table is full: tryReserveOrEvict evicts "a" and reserves the freed slot for the caller, who
+ // is now responsible for splicing in the entry that occupies it -- via insertReserved, since
+ // the reservation already happened and a plain insertHeadEntryAt/increment would double-count.
+ // Both steps go in ONE critical section: tryReserveOrEvict is self-locking, so on its own it
+ // leaves a window where a drain/clear could reset the count out from under the reservation.
+ synchronized (ConcurrentHashtable.getTableWriteLock(state)) {
+ boolean reserved = ConcurrentHashtable.tryReserveOrEvict(state, e -> true);
+ assertTrue(reserved);
+ assertEquals(1, ConcurrentHashtable.estimateSize(state));
+ assertNull(state.buckets.get(0)); // "a" was evicted; the reserved slot has no entry yet
+ ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved"));
+ }
+
+ int evicted = ConcurrentHashtable.evictAll(state, e -> true);
+ assertEquals(1, evicted);
+ assertEquals(0, ConcurrentHashtable.estimateSize(state));
+ assertFalse(ConcurrentHashtable.isFull(state));
+
+ synchronized (ConcurrentHashtable.getWriteLockAt(state, 0)) {
+ ConcurrentHashtable.insertHeadEntryAt(state, 0, new TestEntry(0, "b"));
+ }
+ state.sizeManager.increment();
+ TestEntry viaEvictOne = ConcurrentHashtable.evictOne(state, e -> e.label.equals("b"));
+ assertNotNull(viaEvictOne);
+ assertEquals("b", viaEvictOne.label);
+ assertEquals(0, ConcurrentHashtable.estimateSize(state));
+ }
+
+ /**
+ * Holding the table lock across {@code tryReserveOrEvict} + {@code insertReserved} still keeps a
+ * concurrent {@link ConcurrentHashtable#clear(ConcurrentHashtable.State)} out of the gap -- the
+ * belt-and-braces version of the protocol. {@link
+ * #reservationSurvivesAClearLandingBetweenReserveAndInsert()} covers the case that matters more
+ * now: the pair does not actually need one critical section.
+ */
+ @Test
+ void clearCannotInterleaveBetweenReservationAndInsert() throws InterruptedException {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 1);
+ insertAt(state, 0, "a");
+ state.sizeManager.increment();
+ assertTrue(ConcurrentHashtable.isFull(state));
+
+ Thread clearer = new Thread(() -> ConcurrentHashtable.clear(state), "clearer");
+ synchronized (ConcurrentHashtable.getTableWriteLock(state)) {
+ clearer.start();
+ // Wait until the clear is definitely queued on the monitor we hold, so the interleaving under
+ // test is the one actually attempted rather than one the scheduler happened to avoid.
+ new PollingConditions()
+ .eventually(() -> assertEquals(Thread.State.BLOCKED, clearer.getState()));
+
+ assertTrue(ConcurrentHashtable.tryReserveOrEvict(state, e -> true));
+ ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved"));
+ assertEquals(1, ConcurrentHashtable.estimateSize(state));
+ }
+ clearer.join();
+
+ // The clear ran strictly after the pair, so the table is exactly post-clear: no entry, no
+ // count, and -- the point -- the two agree.
+ assertEquals(0, ConcurrentHashtable.estimateSize(state));
+ assertNull(state.buckets.get(0));
+ assertFalse(ConcurrentHashtable.isFull(state));
+ }
+
+ /**
+ * A clear landing squarely between the reservation and the insert must not void the reservation.
+ * It cannot, because {@link ConcurrentHashtable.SizeManager#release(int)} subtracts what the
+ * sweep removed instead of zeroing the count -- so the claim taken before the clear is still a
+ * claim after it, and the entry the caller then links is accounted for.
+ *
+ * Zeroing would leave the count one below reality here, permanently: eviction decrements too,
+ * so nothing later repairs it, and a capped table admits one extra entry from then on.
+ */
+ @Test
+ void reservationSurvivesAClearLandingBetweenReserveAndInsert() {
+ ConcurrentHashtable.State state =
+ ConcurrentHashtable.State.createBounded(TestEntry.class, 2);
+ insertAt(state, 0, "a");
+ state.sizeManager.increment();
+
+ // Reserve, with no lock held across what follows.
+ assertTrue(ConcurrentHashtable.tryReserveOrEvict(state, e -> false));
+ assertEquals(2, ConcurrentHashtable.estimateSize(state)); // "a" plus our reservation
+
+ // The sweep lands in the gap, removing the one entry that exists. Our slot is not its to give
+ // back, so the count drops by exactly one.
+ ConcurrentHashtable.clear(state);
+ assertEquals(1, ConcurrentHashtable.estimateSize(state));
+
+ // The reservation is still good, and filling it leaves the count matching the entries present.
+ synchronized (ConcurrentHashtable.getWriteLockAt(state, 0)) {
+ ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved"));
+ }
+ assertEquals(1, ConcurrentHashtable.estimateSize(state));
+ assertNotNullLabel(state, 0, "reserved");
+ assertFalse(ConcurrentHashtable.isFull(state));
+ }
+
+ /**
+ * Two reservers racing at the cap cannot both get through: {@code tryReserve} claims first and
+ * refunds on overshoot, so the count is never left above {@link
+ * ConcurrentHashtable.SizeManager#capacity()} once both have finished -- and it achieves that
+ * without a lock.
+ */
+ @Test
+ void concurrentReserversCannotBothPassTheCap() throws InterruptedException {
+ for (int attempt = 0; attempt < 200; attempt++) {
+ ConcurrentHashtable.SizeManager sizeManager = new ConcurrentHashtable.SizeManager(1);
+ java.util.concurrent.atomic.AtomicInteger granted =
+ new java.util.concurrent.atomic.AtomicInteger();
+ java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1);
+ Runnable reserve =
+ () -> {
+ try {
+ start.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ if (sizeManager.tryReserve()) {
+ granted.incrementAndGet();
+ }
+ };
+ Thread t1 = new Thread(reserve, "reserve-1");
+ Thread t2 = new Thread(reserve, "reserve-2");
+ t1.start();
+ t2.start();
+ start.countDown();
+ t1.join();
+ t2.join();
+
+ assertEquals(1, granted.get(), "exactly one reserver should win a capacity of 1");
+ assertEquals(1, sizeManager.estimateSize());
+ }
+ }
+
+ private static void assertNotNullLabel(
+ ConcurrentHashtable.State state, int index, String label) {
+ TestEntry e = state.buckets.get(index);
+ assertNotNull(e);
+ assertEquals(label, e.label);
+ }
+
+ /** Inserts a fresh entry at the given bucket index. Bucket-array length must exceed index. */
+ private static TestEntry insertAt(
+ ConcurrentHashtable.State state, int index, String label) {
+ TestEntry entry = new TestEntry(index, label);
+ synchronized (ConcurrentHashtable.getWriteLockAt(state, index)) {
+ ConcurrentHashtable.insertHeadEntryAt(state, index, entry);
+ }
+ return entry;
+ }
+
+ /** {@code sizeManager.tryReserveOrEvict}, taking the write lock {@code @GuardedBy} requires. */
+ private static boolean tryReserveOrEvict(
+ ConcurrentHashtable.State state, Predicate evictable) {
+ synchronized (ConcurrentHashtable.getTableWriteLock(state)) {
+ return state.sizeManager.tryReserveOrEvict(state.buckets, evictable);
+ }
+ }
+
+ /** {@code sizeManager.evictOne}, taking the write lock {@code @GuardedBy} requires. */
+ private static TestEntry evictOne(
+ ConcurrentHashtable.State state, Predicate evictable) {
+ synchronized (ConcurrentHashtable.getTableWriteLock(state)) {
+ return state.sizeManager.evictOne(state.buckets, evictable);
+ }
+ }
+
+ /** {@code sizeManager.evictAll}, taking the write lock {@code @GuardedBy} requires. */
+ private static int evictAll(
+ ConcurrentHashtable.State state, Predicate evictable) {
+ synchronized (ConcurrentHashtable.getTableWriteLock(state)) {
+ return state.sizeManager.evictAll(state.buckets, evictable);
+ }
+ }
+
+ /** Entry with a caller-controlled {@code keyHash} so tests can place it in an exact bucket. */
+ private static final class TestEntry extends ConcurrentHashtable.Entry {
+ final String label;
+
+ TestEntry(long keyHash, String label) {
+ super(keyHash);
+ this.label = label;
+ }
+ }
+}
diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java
new file mode 100644
index 00000000000..8191e396e14
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java
@@ -0,0 +1,429 @@
+package datadog.trace.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReferenceArray;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Exercises the {@code static} building blocks that {@link ConcurrentHashtable} exposes for the
+ * caller-owned-array path — the custom-table API used when {@link ConcurrentHashtable.D1}/{@link
+ * ConcurrentHashtable.D2}'s object-key constraints don't fit (primitive keys, higher arity, extra
+ * per-entry fields). {@link IntTable} below is a minimal hand-written table with a primitive {@code
+ * int} key, driving the same lock-free-read / locked-write recipe the class Javadoc documents.
+ */
+class ConcurrentHashtableStaticsTest {
+
+ @Test
+ void sizeForRoundsUpToPowerOfTwo() {
+ assertEquals(1, ConcurrentHashtable.sizeFor(1));
+ assertEquals(8, ConcurrentHashtable.sizeFor(5));
+ assertEquals(8, ConcurrentHashtable.sizeFor(8));
+ assertEquals(16, ConcurrentHashtable.sizeFor(9));
+ }
+
+ @Test
+ void createFixedBucketsAllocatesPowerOfTwoSpine() {
+ AtomicReferenceArray buckets =
+ ConcurrentHashtable.createFixedBuckets(IntEntry.class, 10);
+ assertEquals(16, buckets.length());
+ assertNull(buckets.get(0));
+ }
+
+ @Test
+ void getWriteLockIsStableAndNonNull() {
+ AtomicReferenceArray buckets =
+ ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8);
+ Object lock = ConcurrentHashtable.getWriteLock(buckets, 3L);
+ assertNotNull(lock);
+ assertSame(lock, ConcurrentHashtable.getWriteLock(buckets, 3L));
+ // The keyHash form is defined as the index form under bucketIndex.
+ assertSame(
+ lock,
+ ConcurrentHashtable.getWriteLockAt(buckets, ConcurrentHashtable.bucketIndex(buckets, 3L)));
+ }
+
+ /**
+ * The three accessors name three scopes, but a single-lock table answers all of them with one
+ * monitor. Asserting that pins today's granularity as a deliberate choice rather than an
+ * accident: if it ever changes, this is the test that says so, and callers that asked for the
+ * scope they actually mutate keep working.
+ */
+ @Test
+ void allWriteLockScopesResolveToOneMonitorToday() {
+ AtomicReferenceArray