From 6deffb98c1afbff3fabc419df2e8e0ea7a296875 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 18 Jun 2026 11:44:31 -0400 Subject: [PATCH 01/31] feat(util): add ConcurrentHashtable with lock-free D1/D2 composite-key tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors Hashtable's D1/D2 API with concurrent access guarantees: lock-free get via AtomicReferenceArray volatile reads, synchronized getOrCreate with double-checked re-read on miss. Eliminates composite key object allocation on hot read paths — the same structural advantage Hashtable.D2 has over HashMap,V>, but thread-safe. Co-Authored-By: Claude Sonnet 4.6 --- .../trace/util/ConcurrentHashtable.java | 212 ++++++++++++++++++ .../trace/util/ConcurrentHashtableD1Test.java | 141 ++++++++++++ .../trace/util/ConcurrentHashtableD2Test.java | 137 +++++++++++ 3 files changed, 490 insertions(+) create mode 100644 internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java create mode 100644 internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java create mode 100644 internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java 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..1e59bd4bf13 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -0,0 +1,212 @@ +package datadog.trace.util; + +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; + +/** + * Concurrent counterpart to {@link Hashtable}. Provides lock-free reads and locked writes for + * {@link D1} (single-key) and {@link D2} (composite-key) tables. + * + *

Like {@link Hashtable}, capacity is fixed at construction and the table does not resize. + * Unlike {@link Hashtable}, all operations are safe for concurrent access without external + * synchronization. + * + *

The primary advantage over {@link java.util.concurrent.ConcurrentHashMap} for composite-key + * use cases is that {@link D2#get(Object, Object)} and {@link D2#getOrCreate(Object, Object, + * BiFunction)} accept key parts directly — no composite key object is allocated for the lookup. + * {@code ConcurrentHashMap} requires a wrapper object whose ownership may transfer to the map on + * insert; escape analysis must conservatively assume the key escapes even on hit paths, preventing + * scalar replacement. + * + *

Memory model. Bucket slots are held in an {@link AtomicReferenceArray}, so each {@link + * #get} begins with a volatile read of the slot. Entries are inserted at the bucket head: the + * new entry's {@code next} pointer is set before the volatile slot write, so any subsequent + * volatile read of that slot carries happens-before over the full chain — chain {@code next} + * fields do not need to be volatile. + */ +public final class ConcurrentHashtable { + private ConcurrentHashtable() {} + + /** + * Single-key concurrent hash table. Lock-free on hit; locked on miss. + * + * @param the key type + * @param the user's {@link Hashtable.D1.Entry D1.Entry<K>} subclass + */ + public static final class D1> { + + private final AtomicReferenceArray buckets; + private final AtomicInteger size = new AtomicInteger(); + + public D1(int capacity) { + this.buckets = new AtomicReferenceArray<>(Hashtable.Support.sizeFor(capacity)); + } + + public int size() { + return size.get(); + } + + @SuppressWarnings("unchecked") + public TEntry get(K key) { + long keyHash = Hashtable.D1.Entry.hash(key); + for (TEntry te = (TEntry) buckets.get(bucketIndex(keyHash)); te != null; te = te.next()) { + if (te.keyHash == keyHash && te.matches(key)) { + return te; + } + } + return null; + } + + /** + * Returns the entry for {@code key}, creating one via {@code creator} if absent. Lock-free on + * hit; acquires a table-level lock on miss. Re-checks under the lock to avoid duplicate + * entries under concurrent misses. + */ + @SuppressWarnings("unchecked") + public TEntry getOrCreate(K key, Function creator) { + long keyHash = Hashtable.D1.Entry.hash(key); + int index = bucketIndex(keyHash); + for (TEntry te = (TEntry) buckets.get(index); te != null; te = te.next()) { + if (te.keyHash == keyHash && te.matches(key)) { + return te; + } + } + synchronized (this) { + for (TEntry te = (TEntry) buckets.get(index); te != null; te = te.next()) { + if (te.keyHash == keyHash && te.matches(key)) { + return te; + } + } + TEntry newEntry = creator.apply(key); + newEntry.setNext((TEntry) buckets.get(index)); + buckets.set(index, newEntry); + size.incrementAndGet(); + return newEntry; + } + } + + @SuppressWarnings("unchecked") + public void forEach(Consumer consumer) { + for (int i = 0; i < buckets.length(); i++) { + for (TEntry te = (TEntry) buckets.get(i); te != null; te = te.next()) { + consumer.accept(te); + } + } + } + + /** + * 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. + */ + @SuppressWarnings("unchecked") + public void forEach(T context, BiConsumer consumer) { + for (int i = 0; i < buckets.length(); i++) { + for (TEntry te = (TEntry) buckets.get(i); te != null; te = te.next()) { + consumer.accept(context, te); + } + } + } + + private int bucketIndex(long keyHash) { + return (int) (keyHash & (buckets.length() - 1)); + } + } + + /** + * Two-key (composite-key) concurrent hash table. Lock-free on hit; locked on miss. + * + *

Key parts are passed directly to {@link #get} and {@link #getOrCreate}, eliminating the + * per-lookup composite key object allocation that {@code ConcurrentHashMap, V>} + * requires. + * + * @param first key type + * @param second key type + * @param the user's {@link Hashtable.D2.Entry D2.Entry<K1, K2>} subclass + */ + public static final class D2> { + + private final AtomicReferenceArray buckets; + private final AtomicInteger size = new AtomicInteger(); + + public D2(int capacity) { + this.buckets = new AtomicReferenceArray<>(Hashtable.Support.sizeFor(capacity)); + } + + public int size() { + return size.get(); + } + + @SuppressWarnings("unchecked") + public TEntry get(K1 key1, K2 key2) { + long keyHash = Hashtable.D2.Entry.hash(key1, key2); + for (TEntry te = (TEntry) buckets.get(bucketIndex(keyHash)); te != null; te = te.next()) { + if (te.keyHash == keyHash && te.matches(key1, key2)) { + return te; + } + } + return null; + } + + /** + * Returns the entry for {@code (key1, key2)}, creating one via {@code creator} if absent. + * Lock-free on hit; acquires a table-level lock on miss. Re-checks under the lock to avoid + * duplicate entries under concurrent misses. + * + *

The {@code creator} should build an entry whose {@code keyHash} equals {@link + * Hashtable.D2.Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. + */ + @SuppressWarnings("unchecked") + public TEntry getOrCreate( + K1 key1, K2 key2, BiFunction creator) { + long keyHash = Hashtable.D2.Entry.hash(key1, key2); + int index = bucketIndex(keyHash); + for (TEntry te = (TEntry) buckets.get(index); te != null; te = te.next()) { + if (te.keyHash == keyHash && te.matches(key1, key2)) { + return te; + } + } + synchronized (this) { + for (TEntry te = (TEntry) buckets.get(index); te != null; te = te.next()) { + if (te.keyHash == keyHash && te.matches(key1, key2)) { + return te; + } + } + TEntry newEntry = creator.apply(key1, key2); + newEntry.setNext((TEntry) buckets.get(index)); + buckets.set(index, newEntry); + size.incrementAndGet(); + return newEntry; + } + } + + @SuppressWarnings("unchecked") + public void forEach(Consumer consumer) { + for (int i = 0; i < buckets.length(); i++) { + for (TEntry te = (TEntry) buckets.get(i); te != null; te = te.next()) { + consumer.accept(te); + } + } + } + + /** + * 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. + */ + @SuppressWarnings("unchecked") + public void forEach(T context, BiConsumer consumer) { + for (int i = 0; i < buckets.length(); i++) { + for (TEntry te = (TEntry) buckets.get(i); te != null; te = te.next()) { + consumer.accept(context, te); + } + } + } + + private int bucketIndex(long keyHash) { + return (int) (keyHash & (buckets.length() - 1)); + } + } +} 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..66e2cfc2340 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java @@ -0,0 +1,141 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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 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 ConcurrentHashtableD1Test { + + @Test + void getReturnsMappedEntry() { + ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + StringEntry e = table.getOrCreate("hello", k -> new StringEntry(k, 42)); + assertSame(e, table.get("hello")); + assertNull(table.get("world")); + } + + @Test + void getOrCreateOnMissBuildsEntry() { + ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + int[] createCount = {0}; + StringEntry created = + table.getOrCreate( + "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 = new ConcurrentHashtable.D1<>(8); + StringEntry seeded = table.getOrCreate("a", k -> new StringEntry(k, 100)); + int[] createCount = {0}; + StringEntry got = + table.getOrCreate( + "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 = new ConcurrentHashtable.D1<>(8); + StringEntry e = table.getOrCreate(null, k -> new StringEntry(k, 0)); + assertNotNull(e); + assertSame(e, table.get(null)); + } + + @Test + void forEachVisitsAllEntries() { + ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + table.getOrCreate("a", k -> new StringEntry(k, 1)); + table.getOrCreate("b", k -> new StringEntry(k, 2)); + table.getOrCreate("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 = new ConcurrentHashtable.D1<>(8); + table.getOrCreate("x", k -> new StringEntry(k, 10)); + table.getOrCreate("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 = new ConcurrentHashtable.D1<>(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.getOrCreate( + "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()); + } + + // Reuses Hashtable.D1.Entry — ConcurrentHashtable.D1 accepts any D1.Entry subclass. + private static final class StringEntry extends Hashtable.D1.Entry { + final int value; + + StringEntry(String key, int value) { + super(key); + this.value = value; + } + } +} 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..1a3b5e525a0 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java @@ -0,0 +1,137 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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 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 = new ConcurrentHashtable.D2<>(8); + PairEntry ab = table.getOrCreate("a", 1, PairEntry::new); + PairEntry ac = table.getOrCreate("a", 2, PairEntry::new); + PairEntry bb = table.getOrCreate("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 = new ConcurrentHashtable.D2<>(8); + int[] createCount = {0}; + PairEntry created = + table.getOrCreate( + "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 = new ConcurrentHashtable.D2<>(8); + PairEntry seeded = table.getOrCreate("a", 1, PairEntry::new); + int[] createCount = {0}; + PairEntry got = + table.getOrCreate( + "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 = new ConcurrentHashtable.D2<>(8); + table.getOrCreate("a", 1, PairEntry::new); + table.getOrCreate("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 = new ConcurrentHashtable.D2<>(8); + table.getOrCreate("a", 1, PairEntry::new); + table.getOrCreate("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 = new ConcurrentHashtable.D2<>(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.getOrCreate( + "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()); + } + + private static final class PairEntry extends Hashtable.D2.Entry { + PairEntry(String key1, Integer key2) { + super(key1, key2); + } + } +} From 2b6570d5dcec28140fda8559dfff3f3c29efaad1 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 18 Jun 2026 11:56:17 -0400 Subject: [PATCH 02/31] refactor(util): move shared ConcurrentHashtable mechanics into Support class; add D2 benchmark Extract bucketIndex and forEach into ConcurrentHashtable.Support, mirroring the Hashtable.Support pattern. Add ConcurrentHashtableD2Benchmark comparing get and getOrCreate throughput against ConcurrentHashMap and ConcurrentSkipListMap. Co-Authored-By: Claude Sonnet 4.6 --- .../util/ConcurrentHashtableD2Benchmark.java | 179 ++++++++++++++++++ .../trace/util/ConcurrentHashtable.java | 79 ++++---- 2 files changed, 222 insertions(+), 36 deletions(-) create mode 100644 internal-api/src/jmh/java/datadog/trace/util/ConcurrentHashtableD2Benchmark.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/ConcurrentHashtableD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ConcurrentHashtableD2Benchmark.java new file mode 100644 index 00000000000..7219cdfff69 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/ConcurrentHashtableD2Benchmark.java @@ -0,0 +1,179 @@ +package datadog.trace.util; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +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; + +/** + * Compares {@link ConcurrentHashtable.D2} against {@link ConcurrentHashMap} and {@link + * ConcurrentSkipListMap} for shared, concurrent composite-key lookups. + * + *

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). + * + *

    + *
  • get — pure read: D2.get(k1, k2) vs CHM.get(new Key2(k1, k2)). D2 sidesteps the + * composite key allocation entirely; CHM.get does not store the key, but the allocation still + * happens before the call. + *
  • getOrCreate (hit) — the dominant call-site pattern: try to fetch an existing entry, + * create only on first access. On subsequent calls D2 takes the lock-free fast path (same as + * get); CHM.computeIfAbsent with a get-first pattern avoids the lambda capture allocation on + * hits, but still allocates the composite key. + *
+ * + *

ConcurrentSkipListMap is included as a second baseline: it is entirely lock-free for reads + * (CAS-based) but pays for tree traversal and Comparable overhead on every operation. + */ +@Fork(2) +@Warmup(iterations = 2) +@Measurement(iterations = 3) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(MICROSECONDS) +@Threads(8) +public class ConcurrentHashtableD2Benchmark { + + 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 { + for (int i = 0; i < N_KEYS; ++i) { + SOURCE_K1[i] = "key-" + i; + SOURCE_K2[i] = i * 31 + 17; + } + } + + static final class D2Entry extends Hashtable.D2.Entry { + final long value; + + D2Entry(String k1, Integer k2) { + super(k1, k2); + this.value = 1L; + } + } + + /** Composite key for ConcurrentHashMap and ConcurrentSkipListMap 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; + this.hash = Objects.hash(k1, k2); + } + + @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 table instance across all threads, modelling a + * shared instrumentation cache. + */ + @State(Scope.Benchmark) + public static class SharedState { + ConcurrentHashtable.D2 table; + ConcurrentHashMap concurrentHashMap; + ConcurrentSkipListMap skipListMap; + + @Setup(Level.Iteration) + public void setUp() { + table = new ConcurrentHashtable.D2<>(CAPACITY); + concurrentHashMap = new ConcurrentHashMap<>(CAPACITY); + skipListMap = new ConcurrentSkipListMap<>(); + for (int i = 0; i < N_KEYS; ++i) { + table.getOrCreate(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new); + Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]); + concurrentHashMap.put(key, (long) i); + skipListMap.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 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 D2Entry getOrCreate_concurrentHashtable(SharedState s, ThreadState t) { + int i = t.next(); + return s.table.getOrCreate(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new); + } + + /** + * 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); + } +} diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index 1e59bd4bf13..b7b13a27d07 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -23,10 +23,10 @@ * scalar replacement. * *

Memory model. Bucket slots are held in an {@link AtomicReferenceArray}, so each {@link - * #get} begins with a volatile read of the slot. Entries are inserted at the bucket head: the - * new entry's {@code next} pointer is set before the volatile slot write, so any subsequent - * volatile read of that slot carries happens-before over the full chain — chain {@code next} - * fields do not need to be volatile. + * #get} begins with a volatile read of the slot. Entries are inserted at the bucket head: the new + * entry's {@code next} pointer is set before the volatile slot write, so any subsequent volatile + * read of that slot carries happens-before over the full chain — chain {@code next} fields do not + * need to be volatile. */ public final class ConcurrentHashtable { private ConcurrentHashtable() {} @@ -53,7 +53,9 @@ public int size() { @SuppressWarnings("unchecked") public TEntry get(K key) { long keyHash = Hashtable.D1.Entry.hash(key); - for (TEntry te = (TEntry) buckets.get(bucketIndex(keyHash)); te != null; te = te.next()) { + for (TEntry te = (TEntry) buckets.get(Support.bucketIndex(buckets, keyHash)); + te != null; + te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { return te; } @@ -63,13 +65,13 @@ public TEntry get(K key) { /** * Returns the entry for {@code key}, creating one via {@code creator} if absent. Lock-free on - * hit; acquires a table-level lock on miss. Re-checks under the lock to avoid duplicate - * entries under concurrent misses. + * hit; acquires a table-level lock on miss. Re-checks under the lock to avoid duplicate entries + * under concurrent misses. */ @SuppressWarnings("unchecked") public TEntry getOrCreate(K key, Function creator) { long keyHash = Hashtable.D1.Entry.hash(key); - int index = bucketIndex(keyHash); + int index = Support.bucketIndex(buckets, keyHash); for (TEntry te = (TEntry) buckets.get(index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { return te; @@ -89,30 +91,16 @@ public TEntry getOrCreate(K key, Function creator) } } - @SuppressWarnings("unchecked") public void forEach(Consumer consumer) { - for (int i = 0; i < buckets.length(); i++) { - for (TEntry te = (TEntry) buckets.get(i); te != null; te = te.next()) { - consumer.accept(te); - } - } + Support.forEach(buckets, 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. */ - @SuppressWarnings("unchecked") public void forEach(T context, BiConsumer consumer) { - for (int i = 0; i < buckets.length(); i++) { - for (TEntry te = (TEntry) buckets.get(i); te != null; te = te.next()) { - consumer.accept(context, te); - } - } - } - - private int bucketIndex(long keyHash) { - return (int) (keyHash & (buckets.length() - 1)); + Support.forEach(buckets, context, consumer); } } @@ -143,7 +131,9 @@ public int size() { @SuppressWarnings("unchecked") public TEntry get(K1 key1, K2 key2) { long keyHash = Hashtable.D2.Entry.hash(key1, key2); - for (TEntry te = (TEntry) buckets.get(bucketIndex(keyHash)); te != null; te = te.next()) { + for (TEntry te = (TEntry) buckets.get(Support.bucketIndex(buckets, keyHash)); + te != null; + te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { return te; } @@ -163,7 +153,7 @@ public TEntry get(K1 key1, K2 key2) { public TEntry getOrCreate( K1 key1, K2 key2, BiFunction creator) { long keyHash = Hashtable.D2.Entry.hash(key1, key2); - int index = bucketIndex(keyHash); + int index = Support.bucketIndex(buckets, keyHash); for (TEntry te = (TEntry) buckets.get(index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { return te; @@ -183,30 +173,47 @@ public TEntry getOrCreate( } } - @SuppressWarnings("unchecked") public void forEach(Consumer consumer) { - for (int i = 0; i < buckets.length(); i++) { - for (TEntry te = (TEntry) buckets.get(i); te != null; te = te.next()) { - consumer.accept(te); - } - } + Support.forEach(buckets, 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. */ - @SuppressWarnings("unchecked") public void forEach(T context, BiConsumer consumer) { + Support.forEach(buckets, context, consumer); + } + } + + /** Building blocks for concurrent hash-table operations, mirroring {@link Hashtable.Support}. */ + public static final class Support { + private Support() {} + + public static int bucketIndex(AtomicReferenceArray buckets, long keyHash) { + return (int) (keyHash & (buckets.length() - 1)); + } + + @SuppressWarnings("unchecked") + public static void forEach( + AtomicReferenceArray buckets, Consumer consumer) { for (int i = 0; i < buckets.length(); i++) { for (TEntry te = (TEntry) buckets.get(i); te != null; te = te.next()) { - consumer.accept(context, te); + consumer.accept(te); } } } - private int bucketIndex(long keyHash) { - return (int) (keyHash & (buckets.length() - 1)); + @SuppressWarnings("unchecked") + public static void forEach( + AtomicReferenceArray buckets, + T context, + BiConsumer consumer) { + for (int i = 0; i < buckets.length(); i++) { + for (TEntry te = (TEntry) buckets.get(i); te != null; te = te.next()) { + consumer.accept(context, te); + } + } } } } From f415b3b0d96ec3a19b653d4e230a34d85e700c27 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 18 Jun 2026 12:26:12 -0400 Subject: [PATCH 03/31] test(util): add chain collision and concurrent distinct-key tests for ConcurrentHashtable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps filled per-dimension (D1 and D2): - Chain collision: force multiple entries into the same bucket (CollidingKey with fixed hashCode for D1; pigeonhole via 2-bucket table for D2) and verify all entries are reachable after concurrent inserts. - Concurrent distinct keys: 16 threads each insert a unique key simultaneously, verifying final size and that every key is retrievable — exercises concurrent inserts to different buckets, which the single-shared-key test does not cover. Co-Authored-By: Claude Sonnet 4.6 --- .../trace/util/ConcurrentHashtableD1Test.java | 89 +++++++++++++++++++ .../trace/util/ConcurrentHashtableD2Test.java | 60 +++++++++++++ 2 files changed, 149 insertions(+) diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java index 66e2cfc2340..aff0c47537a 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java @@ -129,6 +129,64 @@ void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException assertEquals(1, createCount.get()); } + @Test + void chainedEntriesInSameBucketAreAllReachable() { + // 2 buckets: keyHash & 1 determines the slot. Hashes 0 and 2 both land in bucket 0. + ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(2); + CollidingKey a = new CollidingKey("a", 0); + CollidingKey b = new CollidingKey("b", 0); // same bucket as a + CollidingKey c = new CollidingKey("c", 2); // 2 & 1 == 0, same bucket + CollidingEntry ea = table.getOrCreate(a, CollidingEntry::new); + CollidingEntry eb = table.getOrCreate(b, CollidingEntry::new); + CollidingEntry ec = table.getOrCreate(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 = + new ConcurrentHashtable.D1<>(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.getOrCreate(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)); + } + } + // Reuses Hashtable.D1.Entry — ConcurrentHashtable.D1 accepts any D1.Entry subclass. private static final class StringEntry extends Hashtable.D1.Entry { final int value; @@ -138,4 +196,35 @@ private static final class StringEntry extends Hashtable.D1.Entry { 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 Hashtable.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 index 1a3b5e525a0..46089bf6563 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java @@ -129,6 +129,66 @@ void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException assertEquals(1, createCount.get()); } + @Test + void chainedEntriesInSameBucketAreAllReachable() { + // 2 buckets: 4 entries guarantees at least 2 share a bucket by pigeonhole. + ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(2); + PairEntry e1 = table.getOrCreate("a", 1, PairEntry::new); + PairEntry e2 = table.getOrCreate("a", 2, PairEntry::new); + PairEntry e3 = table.getOrCreate("b", 1, PairEntry::new); + PairEntry e4 = table.getOrCreate("b", 2, PairEntry::new); + assertEquals(4, table.size()); + assertSame(e1, table.get("a", 1)); + assertSame(e2, table.get("a", 2)); + assertSame(e3, table.get("b", 1)); + assertSame(e4, table.get("b", 2)); + 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 = + new ConcurrentHashtable.D2<>(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.getOrCreate(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])); + } + } + private static final class PairEntry extends Hashtable.D2.Entry { PairEntry(String key1, Integer key2) { super(key1, key2); From 68f85d6be3a08aed74699e350d87689a8891f1cd Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 22:13:48 -0400 Subject: [PATCH 04/31] Add Support.bucket() helpers to hide unchecked casts in ConcurrentHashtable Co-Authored-By: Claude Sonnet 4.6 --- .../trace/util/ConcurrentHashtable.java | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index b7b13a27d07..bdec3d3e1ca 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -50,12 +50,9 @@ public int size() { return size.get(); } - @SuppressWarnings("unchecked") public TEntry get(K key) { long keyHash = Hashtable.D1.Entry.hash(key); - for (TEntry te = (TEntry) buckets.get(Support.bucketIndex(buckets, keyHash)); - te != null; - te = te.next()) { + for (TEntry te = Support.bucket(buckets, keyHash); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { return te; } @@ -68,23 +65,22 @@ public TEntry get(K key) { * hit; acquires a table-level lock on miss. Re-checks under the lock to avoid duplicate entries * under concurrent misses. */ - @SuppressWarnings("unchecked") public TEntry getOrCreate(K key, Function creator) { long keyHash = Hashtable.D1.Entry.hash(key); int index = Support.bucketIndex(buckets, keyHash); - for (TEntry te = (TEntry) buckets.get(index); te != null; te = te.next()) { + for (TEntry te = Support.bucket(buckets, index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { return te; } } synchronized (this) { - for (TEntry te = (TEntry) buckets.get(index); te != null; te = te.next()) { + for (TEntry te = Support.bucket(buckets, index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { return te; } } TEntry newEntry = creator.apply(key); - newEntry.setNext((TEntry) buckets.get(index)); + newEntry.setNext(Support.bucket(buckets, index)); buckets.set(index, newEntry); size.incrementAndGet(); return newEntry; @@ -128,12 +124,9 @@ public int size() { return size.get(); } - @SuppressWarnings("unchecked") public TEntry get(K1 key1, K2 key2) { long keyHash = Hashtable.D2.Entry.hash(key1, key2); - for (TEntry te = (TEntry) buckets.get(Support.bucketIndex(buckets, keyHash)); - te != null; - te = te.next()) { + for (TEntry te = Support.bucket(buckets, keyHash); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { return te; } @@ -149,24 +142,23 @@ public TEntry get(K1 key1, K2 key2) { *

The {@code creator} should build an entry whose {@code keyHash} equals {@link * Hashtable.D2.Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. */ - @SuppressWarnings("unchecked") public TEntry getOrCreate( K1 key1, K2 key2, BiFunction creator) { long keyHash = Hashtable.D2.Entry.hash(key1, key2); int index = Support.bucketIndex(buckets, keyHash); - for (TEntry te = (TEntry) buckets.get(index); te != null; te = te.next()) { + for (TEntry te = Support.bucket(buckets, index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { return te; } } synchronized (this) { - for (TEntry te = (TEntry) buckets.get(index); te != null; te = te.next()) { + for (TEntry te = Support.bucket(buckets, index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { return te; } } TEntry newEntry = creator.apply(key1, key2); - newEntry.setNext((TEntry) buckets.get(index)); + newEntry.setNext(Support.bucket(buckets, index)); buckets.set(index, newEntry); size.incrementAndGet(); return newEntry; @@ -194,6 +186,28 @@ public static int bucketIndex(AtomicReferenceArray buckets, lon return (int) (keyHash & (buckets.length() - 1)); } + /** + * Returns the head entry of the bucket that {@code keyHash} maps to, cast to the caller's + * concrete entry type. The unchecked cast lives here so chain-walk loops at call sites don't + * need to thread a raw {@link Hashtable.Entry} variable through. + */ + @SuppressWarnings("unchecked") + public static TEntry bucket( + AtomicReferenceArray buckets, long keyHash) { + return (TEntry) buckets.get(bucketIndex(buckets, keyHash)); + } + + /** + * Returns the head entry of the bucket at {@code index}, cast to the caller's concrete entry + * type. Use when the bucket index is already computed (e.g. inside {@code getOrCreate} where + * the same index is reused across the lock boundary). + */ + @SuppressWarnings("unchecked") + public static TEntry bucket( + AtomicReferenceArray buckets, int index) { + return (TEntry) buckets.get(index); + } + @SuppressWarnings("unchecked") public static void forEach( AtomicReferenceArray buckets, Consumer consumer) { From b350fb46700cb16d66d563074ecfed3076f5f59c Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 22:21:41 -0400 Subject: [PATCH 05/31] Replace ConcurrentHashtableD2Benchmark with ThreadSafeMap{D1,D2} and ThreadSafeCounterBenchmarks Co-Authored-By: Claude Sonnet 4.6 --- .../util/ThreadSafeCounterBenchmark.java | 126 ++++++++++++++ .../trace/util/ThreadSafeMapD1Benchmark.java | 164 ++++++++++++++++++ ...ark.java => ThreadSafeMapD2Benchmark.java} | 60 +++++-- 3 files changed, 334 insertions(+), 16 deletions(-) create mode 100644 internal-api/src/jmh/java/datadog/trace/util/ThreadSafeCounterBenchmark.java create mode 100644 internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java rename internal-api/src/jmh/java/datadog/trace/util/{ConcurrentHashtableD2Benchmark.java => ThreadSafeMapD2Benchmark.java} (69%) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeCounterBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeCounterBenchmark.java new file mode 100644 index 00000000000..6b79598eedb --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeCounterBenchmark.java @@ -0,0 +1,126 @@ +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; + +/** + * Benchmarks the "find and increment" pattern: look up an entry by key, then atomically increment + * its counter. Models per-class or per-method hit counters in the tracer. + * + *

The key insight is that {@link ConcurrentHashtable.D1} allows the counter to be embedded + * directly in the entry as a {@code volatile long} updated via {@link AtomicLongFieldUpdater}, + * avoiding the extra object allocation that {@link ConcurrentHashMap} requires when pairing each + * key with an {@link AtomicLong} or {@link LongAdder}. + * + *

Strategies compared: + * + *

    + *
  • {@link ConcurrentHashtable.D1} + {@link AtomicLongFieldUpdater} — lock-free lookup, inline + * counter; one object per entry total. + *
  • {@link ConcurrentHashMap} + {@link AtomicLong} — striped-lock lookup, one extra object per + * entry for the counter. + *
  • {@link ConcurrentHashMap} + {@link LongAdder} — striped-lock lookup, one extra object per + * entry; {@link LongAdder} reduces CAS contention under high thread counts at the cost of + * slightly higher memory and a more expensive {@code sum()}. + *
+ */ +@Fork(2) +@Warmup(iterations = 2) +@Measurement(iterations = 3) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(MICROSECONDS) +@Threads(8) +public class ThreadSafeCounterBenchmark { + + 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 Hashtable.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 = new ConcurrentHashtable.D1<>(CAPACITY); + atomicLongMap = new ConcurrentHashMap<>(CAPACITY); + longAdderMap = new ConcurrentHashMap<>(CAPACITY); + for (int i = 0; i < N_KEYS; ++i) { + table.getOrCreate(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..4067a00ddac --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java @@ -0,0 +1,164 @@ +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; + +/** + * Compares thread-safe map strategies for shared, concurrent single-key lookups. + * + *

See {@link ThreadSafeMapD2Benchmark} for the composite-key variant, which adds the cost of + * hashing two keys and a wrapper object allocation for map-based alternatives. + * + *

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). + * + *

Strategies compared: + * + *

    + *
  • {@link ConcurrentHashtable.D1} — lock-free reads, no extra allocation per lookup. + *
  • {@link ConcurrentHashMap} — striped locking; the key is the string itself, no wrapper. + *
  • {@link ConcurrentSkipListMap} — fully lock-free (CAS), but pays tree traversal and {@link + * Comparable} overhead on every operation. + *
  • {@link Collections#synchronizedMap} wrapping {@link HashMap} — global lock on every + * operation. Establishes the coarse-locking baseline. + *
+ */ +@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 Hashtable.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 = new ConcurrentHashtable.D1<>(CAPACITY); + concurrentHashMap = new ConcurrentHashMap<>(CAPACITY); + skipListMap = new ConcurrentSkipListMap<>(); + synchronizedHashMap = Collections.synchronizedMap(new HashMap<>(CAPACITY)); + for (int i = 0; i < N_KEYS; ++i) { + table.getOrCreate(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.getOrCreate(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/ConcurrentHashtableD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java similarity index 69% rename from internal-api/src/jmh/java/datadog/trace/util/ConcurrentHashtableD2Benchmark.java rename to internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java index 7219cdfff69..fb9d1b5692f 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ConcurrentHashtableD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -2,6 +2,9 @@ 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; @@ -19,25 +22,24 @@ import org.openjdk.jmh.annotations.Warmup; /** - * Compares {@link ConcurrentHashtable.D2} against {@link ConcurrentHashMap} and {@link - * ConcurrentSkipListMap} for shared, concurrent composite-key lookups. + * Compares thread-safe map strategies for shared, concurrent composite-key lookups. + * + *

See {@link ThreadSafeMapD1Benchmark} for the single-key variant. * *

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). * + *

Strategies compared: + * *

    - *
  • get — pure read: D2.get(k1, k2) vs CHM.get(new Key2(k1, k2)). D2 sidesteps the - * composite key allocation entirely; CHM.get does not store the key, but the allocation still - * happens before the call. - *
  • getOrCreate (hit) — the dominant call-site pattern: try to fetch an existing entry, - * create only on first access. On subsequent calls D2 takes the lock-free fast path (same as - * get); CHM.computeIfAbsent with a get-first pattern avoids the lambda capture allocation on - * hits, but still allocates the composite key. + *
  • {@link ConcurrentHashtable.D2} — lock-free reads, no composite key allocation per lookup. + *
  • {@link ConcurrentHashMap} — striped locking, allocates a {@link Key2} wrapper per lookup. + *
  • {@link ConcurrentSkipListMap} — fully lock-free (CAS), but pays tree traversal and {@link + * Comparable} overhead; allocates {@link Key2} per lookup. + *
  • {@link Collections#synchronizedMap} wrapping {@link HashMap} — global lock on every + * operation; allocates {@link Key2} per lookup. Establishes the coarse-locking baseline. *
- * - *

ConcurrentSkipListMap is included as a second baseline: it is entirely lock-free for reads - * (CAS-based) but pays for tree traversal and Comparable overhead on every operation. */ @Fork(2) @Warmup(iterations = 2) @@ -45,7 +47,7 @@ @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(MICROSECONDS) @Threads(8) -public class ConcurrentHashtableD2Benchmark { +public class ThreadSafeMapD2Benchmark { static final int N_KEYS = 64; static final int CAPACITY = 128; @@ -69,7 +71,7 @@ static final class D2Entry extends Hashtable.D2.Entry { } } - /** Composite key for ConcurrentHashMap and ConcurrentSkipListMap baselines. */ + /** Composite key for map-based baselines. */ static final class Key2 implements Comparable { final String k1; final Integer k2; @@ -103,25 +105,28 @@ public int compareTo(Key2 other) { } /** - * Shared state ({@link Scope#Benchmark}): one table instance across all threads, modelling a - * shared instrumentation cache. + * 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; ConcurrentHashMap concurrentHashMap; ConcurrentSkipListMap skipListMap; + Map synchronizedHashMap; @Setup(Level.Iteration) public void setUp() { table = new ConcurrentHashtable.D2<>(CAPACITY); concurrentHashMap = new ConcurrentHashMap<>(CAPACITY); skipListMap = new ConcurrentSkipListMap<>(); + synchronizedHashMap = Collections.synchronizedMap(new HashMap<>(CAPACITY)); for (int i = 0; i < N_KEYS; ++i) { table.getOrCreate(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new); 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); } } } @@ -156,6 +161,12 @@ public Long get_concurrentSkipListMap(SharedState s, ThreadState t) { 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(); @@ -176,4 +187,21 @@ public Long getOrCreate_concurrentHashMap(SharedState s, ThreadState t) { } 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) { + 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); + } + } } From c925f20e2be7ed3d30d36b55709ac381f93352d3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 22:22:47 -0400 Subject: [PATCH 06/31] Rename ThreadSafeCounterBenchmark to ThreadSafeMapCounterBenchmark Co-Authored-By: Claude Sonnet 4.6 --- ...CounterBenchmark.java => ThreadSafeMapCounterBenchmark.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename internal-api/src/jmh/java/datadog/trace/util/{ThreadSafeCounterBenchmark.java => ThreadSafeMapCounterBenchmark.java} (98%) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeCounterBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java similarity index 98% rename from internal-api/src/jmh/java/datadog/trace/util/ThreadSafeCounterBenchmark.java rename to internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java index 6b79598eedb..a79e624e920 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeCounterBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java @@ -46,7 +46,7 @@ @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(MICROSECONDS) @Threads(8) -public class ThreadSafeCounterBenchmark { +public class ThreadSafeMapCounterBenchmark { static final int N_KEYS = 64; static final int CAPACITY = 128; From 383fed7fb5e9c195eb7f8b7f0694ac8cb95e8346 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 22:26:36 -0400 Subject: [PATCH 07/31] Add Support-based primitive-int K2 benchmark case to ThreadSafeMapD2Benchmark Co-Authored-By: Claude Sonnet 4.6 --- .../trace/util/ThreadSafeMapD2Benchmark.java | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java index fb9d1b5692f..028076a9d9d 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -34,7 +34,13 @@ * *

    *
  • {@link ConcurrentHashtable.D2} — lock-free reads, no composite key allocation per lookup. - *
  • {@link ConcurrentHashMap} — striped locking, allocates a {@link Key2} wrapper per lookup. + * K2 is {@link Integer} (boxed), so EA may still eliminate the box on hits, but the + * allocation is observable on misses. + *
  • {@link ConcurrentHashtable.Support} (custom entry) — same lock-free read path, but K2 is a + * primitive {@code int} embedded directly in the entry. No boxing at any point; demonstrates + * the flexibility available when {@code D2}'s object-key constraint is too limiting. + *
  • {@link ConcurrentHashMap} — striped locking, allocates a {@link Key2} wrapper per lookup + * (boxes the {@code int} K2 inside). *
  • {@link ConcurrentSkipListMap} — fully lock-free (CAS), but pays tree traversal and {@link * Comparable} overhead; allocates {@link Key2} per lookup. *
  • {@link Collections#synchronizedMap} wrapping {@link HashMap} — global lock on every @@ -54,11 +60,13 @@ public class ThreadSafeMapD2Benchmark { 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[i] = i * 31 + 17; + SOURCE_K2_INT[i] = i * 31 + 17; + SOURCE_K2[i] = SOURCE_K2_INT[i]; } } @@ -71,6 +79,32 @@ static final class D2Entry extends Hashtable.D2.Entry { } } + /** + * Support-based entry with a primitive {@code int} K2 — no boxing at any point. The hash is + * computed with the same formula as {@link Hashtable.D2.Entry#hash} but avoids the {@link + * Integer#hashCode(int)} boxing path by calling {@link LongHashingUtils} directly. + */ + static final class SupportEntry extends Hashtable.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; @@ -111,6 +145,7 @@ public int compareTo(Key2 other) { @State(Scope.Benchmark) public static class SharedState { ConcurrentHashtable.D2 table; + java.util.concurrent.atomic.AtomicReferenceArray supportBuckets; ConcurrentHashMap concurrentHashMap; ConcurrentSkipListMap skipListMap; Map synchronizedHashMap; @@ -118,11 +153,20 @@ public static class SharedState { @Setup(Level.Iteration) public void setUp() { table = new ConcurrentHashtable.D2<>(CAPACITY); + supportBuckets = + new java.util.concurrent.atomic.AtomicReferenceArray<>( + Hashtable.Support.sizeFor(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.getOrCreate(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new); + // populate support table + SupportEntry se = new SupportEntry(SOURCE_K1[i], k2); + int idx = ConcurrentHashtable.Support.bucketIndex(supportBuckets, se.keyHash); + se.setNext(ConcurrentHashtable.Support.bucket(supportBuckets, idx)); + supportBuckets.set(idx, se); Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]); concurrentHashMap.put(key, (long) i); skipListMap.put(key, (long) i); @@ -149,6 +193,22 @@ public D2Entry get_concurrentHashtable(SharedState s, ThreadState t) { 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.Support.bucket(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(); From 4c71ba0aaa814eeba3c41658f0d3363efb8e15ac Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 22:30:10 -0400 Subject: [PATCH 08/31] Document synchronization contract on ConcurrentHashtable.Support Co-Authored-By: Claude Sonnet 4.6 --- .../trace/util/ConcurrentHashtable.java | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index bdec3d3e1ca..3c802bab4de 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -178,7 +178,30 @@ public void forEach(T context, BiConsumer consume } } - /** Building blocks for concurrent hash-table operations, mirroring {@link Hashtable.Support}. */ + /** + * Building blocks for concurrent hash-table operations, mirroring {@link Hashtable.Support}. + * + *

    Use {@link D1} or {@link D2} when their object-key constraints are acceptable — they handle + * synchronization internally. Use {@code Support} directly only when you need primitive key + * components or other entry-level flexibility that {@code D1}/{@code D2} cannot provide. + * + *

    Synchronization contract. {@link #bucket} performs a volatile read of the bucket slot + * and is safe to call from any thread without a lock — this is the lock-free read path. Writes + * (inserting a new entry) are the caller's responsibility: use the same double-checked locking + * pattern that {@link D1} and {@link D2} use internally — + * + *

      + *
    1. Lock-free pre-check: walk the chain via {@link #bucket}; return if found. + *
    2. Acquire a lock on a stable object owned by the same class that owns the {@code buckets} + * array (typically {@code synchronized (this)}). + *
    3. Re-check under the lock (another thread may have inserted between step 1 and step 2). + *
    4. Build the new entry, set its {@code next} via {@link Hashtable.Entry#setNext}, then write + * it to the bucket with {@link AtomicReferenceArray#set} (volatile write). + *
    + * + * Locking on the {@code AtomicReferenceArray} itself is also valid but no cleaner — pick + * whichever lock object is most natural for the owning class. + */ public static final class Support { private Support() {} From 627bf8f4e89e86fd03325794f5d0280ef9302b4e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 22:30:58 -0400 Subject: [PATCH 09/31] Note lock-striping advantage of using ConcurrentHashtable.Support directly Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/datadog/trace/util/ConcurrentHashtable.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index 3c802bab4de..03db0b86739 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -201,6 +201,11 @@ public void forEach(T context, BiConsumer consume * * Locking on the {@code AtomicReferenceArray} itself is also valid but no cleaner — pick * whichever lock object is most natural for the owning class. + * + *

    One advantage of using {@code Support} directly over {@link D1}/{@link D2} is that the + * caller controls the lock object, enabling lock striping: shard the lock by bucket index or key + * hash to reduce write-path contention if profiling shows the single table-level lock is a + * bottleneck. */ public static final class Support { private Support() {} From 61395a0bb99b3cf64eb87a24ab6e5dc870968c4f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 22:35:18 -0400 Subject: [PATCH 10/31] Add getOrCreate_support and getOrCreate_concurrentSkipListMap to ThreadSafeMapD2Benchmark Co-Authored-By: Claude Sonnet 4.6 --- .../trace/util/ThreadSafeMapD2Benchmark.java | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java index 028076a9d9d..ebaff671120 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -42,7 +42,8 @@ *

  • {@link ConcurrentHashMap} — striped locking, allocates a {@link Key2} wrapper per lookup * (boxes the {@code int} K2 inside). *
  • {@link ConcurrentSkipListMap} — fully lock-free (CAS), but pays tree traversal and {@link - * Comparable} overhead; allocates {@link Key2} per lookup. + * Comparable} overhead; allocates {@link Key2} per lookup. {@code getOrCreate} uses + * get-then-{@code putIfAbsent} (no native {@code computeIfAbsent}). *
  • {@link Collections#synchronizedMap} wrapping {@link HashMap} — global lock on every * operation; allocates {@link Key2} per lookup. Establishes the coarse-locking baseline. *
@@ -233,6 +234,35 @@ public D2Entry getOrCreate_concurrentHashtable(SharedState s, ThreadState t) { return s.table.getOrCreate(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.Support.bucketIndex(s.supportBuckets, keyHash); + for (SupportEntry e = ConcurrentHashtable.Support.bucket(s.supportBuckets, index); + e != null; + e = e.next()) { + if (e.keyHash == keyHash && e.matches(k1, k2)) { + return e; + } + } + synchronized (s.supportBuckets) { + for (SupportEntry e = ConcurrentHashtable.Support.bucket(s.supportBuckets, index); + e != null; + e = e.next()) { + if (e.keyHash == keyHash && e.matches(k1, k2)) { + return e; + } + } + SupportEntry newEntry = new SupportEntry(k1, k2); + newEntry.setNext(ConcurrentHashtable.Support.bucket(s.supportBuckets, index)); + s.supportBuckets.set(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. @@ -248,6 +278,22 @@ public Long getOrCreate_concurrentHashMap(SharedState s, ThreadState t) { 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. From 9591965f272e393d83a95a76c7296cd34b3fcc33 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 23 Jun 2026 01:20:29 -0400 Subject: [PATCH 11/31] Add Java 17 benchmark results to ThreadSafeMap Javadocs Co-Authored-By: Claude Sonnet 4.6 --- .../util/ThreadSafeMapCounterBenchmark.java | 21 ++++++++++++ .../trace/util/ThreadSafeMapD1Benchmark.java | 28 ++++++++++++++++ .../trace/util/ThreadSafeMapD2Benchmark.java | 33 +++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java index a79e624e920..985cbf9a734 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java @@ -39,6 +39,27 @@ * entry; {@link LongAdder} reduces CAS contention under high thread counts at the cost of * slightly higher memory and a more expensive {@code sum()}. * + * + *

Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys): + * + *

{@code
+ * Benchmark                          Score   Units
+ * increment_longAdder                   79   ops/us  (fastest)
+ * 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) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java index 4067a00ddac..13d2825fdad 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java @@ -40,6 +40,34 @@ *
  • {@link Collections#synchronizedMap} wrapping {@link HashMap} — global lock on every * operation. Establishes the coarse-locking baseline. * + * + *

    Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys): + * + *

    {@code
    + * Benchmark                             Score   Units
    + * get_concurrentHashtable               1583   ops/us  (fastest)
    + * get_concurrentHashMap                 1145   ops/us
    + * get_concurrentSkipListMap              170   ops/us
    + * get_synchronizedHashMap                 33   ops/us
    + *
    + * getOrCreate_concurrentHashtable       1450   ops/us  (fastest)
    + * 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) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java index ebaff671120..c98501fe820 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -47,6 +47,39 @@ *
  • {@link Collections#synchronizedMap} wrapping {@link HashMap} — global lock on every * operation; allocates {@link Key2} per lookup. Establishes the coarse-locking baseline. * + * + *

    Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys): + * + *

    {@code
    + * Benchmark                              Score   Units
    + * get_concurrentHashtable                1452   ops/us  (tied fastest)
    + * get_support                            1450   ops/us  (primitive int K2)
    + * get_concurrentHashMap                   777   ops/us  (allocates Key2 wrapper)
    + * get_concurrentSkipListMap               146   ops/us
    + * get_synchronizedHashMap                  27   ops/us
    + *
    + * getOrCreate_support                    1379   ops/us  (fastest)
    + * 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) From 71385ac193a9990202e517b7e51b0305c76c3aea Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 23 Jun 2026 14:02:31 -0400 Subject: [PATCH 12/31] Remove superseded ThreadSafeMapBenchmark Replaced by the ThreadSafeMap{D1,D2,Counter}Benchmark split. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../trace/util/ThreadSafeMapBenchmark.java | 180 ------------------ 1 file changed, 180 deletions(-) delete mode 100644 internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java 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 793627a37e6..00000000000 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java +++ /dev/null @@ -1,180 +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.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) - *
    - * - *

    - * - *

    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) -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(); - } - - static int sharedLookupIndex = 0; - - static String nextLookupKey() { - return nextLookupKey(EQUAL_KEYS); - } - - static String nextLookupKey(String[] keys) { - int localIndex = ++sharedLookupIndex; - if (localIndex >= keys.length) { - sharedLookupIndex = 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); - } - } - - 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()); - } -} From bc0ecfd88c693f9023090fc1d0cf5fee3201b944 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 23 Jun 2026 14:02:47 -0400 Subject: [PATCH 13/31] Add remove/removeIf/drain/clear to ConcurrentHashtable Give ConcurrentHashtable its own entry hierarchy (Entry / D1.Entry / D2.Entry) with a volatile next pointer, independent of the single-threaded Hashtable. The volatile chain pointer lets a chain splice under the write lock be observed by lock-free readers, which makes removal safe: - remove(key) unlink a single entry - removeIf(predicate) sweep the whole table under one lock - drain(sink) read-and-reset: remove every entry, handing each to a caller-supplied accumulator (Consumer + context-passing BiConsumer overload) -- the flush/publish primitive - clear() empty the table Removed entries keep their own next pointer intact so an in-flight reader can still traverse forward. Migrates the ThreadSafeMap* benchmarks to the new entry base. Adds single-threaded and concurrent removal tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../util/ThreadSafeMapCounterBenchmark.java | 2 +- .../trace/util/ThreadSafeMapD1Benchmark.java | 2 +- .../trace/util/ThreadSafeMapD2Benchmark.java | 8 +- .../trace/util/ConcurrentHashtable.java | 436 ++++++++++++++++-- .../trace/util/ConcurrentHashtableD1Test.java | 183 +++++++- .../trace/util/ConcurrentHashtableD2Test.java | 105 ++++- 6 files changed, 684 insertions(+), 52 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java index 985cbf9a734..34ba1e485b8 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java @@ -80,7 +80,7 @@ public class ThreadSafeMapCounterBenchmark { } } - static final class CounterEntry extends Hashtable.D1.Entry { + static final class CounterEntry extends ConcurrentHashtable.D1.Entry { private static final AtomicLongFieldUpdater COUNT = AtomicLongFieldUpdater.newUpdater(CounterEntry.class, "count"); diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java index 13d2825fdad..6dfa6bcca3e 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java @@ -88,7 +88,7 @@ public class ThreadSafeMapD1Benchmark { } } - static final class D1Entry extends Hashtable.D1.Entry { + static final class D1Entry extends ConcurrentHashtable.D1.Entry { final long value; D1Entry(String key) { diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java index c98501fe820..f2b0fff7210 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -104,7 +104,7 @@ public class ThreadSafeMapD2Benchmark { } } - static final class D2Entry extends Hashtable.D2.Entry { + static final class D2Entry extends ConcurrentHashtable.D2.Entry { final long value; D2Entry(String k1, Integer k2) { @@ -118,7 +118,7 @@ static final class D2Entry extends Hashtable.D2.Entry { * computed with the same formula as {@link Hashtable.D2.Entry#hash} but avoids the {@link * Integer#hashCode(int)} boxing path by calling {@link LongHashingUtils} directly. */ - static final class SupportEntry extends Hashtable.Entry { + static final class SupportEntry extends ConcurrentHashtable.Entry { final String k1; final int k2; final long value; @@ -179,7 +179,7 @@ public int compareTo(Key2 other) { @State(Scope.Benchmark) public static class SharedState { ConcurrentHashtable.D2 table; - java.util.concurrent.atomic.AtomicReferenceArray supportBuckets; + java.util.concurrent.atomic.AtomicReferenceArray supportBuckets; ConcurrentHashMap concurrentHashMap; ConcurrentSkipListMap skipListMap; Map synchronizedHashMap; @@ -189,7 +189,7 @@ public void setUp() { table = new ConcurrentHashtable.D2<>(CAPACITY); supportBuckets = new java.util.concurrent.atomic.AtomicReferenceArray<>( - Hashtable.Support.sizeFor(CAPACITY)); + ConcurrentHashtable.Support.sizeFor(CAPACITY)); concurrentHashMap = new ConcurrentHashMap<>(CAPACITY); skipListMap = new ConcurrentSkipListMap<>(); synchronizedHashMap = Collections.synchronizedMap(new HashMap<>(CAPACITY)); diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index 03db0b86739..e06f027e05a 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -1,15 +1,24 @@ package datadog.trace.util; +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; /** - * Concurrent counterpart to {@link Hashtable}. Provides lock-free reads and locked writes for - * {@link D1} (single-key) and {@link D2} (composite-key) tables. + * Concurrent hash table providing lock-free reads and locked writes for {@link D1} (single-key) and + * {@link D2} (composite-key) tables. + * + *

    The API deliberately mirrors {@link Hashtable} so the two are familiar to use, but the two + * share no implementation: {@code ConcurrentHashtable} carries its own {@link Entry} + * hierarchy with a {@code volatile} chain pointer and its own write paths. The single-threaded and + * concurrent variants evolve under different constraints (the concurrent one must reason about the + * memory model on every mutation), so coupling them through a shared base would be a hazard, not a + * convenience. * *

    Like {@link Hashtable}, capacity is fixed at construction and the table does not resize. * Unlike {@link Hashtable}, all operations are safe for concurrent access without external @@ -23,27 +32,95 @@ * scalar replacement. * *

    Memory model. Bucket slots are held in an {@link AtomicReferenceArray}, so each {@link - * #get} begins with a volatile read of the slot. Entries are inserted at the bucket head: the new - * entry's {@code next} pointer is set before the volatile slot write, so any subsequent volatile - * read of that slot carries happens-before over the full chain — chain {@code next} fields do not - * need to be volatile. + * D1#get}/{@link D2#get} begins with a volatile read of the slot. The chain {@code next} pointer is + * {@code volatile} as well, so every step of a chain walk is a volatile read. This is what makes + * removal safe: a splice (re-pointing a predecessor's {@code next} past the removed entry, + * or replacing the bucket head) is a volatile write that lock-free readers observe. The cost is a + * volatile read per chain step and a slightly more expensive insert; the benefit is that the table + * supports removal — {@link D1#remove}, {@link D1#removeIf}, {@link D1#drain}, and {@link D1#clear} + * — rather than being append-only. {@link D1#drain} is the read-and-reset primitive for flush/ + * publish workflows: it removes every entry while handing each to a caller-supplied sink. + * + *

    Removal and in-flight readers. A removed entry's own {@code next} pointer is left + * intact (it is never nulled). A reader that had already advanced onto the entry being removed must + * still be able to follow {@code next} forward to the rest of the chain; the detached entry is + * simply unreachable for new lookups and becomes garbage once no in-flight reader references it. A + * concurrent lookup racing a removal may observe either the pre- or post-removal state — both are + * valid linearizations. */ public final class ConcurrentHashtable { private ConcurrentHashtable() {} /** - * Single-key concurrent hash table. Lock-free on hit; locked on miss. + * 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 mechanics with {@link Support}. + */ + public abstract static class Entry { + public final long keyHash; + private volatile Entry next = null; + + protected Entry(long keyHash) { + this.keyHash = keyHash; + } + + public final void setNext(TEntry next) { + this.next = next; + } + + @SuppressWarnings("unchecked") + 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 Hashtable.D1.Entry D1.Entry<K>} subclass + * @param the user's {@link D1.Entry D1.Entry<K>} subclass */ - public static final class D1> { + 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; - private final AtomicReferenceArray buckets; + protected Entry(K key) { + super(hash(key)); + this.key = key; + } + + public boolean matches(Object key) { + return Objects.equals(this.key, 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(Object key) { + return (key == null) ? Long.MIN_VALUE : key.hashCode(); + } + } + + private final AtomicReferenceArray buckets; private final AtomicInteger size = new AtomicInteger(); public D1(int capacity) { - this.buckets = new AtomicReferenceArray<>(Hashtable.Support.sizeFor(capacity)); + this.buckets = new AtomicReferenceArray<>(Support.sizeFor(capacity)); } public int size() { @@ -51,7 +128,7 @@ public int size() { } public TEntry get(K key) { - long keyHash = Hashtable.D1.Entry.hash(key); + long keyHash = D1.Entry.hash(key); for (TEntry te = Support.bucket(buckets, keyHash); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { return te; @@ -66,7 +143,7 @@ public TEntry get(K key) { * under concurrent misses. */ public TEntry getOrCreate(K key, Function creator) { - long keyHash = Hashtable.D1.Entry.hash(key); + long keyHash = D1.Entry.hash(key); int index = Support.bucketIndex(buckets, keyHash); for (TEntry te = Support.bucket(buckets, index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { @@ -87,6 +164,77 @@ public TEntry getOrCreate(K key, Function creator) } } + /** + * 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). + */ + public TEntry remove(K key) { + long keyHash = D1.Entry.hash(key); + int index = Support.bucketIndex(buckets, keyHash); + synchronized (this) { + ConcurrentHashtable.Entry prev = null; + for (TEntry te = Support.bucket(buckets, index); te != null; prev = te, te = te.next()) { + if (te.keyHash == keyHash && te.matches(key)) { + Support.unlink(buckets, index, prev, te); + size.decrementAndGet(); + return te; + } + } + 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(Predicate predicate) { + synchronized (this) { + return Support.removeIf(buckets, size, predicate); + } + } + + /** + * Removes every entry, passing each removed entry to {@code sink} as it is unlinked — the + * read-and-reset primitive for flush/publish workflows (drain the table into a telemetry batch, + * an event emitter, etc.). The whole drain runs under the table-level lock, so it is atomic + * with respect to other writers; {@code sink} therefore runs under the lock and should be cheap + * (accumulate into a collection rather than doing heavy work inline). Equivalent to {@code + * forEach}-then-{@code clear} but in a single locked pass that observes exactly what was + * removed. + * + *

    A capturing-lambda {@code sink} is fine here — drain is a rare flush operation — but a + * context-passing overload is offered for callers that prefer to avoid the allocation. + */ + public void drain(Consumer sink) { + synchronized (this) { + Support.drain(buckets, sink); + size.set(0); + } + } + + /** + * 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(T context, BiConsumer sink) { + synchronized (this) { + Support.drain(buckets, context, sink); + size.set(0); + } + } + + /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ + public void clear() { + synchronized (this) { + Support.clear(buckets); + size.set(0); + } + } + public void forEach(Consumer consumer) { Support.forEach(buckets, consumer); } @@ -101,7 +249,7 @@ public void forEach(T context, BiConsumer consume } /** - * Two-key (composite-key) concurrent hash table. Lock-free on hit; locked on miss. + * Two-key (composite-key) concurrent hash table. Lock-free on hit; locked on miss/mutation. * *

    Key parts are passed directly to {@link #get} and {@link #getOrCreate}, eliminating the * per-lookup composite key object allocation that {@code ConcurrentHashMap, V>} @@ -109,15 +257,42 @@ public void forEach(T context, BiConsumer consume * * @param first key type * @param second key type - * @param the user's {@link Hashtable.D2.Entry D2.Entry<K1, K2>} subclass + * @param the user's {@link D2.Entry D2.Entry<K1, K2>} subclass */ - public static final class D2> { + 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; - private final AtomicReferenceArray buckets; + protected Entry(K1 key1, K2 key2) { + super(hash(key1, key2)); + this.key1 = key1; + this.key2 = key2; + } + + public boolean matches(K1 key1, K2 key2) { + return Objects.equals(this.key1, key1) && Objects.equals(this.key2, key2); + } + + /** Returns the 64-bit lookup hash combining both key parts via {@link LongHashingUtils}. */ + public static long hash(Object key1, Object key2) { + return LongHashingUtils.hash(key1, key2); + } + } + + private final AtomicReferenceArray buckets; private final AtomicInteger size = new AtomicInteger(); public D2(int capacity) { - this.buckets = new AtomicReferenceArray<>(Hashtable.Support.sizeFor(capacity)); + this.buckets = new AtomicReferenceArray<>(Support.sizeFor(capacity)); } public int size() { @@ -125,7 +300,7 @@ public int size() { } public TEntry get(K1 key1, K2 key2) { - long keyHash = Hashtable.D2.Entry.hash(key1, key2); + long keyHash = D2.Entry.hash(key1, key2); for (TEntry te = Support.bucket(buckets, keyHash); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { return te; @@ -140,11 +315,11 @@ public TEntry get(K1 key1, K2 key2) { * duplicate entries under concurrent misses. * *

    The {@code creator} should build an entry whose {@code keyHash} equals {@link - * Hashtable.D2.Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. + * D2.Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. */ public TEntry getOrCreate( K1 key1, K2 key2, BiFunction creator) { - long keyHash = Hashtable.D2.Entry.hash(key1, key2); + long keyHash = D2.Entry.hash(key1, key2); int index = Support.bucketIndex(buckets, keyHash); for (TEntry te = Support.bucket(buckets, index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { @@ -165,6 +340,77 @@ public TEntry getOrCreate( } } + /** + * 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). + */ + public TEntry remove(K1 key1, K2 key2) { + long keyHash = D2.Entry.hash(key1, key2); + int index = Support.bucketIndex(buckets, keyHash); + synchronized (this) { + ConcurrentHashtable.Entry prev = null; + for (TEntry te = Support.bucket(buckets, index); te != null; prev = te, te = te.next()) { + if (te.keyHash == keyHash && te.matches(key1, key2)) { + Support.unlink(buckets, index, prev, te); + size.decrementAndGet(); + return te; + } + } + 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(Predicate predicate) { + synchronized (this) { + return Support.removeIf(buckets, size, predicate); + } + } + + /** + * Removes every entry, passing each removed entry to {@code sink} as it is unlinked — the + * read-and-reset primitive for flush/publish workflows (drain the table into a telemetry batch, + * an event emitter, etc.). The whole drain runs under the table-level lock, so it is atomic + * with respect to other writers; {@code sink} therefore runs under the lock and should be cheap + * (accumulate into a collection rather than doing heavy work inline). Equivalent to {@code + * forEach}-then-{@code clear} but in a single locked pass that observes exactly what was + * removed. + * + *

    A capturing-lambda {@code sink} is fine here — drain is a rare flush operation — but a + * context-passing overload is offered for callers that prefer to avoid the allocation. + */ + public void drain(Consumer sink) { + synchronized (this) { + Support.drain(buckets, sink); + size.set(0); + } + } + + /** + * 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(T context, BiConsumer sink) { + synchronized (this) { + Support.drain(buckets, context, sink); + size.set(0); + } + } + + /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ + public void clear() { + synchronized (this) { + Support.clear(buckets); + size.set(0); + } + } + public void forEach(Consumer consumer) { Support.forEach(buckets, consumer); } @@ -179,28 +425,34 @@ public void forEach(T context, BiConsumer consume } /** - * Building blocks for concurrent hash-table operations, mirroring {@link Hashtable.Support}. + * Building blocks for concurrent hash-table operations. * *

    Use {@link D1} or {@link D2} when their object-key constraints are acceptable — they handle * synchronization internally. Use {@code Support} directly only when you need primitive key * components or other entry-level flexibility that {@code D1}/{@code D2} cannot provide. * - *

    Synchronization contract. {@link #bucket} performs a volatile read of the bucket slot - * and is safe to call from any thread without a lock — this is the lock-free read path. Writes - * (inserting a new entry) are the caller's responsibility: use the same double-checked locking - * pattern that {@link D1} and {@link D2} use internally — + *

    Read path. {@link #bucket} performs a volatile read of the bucket slot and is safe to + * call from any thread without a lock; chain {@code next} pointers are volatile, so chain walks + * are lock-free. + * + *

    Write path (insert). Writes are the caller's responsibility. Use the same + * double-checked locking pattern that {@link D1} and {@link D2} use internally: * *

      *
    1. Lock-free pre-check: walk the chain via {@link #bucket}; return if found. *
    2. Acquire a lock on a stable object owned by the same class that owns the {@code buckets} * array (typically {@code synchronized (this)}). *
    3. Re-check under the lock (another thread may have inserted between step 1 and step 2). - *
    4. Build the new entry, set its {@code next} via {@link Hashtable.Entry#setNext}, then write - * it to the bucket with {@link AtomicReferenceArray#set} (volatile write). + *
    5. Build the new entry, set its {@code next} via {@link Entry#setNext}, then write it to the + * bucket with {@link AtomicReferenceArray#set} (volatile write). *
    * - * Locking on the {@code AtomicReferenceArray} itself is also valid but no cleaner — pick - * whichever lock object is most natural for the owning class. + *

    Write path (remove). Under the lock, splice the entry out with {@link #unlink}: it + * re-points the predecessor's {@code next} (or the bucket head) past the removed entry via a + * volatile write that lock-free readers observe. The removed entry's own {@code next} is left + * intact so a reader already positioned on it can still traverse forward to the rest of the + * chain. For full or predicate-driven sweeps, hold the lock and call {@link #removeIf} or {@link + * #clear}. * *

    One advantage of using {@code Support} directly over {@link D1}/{@link D2} is that the * caller controls the lock object, enabling lock striping: shard the lock by bucket index or key @@ -210,18 +462,27 @@ public void forEach(T context, BiConsumer consume public static final class Support { private Support() {} - public static int bucketIndex(AtomicReferenceArray buckets, long keyHash) { + /** + * 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. + */ + public static int sizeFor(int requestedSize) { + return Hashtable.Support.sizeFor(requestedSize); + } + + public static int bucketIndex( + AtomicReferenceArray buckets, long keyHash) { return (int) (keyHash & (buckets.length() - 1)); } /** * Returns the head entry of the bucket that {@code keyHash} maps to, cast to the caller's * concrete entry type. The unchecked cast lives here so chain-walk loops at call sites don't - * need to thread a raw {@link Hashtable.Entry} variable through. + * need to thread a raw {@link Entry} variable through. */ @SuppressWarnings("unchecked") - public static TEntry bucket( - AtomicReferenceArray buckets, long keyHash) { + public static TEntry bucket( + AtomicReferenceArray buckets, long keyHash) { return (TEntry) buckets.get(bucketIndex(buckets, keyHash)); } @@ -231,14 +492,109 @@ public static TEntry bucket( * the same index is reused across the lock boundary). */ @SuppressWarnings("unchecked") - public static TEntry bucket( - AtomicReferenceArray buckets, int index) { + public static TEntry bucket( + AtomicReferenceArray buckets, int index) { return (TEntry) buckets.get(index); } + /** + * 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. Must be called under the table's write lock; + * does not touch size accounting. + */ + public static void unlink( + AtomicReferenceArray buckets, + int index, + ConcurrentHashtable.Entry prev, + ConcurrentHashtable.Entry entry) { + ConcurrentHashtable.Entry next = entry.next(); + if (prev == null) { + buckets.set(index, next); + } else { + prev.setNext(next); + } + } + + /** + * Removes every entry matching {@code predicate} from {@code buckets}, decrementing {@code + * size} once per removal. Must be called under the table's write lock. + */ + @SuppressWarnings("unchecked") + public static boolean removeIf( + AtomicReferenceArray buckets, + AtomicInteger size, + Predicate predicate) { + boolean removed = false; + for (int i = 0; i < buckets.length(); i++) { + ConcurrentHashtable.Entry prev = null; + for (ConcurrentHashtable.Entry e = buckets.get(i); e != null; e = e.next()) { + if (predicate.test((TEntry) 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; + } + + /** + * Removes every entry, passing each to {@code sink} as its bucket is cleared. Each bucket head + * is nulled (a volatile write that publishes the removal) before its chain is fed to {@code + * sink}, so new readers see an empty bucket while the detached chain — whose {@code next} + * pointers stay intact — is handed to the caller. Must be called under the table's write lock; + * does not touch size accounting. + */ + @SuppressWarnings("unchecked") + public static void drain( + AtomicReferenceArray buckets, Consumer sink) { + for (int i = 0; i < buckets.length(); i++) { + ConcurrentHashtable.Entry head = buckets.get(i); + if (head == null) { + continue; + } + buckets.set(i, null); + for (ConcurrentHashtable.Entry e = head; e != null; e = e.next()) { + sink.accept((TEntry) e); + } + } + } + + /** Context-passing variant of {@link #drain(AtomicReferenceArray, Consumer)}. */ + @SuppressWarnings("unchecked") + public static void drain( + AtomicReferenceArray buckets, + T context, + BiConsumer sink) { + for (int i = 0; i < buckets.length(); i++) { + ConcurrentHashtable.Entry head = buckets.get(i); + if (head == null) { + continue; + } + buckets.set(i, null); + for (ConcurrentHashtable.Entry e = head; e != null; e = e.next()) { + sink.accept(context, (TEntry) e); + } + } + } + + /** Nulls every bucket head. Must be called under the table's write lock. */ + public static void clear(AtomicReferenceArray buckets) { + for (int i = 0; i < buckets.length(); i++) { + buckets.set(i, null); + } + } + @SuppressWarnings("unchecked") - public static void forEach( - AtomicReferenceArray buckets, Consumer consumer) { + public static void forEach( + AtomicReferenceArray buckets, + Consumer consumer) { for (int i = 0; i < buckets.length(); i++) { for (TEntry te = (TEntry) buckets.get(i); te != null; te = te.next()) { consumer.accept(te); @@ -247,8 +603,8 @@ public static void forEach( } @SuppressWarnings("unchecked") - public static void forEach( - AtomicReferenceArray buckets, + public static void forEach( + AtomicReferenceArray buckets, T context, BiConsumer consumer) { for (int i = 0; i < buckets.length(); i++) { diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java index aff0c47537a..1849a6e6f78 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java @@ -1,14 +1,17 @@ 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 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; @@ -153,8 +156,7 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException for (int i = 0; i < threads; i++) { keys[i] = "key-" + i; } - ConcurrentHashtable.D1 table = - new ConcurrentHashtable.D1<>(threads * 2); + ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(threads * 2); CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -187,8 +189,179 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException } } - // Reuses Hashtable.D1.Entry — ConcurrentHashtable.D1 accepts any D1.Entry subclass. - private static final class StringEntry extends Hashtable.D1.Entry { + @Test + void removeReturnsEntryAndShrinks() { + ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + StringEntry a = table.getOrCreate("a", k -> new StringEntry(k, 1)); + table.getOrCreate("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 = new ConcurrentHashtable.D1<>(8); + table.getOrCreate("a", k -> new StringEntry(k, 1)); + assertNull(table.remove("missing")); + assertEquals(1, table.size()); + } + + @Test + void removeHeadMiddleAndTailOfSameBucketChain() { + // Capacity 1 forces every key into a single bucket, so a, b, c form one chain. + ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(1); + CollidingKey a = new CollidingKey("a", 0); + CollidingKey b = new CollidingKey("b", 0); + CollidingKey c = new CollidingKey("c", 0); + table.getOrCreate(a, CollidingEntry::new); + table.getOrCreate(b, CollidingEntry::new); + table.getOrCreate(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 = new ConcurrentHashtable.D1<>(16); + for (int i = 0; i < 10; i++) { + final int v = i; + table.getOrCreate("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 = new ConcurrentHashtable.D1<>(8); + table.getOrCreate("a", k -> new StringEntry(k, 1)); + assertFalse(table.removeIf(e -> false)); + assertEquals(1, table.size()); + } + + @Test + void clearEmptiesTableAndLeavesItUsable() { + ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + table.getOrCreate("a", k -> new StringEntry(k, 1)); + table.getOrCreate("b", k -> new StringEntry(k, 2)); + table.clear(); + assertEquals(0, table.size()); + assertNull(table.get("a")); + assertNull(table.get("b")); + StringEntry c = table.getOrCreate("c", k -> new StringEntry(k, 3)); + assertSame(c, table.get("c")); + assertEquals(1, table.size()); + } + + @Test + void drainRemovesEveryEntryAndFeedsSink() { + ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + table.getOrCreate("a", k -> new StringEntry(k, 1)); + table.getOrCreate("b", k -> new StringEntry(k, 2)); + table.getOrCreate("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.getOrCreate("d", k -> new StringEntry(k, 4)); + assertSame(d, table.get("d")); + assertEquals(1, table.size()); + } + + @Test + void drainWithContextFeedsSink() { + ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + table.getOrCreate("a", k -> new StringEntry(k, 1)); + table.getOrCreate("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 = new ConcurrentHashtable.D1<>(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 { + // Capacity 1 puts every key in one bucket so removal splices a chain the reader is walking. + ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(1); + int n = 8; + CollidingKey[] keys = new CollidingKey[n]; + for (int i = 0; i < n; i++) { + keys[i] = new CollidingKey("k" + i, 0); + table.getOrCreate(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.getOrCreate(churn, CollidingEntry::new); + } + stop.set(true); + reader.join(); + + assertEquals(0, missed.get(), "stable chain members must never be unreachable during removal"); + } + + private static final class StringEntry extends ConcurrentHashtable.D1.Entry { final int value; StringEntry(String key, int value) { @@ -222,7 +395,7 @@ public boolean equals(Object o) { } } - private static final class CollidingEntry extends Hashtable.D1.Entry { + 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 index 46089bf6563..ebb519e4788 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java @@ -1,11 +1,13 @@ 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 java.util.Arrays; import java.util.HashSet; import java.util.Set; import java.util.concurrent.CountDownLatch; @@ -189,7 +191,108 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException } } - private static final class PairEntry extends Hashtable.D2.Entry { + @Test + void removeReturnsEntryAndShrinks() { + ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + PairEntry ab = table.getOrCreate("a", 1, PairEntry::new); + table.getOrCreate("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 = new ConcurrentHashtable.D2<>(8); + table.getOrCreate("a", 1, PairEntry::new); + assertNull(table.remove("a", 99)); + assertNull(table.remove("z", 1)); + assertEquals(1, table.size()); + } + + @Test + void removeMiddleOfSameBucketChainKeepsOthersReachable() { + // Capacity 1 forces every pair into a single bucket chain. + ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(1); + table.getOrCreate("a", 1, PairEntry::new); + PairEntry mid = table.getOrCreate("a", 2, PairEntry::new); + table.getOrCreate("a", 3, PairEntry::new); + + assertSame(mid, table.remove("a", 2)); + assertNull(table.get("a", 2)); + assertNotNull(table.get("a", 1)); + assertNotNull(table.get("a", 3)); + assertEquals(2, table.size()); + } + + @Test + void removeIfRemovesMatchingEntries() { + ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(16); + for (int i = 0; i < 10; i++) { + table.getOrCreate("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 = new ConcurrentHashtable.D2<>(8); + table.getOrCreate("a", 1, PairEntry::new); + assertFalse(table.removeIf(e -> false)); + assertEquals(1, table.size()); + } + + @Test + void clearEmptiesTableAndLeavesItUsable() { + ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + table.getOrCreate("a", 1, PairEntry::new); + table.getOrCreate("b", 2, PairEntry::new); + table.clear(); + assertEquals(0, table.size()); + assertNull(table.get("a", 1)); + PairEntry c = table.getOrCreate("c", 3, PairEntry::new); + assertSame(c, table.get("c", 3)); + assertEquals(1, table.size()); + } + + @Test + void drainRemovesEveryEntryAndFeedsSink() { + ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + table.getOrCreate("a", 1, PairEntry::new); + table.getOrCreate("a", 2, PairEntry::new); + table.getOrCreate("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.getOrCreate("c", 3, PairEntry::new); + assertSame(c, table.get("c", 3)); + assertEquals(1, table.size()); + } + + @Test + void drainWithContextFeedsSink() { + ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + table.getOrCreate("a", 1, PairEntry::new); + table.getOrCreate("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()); + } + + private static final class PairEntry extends ConcurrentHashtable.D2.Entry { PairEntry(String key1, Integer key2) { super(key1, key2); } From 42c0616cb140e3e48417537ccf511dd637bf91dd Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 23 Jun 2026 18:05:48 -0400 Subject: [PATCH 14/31] Note interned-key lookups in ThreadSafeMap benchmarks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lookups reuse the interned KEYS/SOURCE_* instances used to populate the table, so they exercise the == identity fast path — deliberate and realistic for the tracer (keys are typically interned tag-name constants), not an oversight. Clarifies so it isn't misread against the equals()-path numbers elsewhere. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../datadog/trace/util/ThreadSafeMapCounterBenchmark.java | 5 +++++ .../java/datadog/trace/util/ThreadSafeMapD1Benchmark.java | 6 ++++++ .../java/datadog/trace/util/ThreadSafeMapD2Benchmark.java | 6 ++++++ 3 files changed, 17 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java index 34ba1e485b8..6fc2b160e01 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java @@ -40,6 +40,11 @@ * slightly higher memory and a more expensive {@code sum()}. * * + *

    Key identity. Lookups reuse the same interned {@code KEYS} instances used to populate + * the table, so they hit the {@code ==} identity fast path rather than {@code equals()}. This is + * deliberate and realistic for the tracer, whose keys are typically interned string literals + * (tag-name constants); it is not an oversight. + * *

    Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys): * *

    {@code
    diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java
    index 6dfa6bcca3e..e8f69b1d893 100644
    --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java
    +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java
    @@ -41,6 +41,12 @@
      *       operation. Establishes the coarse-locking baseline.
      * 
      *
    + * 

    Key identity. Lookups reuse the same interned {@code KEYS} instances used to populate + * the table, so they hit the {@code ==} identity fast path rather than {@code equals()}. This is + * deliberate and realistic for the tracer, whose map keys are typically interned string literals + * (tag-name constants); it is not an oversight. ({@code ImmutableMapBenchmark} covers the + * distinct-instance {@code equals()} path explicitly via its {@code _sameKey} vs default variants.) + * *

    Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys): * *

    {@code
    diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java
    index f2b0fff7210..777716ccbb2 100644
    --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java
    +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java
    @@ -48,6 +48,12 @@
      *       operation; allocates {@link Key2} per lookup. Establishes the coarse-locking baseline.
      * 
      *
    + * 

    Key identity. Lookups reuse the same interned {@code SOURCE_K1} strings and cached + * {@code SOURCE_K2} Integers used to populate the table, so the key-part comparisons hit the {@code + * ==} identity fast path rather than {@code equals()}. This is deliberate and realistic for the + * tracer, whose keys are typically interned literals (tag-name constants) and small boxed ints; it + * is not an oversight. + * *

    Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys): * *

    {@code
    
    From 69df37bba8c9943fcc95bf92f77be74926ba4d44 Mon Sep 17 00:00:00 2001
    From: Douglas Q Hawkins 
    Date: Wed, 29 Jul 2026 08:28:53 -0400
    Subject: [PATCH 15/31] Reshape ConcurrentHashtable to match Hashtable +
     FlatHashtable family shape
    
    Flatten the nested Support class onto the ConcurrentHashtable namespace
    (static fns over a caller-owned AtomicReferenceArray, mirroring FlatHashtable)
    and type the bucket arrays AtomicReferenceArray so the unchecked casts
    on the bucket read paths disappear.
    
    - createFixedBuckets(entryClass, capacity) factories on ConcurrentHashtable
      (returns the raw spine), D1, and D2 (return a D1/D2); D1(int)/D2(int) ctors
      are now private. entryClass is a symmetry + type-inference anchor here (the
      AtomicReferenceArray spine is erased, so it isn't consumed for allocation the
      way FlatHashtable's E[] is).
    - key()/key1()/key2() accessors on D1.Entry/D2.Entry to match Hashtable
      post-#12044.
    - Context-passing forEach/drain overloads use  for the context type param.
    - Double-checked-locking + lock-striping recipes moved to the class Javadoc.
    
    Co-Authored-By: Claude Opus 4.8 
    ---
     .../trace/util/ConcurrentHashtable.java       | 444 ++++++++++--------
     1 file changed, 241 insertions(+), 203 deletions(-)
    
    diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java
    index e06f027e05a..262e4a5ea9c 100644
    --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java
    +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java
    @@ -47,6 +47,32 @@
      * simply unreachable for new lookups and becomes garbage once no in-flight reader references it. A
      * concurrent lookup racing a removal may observe either the pre- or post-removal state — both are
      * valid linearizations.
    + *
    + * 

    Custom tables (higher arity / primitive keys). Use {@link D1} or {@link D2} when their + * object-key constraints are acceptable — they handle synchronization internally. When you need + * primitive key components, three-or-more key parts, or want to own the lock strategy, drive the + * table yourself with the static building blocks on this class: allocate the spine with {@link + * #createFixedBuckets(Class, int)}, then operate on it with {@link #bucket}, {@link #unlink}, + * {@link #removeIf}, {@link #drain}, {@link #clear}, and {@link #forEach}. This is the same "static + * functions over a caller-owned array" shape as {@link Hashtable} (see how {@code AggregateTable} + * uses {@code Hashtable}); the calling class then owns the array and exposes whatever operations it + * needs. Subclass {@link Entry} directly for such tables. + * + *

    Write path for custom tables. Writes are the caller's responsibility. Use the same + * double-checked locking pattern that {@link D1} and {@link D2} use internally: + * + *

      + *
    1. Lock-free pre-check: walk the chain via {@link #bucket}; return if found. + *
    2. Acquire a lock on a stable object owned by the same class that owns the {@code buckets} + * array (typically {@code synchronized (this)}). + *
    3. Re-check under the lock (another thread may have inserted between step 1 and step 2). + *
    4. Build the new entry, set its {@code next} via {@link Entry#setNext}, then write it to the + * bucket with {@link AtomicReferenceArray#set} (volatile write). + *
    + * + *

    Because the caller owns the lock object, custom tables can lock-stripe: shard the lock + * by bucket index or key hash to reduce write-path contention if profiling shows the single + * table-level lock (used by {@link D1}/{@link D2}) is a bottleneck. */ public final class ConcurrentHashtable { private ConcurrentHashtable() {} @@ -60,7 +86,8 @@ private ConcurrentHashtable() {} * *

    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 mechanics with {@link Support}. + * 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; @@ -102,6 +129,11 @@ protected Entry(K key) { this.key = key; } + /** The key this entry was created with. */ + public K key() { + return this.key; + } + public boolean matches(Object key) { return Objects.equals(this.key, key); } @@ -116,11 +148,24 @@ public static long hash(Object key) { } } - private final AtomicReferenceArray buckets; + private final AtomicReferenceArray buckets; private final AtomicInteger size = new AtomicInteger(); - public D1(int capacity) { - this.buckets = new AtomicReferenceArray<>(Support.sizeFor(capacity)); + private D1(AtomicReferenceArray buckets) { + this.buckets = buckets; + } + + /** + * Creates a single-key table with a fixed bucket count sized for {@code capacity} entries. The + * {@code entryClass} pins the concrete entry type so the compiler infers both {@code K} and + * {@code TEntry} at the call site — e.g. {@code D1.createFixedBuckets(MyEntry.class, 64)} — and + * keeps the factory symmetric with the rest of the flat-collections family (see {@link + * ConcurrentHashtable#createFixedBuckets(Class, int)} for why the class isn't otherwise + * consumed here). Capacity is fixed; the table does not resize. + */ + public static > D1 createFixedBuckets( + Class entryClass, int capacity) { + return new D1<>(ConcurrentHashtable.createFixedBuckets(entryClass, capacity)); } public int size() { @@ -129,7 +174,7 @@ public int size() { public TEntry get(K key) { long keyHash = D1.Entry.hash(key); - for (TEntry te = Support.bucket(buckets, keyHash); te != null; te = te.next()) { + for (TEntry te = bucket(buckets, keyHash); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { return te; } @@ -144,20 +189,20 @@ public TEntry get(K key) { */ public TEntry getOrCreate(K key, Function creator) { long keyHash = D1.Entry.hash(key); - int index = Support.bucketIndex(buckets, keyHash); - for (TEntry te = Support.bucket(buckets, index); te != null; te = te.next()) { + int index = bucketIndex(buckets, keyHash); + for (TEntry te = bucket(buckets, index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { return te; } } synchronized (this) { - for (TEntry te = Support.bucket(buckets, index); te != null; te = te.next()) { + for (TEntry te = bucket(buckets, index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { return te; } } TEntry newEntry = creator.apply(key); - newEntry.setNext(Support.bucket(buckets, index)); + newEntry.setNext(bucket(buckets, index)); buckets.set(index, newEntry); size.incrementAndGet(); return newEntry; @@ -171,12 +216,12 @@ public TEntry getOrCreate(K key, Function creator) */ public TEntry remove(K key) { long keyHash = D1.Entry.hash(key); - int index = Support.bucketIndex(buckets, keyHash); + int index = bucketIndex(buckets, keyHash); synchronized (this) { - ConcurrentHashtable.Entry prev = null; - for (TEntry te = Support.bucket(buckets, index); te != null; prev = te, te = te.next()) { + TEntry prev = null; + for (TEntry te = bucket(buckets, index); te != null; prev = te, te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { - Support.unlink(buckets, index, prev, te); + unlink(buckets, index, prev, te); size.decrementAndGet(); return te; } @@ -192,7 +237,7 @@ public TEntry remove(K key) { */ public boolean removeIf(Predicate predicate) { synchronized (this) { - return Support.removeIf(buckets, size, predicate); + return ConcurrentHashtable.removeIf(buckets, size, predicate); } } @@ -210,7 +255,7 @@ public boolean removeIf(Predicate predicate) { */ public void drain(Consumer sink) { synchronized (this) { - Support.drain(buckets, sink); + ConcurrentHashtable.drain(buckets, sink); size.set(0); } } @@ -220,9 +265,9 @@ public void drain(Consumer sink) { * 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(T context, BiConsumer sink) { + public void drain(C context, BiConsumer sink) { synchronized (this) { - Support.drain(buckets, context, sink); + ConcurrentHashtable.drain(buckets, context, sink); size.set(0); } } @@ -230,21 +275,21 @@ public void drain(T context, BiConsumer sink) { /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ public void clear() { synchronized (this) { - Support.clear(buckets); + ConcurrentHashtable.clear(buckets); size.set(0); } } public void forEach(Consumer consumer) { - Support.forEach(buckets, consumer); + ConcurrentHashtable.forEach(buckets, 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(T context, BiConsumer consumer) { - Support.forEach(buckets, context, consumer); + public void forEach(C context, BiConsumer consumer) { + ConcurrentHashtable.forEach(buckets, context, consumer); } } @@ -278,6 +323,16 @@ protected Entry(K1 key1, K2 key2) { this.key2 = key2; } + /** The first key part this entry was created with. */ + public K1 key1() { + return this.key1; + } + + /** The second key part this entry was created with. */ + public K2 key2() { + return this.key2; + } + public boolean matches(K1 key1, K2 key2) { return Objects.equals(this.key1, key1) && Objects.equals(this.key2, key2); } @@ -288,11 +343,24 @@ public static long hash(Object key1, Object key2) { } } - private final AtomicReferenceArray buckets; + private final AtomicReferenceArray buckets; private final AtomicInteger size = new AtomicInteger(); - public D2(int capacity) { - this.buckets = new AtomicReferenceArray<>(Support.sizeFor(capacity)); + private D2(AtomicReferenceArray buckets) { + this.buckets = buckets; + } + + /** + * Creates a composite-key table with a fixed bucket count sized for {@code capacity} entries. + * The {@code entryClass} pins the concrete entry type so the compiler infers {@code K1}, {@code + * K2}, and {@code TEntry} at the call site — e.g. {@code D2.createFixedBuckets(MyEntry.class, + * 64)} — and keeps the factory symmetric with the rest of the flat-collections family (see + * {@link ConcurrentHashtable#createFixedBuckets(Class, int)} for why the class isn't otherwise + * consumed here). Capacity is fixed; the table does not resize. + */ + public static > D2 createFixedBuckets( + Class entryClass, int capacity) { + return new D2<>(ConcurrentHashtable.createFixedBuckets(entryClass, capacity)); } public int size() { @@ -301,7 +369,7 @@ public int size() { public TEntry get(K1 key1, K2 key2) { long keyHash = D2.Entry.hash(key1, key2); - for (TEntry te = Support.bucket(buckets, keyHash); te != null; te = te.next()) { + for (TEntry te = bucket(buckets, keyHash); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { return te; } @@ -320,20 +388,20 @@ public TEntry get(K1 key1, K2 key2) { public TEntry getOrCreate( K1 key1, K2 key2, BiFunction creator) { long keyHash = D2.Entry.hash(key1, key2); - int index = Support.bucketIndex(buckets, keyHash); - for (TEntry te = Support.bucket(buckets, index); te != null; te = te.next()) { + int index = bucketIndex(buckets, keyHash); + for (TEntry te = bucket(buckets, index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { return te; } } synchronized (this) { - for (TEntry te = Support.bucket(buckets, index); te != null; te = te.next()) { + for (TEntry te = bucket(buckets, index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { return te; } } TEntry newEntry = creator.apply(key1, key2); - newEntry.setNext(Support.bucket(buckets, index)); + newEntry.setNext(bucket(buckets, index)); buckets.set(index, newEntry); size.incrementAndGet(); return newEntry; @@ -347,12 +415,12 @@ public TEntry getOrCreate( */ public TEntry remove(K1 key1, K2 key2) { long keyHash = D2.Entry.hash(key1, key2); - int index = Support.bucketIndex(buckets, keyHash); + int index = bucketIndex(buckets, keyHash); synchronized (this) { - ConcurrentHashtable.Entry prev = null; - for (TEntry te = Support.bucket(buckets, index); te != null; prev = te, te = te.next()) { + TEntry prev = null; + for (TEntry te = bucket(buckets, index); te != null; prev = te, te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { - Support.unlink(buckets, index, prev, te); + unlink(buckets, index, prev, te); size.decrementAndGet(); return te; } @@ -368,7 +436,7 @@ public TEntry remove(K1 key1, K2 key2) { */ public boolean removeIf(Predicate predicate) { synchronized (this) { - return Support.removeIf(buckets, size, predicate); + return ConcurrentHashtable.removeIf(buckets, size, predicate); } } @@ -386,7 +454,7 @@ public boolean removeIf(Predicate predicate) { */ public void drain(Consumer sink) { synchronized (this) { - Support.drain(buckets, sink); + ConcurrentHashtable.drain(buckets, sink); size.set(0); } } @@ -396,9 +464,9 @@ public void drain(Consumer sink) { * 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(T context, BiConsumer sink) { + public void drain(C context, BiConsumer sink) { synchronized (this) { - Support.drain(buckets, context, sink); + ConcurrentHashtable.drain(buckets, context, sink); size.set(0); } } @@ -406,211 +474,181 @@ public void drain(T context, BiConsumer sink) { /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ public void clear() { synchronized (this) { - Support.clear(buckets); + ConcurrentHashtable.clear(buckets); size.set(0); } } public void forEach(Consumer consumer) { - Support.forEach(buckets, consumer); + ConcurrentHashtable.forEach(buckets, 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(T context, BiConsumer consumer) { - Support.forEach(buckets, context, consumer); + public void forEach(C context, BiConsumer consumer) { + ConcurrentHashtable.forEach(buckets, context, consumer); } } + // --------------------------------------------------------------------------------------------- + // 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, or caller-owned locking) + // when D1/D2 don't fit; D1/D2 delegate to them internally. + // --------------------------------------------------------------------------------------------- + /** - * Building blocks for concurrent hash-table operations. - * - *

    Use {@link D1} or {@link D2} when their object-key constraints are acceptable — they handle - * synchronization internally. Use {@code Support} directly only when you need primitive key - * components or other entry-level flexibility that {@code D1}/{@code D2} cannot provide. - * - *

    Read path. {@link #bucket} performs a volatile read of the bucket slot and is safe to - * call from any thread without a lock; chain {@code next} pointers are volatile, so chain walks - * are lock-free. + * Allocates a fixed-size bucket array sized to hold {@code capacity} entries: {@code capacity} + * rounded up to the next power of two. * - *

    Write path (insert). Writes are the caller's responsibility. Use the same - * double-checked locking pattern that {@link D1} and {@link D2} use internally: - * - *

      - *
    1. Lock-free pre-check: walk the chain via {@link #bucket}; return if found. - *
    2. Acquire a lock on a stable object owned by the same class that owns the {@code buckets} - * array (typically {@code synchronized (this)}). - *
    3. Re-check under the lock (another thread may have inserted between step 1 and step 2). - *
    4. Build the new entry, set its {@code next} via {@link Entry#setNext}, then write it to the - * bucket with {@link AtomicReferenceArray#set} (volatile write). - *
    - * - *

    Write path (remove). Under the lock, splice the entry out with {@link #unlink}: it - * re-points the predecessor's {@code next} (or the bucket head) past the removed entry via a - * volatile write that lock-free readers observe. The removed entry's own {@code next} is left - * intact so a reader already positioned on it can still traverse forward to the rest of the - * chain. For full or predicate-driven sweeps, hold the lock and call {@link #removeIf} or {@link - * #clear}. - * - *

    One advantage of using {@code Support} directly over {@link D1}/{@link D2} is that the - * caller controls the lock object, enabling lock striping: shard the lock by bucket index or key - * hash to reduce write-path contention if profiling shows the single table-level lock is a - * bottleneck. + *

    Unlike {@code FlatHashtable}, whose open-addressing spine is a genuine {@code E[]} that must + * be reflectively allocated from {@code entryClass}, the concurrent spine is an {@link + * AtomicReferenceArray} whose element type is erased — so {@code entryClass} is not used + * to allocate here. It is accepted purely to (a) keep the factory symmetric with the rest of the + * flat-collections family and (b) act as a type-inference anchor so callers write {@code + * createFixedBuckets(MyEntry.class, n)} and get back a precisely typed {@code + * AtomicReferenceArray} without an explicit witness. */ - public static final class Support { - private Support() {} + public static AtomicReferenceArray createFixedBuckets( + Class entryClass, int capacity) { + return new AtomicReferenceArray<>(sizeFor(capacity)); + } - /** - * 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. - */ - public static int sizeFor(int requestedSize) { - return Hashtable.Support.sizeFor(requestedSize); - } + /** + * 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. Shares {@link Hashtable}'s + * sizing so the two families round identically. + */ + public static int sizeFor(int requestedSize) { + return Hashtable.Support.sizeFor(requestedSize); + } - public static int bucketIndex( - AtomicReferenceArray buckets, long keyHash) { - return (int) (keyHash & (buckets.length() - 1)); - } + public static int bucketIndex(AtomicReferenceArray buckets, long keyHash) { + return (int) (keyHash & (buckets.length() - 1)); + } - /** - * Returns the head entry of the bucket that {@code keyHash} maps to, cast to the caller's - * concrete entry type. The unchecked cast lives here so chain-walk loops at call sites don't - * need to thread a raw {@link Entry} variable through. - */ - @SuppressWarnings("unchecked") - public static TEntry bucket( - AtomicReferenceArray buckets, long keyHash) { - return (TEntry) buckets.get(bucketIndex(buckets, keyHash)); - } + /** + * 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. + */ + public static TEntry bucket( + AtomicReferenceArray buckets, long keyHash) { + return buckets.get(bucketIndex(buckets, keyHash)); + } - /** - * Returns the head entry of the bucket at {@code index}, cast to the caller's concrete entry - * type. Use when the bucket index is already computed (e.g. inside {@code getOrCreate} where - * the same index is reused across the lock boundary). - */ - @SuppressWarnings("unchecked") - public static TEntry bucket( - AtomicReferenceArray buckets, int index) { - return (TEntry) buckets.get(index); - } + /** + * 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). + */ + public static TEntry bucket( + AtomicReferenceArray buckets, int index) { + return buckets.get(index); + } - /** - * 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. Must be called under the table's write lock; - * does not touch size accounting. - */ - public static void unlink( - AtomicReferenceArray buckets, - int index, - ConcurrentHashtable.Entry prev, - ConcurrentHashtable.Entry entry) { - ConcurrentHashtable.Entry next = entry.next(); - if (prev == null) { - buckets.set(index, next); - } else { - prev.setNext(next); - } + /** + * 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. Must be called under the table's write lock; does + * not touch size accounting. + */ + public static void unlink( + AtomicReferenceArray buckets, int index, TEntry prev, TEntry entry) { + TEntry next = entry.next(); + if (prev == null) { + buckets.set(index, next); + } else { + prev.setNext(next); } + } - /** - * Removes every entry matching {@code predicate} from {@code buckets}, decrementing {@code - * size} once per removal. Must be called under the table's write lock. - */ - @SuppressWarnings("unchecked") - public static boolean removeIf( - AtomicReferenceArray buckets, - AtomicInteger size, - Predicate predicate) { - boolean removed = false; - for (int i = 0; i < buckets.length(); i++) { - ConcurrentHashtable.Entry prev = null; - for (ConcurrentHashtable.Entry e = buckets.get(i); e != null; e = e.next()) { - if (predicate.test((TEntry) 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; - } + /** + * Removes every entry matching {@code predicate} from {@code buckets}, decrementing {@code size} + * once per removal. Must be called under the table's write lock. + */ + public static boolean removeIf( + AtomicReferenceArray buckets, + AtomicInteger size, + Predicate predicate) { + 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; } + return removed; + } - /** - * Removes every entry, passing each to {@code sink} as its bucket is cleared. Each bucket head - * is nulled (a volatile write that publishes the removal) before its chain is fed to {@code - * sink}, so new readers see an empty bucket while the detached chain — whose {@code next} - * pointers stay intact — is handed to the caller. Must be called under the table's write lock; - * does not touch size accounting. - */ - @SuppressWarnings("unchecked") - public static void drain( - AtomicReferenceArray buckets, Consumer sink) { - for (int i = 0; i < buckets.length(); i++) { - ConcurrentHashtable.Entry head = buckets.get(i); - if (head == null) { - continue; - } - buckets.set(i, null); - for (ConcurrentHashtable.Entry e = head; e != null; e = e.next()) { - sink.accept((TEntry) e); - } + /** + * Removes every entry, passing each to {@code sink} as its bucket is cleared. Each bucket head is + * nulled (a volatile write that publishes the removal) before its chain is fed to {@code sink}, + * so new readers see an empty bucket while the detached chain — whose {@code next} pointers stay + * intact — is handed to the caller. Must be called under the table's write lock; does not touch + * size accounting. + */ + public static void drain( + AtomicReferenceArray buckets, Consumer sink) { + 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()) { + sink.accept(e); } } + } - /** Context-passing variant of {@link #drain(AtomicReferenceArray, Consumer)}. */ - @SuppressWarnings("unchecked") - public static void drain( - AtomicReferenceArray buckets, - T context, - BiConsumer sink) { - for (int i = 0; i < buckets.length(); i++) { - ConcurrentHashtable.Entry head = buckets.get(i); - if (head == null) { - continue; - } - buckets.set(i, null); - for (ConcurrentHashtable.Entry e = head; e != null; e = e.next()) { - sink.accept(context, (TEntry) e); - } + /** Context-passing variant of {@link #drain(AtomicReferenceArray, Consumer)}. */ + public static void drain( + AtomicReferenceArray buckets, C context, BiConsumer sink) { + 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()) { + sink.accept(context, e); } } + } - /** Nulls every bucket head. Must be called under the table's write lock. */ - public static void clear(AtomicReferenceArray buckets) { - for (int i = 0; i < buckets.length(); i++) { - buckets.set(i, null); - } + /** Nulls every bucket head. Must be called under the table's write lock. */ + public static void clear(AtomicReferenceArray buckets) { + for (int i = 0; i < buckets.length(); i++) { + buckets.set(i, null); } + } - @SuppressWarnings("unchecked") - public static void forEach( - AtomicReferenceArray buckets, - Consumer consumer) { - for (int i = 0; i < buckets.length(); i++) { - for (TEntry te = (TEntry) buckets.get(i); te != null; te = te.next()) { - consumer.accept(te); - } + public static void forEach( + AtomicReferenceArray buckets, Consumer consumer) { + for (int i = 0; i < buckets.length(); i++) { + for (TEntry te = buckets.get(i); te != null; te = te.next()) { + consumer.accept(te); } } + } - @SuppressWarnings("unchecked") - public static void forEach( - AtomicReferenceArray buckets, - T context, - BiConsumer consumer) { - for (int i = 0; i < buckets.length(); i++) { - for (TEntry te = (TEntry) buckets.get(i); te != null; te = te.next()) { - consumer.accept(context, te); - } + public static void forEach( + AtomicReferenceArray buckets, + C context, + BiConsumer consumer) { + for (int i = 0; i < buckets.length(); i++) { + for (TEntry te = buckets.get(i); te != null; te = te.next()) { + consumer.accept(context, te); } } } From 5829238fdee47cbd8e02d1973d4bc610e8786d69 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 08:28:56 -0400 Subject: [PATCH 16/31] Update ConcurrentHashtable tests + benchmarks to createFixedBuckets API Move D1/D2 tests and the ThreadSafeMap{Counter,D1,D2} benchmarks off the removed public ctors / Support class onto createFixedBuckets and the flattened ConcurrentHashtable.* static fns. The D2 benchmark's raw-array custom-entry arm now drives a typed AtomicReferenceArray. Co-Authored-By: Claude Opus 4.8 --- .../util/ThreadSafeMapCounterBenchmark.java | 2 +- .../trace/util/ThreadSafeMapD1Benchmark.java | 2 +- .../trace/util/ThreadSafeMapD2Benchmark.java | 29 +++++----- .../trace/util/ConcurrentHashtableD1Test.java | 57 ++++++++++++------- .../trace/util/ConcurrentHashtableD2Test.java | 47 +++++++++------ 5 files changed, 85 insertions(+), 52 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java index 6fc2b160e01..311f2eae201 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java @@ -112,7 +112,7 @@ public static class SharedState { @Setup(Level.Iteration) public void setUp() { - table = new ConcurrentHashtable.D1<>(CAPACITY); + table = ConcurrentHashtable.D1.createFixedBuckets(CounterEntry.class, CAPACITY); atomicLongMap = new ConcurrentHashMap<>(CAPACITY); longAdderMap = new ConcurrentHashMap<>(CAPACITY); for (int i = 0; i < N_KEYS; ++i) { diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java index e8f69b1d893..091b6c9fe60 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java @@ -116,7 +116,7 @@ public static class SharedState { @Setup(Level.Iteration) public void setUp() { - table = new ConcurrentHashtable.D1<>(CAPACITY); + table = ConcurrentHashtable.D1.createFixedBuckets(D1Entry.class, CAPACITY); concurrentHashMap = new ConcurrentHashMap<>(CAPACITY); skipListMap = new ConcurrentSkipListMap<>(); synchronizedHashMap = Collections.synchronizedMap(new HashMap<>(CAPACITY)); diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java index 777716ccbb2..1891cfbe932 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -36,9 +36,10 @@ *

  • {@link ConcurrentHashtable.D2} — lock-free reads, no composite key allocation per lookup. * K2 is {@link Integer} (boxed), so EA may still eliminate the box on hits, but the * allocation is observable on misses. - *
  • {@link ConcurrentHashtable.Support} (custom entry) — same lock-free read path, but K2 is a - * primitive {@code int} embedded directly in the entry. No boxing at any point; demonstrates - * the flexibility available when {@code D2}'s object-key constraint is too limiting. + *
  • {@link ConcurrentHashtable} building blocks (custom entry) — same lock-free read path, but + * K2 is a primitive {@code int} embedded directly in the entry. No boxing at any point; + * demonstrates the flexibility available when {@code D2}'s object-key constraint is too + * limiting. *
  • {@link ConcurrentHashMap} — striped locking, allocates a {@link Key2} wrapper per lookup * (boxes the {@code int} K2 inside). *
  • {@link ConcurrentSkipListMap} — fully lock-free (CAS), but pays tree traversal and {@link @@ -185,17 +186,15 @@ public int compareTo(Key2 other) { @State(Scope.Benchmark) public static class SharedState { ConcurrentHashtable.D2 table; - java.util.concurrent.atomic.AtomicReferenceArray supportBuckets; + java.util.concurrent.atomic.AtomicReferenceArray supportBuckets; ConcurrentHashMap concurrentHashMap; ConcurrentSkipListMap skipListMap; Map synchronizedHashMap; @Setup(Level.Iteration) public void setUp() { - table = new ConcurrentHashtable.D2<>(CAPACITY); - supportBuckets = - new java.util.concurrent.atomic.AtomicReferenceArray<>( - ConcurrentHashtable.Support.sizeFor(CAPACITY)); + table = ConcurrentHashtable.D2.createFixedBuckets(D2Entry.class, CAPACITY); + supportBuckets = ConcurrentHashtable.createFixedBuckets(SupportEntry.class, CAPACITY); concurrentHashMap = new ConcurrentHashMap<>(CAPACITY); skipListMap = new ConcurrentSkipListMap<>(); synchronizedHashMap = Collections.synchronizedMap(new HashMap<>(CAPACITY)); @@ -204,8 +203,8 @@ public void setUp() { table.getOrCreate(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new); // populate support table SupportEntry se = new SupportEntry(SOURCE_K1[i], k2); - int idx = ConcurrentHashtable.Support.bucketIndex(supportBuckets, se.keyHash); - se.setNext(ConcurrentHashtable.Support.bucket(supportBuckets, idx)); + int idx = ConcurrentHashtable.bucketIndex(supportBuckets, se.keyHash); + se.setNext(ConcurrentHashtable.bucket(supportBuckets, idx)); supportBuckets.set(idx, se); Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]); concurrentHashMap.put(key, (long) i); @@ -239,7 +238,7 @@ public SupportEntry get_support(SharedState s, ThreadState t) { String k1 = SOURCE_K1[i]; int k2 = SOURCE_K2_INT[i]; long keyHash = SupportEntry.hash(k1, k2); - for (SupportEntry e = ConcurrentHashtable.Support.bucket(s.supportBuckets, keyHash); + for (SupportEntry e = ConcurrentHashtable.bucket(s.supportBuckets, keyHash); e != null; e = e.next()) { if (e.keyHash == keyHash && e.matches(k1, k2)) { @@ -279,8 +278,8 @@ public SupportEntry getOrCreate_support(SharedState s, ThreadState t) { String k1 = SOURCE_K1[i]; int k2 = SOURCE_K2_INT[i]; long keyHash = SupportEntry.hash(k1, k2); - int index = ConcurrentHashtable.Support.bucketIndex(s.supportBuckets, keyHash); - for (SupportEntry e = ConcurrentHashtable.Support.bucket(s.supportBuckets, index); + int index = ConcurrentHashtable.bucketIndex(s.supportBuckets, keyHash); + for (SupportEntry e = ConcurrentHashtable.bucket(s.supportBuckets, index); e != null; e = e.next()) { if (e.keyHash == keyHash && e.matches(k1, k2)) { @@ -288,7 +287,7 @@ public SupportEntry getOrCreate_support(SharedState s, ThreadState t) { } } synchronized (s.supportBuckets) { - for (SupportEntry e = ConcurrentHashtable.Support.bucket(s.supportBuckets, index); + for (SupportEntry e = ConcurrentHashtable.bucket(s.supportBuckets, index); e != null; e = e.next()) { if (e.keyHash == keyHash && e.matches(k1, k2)) { @@ -296,7 +295,7 @@ public SupportEntry getOrCreate_support(SharedState s, ThreadState t) { } } SupportEntry newEntry = new SupportEntry(k1, k2); - newEntry.setNext(ConcurrentHashtable.Support.bucket(s.supportBuckets, index)); + newEntry.setNext(ConcurrentHashtable.bucket(s.supportBuckets, index)); s.supportBuckets.set(index, newEntry); return newEntry; } diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java index 1849a6e6f78..49782db69df 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java @@ -19,7 +19,8 @@ class ConcurrentHashtableD1Test { @Test void getReturnsMappedEntry() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); StringEntry e = table.getOrCreate("hello", k -> new StringEntry(k, 42)); assertSame(e, table.get("hello")); assertNull(table.get("world")); @@ -27,7 +28,8 @@ void getReturnsMappedEntry() { @Test void getOrCreateOnMissBuildsEntry() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); int[] createCount = {0}; StringEntry created = table.getOrCreate( @@ -44,7 +46,8 @@ void getOrCreateOnMissBuildsEntry() { @Test void getOrCreateOnHitSkipsCreator() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); StringEntry seeded = table.getOrCreate("a", k -> new StringEntry(k, 100)); int[] createCount = {0}; StringEntry got = @@ -61,7 +64,8 @@ void getOrCreateOnHitSkipsCreator() { @Test void nullKeyIsSupported() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); StringEntry e = table.getOrCreate(null, k -> new StringEntry(k, 0)); assertNotNull(e); assertSame(e, table.get(null)); @@ -69,7 +73,8 @@ void nullKeyIsSupported() { @Test void forEachVisitsAllEntries() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); table.getOrCreate("a", k -> new StringEntry(k, 1)); table.getOrCreate("b", k -> new StringEntry(k, 2)); table.getOrCreate("c", k -> new StringEntry(k, 3)); @@ -83,7 +88,8 @@ void forEachVisitsAllEntries() { @Test void forEachWithContextPassesContext() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); table.getOrCreate("x", k -> new StringEntry(k, 10)); table.getOrCreate("y", k -> new StringEntry(k, 20)); Set seen = new HashSet<>(); @@ -95,7 +101,8 @@ void forEachWithContextPassesContext() { @Test void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); int threads = 16; CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -135,7 +142,8 @@ void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException @Test void chainedEntriesInSameBucketAreAllReachable() { // 2 buckets: keyHash & 1 determines the slot. Hashes 0 and 2 both land in bucket 0. - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(2); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(CollidingEntry.class, 2); CollidingKey a = new CollidingKey("a", 0); CollidingKey b = new CollidingKey("b", 0); // same bucket as a CollidingKey c = new CollidingKey("c", 2); // 2 & 1 == 0, same bucket @@ -156,7 +164,8 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException for (int i = 0; i < threads; i++) { keys[i] = "key-" + i; } - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(threads * 2); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, threads * 2); CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -191,7 +200,8 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException @Test void removeReturnsEntryAndShrinks() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); StringEntry a = table.getOrCreate("a", k -> new StringEntry(k, 1)); table.getOrCreate("b", k -> new StringEntry(k, 2)); assertSame(a, table.remove("a")); @@ -202,7 +212,8 @@ void removeReturnsEntryAndShrinks() { @Test void removeAbsentKeyReturnsNull() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); table.getOrCreate("a", k -> new StringEntry(k, 1)); assertNull(table.remove("missing")); assertEquals(1, table.size()); @@ -211,7 +222,8 @@ void removeAbsentKeyReturnsNull() { @Test void removeHeadMiddleAndTailOfSameBucketChain() { // Capacity 1 forces every key into a single bucket, so a, b, c form one chain. - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(1); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(CollidingEntry.class, 1); CollidingKey a = new CollidingKey("a", 0); CollidingKey b = new CollidingKey("b", 0); CollidingKey c = new CollidingKey("c", 0); @@ -235,7 +247,8 @@ void removeHeadMiddleAndTailOfSameBucketChain() { @Test void removeIfRemovesMatchingEntries() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(16); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 16); for (int i = 0; i < 10; i++) { final int v = i; table.getOrCreate("k" + i, k -> new StringEntry(k, v)); @@ -253,7 +266,8 @@ void removeIfRemovesMatchingEntries() { @Test void removeIfReturnsFalseWhenNothingMatches() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); table.getOrCreate("a", k -> new StringEntry(k, 1)); assertFalse(table.removeIf(e -> false)); assertEquals(1, table.size()); @@ -261,7 +275,8 @@ void removeIfReturnsFalseWhenNothingMatches() { @Test void clearEmptiesTableAndLeavesItUsable() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); table.getOrCreate("a", k -> new StringEntry(k, 1)); table.getOrCreate("b", k -> new StringEntry(k, 2)); table.clear(); @@ -275,7 +290,8 @@ void clearEmptiesTableAndLeavesItUsable() { @Test void drainRemovesEveryEntryAndFeedsSink() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); table.getOrCreate("a", k -> new StringEntry(k, 1)); table.getOrCreate("b", k -> new StringEntry(k, 2)); table.getOrCreate("c", k -> new StringEntry(k, 3)); @@ -300,7 +316,8 @@ void drainRemovesEveryEntryAndFeedsSink() { @Test void drainWithContextFeedsSink() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); table.getOrCreate("a", k -> new StringEntry(k, 1)); table.getOrCreate("b", k -> new StringEntry(k, 2)); @@ -313,7 +330,8 @@ void drainWithContextFeedsSink() { @Test void drainOnEmptyTableInvokesSinkZeroTimes() { - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(8); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); int[] count = {0}; table.drain(e -> count[0]++); assertEquals(0, count[0]); @@ -328,7 +346,8 @@ void drainOnEmptyTableInvokesSinkZeroTimes() { @Test void concurrentReadsStaySafeWhileOneChainMemberChurns() throws InterruptedException { // Capacity 1 puts every key in one bucket so removal splices a chain the reader is walking. - ConcurrentHashtable.D1 table = new ConcurrentHashtable.D1<>(1); + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createFixedBuckets(CollidingEntry.class, 1); int n = 8; CollidingKey[] keys = new CollidingKey[n]; for (int i = 0; i < n; i++) { diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java index ebb519e4788..76a1321c1b0 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java @@ -18,7 +18,8 @@ class ConcurrentHashtableD2Test { @Test void pairKeysParticipateInIdentity() { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); PairEntry ab = table.getOrCreate("a", 1, PairEntry::new); PairEntry ac = table.getOrCreate("a", 2, PairEntry::new); PairEntry bb = table.getOrCreate("b", 1, PairEntry::new); @@ -31,7 +32,8 @@ void pairKeysParticipateInIdentity() { @Test void getOrCreateOnMissBuildsEntryViaCreator() { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); int[] createCount = {0}; PairEntry created = table.getOrCreate( @@ -51,7 +53,8 @@ void getOrCreateOnMissBuildsEntryViaCreator() { @Test void getOrCreateOnHitSkipsCreator() { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); PairEntry seeded = table.getOrCreate("a", 1, PairEntry::new); int[] createCount = {0}; PairEntry got = @@ -69,7 +72,8 @@ void getOrCreateOnHitSkipsCreator() { @Test void forEachVisitsBothPairs() { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); table.getOrCreate("a", 1, PairEntry::new); table.getOrCreate("b", 2, PairEntry::new); Set seen = new HashSet<>(); @@ -81,7 +85,8 @@ void forEachVisitsBothPairs() { @Test void forEachWithContextPassesContextToConsumer() { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); table.getOrCreate("a", 1, PairEntry::new); table.getOrCreate("b", 2, PairEntry::new); Set seen = new HashSet<>(); @@ -93,7 +98,8 @@ void forEachWithContextPassesContextToConsumer() { @Test void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); int threads = 16; CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -134,7 +140,8 @@ void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException @Test void chainedEntriesInSameBucketAreAllReachable() { // 2 buckets: 4 entries guarantees at least 2 share a bucket by pigeonhole. - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(2); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 2); PairEntry e1 = table.getOrCreate("a", 1, PairEntry::new); PairEntry e2 = table.getOrCreate("a", 2, PairEntry::new); PairEntry e3 = table.getOrCreate("b", 1, PairEntry::new); @@ -157,7 +164,7 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException k2s[i] = i; } ConcurrentHashtable.D2 table = - new ConcurrentHashtable.D2<>(threads * 2); + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, threads * 2); CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -193,7 +200,8 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException @Test void removeReturnsEntryAndShrinks() { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); PairEntry ab = table.getOrCreate("a", 1, PairEntry::new); table.getOrCreate("a", 2, PairEntry::new); assertSame(ab, table.remove("a", 1)); @@ -204,7 +212,8 @@ void removeReturnsEntryAndShrinks() { @Test void removeAbsentKeyReturnsNull() { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); table.getOrCreate("a", 1, PairEntry::new); assertNull(table.remove("a", 99)); assertNull(table.remove("z", 1)); @@ -214,7 +223,8 @@ void removeAbsentKeyReturnsNull() { @Test void removeMiddleOfSameBucketChainKeepsOthersReachable() { // Capacity 1 forces every pair into a single bucket chain. - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(1); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 1); table.getOrCreate("a", 1, PairEntry::new); PairEntry mid = table.getOrCreate("a", 2, PairEntry::new); table.getOrCreate("a", 3, PairEntry::new); @@ -228,7 +238,8 @@ void removeMiddleOfSameBucketChainKeepsOthersReachable() { @Test void removeIfRemovesMatchingEntries() { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(16); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 16); for (int i = 0; i < 10; i++) { table.getOrCreate("k", i, PairEntry::new); } @@ -242,7 +253,8 @@ void removeIfRemovesMatchingEntries() { @Test void removeIfReturnsFalseWhenNothingMatches() { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); table.getOrCreate("a", 1, PairEntry::new); assertFalse(table.removeIf(e -> false)); assertEquals(1, table.size()); @@ -250,7 +262,8 @@ void removeIfReturnsFalseWhenNothingMatches() { @Test void clearEmptiesTableAndLeavesItUsable() { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); table.getOrCreate("a", 1, PairEntry::new); table.getOrCreate("b", 2, PairEntry::new); table.clear(); @@ -263,7 +276,8 @@ void clearEmptiesTableAndLeavesItUsable() { @Test void drainRemovesEveryEntryAndFeedsSink() { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); table.getOrCreate("a", 1, PairEntry::new); table.getOrCreate("a", 2, PairEntry::new); table.getOrCreate("b", 1, PairEntry::new); @@ -281,7 +295,8 @@ void drainRemovesEveryEntryAndFeedsSink() { @Test void drainWithContextFeedsSink() { - ConcurrentHashtable.D2 table = new ConcurrentHashtable.D2<>(8); + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); table.getOrCreate("a", 1, PairEntry::new); table.getOrCreate("b", 2, PairEntry::new); From a76b741f91b8e0e8259782a7e8b152e8cd9937dd Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 09:17:36 -0400 Subject: [PATCH 17/31] Move write locking into ConcurrentHashtable static helpers The bucket AtomicReferenceArray is the per-table write monitor, obtained via getWriteLock(buckets) (opaque accessor, single source of truth) so callers never hardcode what to synchronize on. Reads (bucket/forEach) stay lock-free; whole-table mutators (removeIf/drain/clear) self-lock; the single-slot write primitives (insertHeadEntry/unlink) are caller-locked and assert Thread.holdsLock(getWriteLock(buckets)) under -ea. insertHeadEntry mirrors Hashtable's insert helper so custom tables publish entries without touching the chain pointer directly; Entry.setNext is demoted to package-private accordingly while next() stays public for lock-free chain walks. Adapts ThreadSafeMapD2Benchmark call sites to the new API. Co-Authored-By: Claude Opus 4.8 --- .../trace/util/ThreadSafeMapD2Benchmark.java | 11 +- .../trace/util/ConcurrentHashtable.java | 200 ++++++++++++------ 2 files changed, 135 insertions(+), 76 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java index 1891cfbe932..e753f5d5688 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -203,9 +203,9 @@ public void setUp() { table.getOrCreate(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new); // populate support table SupportEntry se = new SupportEntry(SOURCE_K1[i], k2); - int idx = ConcurrentHashtable.bucketIndex(supportBuckets, se.keyHash); - se.setNext(ConcurrentHashtable.bucket(supportBuckets, idx)); - supportBuckets.set(idx, se); + synchronized (ConcurrentHashtable.getWriteLock(supportBuckets)) { + ConcurrentHashtable.insertHeadEntry(supportBuckets, se.keyHash, se); + } Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]); concurrentHashMap.put(key, (long) i); skipListMap.put(key, (long) i); @@ -286,7 +286,7 @@ public SupportEntry getOrCreate_support(SharedState s, ThreadState t) { return e; } } - synchronized (s.supportBuckets) { + synchronized (ConcurrentHashtable.getWriteLock(s.supportBuckets)) { for (SupportEntry e = ConcurrentHashtable.bucket(s.supportBuckets, index); e != null; e = e.next()) { @@ -295,8 +295,7 @@ public SupportEntry getOrCreate_support(SharedState s, ThreadState t) { } } SupportEntry newEntry = new SupportEntry(k1, k2); - newEntry.setNext(ConcurrentHashtable.bucket(s.supportBuckets, index)); - s.supportBuckets.set(index, newEntry); + ConcurrentHashtable.insertHeadEntry(s.supportBuckets, index, newEntry); return newEntry; } } diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index 262e4a5ea9c..afe007128cf 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -50,7 +50,7 @@ * *

    Custom tables (higher arity / primitive keys). Use {@link D1} or {@link D2} when their * object-key constraints are acceptable — they handle synchronization internally. When you need - * primitive key components, three-or-more key parts, or want to own the lock strategy, drive the + * primitive key components, three-or-more key parts, or extra per-entry value fields, drive the * table yourself with the static building blocks on this class: allocate the spine with {@link * #createFixedBuckets(Class, int)}, then operate on it with {@link #bucket}, {@link #unlink}, * {@link #removeIf}, {@link #drain}, {@link #clear}, and {@link #forEach}. This is the same "static @@ -58,21 +58,29 @@ * uses {@code Hashtable}); the calling class then owns the array and exposes whatever operations it * needs. Subclass {@link Entry} directly for such tables. * - *

    Write path for custom tables. Writes are the caller's responsibility. Use the same - * double-checked locking pattern that {@link D1} and {@link D2} use internally: + *

    Locking model. Writes are guarded by a per-table monitor obtained from {@link + * #getWriteLock(AtomicReferenceArray)} — treat it as opaque rather than assuming it is the array. + * Reads are lock-free: {@link #bucket} walks and {@link #forEach} take no lock and are safe from + * any thread. The whole-table mutators — {@link #removeIf}, {@link #drain}, {@link #clear} — are + * self-locking ({@code synchronized (getWriteLock(buckets))} internally), so a custom table + * calls them directly with no lock of its own. The only writes a custom table performs by hand are + * single-key insert and remove; each is an atomic check-then-write that the caller wraps in {@code + * synchronized (getWriteLock(buckets))} so it excludes other writers and the self-locking mutators + * (same monitor, so it nests cleanly with the built-ins): * *

      *
    1. Lock-free pre-check: walk the chain via {@link #bucket}; return if found. - *
    2. Acquire a lock on a stable object owned by the same class that owns the {@code buckets} - * array (typically {@code synchronized (this)}). + *
    3. {@code synchronized (getWriteLock(buckets))} — take the table's write monitor. *
    4. Re-check under the lock (another thread may have inserted between step 1 and step 2). - *
    5. Build the new entry, set its {@code next} via {@link Entry#setNext}, then write it to the - * bucket with {@link AtomicReferenceArray#set} (volatile write). + *
    6. Insert: build the entry and publish it with {@link #insertHeadEntry}. Remove: splice it out + * with {@link #unlink}. Both are volatile writes that lock-free readers observe atomically. *
    * - *

    Because the caller owns the lock object, custom tables can lock-stripe: shard the lock - * by bucket index or key hash to reduce write-path contention if profiling shows the single - * table-level lock (used by {@link D1}/{@link D2}) is a bottleneck. + *

    {@link #bucket} (a lock-free read), {@link #insertHeadEntry}, and {@link #unlink} are the + * single-slot primitives for that hand-written path; the two mutating ones do not lock, so + * call them only inside the caller's {@code synchronized (getWriteLock(buckets))} block. The + * entry's chain pointer is written for you by those helpers — custom tables never touch it + * directly. */ public final class ConcurrentHashtable { private ConcurrentHashtable() {} @@ -97,7 +105,10 @@ protected Entry(long keyHash) { this.keyHash = keyHash; } - public final void setNext(TEntry next) { + // 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; } @@ -195,15 +206,14 @@ public TEntry getOrCreate(K key, Function creator) return te; } } - synchronized (this) { + synchronized (getWriteLock(buckets)) { for (TEntry te = bucket(buckets, index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { return te; } } TEntry newEntry = creator.apply(key); - newEntry.setNext(bucket(buckets, index)); - buckets.set(index, newEntry); + insertHeadEntry(buckets, index, newEntry); size.incrementAndGet(); return newEntry; } @@ -217,7 +227,7 @@ public TEntry getOrCreate(K key, Function creator) public TEntry remove(K key) { long keyHash = D1.Entry.hash(key); int index = bucketIndex(buckets, keyHash); - synchronized (this) { + synchronized (getWriteLock(buckets)) { TEntry prev = null; for (TEntry te = bucket(buckets, index); te != null; prev = te, te = te.next()) { if (te.keyHash == keyHash && te.matches(key)) { @@ -236,9 +246,7 @@ public TEntry remove(K key) { * concurrent writers are excluded; lock-free readers continue throughout. */ public boolean removeIf(Predicate predicate) { - synchronized (this) { - return ConcurrentHashtable.removeIf(buckets, size, predicate); - } + return ConcurrentHashtable.removeIf(buckets, size, predicate); } /** @@ -254,7 +262,7 @@ public boolean removeIf(Predicate predicate) { * context-passing overload is offered for callers that prefer to avoid the allocation. */ public void drain(Consumer sink) { - synchronized (this) { + synchronized (getWriteLock(buckets)) { ConcurrentHashtable.drain(buckets, sink); size.set(0); } @@ -266,7 +274,7 @@ public void drain(Consumer sink) { * event builder) to avoid a capturing-lambda allocation. */ public void drain(C context, BiConsumer sink) { - synchronized (this) { + synchronized (getWriteLock(buckets)) { ConcurrentHashtable.drain(buckets, context, sink); size.set(0); } @@ -274,7 +282,7 @@ public void drain(C context, BiConsumer sink) { /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ public void clear() { - synchronized (this) { + synchronized (getWriteLock(buckets)) { ConcurrentHashtable.clear(buckets); size.set(0); } @@ -394,15 +402,14 @@ public TEntry getOrCreate( return te; } } - synchronized (this) { + synchronized (getWriteLock(buckets)) { for (TEntry te = bucket(buckets, index); te != null; te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { return te; } } TEntry newEntry = creator.apply(key1, key2); - newEntry.setNext(bucket(buckets, index)); - buckets.set(index, newEntry); + insertHeadEntry(buckets, index, newEntry); size.incrementAndGet(); return newEntry; } @@ -416,7 +423,7 @@ public TEntry getOrCreate( public TEntry remove(K1 key1, K2 key2) { long keyHash = D2.Entry.hash(key1, key2); int index = bucketIndex(buckets, keyHash); - synchronized (this) { + synchronized (getWriteLock(buckets)) { TEntry prev = null; for (TEntry te = bucket(buckets, index); te != null; prev = te, te = te.next()) { if (te.keyHash == keyHash && te.matches(key1, key2)) { @@ -435,9 +442,7 @@ public TEntry remove(K1 key1, K2 key2) { * concurrent writers are excluded; lock-free readers continue throughout. */ public boolean removeIf(Predicate predicate) { - synchronized (this) { - return ConcurrentHashtable.removeIf(buckets, size, predicate); - } + return ConcurrentHashtable.removeIf(buckets, size, predicate); } /** @@ -453,7 +458,7 @@ public boolean removeIf(Predicate predicate) { * context-passing overload is offered for callers that prefer to avoid the allocation. */ public void drain(Consumer sink) { - synchronized (this) { + synchronized (getWriteLock(buckets)) { ConcurrentHashtable.drain(buckets, sink); size.set(0); } @@ -465,7 +470,7 @@ public void drain(Consumer sink) { * event builder) to avoid a capturing-lambda allocation. */ public void drain(C context, BiConsumer sink) { - synchronized (this) { + synchronized (getWriteLock(buckets)) { ConcurrentHashtable.drain(buckets, context, sink); size.set(0); } @@ -473,7 +478,7 @@ public void drain(C context, BiConsumer sink) { /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ public void clear() { - synchronized (this) { + synchronized (getWriteLock(buckets)) { ConcurrentHashtable.clear(buckets); size.set(0); } @@ -494,8 +499,12 @@ public void forEach(C context, BiConsumer consume // --------------------------------------------------------------------------------------------- // 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, or caller-owned locking) - // when D1/D2 don't fit; D1/D2 delegate to them internally. + // 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)) block. + // Readers + // (bucket walks, forEach) are lock-free. // --------------------------------------------------------------------------------------------- /** @@ -524,6 +533,18 @@ public static int sizeFor(int requestedSize) { return Hashtable.Support.sizeFor(requestedSize); } + /** + * Returns the monitor that guards writes to {@code buckets}. A custom table locks on this — + * {@code synchronized (getWriteLock(buckets)) { … }} — around its scan-then-insert/remove so it + * excludes other writers and the self-locking whole-table mutators (they lock on the same + * monitor, so the blocks nest). Treat the returned object as opaque: it happens to be the + * array today, but obtain it here rather than assuming that, so callers stay correct if the + * monitor ever changes. + */ + public static Object getWriteLock(AtomicReferenceArray buckets) { + return buckets; + } + public static int bucketIndex(AtomicReferenceArray buckets, long keyHash) { return (int) (keyHash & (buckets.length() - 1)); } @@ -547,16 +568,44 @@ public static TEntry bucket( return buckets.get(index); } + /** + * Splices {@code entry} in as the new head of the chain at {@code index}, publishing it with a + * volatile {@link AtomicReferenceArray#set} so lock-free readers observe the whole entry (its + * {@code next} already points at the old head) atomically. Single-slot primitive: it does not + * lock, so call it inside the caller's {@code synchronized (getWriteLock(buckets))} block, after + * re-checking the chain for the key under that lock. Does not touch size accounting. + */ + public static void insertHeadEntry( + AtomicReferenceArray buckets, int index, TEntry entry) { + assert Thread.holdsLock(getWriteLock(buckets)) + : "insertHeadEntry called without holding getWriteLock(buckets)"; + entry.setNext(buckets.get(index)); + buckets.set(index, entry); + } + + /** + * Convenience overload of {@link #insertHeadEntry(AtomicReferenceArray, int, Entry)} that derives + * the bucket index from {@code keyHash}. Prefer the int-taking overload when the index is already + * computed (e.g. a {@code getOrCreate} that reuses it across the lock-free pre-check). + */ + public static void insertHeadEntry( + AtomicReferenceArray buckets, long keyHash, TEntry entry) { + insertHeadEntry(buckets, bucketIndex(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. Must be called under the table's write lock; does - * not touch size accounting. + * 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 (getWriteLock(buckets))} block. Does not + * touch size accounting. */ public static void unlink( AtomicReferenceArray buckets, int index, TEntry prev, TEntry entry) { + assert Thread.holdsLock(getWriteLock(buckets)) + : "unlink called without holding getWriteLock(buckets)"; TEntry next = entry.next(); if (prev == null) { buckets.set(index, next); @@ -567,69 +616,80 @@ public static void unlink( /** * Removes every entry matching {@code predicate} from {@code buckets}, decrementing {@code size} - * once per removal. Must be called under the table's write lock. + * 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( AtomicReferenceArray buckets, AtomicInteger size, Predicate predicate) { - 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; + synchronized (getWriteLock(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; } - return removed; } /** * Removes every entry, passing each to {@code sink} as its bucket is cleared. Each bucket head is * nulled (a volatile write that publishes the removal) before its chain is fed to {@code sink}, * so new readers see an empty bucket while the detached chain — whose {@code next} pointers stay - * intact — is handed to the caller. Must be called under the table's write lock; does not touch - * size accounting. + * intact — is handed to the caller. Self-locking: synchronizes on {@code buckets} for the whole + * pass. Does not touch size accounting, so a caller tracking size resets it inside its own {@code + * synchronized (getWriteLock(buckets))} block (which nests with this one on the same monitor). */ public static void drain( AtomicReferenceArray buckets, Consumer sink) { - 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()) { - sink.accept(e); + synchronized (getWriteLock(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()) { + sink.accept(e); + } } } } - /** Context-passing variant of {@link #drain(AtomicReferenceArray, Consumer)}. */ + /** Context-passing variant of {@link #drain(AtomicReferenceArray, Consumer)}. Self-locking. */ public static void drain( AtomicReferenceArray buckets, C context, BiConsumer sink) { - 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()) { - sink.accept(context, e); + synchronized (getWriteLock(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()) { + sink.accept(context, e); + } } } } - /** Nulls every bucket head. Must be called under the table's write lock. */ + /** Nulls every bucket head. Self-locking: synchronizes on {@code buckets}. */ public static void clear(AtomicReferenceArray buckets) { - for (int i = 0; i < buckets.length(); i++) { - buckets.set(i, null); + synchronized (getWriteLock(buckets)) { + for (int i = 0; i < buckets.length(); i++) { + buckets.set(i, null); + } } } From 40bcc02cf40f319c83bfca6f9da4887f10aa50e0 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 09:30:34 -0400 Subject: [PATCH 18/31] Devirtualize matches() equals + drop varargs hash from benchmark key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit matches() now invokes equals() on the lookup parameter rather than the stored field (D1: key, D2: key1/key2). When matches() inlines into get/getOrCreate the caller's key type is known, so the JIT can devirtualize the equals() call; Objects.equals still short-circuits on == first, so interned keys keep the identity fast path. ThreadSafeMapD2Benchmark's Key2 dropped Objects.hash(...) — its varargs Object[] allocation penalized the map baselines with an alloc the wrapper itself doesn't need, overstating the ConcurrentHashtable advantage the benchmark measures. Uses a plain 31*h1 + h2 hash instead. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/ThreadSafeMapD2Benchmark.java | 5 ++++- .../java/datadog/trace/util/ConcurrentHashtable.java | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java index e753f5d5688..0cf73df0932 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -155,7 +155,10 @@ static final class Key2 implements Comparable { Key2(String k1, Integer k2) { this.k1 = k1; this.k2 = k2; - this.hash = Objects.hash(k1, 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 diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index afe007128cf..2e6fb2c9190 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -146,7 +146,10 @@ public K key() { } public boolean matches(Object key) { - return Objects.equals(this.key, key); + // equals() is invoked on the lookup parameter, not the stored field: when matches() inlines + // into get/getOrCreate the caller's key type is known, so the JIT can devirtualize the + // equals() call. Objects.equals short-circuits on ==, so interned keys still hit identity. + return Objects.equals(key, this.key); } /** @@ -342,7 +345,11 @@ public K2 key2() { } public boolean matches(K1 key1, K2 key2) { - return Objects.equals(this.key1, key1) && Objects.equals(this.key2, key2); + // equals() is invoked on the lookup parameters, not the stored fields: when matches() + // inlines + // into get/getOrCreate the caller's key types are known, so the JIT can devirtualize the + // equals() calls. Objects.equals short-circuits on ==, so interned keys still hit identity. + return Objects.equals(key1, this.key1) && Objects.equals(key2, this.key2); } /** Returns the 64-bit lookup hash combining both key parts via {@link LongHashingUtils}. */ From 5eca37f834e8a86c6ed5788d094e8307ec3dc41f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 10:22:33 -0400 Subject: [PATCH 19/31] Add coverage for ConcurrentHashtable static building blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D1/D2 wrappers were well covered but the caller-owned-array path — the static building blocks that back custom tables (primitive/higher-arity keys) — had no direct tests. Adds ConcurrentHashtableStaticsTest, which drives a hand-written primitive-int-key table (IntTable) through the documented lock-free-read / locked-write recipe. Covers sizeFor, createFixedBuckets, getWriteLock, bucketIndex, both bucket and insertHeadEntry overloads, unlink (head/middle/tail), and the static removeIf/drain/drain-with-context/clear/forEach primitives. Also asserts the Thread.holdsLock guards on insertHeadEntry/unlink fire when called without the write lock (guarded by an -ea check), plus exactly-once and lock-free reader-safety races driven entirely through the statics. Co-Authored-By: Claude Opus 4.8 --- .../util/ConcurrentHashtableStaticsTest.java | 404 ++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java 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..8a458a238bd --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java @@ -0,0 +1,404 @@ +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); + assertNotNull(lock); + assertSame(lock, ConcurrentHashtable.getWriteLock(buckets)); + } + + @Test + void bucketIndexMasksToArrayLength() { + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); // length 8, mask 7 + assertEquals(0, ConcurrentHashtable.bucketIndex(buckets, 8L)); + assertEquals(1, ConcurrentHashtable.bucketIndex(buckets, 9L)); + assertEquals(7, ConcurrentHashtable.bucketIndex(buckets, 7L)); + } + + @Test + void insertGetAndRemoveViaStatics() { + IntTable table = new IntTable(8); + IntEntry a = table.getOrCreate(1, 100); + IntEntry b = table.getOrCreate(2, 200); + assertEquals(2, table.size.get()); + assertSame(a, table.get(1)); + assertSame(b, table.get(2)); + assertNull(table.get(3)); + + // getOrCreate on a hit returns the existing entry, no new insert. + assertSame(a, table.getOrCreate(1, 999)); + assertEquals(2, table.size.get()); + + assertSame(a, table.remove(1)); + assertNull(table.get(1)); + assertNull(table.remove(1)); // already gone + assertEquals(1, table.size.get()); + } + + @Test + void insertHeadEntryByKeyHashOverloadPlacesInMaskedBucket() { + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); // mask 7 + IntEntry e = new IntEntry(9, 1); // keyHash 9 → bucket 1 + synchronized (ConcurrentHashtable.getWriteLock(buckets)) { + ConcurrentHashtable.insertHeadEntry(buckets, e.keyHash, e); + } + assertSame(e, ConcurrentHashtable.bucket(buckets, 9L)); // keyHash overload + assertSame(e, ConcurrentHashtable.bucket(buckets, 1)); // index overload + assertNull(buckets.get(0)); + } + + @Test + void unlinkRemovesHeadMiddleAndTailOfChain() { + // Capacity 1 → mask 0 → every key lands in bucket 0, forming one chain. + IntTable table = new IntTable(1); + IntEntry a = table.getOrCreate(1, 1); + IntEntry b = table.getOrCreate(2, 2); + IntEntry c = table.getOrCreate(3, 3); + assertEquals(3, table.size.get()); + + // Remove the middle: head and tail stay reachable. + assertSame(b, table.remove(2)); + assertNull(table.get(2)); + assertSame(a, table.get(1)); + assertSame(c, table.get(3)); + assertEquals(2, table.size.get()); + + // Remove the head, then the last remaining. + assertSame(c, table.remove(3)); + assertSame(a, table.remove(1)); + assertEquals(0, table.size.get()); + assertNull(table.get(1)); + } + + @Test + void staticRemoveIfRemovesMatchingAndDecrementsSize() { + IntTable table = new IntTable(16); + for (int i = 0; i < 10; i++) { + table.getOrCreate(i, i); + } + boolean removed = + ConcurrentHashtable.removeIf(table.buckets, table.size, e -> e.value % 2 == 0); + assertTrue(removed); + assertEquals(5, table.size.get()); + for (int i = 0; i < 10; i++) { + if (i % 2 == 0) { + assertNull(table.get(i)); + } else { + assertNotNull(table.get(i)); + } + } + } + + @Test + void staticRemoveIfReturnsFalseWhenNothingMatches() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 1); + assertFalse(ConcurrentHashtable.removeIf(table.buckets, table.size, e -> false)); + assertEquals(1, table.size.get()); + } + + @Test + void staticDrainRemovesEveryEntryAndFeedsSink() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 10); + table.getOrCreate(2, 20); + table.getOrCreate(3, 30); + + Set keys = new HashSet<>(); + int[] sum = {0}; + ConcurrentHashtable.drain( + table.buckets, + e -> { + keys.add(e.key); + sum[0] += e.value; + }); + + assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2, 3)), keys); + assertEquals(60, sum[0]); + // drain does not touch size accounting on the static path — the caller resets it. + for (int i = 1; i <= 3; i++) { + assertNull(table.get(i)); + } + } + + @Test + void staticDrainWithContextFeedsSink() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 10); + table.getOrCreate(2, 20); + + Set keys = new HashSet<>(); + ConcurrentHashtable.drain(table.buckets, keys, (ctx, e) -> ctx.add(e.key)); + + assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2)), keys); + assertNull(table.get(1)); + } + + @Test + void staticClearEmptiesEveryBucket() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 1); + table.getOrCreate(2, 2); + ConcurrentHashtable.clear(table.buckets); + assertNull(table.get(1)); + assertNull(table.get(2)); + for (int i = 0; i < table.buckets.length(); i++) { + assertNull(table.buckets.get(i)); + } + } + + @Test + void staticForEachVisitsEveryEntry() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 1); + table.getOrCreate(2, 2); + table.getOrCreate(3, 3); + + Set seen = new HashSet<>(); + ConcurrentHashtable.forEach(table.buckets, e -> seen.add(e.key)); + assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2, 3)), seen); + + Set seenCtx = new HashSet<>(); + ConcurrentHashtable.forEach(table.buckets, seenCtx, (ctx, e) -> ctx.add(e.key)); + assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2, 3)), seenCtx); + } + + @Test + void insertHeadEntryWithoutLockTripsAssertion() { + assumeTrue(assertionsEnabled(), "assert-guard test requires -ea"); + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); + assertThrows( + AssertionError.class, + () -> ConcurrentHashtable.insertHeadEntry(buckets, 0, new IntEntry(1, 1))); + } + + @Test + void unlinkWithoutLockTripsAssertion() { + assumeTrue(assertionsEnabled(), "assert-guard test requires -ea"); + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); + IntEntry e = new IntEntry(1, 1); + synchronized (ConcurrentHashtable.getWriteLock(buckets)) { + ConcurrentHashtable.insertHeadEntry(buckets, 0, e); + } + assertThrows(AssertionError.class, () -> ConcurrentHashtable.unlink(buckets, 0, null, e)); + } + + @Test + void concurrentGetOrCreateViaStaticsProducesExactlyOneEntry() throws InterruptedException { + IntTable table = new IntTable(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.getOrCreateCounting(7, createCount); + }); + workers[i].start(); + } + ready.await(); + go.countDown(); + for (Thread w : workers) { + w.join(); + } + + assertEquals(1, table.size.get()); + assertEquals(1, createCount.get()); + } + + @Test + void concurrentReadsStaySafeWhileOneChainMemberChurnsViaStatics() throws InterruptedException { + // Capacity 1 puts every key in one bucket so unlink splices a chain the reader is walking. + IntTable table = new IntTable(1); + int n = 8; + for (int i = 0; i < n; i++) { + table.getOrCreate(i, i); + } + int churn = 0; // keys 1..n-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(i) == null) { + missed.incrementAndGet(); + } + } + } + }); + reader.start(); + for (int r = 0; r < 100_000; r++) { + table.remove(churn); + table.getOrCreate(churn, churn); + } + stop.set(true); + reader.join(); + + assertEquals(0, missed.get(), "stable chain members must never be unreachable during removal"); + } + + private static boolean assertionsEnabled() { + boolean enabled = false; + assert enabled = true; + return enabled; + } + + /** Primitive-{@code int}-key entry: no boxing, keyHash is the key itself. */ + private static final class IntEntry extends ConcurrentHashtable.Entry { + final int key; + final int value; + + IntEntry(int key, int value) { + super(key); + this.key = key; + this.value = value; + } + + boolean matches(int key) { + return this.key == key; + } + } + + /** + * Minimal hand-written table over a caller-owned {@link AtomicReferenceArray}, following the + * documented recipe: lock-free pre-check, then re-check + mutate under {@code + * getWriteLock(buckets)}. + */ + private static final class IntTable { + final AtomicReferenceArray buckets; + final AtomicInteger size = new AtomicInteger(); + + IntTable(int capacity) { + this.buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, capacity); + } + + IntEntry get(int key) { + for (IntEntry e = ConcurrentHashtable.bucket(buckets, (long) key); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + return null; + } + + IntEntry getOrCreate(int key, int value) { + int index = ConcurrentHashtable.bucketIndex(buckets, key); + for (IntEntry e = ConcurrentHashtable.bucket(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + synchronized (ConcurrentHashtable.getWriteLock(buckets)) { + for (IntEntry e = ConcurrentHashtable.bucket(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + IntEntry created = new IntEntry(key, value); + ConcurrentHashtable.insertHeadEntry(buckets, index, created); + size.incrementAndGet(); + return created; + } + } + + /** {@link #getOrCreate} variant that counts real creations, for the exactly-once race test. */ + IntEntry getOrCreateCounting(int key, AtomicInteger createCount) { + int index = ConcurrentHashtable.bucketIndex(buckets, key); + for (IntEntry e = ConcurrentHashtable.bucket(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + synchronized (ConcurrentHashtable.getWriteLock(buckets)) { + for (IntEntry e = ConcurrentHashtable.bucket(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + createCount.incrementAndGet(); + IntEntry created = new IntEntry(key, 0); + ConcurrentHashtable.insertHeadEntry(buckets, index, created); + size.incrementAndGet(); + return created; + } + } + + IntEntry remove(int key) { + int index = ConcurrentHashtable.bucketIndex(buckets, key); + synchronized (ConcurrentHashtable.getWriteLock(buckets)) { + IntEntry prev = null; + for (IntEntry e = ConcurrentHashtable.bucket(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + ConcurrentHashtable.unlink(buckets, index, prev, e); + size.decrementAndGet(); + return e; + } + prev = e; + } + return null; + } + } + } +} From e1b33c60cd0c15a0065172bf8ff2dc4010b71b1b Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 10:33:46 -0400 Subject: [PATCH 20/31] Document drain's throwing-sink contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged that if a drain sink throws part-way, already-detached entries are gone while size() still reports the pre-drain count. Rather than add per-entry size bookkeeping to a path that only matters when the caller is already in error (a throwing sink is a half-published flush with no rollback), document that the sink must not throw — on the D1/D2 drain wrappers and the static drain primitive. Co-Authored-By: Claude Opus 4.8 --- .../trace/util/ConcurrentHashtable.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index 2e6fb2c9190..4c8fdf7af35 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -263,6 +263,13 @@ public boolean removeIf(Predicate predicate) { * *

    A capturing-lambda {@code sink} is fine here — drain is a rare flush operation — but a * context-passing overload is offered for callers that prefer to avoid the allocation. + * + *

    Contract: {@code sink} must not throw. Entries are detached as the sweep proceeds + * and {@code size} is reset only after it completes, so a {@code sink} that throws part-way + * leaves those already-detached entries gone while {@code size()} still reports the pre-drain + * count. The drain is not rolled back; a throwing sink is a caller error that also means a + * half-published flush. This is intentional — the alternative is per-entry size bookkeeping on + * a path that only matters when the caller is already in error. */ public void drain(Consumer sink) { synchronized (getWriteLock(buckets)) { @@ -463,6 +470,13 @@ public boolean removeIf(Predicate predicate) { * *

    A capturing-lambda {@code sink} is fine here — drain is a rare flush operation — but a * context-passing overload is offered for callers that prefer to avoid the allocation. + * + *

    Contract: {@code sink} must not throw. Entries are detached as the sweep proceeds + * and {@code size} is reset only after it completes, so a {@code sink} that throws part-way + * leaves those already-detached entries gone while {@code size()} still reports the pre-drain + * count. The drain is not rolled back; a throwing sink is a caller error that also means a + * half-published flush. This is intentional — the alternative is per-entry size bookkeeping on + * a path that only matters when the caller is already in error. */ public void drain(Consumer sink) { synchronized (getWriteLock(buckets)) { @@ -657,6 +671,10 @@ public static boolean removeIf( * intact — is handed to the caller. Self-locking: synchronizes on {@code buckets} for the whole * pass. Does not touch size accounting, so a caller tracking size resets it inside its own {@code * synchronized (getWriteLock(buckets))} block (which nests with this one on the same monitor). + * + *

    {@code sink} must not throw: buckets are detached as the sweep proceeds, so a sink that + * throws part-way leaves earlier buckets drained and later ones intact, and any caller-side size + * reset never runs. The drain is not rolled back — a throwing sink is a caller error. */ public static void drain( AtomicReferenceArray buckets, Consumer sink) { From 2decaa463e732eb5c69910838122ab72cdf898b4 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 10:39:16 -0400 Subject: [PATCH 21/31] Shorten matches() devirtualization comments Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/ConcurrentHashtable.java | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index 4c8fdf7af35..62106fc16da 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -146,9 +146,8 @@ public K key() { } public boolean matches(Object key) { - // equals() is invoked on the lookup parameter, not the stored field: when matches() inlines - // into get/getOrCreate the caller's key type is known, so the JIT can devirtualize the - // equals() call. Objects.equals short-circuits on ==, so interned keys still hit identity. + // 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); } @@ -352,10 +351,8 @@ public K2 key2() { } public boolean matches(K1 key1, K2 key2) { - // equals() is invoked on the lookup parameters, not the stored fields: when matches() - // inlines - // into get/getOrCreate the caller's key types are known, so the JIT can devirtualize the - // equals() calls. Objects.equals short-circuits on ==, so interned keys still hit identity. + // 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); } From ae9d10f2010f1de4a30f8baa57270f8bad123116 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 10:41:03 -0400 Subject: [PATCH 22/31] Rename chain-walk loop variable te -> curEntry Co-Authored-By: Claude Opus 4.8 --- .../trace/util/ConcurrentHashtable.java | 72 +++++++++++-------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index 62106fc16da..0a1673cbba4 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -187,9 +187,11 @@ public int size() { public TEntry get(K key) { long keyHash = D1.Entry.hash(key); - for (TEntry te = bucket(buckets, keyHash); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key)) { - return te; + for (TEntry curEntry = bucket(buckets, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; } } return null; @@ -203,15 +205,17 @@ public TEntry get(K key) { public TEntry getOrCreate(K key, Function creator) { long keyHash = D1.Entry.hash(key); int index = bucketIndex(buckets, keyHash); - for (TEntry te = bucket(buckets, index); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key)) { - return te; + for (TEntry curEntry = bucket(buckets, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; } } synchronized (getWriteLock(buckets)) { - for (TEntry te = bucket(buckets, index); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key)) { - return te; + for (TEntry curEntry = bucket(buckets, index); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return curEntry; } } TEntry newEntry = creator.apply(key); @@ -231,11 +235,13 @@ public TEntry remove(K key) { int index = bucketIndex(buckets, keyHash); synchronized (getWriteLock(buckets)) { TEntry prev = null; - for (TEntry te = bucket(buckets, index); te != null; prev = te, te = te.next()) { - if (te.keyHash == keyHash && te.matches(key)) { - unlink(buckets, index, prev, te); + for (TEntry curEntry = bucket(buckets, index); + curEntry != null; + prev = curEntry, curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + unlink(buckets, index, prev, curEntry); size.decrementAndGet(); - return te; + return curEntry; } } return null; @@ -388,9 +394,11 @@ public int size() { public TEntry get(K1 key1, K2 key2) { long keyHash = D2.Entry.hash(key1, key2); - for (TEntry te = bucket(buckets, keyHash); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key1, key2)) { - return te; + for (TEntry curEntry = bucket(buckets, keyHash); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; } } return null; @@ -408,15 +416,17 @@ public TEntry getOrCreate( K1 key1, K2 key2, BiFunction creator) { long keyHash = D2.Entry.hash(key1, key2); int index = bucketIndex(buckets, keyHash); - for (TEntry te = bucket(buckets, index); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key1, key2)) { - return te; + for (TEntry curEntry = bucket(buckets, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; } } synchronized (getWriteLock(buckets)) { - for (TEntry te = bucket(buckets, index); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key1, key2)) { - return te; + for (TEntry curEntry = bucket(buckets, index); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; } } TEntry newEntry = creator.apply(key1, key2); @@ -436,11 +446,13 @@ public TEntry remove(K1 key1, K2 key2) { int index = bucketIndex(buckets, keyHash); synchronized (getWriteLock(buckets)) { TEntry prev = null; - for (TEntry te = bucket(buckets, index); te != null; prev = te, te = te.next()) { - if (te.keyHash == keyHash && te.matches(key1, key2)) { - unlink(buckets, index, prev, te); + for (TEntry curEntry = bucket(buckets, index); + curEntry != null; + prev = curEntry, curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + unlink(buckets, index, prev, curEntry); size.decrementAndGet(); - return te; + return curEntry; } } return null; @@ -718,8 +730,8 @@ public static void clear(AtomicReferenceArray buckets) { public static void forEach( AtomicReferenceArray buckets, Consumer consumer) { for (int i = 0; i < buckets.length(); i++) { - for (TEntry te = buckets.get(i); te != null; te = te.next()) { - consumer.accept(te); + for (TEntry curEntry = buckets.get(i); curEntry != null; curEntry = curEntry.next()) { + consumer.accept(curEntry); } } } @@ -729,8 +741,8 @@ public static void forEach( C context, BiConsumer consumer) { for (int i = 0; i < buckets.length(); i++) { - for (TEntry te = buckets.get(i); te != null; te = te.next()) { - consumer.accept(context, te); + for (TEntry curEntry = buckets.get(i); curEntry != null; curEntry = curEntry.next()) { + consumer.accept(context, curEntry); } } } From d12e93d4724db2007b7addf58728132a69b07327 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 12:39:07 -0400 Subject: [PATCH 23/31] ConcurrentHashtable: annotate nullability (@Nonnull/@Nullable) Co-Authored-By: Claude Opus 4.8 --- .../trace/util/ConcurrentHashtable.java | 108 +++++++++++------- 1 file changed, 67 insertions(+), 41 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index 0a1673cbba4..52dd2449090 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -8,6 +8,8 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; /** * Concurrent hash table providing lock-free reads and locked writes for {@link D1} (single-key) and @@ -113,6 +115,7 @@ final void setNext(TEntry next) { } @SuppressWarnings("unchecked") + @Nullable public final TEntry next() { return (TEntry) this.next; } @@ -135,17 +138,18 @@ public static final class D1> { public abstract static class Entry extends ConcurrentHashtable.Entry { final K key; - protected Entry(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(Object 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); @@ -156,7 +160,7 @@ public boolean matches(Object key) { * 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(Object key) { + public static long hash(@Nullable Object key) { return (key == null) ? Long.MIN_VALUE : key.hashCode(); } } @@ -176,8 +180,9 @@ private D1(AtomicReferenceArray buckets) { * ConcurrentHashtable#createFixedBuckets(Class, int)} for why the class isn't otherwise * consumed here). Capacity is fixed; the table does not resize. */ + @Nonnull public static > D1 createFixedBuckets( - Class entryClass, int capacity) { + @Nonnull Class entryClass, int capacity) { return new D1<>(ConcurrentHashtable.createFixedBuckets(entryClass, capacity)); } @@ -185,7 +190,8 @@ public int size() { return size.get(); } - public TEntry get(K key) { + @Nullable + public TEntry get(@Nullable K key) { long keyHash = D1.Entry.hash(key); for (TEntry curEntry = bucket(buckets, keyHash); curEntry != null; @@ -202,7 +208,9 @@ public TEntry get(K key) { * hit; acquires a table-level lock on miss. Re-checks under the lock to avoid duplicate entries * under concurrent misses. */ - public TEntry getOrCreate(K key, Function creator) { + @Nonnull + public TEntry getOrCreate( + @Nullable K key, @Nonnull Function creator) { long keyHash = D1.Entry.hash(key); int index = bucketIndex(buckets, keyHash); for (TEntry curEntry = bucket(buckets, index); curEntry != null; curEntry = curEntry.next()) { @@ -230,7 +238,8 @@ public TEntry getOrCreate(K key, Function creator) * 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). */ - public TEntry remove(K key) { + @Nullable + public TEntry remove(@Nullable K key) { long keyHash = D1.Entry.hash(key); int index = bucketIndex(buckets, keyHash); synchronized (getWriteLock(buckets)) { @@ -253,7 +262,7 @@ public TEntry remove(K key) { * 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(Predicate predicate) { + public boolean removeIf(@Nonnull Predicate predicate) { return ConcurrentHashtable.removeIf(buckets, size, predicate); } @@ -276,7 +285,7 @@ public boolean removeIf(Predicate predicate) { * half-published flush. This is intentional — the alternative is per-entry size bookkeeping on * a path that only matters when the caller is already in error. */ - public void drain(Consumer sink) { + public void drain(@Nonnull Consumer sink) { synchronized (getWriteLock(buckets)) { ConcurrentHashtable.drain(buckets, sink); size.set(0); @@ -288,7 +297,7 @@ public void drain(Consumer sink) { * 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, BiConsumer sink) { + public void drain(C context, @Nonnull BiConsumer sink) { synchronized (getWriteLock(buckets)) { ConcurrentHashtable.drain(buckets, context, sink); size.set(0); @@ -303,7 +312,7 @@ public void clear() { } } - public void forEach(Consumer consumer) { + public void forEach(@Nonnull Consumer consumer) { ConcurrentHashtable.forEach(buckets, consumer); } @@ -311,7 +320,7 @@ public void forEach(Consumer 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, BiConsumer consumer) { + public void forEach(C context, @Nonnull BiConsumer consumer) { ConcurrentHashtable.forEach(buckets, context, consumer); } } @@ -340,30 +349,32 @@ public abstract static class Entry extends ConcurrentHashtable.Entry { final K1 key1; final K2 key2; - protected Entry(K1 key1, 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(K1 key1, K2 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(Object key1, Object key2) { + public static long hash(@Nullable Object key1, @Nullable Object key2) { return LongHashingUtils.hash(key1, key2); } } @@ -383,8 +394,9 @@ private D2(AtomicReferenceArray buckets) { * {@link ConcurrentHashtable#createFixedBuckets(Class, int)} for why the class isn't otherwise * consumed here). Capacity is fixed; the table does not resize. */ + @Nonnull public static > D2 createFixedBuckets( - Class entryClass, int capacity) { + @Nonnull Class entryClass, int capacity) { return new D2<>(ConcurrentHashtable.createFixedBuckets(entryClass, capacity)); } @@ -392,7 +404,8 @@ public int size() { return size.get(); } - public TEntry get(K1 key1, K2 key2) { + @Nullable + public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { long keyHash = D2.Entry.hash(key1, key2); for (TEntry curEntry = bucket(buckets, keyHash); curEntry != null; @@ -412,8 +425,11 @@ public TEntry get(K1 key1, K2 key2) { *

    The {@code creator} should build an entry whose {@code keyHash} equals {@link * D2.Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. */ + @Nonnull public TEntry getOrCreate( - K1 key1, K2 key2, BiFunction creator) { + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction creator) { long keyHash = D2.Entry.hash(key1, key2); int index = bucketIndex(buckets, keyHash); for (TEntry curEntry = bucket(buckets, index); curEntry != null; curEntry = curEntry.next()) { @@ -441,7 +457,8 @@ public TEntry getOrCreate( * 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). */ - public TEntry remove(K1 key1, K2 key2) { + @Nullable + public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { long keyHash = D2.Entry.hash(key1, key2); int index = bucketIndex(buckets, keyHash); synchronized (getWriteLock(buckets)) { @@ -464,7 +481,7 @@ public TEntry remove(K1 key1, K2 key2) { * 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(Predicate predicate) { + public boolean removeIf(@Nonnull Predicate predicate) { return ConcurrentHashtable.removeIf(buckets, size, predicate); } @@ -487,7 +504,7 @@ public boolean removeIf(Predicate predicate) { * half-published flush. This is intentional — the alternative is per-entry size bookkeeping on * a path that only matters when the caller is already in error. */ - public void drain(Consumer sink) { + public void drain(@Nonnull Consumer sink) { synchronized (getWriteLock(buckets)) { ConcurrentHashtable.drain(buckets, sink); size.set(0); @@ -499,7 +516,7 @@ public void drain(Consumer sink) { * 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, BiConsumer sink) { + public void drain(C context, @Nonnull BiConsumer sink) { synchronized (getWriteLock(buckets)) { ConcurrentHashtable.drain(buckets, context, sink); size.set(0); @@ -514,7 +531,7 @@ public void clear() { } } - public void forEach(Consumer consumer) { + public void forEach(@Nonnull Consumer consumer) { ConcurrentHashtable.forEach(buckets, consumer); } @@ -522,7 +539,7 @@ public void forEach(Consumer 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, BiConsumer consumer) { + public void forEach(C context, @Nonnull BiConsumer consumer) { ConcurrentHashtable.forEach(buckets, context, consumer); } } @@ -549,8 +566,9 @@ public void forEach(C context, BiConsumer consume * createFixedBuckets(MyEntry.class, n)} and get back a precisely typed {@code * AtomicReferenceArray} without an explicit witness. */ + @Nonnull public static AtomicReferenceArray createFixedBuckets( - Class entryClass, int capacity) { + @Nonnull Class entryClass, int capacity) { return new AtomicReferenceArray<>(sizeFor(capacity)); } @@ -571,11 +589,12 @@ public static int sizeFor(int requestedSize) { * array today, but obtain it here rather than assuming that, so callers stay correct if the * monitor ever changes. */ - public static Object getWriteLock(AtomicReferenceArray buckets) { + @Nonnull + public static Object getWriteLock(@Nonnull AtomicReferenceArray buckets) { return buckets; } - public static int bucketIndex(AtomicReferenceArray buckets, long keyHash) { + public static int bucketIndex(@Nonnull AtomicReferenceArray buckets, long keyHash) { return (int) (keyHash & (buckets.length() - 1)); } @@ -583,8 +602,9 @@ public static int bucketIndex(AtomicReferenceArray buckets, long keyHash) { * 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. */ + @Nullable public static TEntry bucket( - AtomicReferenceArray buckets, long keyHash) { + @Nonnull AtomicReferenceArray buckets, long keyHash) { return buckets.get(bucketIndex(buckets, keyHash)); } @@ -593,8 +613,9 @@ public static TEntry bucket( * computed (e.g. inside {@code getOrCreate} where the same index is reused across the lock * boundary). */ + @Nullable public static TEntry bucket( - AtomicReferenceArray buckets, int index) { + @Nonnull AtomicReferenceArray buckets, int index) { return buckets.get(index); } @@ -606,7 +627,7 @@ public static TEntry bucket( * re-checking the chain for the key under that lock. Does not touch size accounting. */ public static void insertHeadEntry( - AtomicReferenceArray buckets, int index, TEntry entry) { + @Nonnull AtomicReferenceArray buckets, int index, @Nonnull TEntry entry) { assert Thread.holdsLock(getWriteLock(buckets)) : "insertHeadEntry called without holding getWriteLock(buckets)"; entry.setNext(buckets.get(index)); @@ -619,7 +640,7 @@ public static void insertHeadEntry( * computed (e.g. a {@code getOrCreate} that reuses it across the lock-free pre-check). */ public static void insertHeadEntry( - AtomicReferenceArray buckets, long keyHash, TEntry entry) { + @Nonnull AtomicReferenceArray buckets, long keyHash, @Nonnull TEntry entry) { insertHeadEntry(buckets, bucketIndex(buckets, keyHash), entry); } @@ -633,7 +654,10 @@ public static void insertHeadEntry( * touch size accounting. */ public static void unlink( - AtomicReferenceArray buckets, int index, TEntry prev, TEntry entry) { + @Nonnull AtomicReferenceArray buckets, + int index, + @Nullable TEntry prev, + @Nonnull TEntry entry) { assert Thread.holdsLock(getWriteLock(buckets)) : "unlink called without holding getWriteLock(buckets)"; TEntry next = entry.next(); @@ -651,9 +675,9 @@ public static void unlink( * throughout. */ public static boolean removeIf( - AtomicReferenceArray buckets, - AtomicInteger size, - Predicate predicate) { + @Nonnull AtomicReferenceArray buckets, + @Nonnull AtomicInteger size, + @Nonnull Predicate predicate) { synchronized (getWriteLock(buckets)) { boolean removed = false; for (int i = 0; i < buckets.length(); i++) { @@ -686,7 +710,7 @@ public static boolean removeIf( * reset never runs. The drain is not rolled back — a throwing sink is a caller error. */ public static void drain( - AtomicReferenceArray buckets, Consumer sink) { + @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer sink) { synchronized (getWriteLock(buckets)) { for (int i = 0; i < buckets.length(); i++) { TEntry head = buckets.get(i); @@ -703,7 +727,9 @@ public static void drain( /** Context-passing variant of {@link #drain(AtomicReferenceArray, Consumer)}. Self-locking. */ public static void drain( - AtomicReferenceArray buckets, C context, BiConsumer sink) { + @Nonnull AtomicReferenceArray buckets, + C context, + @Nonnull BiConsumer sink) { synchronized (getWriteLock(buckets)) { for (int i = 0; i < buckets.length(); i++) { TEntry head = buckets.get(i); @@ -719,7 +745,7 @@ public static void drain( } /** Nulls every bucket head. Self-locking: synchronizes on {@code buckets}. */ - public static void clear(AtomicReferenceArray buckets) { + public static void clear(@Nonnull AtomicReferenceArray buckets) { synchronized (getWriteLock(buckets)) { for (int i = 0; i < buckets.length(); i++) { buckets.set(i, null); @@ -728,7 +754,7 @@ public static void clear(AtomicReferenceArray buckets) { } public static void forEach( - AtomicReferenceArray buckets, Consumer consumer) { + @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer consumer) { for (int i = 0; i < buckets.length(); i++) { for (TEntry curEntry = buckets.get(i); curEntry != null; curEntry = curEntry.next()) { consumer.accept(curEntry); @@ -737,9 +763,9 @@ public static void forEach( } public static void forEach( - AtomicReferenceArray buckets, + @Nonnull AtomicReferenceArray buckets, C context, - BiConsumer consumer) { + @Nonnull BiConsumer consumer) { for (int i = 0; i < buckets.length(); i++) { for (TEntry curEntry = buckets.get(i); curEntry != null; curEntry = curEntry.next()) { consumer.accept(context, curEntry); From 4a20ba83d45d816f657e7fa34d76e22d218d382d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 29 Jul 2026 19:02:14 -0400 Subject: [PATCH 24/31] Annotate ConcurrentHashtable.D1/D2 @ThreadSafe and unlocked mutators @GuardedBy D1 and D2 are thread-safe (lock-free reads, locked writes), so mark them @ThreadSafe at the type level. The hand-written mutating building blocks insertHeadEntry and unlink require the caller to hold the table write monitor (they already assert Thread.holdsLock(getWriteLock(buckets))); make that precondition static/tooling-visible with @GuardedBy("getWriteLock(buckets)"). Deliberately leave the final, individually-thread-safe buckets/size fields unannotated: reads are lock-free by design, so @GuardedBy there would misdescribe the contract. Co-Authored-By: Claude Opus 4.8 --- .../main/java/datadog/trace/util/ConcurrentHashtable.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index 52dd2449090..c61317e5313 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -10,6 +10,8 @@ import java.util.function.Predicate; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import javax.annotation.concurrent.GuardedBy; +import javax.annotation.concurrent.ThreadSafe; /** * Concurrent hash table providing lock-free reads and locked writes for {@link D1} (single-key) and @@ -127,6 +129,7 @@ public final TEntry next() { * @param the key type * @param the user's {@link D1.Entry D1.Entry<K>} subclass */ + @ThreadSafe public static final class D1> { /** @@ -336,6 +339,7 @@ public void forEach(C context, @Nonnull BiConsumer second key type * @param the user's {@link D2.Entry D2.Entry<K1, K2>} subclass */ + @ThreadSafe public static final class D2> { /** @@ -626,6 +630,7 @@ public static TEntry bucket( * lock, so call it inside the caller's {@code synchronized (getWriteLock(buckets))} block, after * re-checking the chain for the key under that lock. Does not touch size accounting. */ + @GuardedBy("getWriteLock(buckets)") public static void insertHeadEntry( @Nonnull AtomicReferenceArray buckets, int index, @Nonnull TEntry entry) { assert Thread.holdsLock(getWriteLock(buckets)) @@ -639,6 +644,7 @@ public static void insertHeadEntry( * the bucket index from {@code keyHash}. Prefer the int-taking overload when the index is already * computed (e.g. a {@code getOrCreate} that reuses it across the lock-free pre-check). */ + @GuardedBy("getWriteLock(buckets)") public static void insertHeadEntry( @Nonnull AtomicReferenceArray buckets, long keyHash, @Nonnull TEntry entry) { insertHeadEntry(buckets, bucketIndex(buckets, keyHash), entry); @@ -653,6 +659,7 @@ public static void insertHeadEntry( * so call it inside the caller's {@code synchronized (getWriteLock(buckets))} block. Does not * touch size accounting. */ + @GuardedBy("getWriteLock(buckets)") public static void unlink( @Nonnull AtomicReferenceArray buckets, int index, From 8955442022e1201cbf41183c51489a34807fbeec Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 20 Aug 2026 15:00:02 -0400 Subject: [PATCH 25/31] Rename bucket/insertHeadEntry overloads to fix silent int/long ambiguity An int-typed key hash calling the overloaded bucket(buckets, hash) or insertHeadEntry(buckets, hash, entry) binds to the int-index overload instead of widening to long, treating the raw hash as an array index. Split into distinct bucketAt/insertHeadEntryAt (index-based) and bucketFor/insertHeadEntryFor (hash-based) so there's no overload to mis-resolve. Co-Authored-By: Claude Sonnet 5 --- .../trace/util/ThreadSafeMapD2Benchmark.java | 10 +- .../trace/util/ConcurrentHashtable.java | 98 +++++++++++-------- .../util/ConcurrentHashtableStaticsTest.java | 31 +++--- 3 files changed, 80 insertions(+), 59 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java index 0cf73df0932..a8135fe3708 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -207,7 +207,7 @@ public void setUp() { // populate support table SupportEntry se = new SupportEntry(SOURCE_K1[i], k2); synchronized (ConcurrentHashtable.getWriteLock(supportBuckets)) { - ConcurrentHashtable.insertHeadEntry(supportBuckets, se.keyHash, se); + ConcurrentHashtable.insertHeadEntryFor(supportBuckets, se.keyHash, se); } Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]); concurrentHashMap.put(key, (long) i); @@ -241,7 +241,7 @@ public SupportEntry get_support(SharedState s, ThreadState t) { String k1 = SOURCE_K1[i]; int k2 = SOURCE_K2_INT[i]; long keyHash = SupportEntry.hash(k1, k2); - for (SupportEntry e = ConcurrentHashtable.bucket(s.supportBuckets, keyHash); + for (SupportEntry e = ConcurrentHashtable.bucketFor(s.supportBuckets, keyHash); e != null; e = e.next()) { if (e.keyHash == keyHash && e.matches(k1, k2)) { @@ -282,7 +282,7 @@ public SupportEntry getOrCreate_support(SharedState s, ThreadState t) { int k2 = SOURCE_K2_INT[i]; long keyHash = SupportEntry.hash(k1, k2); int index = ConcurrentHashtable.bucketIndex(s.supportBuckets, keyHash); - for (SupportEntry e = ConcurrentHashtable.bucket(s.supportBuckets, index); + for (SupportEntry e = ConcurrentHashtable.bucketAt(s.supportBuckets, index); e != null; e = e.next()) { if (e.keyHash == keyHash && e.matches(k1, k2)) { @@ -290,7 +290,7 @@ public SupportEntry getOrCreate_support(SharedState s, ThreadState t) { } } synchronized (ConcurrentHashtable.getWriteLock(s.supportBuckets)) { - for (SupportEntry e = ConcurrentHashtable.bucket(s.supportBuckets, index); + for (SupportEntry e = ConcurrentHashtable.bucketAt(s.supportBuckets, index); e != null; e = e.next()) { if (e.keyHash == keyHash && e.matches(k1, k2)) { @@ -298,7 +298,7 @@ public SupportEntry getOrCreate_support(SharedState s, ThreadState t) { } } SupportEntry newEntry = new SupportEntry(k1, k2); - ConcurrentHashtable.insertHeadEntry(s.supportBuckets, index, newEntry); + ConcurrentHashtable.insertHeadEntryAt(s.supportBuckets, index, newEntry); return newEntry; } } diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index c61317e5313..701fcca0a0c 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -56,35 +56,38 @@ * object-key constraints are acceptable — they handle synchronization internally. When you need * primitive key components, three-or-more key parts, or extra per-entry value fields, drive the * table yourself with the static building blocks on this class: allocate the spine with {@link - * #createFixedBuckets(Class, int)}, then operate on it with {@link #bucket}, {@link #unlink}, - * {@link #removeIf}, {@link #drain}, {@link #clear}, and {@link #forEach}. This is the same "static - * functions over a caller-owned array" shape as {@link Hashtable} (see how {@code AggregateTable} - * uses {@code Hashtable}); the calling class then owns the array and exposes whatever operations it - * needs. Subclass {@link Entry} directly for such tables. + * #createFixedBuckets(Class, int)}, then operate on it with {@link #bucketFor} / {@link #bucketAt}, + * {@link #unlink}, {@link #removeIf}, {@link #drain}, {@link #clear}, and {@link #forEach}. This is + * the same "static functions over a caller-owned array" shape as {@link Hashtable} (see how {@code + * AggregateTable} uses {@code Hashtable}); the calling class then owns the array and exposes + * whatever operations it needs. Subclass {@link Entry} directly for such tables. * *

    Locking model. Writes are guarded by a per-table monitor obtained from {@link * #getWriteLock(AtomicReferenceArray)} — treat it as opaque rather than assuming it is the array. - * Reads are lock-free: {@link #bucket} walks and {@link #forEach} take no lock and are safe from - * any thread. The whole-table mutators — {@link #removeIf}, {@link #drain}, {@link #clear} — are - * self-locking ({@code synchronized (getWriteLock(buckets))} internally), so a custom table - * calls them directly with no lock of its own. The only writes a custom table performs by hand are - * single-key insert and remove; each is an atomic check-then-write that the caller wraps in {@code - * synchronized (getWriteLock(buckets))} so it excludes other writers and the self-locking mutators - * (same monitor, so it nests cleanly with the built-ins): + * Reads are lock-free: {@link #bucketFor} / {@link #bucketAt} walks and {@link #forEach} take no + * lock and are safe from any thread. The whole-table mutators — {@link #removeIf}, {@link #drain}, + * {@link #clear} — are self-locking ({@code synchronized (getWriteLock(buckets))} + * internally), so a custom table calls them directly with no lock of its own. The only writes a + * custom table performs by hand are single-key insert and remove; each is an atomic + * check-then-write that the caller wraps in {@code synchronized (getWriteLock(buckets))} so it + * excludes other writers and the self-locking mutators (same monitor, so it nests cleanly with the + * built-ins): * *

      - *
    1. Lock-free pre-check: walk the chain via {@link #bucket}; return if found. + *
    2. Lock-free pre-check: walk the chain via {@link #bucketFor} / {@link #bucketAt}; return if + * found. *
    3. {@code synchronized (getWriteLock(buckets))} — take the table's write monitor. *
    4. Re-check under the lock (another thread may have inserted between step 1 and step 2). - *
    5. Insert: build the entry and publish it with {@link #insertHeadEntry}. Remove: splice it out - * with {@link #unlink}. Both are volatile writes that lock-free readers observe atomically. + *
    6. Insert: build the entry and publish it with {@link #insertHeadEntryFor} / {@link + * #insertHeadEntryAt}. Remove: splice it out with {@link #unlink}. Both are volatile writes + * that lock-free readers observe atomically. *
    * - *

    {@link #bucket} (a lock-free read), {@link #insertHeadEntry}, and {@link #unlink} are the - * single-slot primitives for that hand-written path; the two mutating ones do not lock, so - * call them only inside the caller's {@code synchronized (getWriteLock(buckets))} block. The - * entry's chain pointer is written for you by those helpers — custom tables never touch it - * directly. + *

    {@link #bucketFor} / {@link #bucketAt} (a lock-free read), {@link #insertHeadEntryFor} / + * {@link #insertHeadEntryAt}, and {@link #unlink} are the single-slot primitives for that + * hand-written path; the two mutating ones do not lock, so call them only inside the + * caller's {@code synchronized (getWriteLock(buckets))} block. The entry's chain pointer is written + * for you by those helpers — custom tables never touch it directly. */ public final class ConcurrentHashtable { private ConcurrentHashtable() {} @@ -196,7 +199,7 @@ public int size() { @Nullable public TEntry get(@Nullable K key) { long keyHash = D1.Entry.hash(key); - for (TEntry curEntry = bucket(buckets, keyHash); + for (TEntry curEntry = bucketFor(buckets, keyHash); curEntry != null; curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key)) { @@ -216,13 +219,15 @@ public TEntry getOrCreate( @Nullable K key, @Nonnull Function creator) { long keyHash = D1.Entry.hash(key); int index = bucketIndex(buckets, keyHash); - for (TEntry curEntry = bucket(buckets, index); curEntry != null; curEntry = curEntry.next()) { + for (TEntry curEntry = bucketAt(buckets, index); + curEntry != null; + curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key)) { return curEntry; } } synchronized (getWriteLock(buckets)) { - for (TEntry curEntry = bucket(buckets, index); + for (TEntry curEntry = bucketAt(buckets, index); curEntry != null; curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key)) { @@ -230,7 +235,7 @@ public TEntry getOrCreate( } } TEntry newEntry = creator.apply(key); - insertHeadEntry(buckets, index, newEntry); + insertHeadEntryAt(buckets, index, newEntry); size.incrementAndGet(); return newEntry; } @@ -247,7 +252,7 @@ public TEntry remove(@Nullable K key) { int index = bucketIndex(buckets, keyHash); synchronized (getWriteLock(buckets)) { TEntry prev = null; - for (TEntry curEntry = bucket(buckets, index); + for (TEntry curEntry = bucketAt(buckets, index); curEntry != null; prev = curEntry, curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key)) { @@ -411,7 +416,7 @@ public int size() { @Nullable public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { long keyHash = D2.Entry.hash(key1, key2); - for (TEntry curEntry = bucket(buckets, keyHash); + for (TEntry curEntry = bucketFor(buckets, keyHash); curEntry != null; curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { @@ -436,13 +441,15 @@ public TEntry getOrCreate( @Nonnull BiFunction creator) { long keyHash = D2.Entry.hash(key1, key2); int index = bucketIndex(buckets, keyHash); - for (TEntry curEntry = bucket(buckets, index); curEntry != null; curEntry = curEntry.next()) { + for (TEntry curEntry = bucketAt(buckets, index); + curEntry != null; + curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { return curEntry; } } synchronized (getWriteLock(buckets)) { - for (TEntry curEntry = bucket(buckets, index); + for (TEntry curEntry = bucketAt(buckets, index); curEntry != null; curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { @@ -450,7 +457,7 @@ public TEntry getOrCreate( } } TEntry newEntry = creator.apply(key1, key2); - insertHeadEntry(buckets, index, newEntry); + insertHeadEntryAt(buckets, index, newEntry); size.incrementAndGet(); return newEntry; } @@ -467,7 +474,7 @@ public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { int index = bucketIndex(buckets, keyHash); synchronized (getWriteLock(buckets)) { TEntry prev = null; - for (TEntry curEntry = bucket(buckets, index); + for (TEntry curEntry = bucketAt(buckets, index); curEntry != null; prev = curEntry, curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { @@ -605,9 +612,16 @@ public static int bucketIndex(@Nonnull AtomicReferenceArray buckets, long key /** * 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 bucket( + public static TEntry bucketFor( @Nonnull AtomicReferenceArray buckets, long keyHash) { return buckets.get(bucketIndex(buckets, keyHash)); } @@ -615,10 +629,11 @@ public static TEntry bucket( /** * 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). + * boundary). See {@link #bucketFor} for why this is a distinct name rather than an {@code int} + * overload of it. */ @Nullable - public static TEntry bucket( + public static TEntry bucketAt( @Nonnull AtomicReferenceArray buckets, int index) { return buckets.get(index); } @@ -629,25 +644,28 @@ public static TEntry bucket( * {@code next} already points at the old head) atomically. Single-slot primitive: it does not * lock, so call it inside the caller's {@code synchronized (getWriteLock(buckets))} block, after * re-checking the chain for the key under that lock. Does not touch size accounting. + * + *

    See {@link #bucketFor} for why this is a distinct name rather than an {@code int} overload + * of {@link #insertHeadEntryFor}. */ @GuardedBy("getWriteLock(buckets)") - public static void insertHeadEntry( + public static void insertHeadEntryAt( @Nonnull AtomicReferenceArray buckets, int index, @Nonnull TEntry entry) { assert Thread.holdsLock(getWriteLock(buckets)) - : "insertHeadEntry called without holding getWriteLock(buckets)"; + : "insertHeadEntryAt called without holding getWriteLock(buckets)"; entry.setNext(buckets.get(index)); buckets.set(index, entry); } /** - * Convenience overload of {@link #insertHeadEntry(AtomicReferenceArray, int, Entry)} that derives - * the bucket index from {@code keyHash}. Prefer the int-taking overload when the index is already - * computed (e.g. a {@code getOrCreate} that reuses it across the lock-free pre-check). + * 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)") - public static void insertHeadEntry( + public static void insertHeadEntryFor( @Nonnull AtomicReferenceArray buckets, long keyHash, @Nonnull TEntry entry) { - insertHeadEntry(buckets, bucketIndex(buckets, keyHash), entry); + insertHeadEntryAt(buckets, bucketIndex(buckets, keyHash), entry); } /** diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java index 8a458a238bd..0a2839b6fa7 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java @@ -81,15 +81,16 @@ void insertGetAndRemoveViaStatics() { } @Test - void insertHeadEntryByKeyHashOverloadPlacesInMaskedBucket() { + void insertHeadEntryForPlacesInBucketMaskedFromKeyHash() { AtomicReferenceArray buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); // mask 7 IntEntry e = new IntEntry(9, 1); // keyHash 9 → bucket 1 synchronized (ConcurrentHashtable.getWriteLock(buckets)) { - ConcurrentHashtable.insertHeadEntry(buckets, e.keyHash, e); + ConcurrentHashtable.insertHeadEntryFor(buckets, e.keyHash, e); } - assertSame(e, ConcurrentHashtable.bucket(buckets, 9L)); // keyHash overload - assertSame(e, ConcurrentHashtable.bucket(buckets, 1)); // index overload + assertSame(e, ConcurrentHashtable.bucketFor(buckets, 9L)); // masks keyHash to the bucket index + assertSame( + e, ConcurrentHashtable.bucketAt(buckets, 1)); // same slot, addressed directly by index assertNull(buckets.get(0)); } @@ -216,7 +217,7 @@ void insertHeadEntryWithoutLockTripsAssertion() { ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); assertThrows( AssertionError.class, - () -> ConcurrentHashtable.insertHeadEntry(buckets, 0, new IntEntry(1, 1))); + () -> ConcurrentHashtable.insertHeadEntryAt(buckets, 0, new IntEntry(1, 1))); } @Test @@ -226,7 +227,7 @@ void unlinkWithoutLockTripsAssertion() { ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); IntEntry e = new IntEntry(1, 1); synchronized (ConcurrentHashtable.getWriteLock(buckets)) { - ConcurrentHashtable.insertHeadEntry(buckets, 0, e); + ConcurrentHashtable.insertHeadEntryAt(buckets, 0, e); } assertThrows(AssertionError.class, () -> ConcurrentHashtable.unlink(buckets, 0, null, e)); } @@ -335,7 +336,9 @@ private static final class IntTable { } IntEntry get(int key) { - for (IntEntry e = ConcurrentHashtable.bucket(buckets, (long) key); e != null; e = e.next()) { + for (IntEntry e = ConcurrentHashtable.bucketFor(buckets, (long) key); + e != null; + e = e.next()) { if (e.matches(key)) { return e; } @@ -345,19 +348,19 @@ IntEntry get(int key) { IntEntry getOrCreate(int key, int value) { int index = ConcurrentHashtable.bucketIndex(buckets, key); - for (IntEntry e = ConcurrentHashtable.bucket(buckets, index); e != null; e = e.next()) { + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { if (e.matches(key)) { return e; } } synchronized (ConcurrentHashtable.getWriteLock(buckets)) { - for (IntEntry e = ConcurrentHashtable.bucket(buckets, index); e != null; e = e.next()) { + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { if (e.matches(key)) { return e; } } IntEntry created = new IntEntry(key, value); - ConcurrentHashtable.insertHeadEntry(buckets, index, created); + ConcurrentHashtable.insertHeadEntryAt(buckets, index, created); size.incrementAndGet(); return created; } @@ -366,20 +369,20 @@ IntEntry getOrCreate(int key, int value) { /** {@link #getOrCreate} variant that counts real creations, for the exactly-once race test. */ IntEntry getOrCreateCounting(int key, AtomicInteger createCount) { int index = ConcurrentHashtable.bucketIndex(buckets, key); - for (IntEntry e = ConcurrentHashtable.bucket(buckets, index); e != null; e = e.next()) { + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { if (e.matches(key)) { return e; } } synchronized (ConcurrentHashtable.getWriteLock(buckets)) { - for (IntEntry e = ConcurrentHashtable.bucket(buckets, index); e != null; e = e.next()) { + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { if (e.matches(key)) { return e; } } createCount.incrementAndGet(); IntEntry created = new IntEntry(key, 0); - ConcurrentHashtable.insertHeadEntry(buckets, index, created); + ConcurrentHashtable.insertHeadEntryAt(buckets, index, created); size.incrementAndGet(); return created; } @@ -389,7 +392,7 @@ IntEntry remove(int key) { int index = ConcurrentHashtable.bucketIndex(buckets, key); synchronized (ConcurrentHashtable.getWriteLock(buckets)) { IntEntry prev = null; - for (IntEntry e = ConcurrentHashtable.bucket(buckets, index); e != null; e = e.next()) { + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { if (e.matches(key)) { ConcurrentHashtable.unlink(buckets, index, prev, e); size.decrementAndGet(); From 2a1865851e978ee6a6c4007f557d9cb099a18afa Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 13:04:10 -0400 Subject: [PATCH 26/31] Assert against double-inserting the same Entry instance Mirrors the same guard added to Hashtable.insertHeadEntryAt. Here it also catches reinserting an already-unlinked entry: unlink() deliberately leaves next intact so in-flight lock-free readers can keep traversing, so overwriting it via a reinsert would corrupt that traversal. --- .../src/main/java/datadog/trace/util/ConcurrentHashtable.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index 701fcca0a0c..102a000b567 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -653,6 +653,10 @@ public static void insertHeadEntryAt( @Nonnull AtomicReferenceArray buckets, int index, @Nonnull TEntry entry) { assert Thread.holdsLock(getWriteLock(buckets)) : "insertHeadEntryAt called without holding getWriteLock(buckets)"; + 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); } From 09a725f349423b7e4a97dc7f8bd49f278ede89d9 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 16:13:06 -0400 Subject: [PATCH 27/31] Port Hashtable's SizeManager eviction to ConcurrentHashtable Bundles buckets + a cursor-based SizeManager into a State, threaded through D1/D2 as tryGetOrCreateOrEvict(OrNull) so callers can cap table size and evict on overflow. Renames createFixedBuckets -> createCapped and getOrCreate -> tryGetOrCreate(OrNull) to reflect the capacity-aware contract. Adds unit test coverage for SizeManager's reserve/evict/reset behavior and the D1/D2 eviction paths. Co-Authored-By: Claude Sonnet 5 --- .../util/ThreadSafeMapCounterBenchmark.java | 4 +- .../trace/util/ThreadSafeMapD1Benchmark.java | 6 +- .../trace/util/ThreadSafeMapD2Benchmark.java | 6 +- .../trace/util/ConcurrentHashtable.java | 693 +++++++++++++++--- .../trace/util/ConcurrentHashtableD1Test.java | 198 +++-- .../trace/util/ConcurrentHashtableD2Test.java | 204 ++++-- .../ConcurrentHashtableSizeManagerTest.java | 301 ++++++++ 7 files changed, 1192 insertions(+), 220 deletions(-) create mode 100644 internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java index 311f2eae201..a78a66f6672 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java @@ -112,11 +112,11 @@ public static class SharedState { @Setup(Level.Iteration) public void setUp() { - table = ConcurrentHashtable.D1.createFixedBuckets(CounterEntry.class, CAPACITY); + table = ConcurrentHashtable.D1.createCapped(CounterEntry.class, CAPACITY); atomicLongMap = new ConcurrentHashMap<>(CAPACITY); longAdderMap = new ConcurrentHashMap<>(CAPACITY); for (int i = 0; i < N_KEYS; ++i) { - table.getOrCreate(KEYS[i], CounterEntry::new); + table.tryGetOrCreateOrNull(KEYS[i], CounterEntry::new); atomicLongMap.put(KEYS[i], new AtomicLong()); longAdderMap.put(KEYS[i], new LongAdder()); } diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java index 091b6c9fe60..fcf5b07c433 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java @@ -116,12 +116,12 @@ public static class SharedState { @Setup(Level.Iteration) public void setUp() { - table = ConcurrentHashtable.D1.createFixedBuckets(D1Entry.class, CAPACITY); + table = ConcurrentHashtable.D1.createCapped(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.getOrCreate(KEYS[i], D1Entry::new); + table.tryGetOrCreateOrNull(KEYS[i], D1Entry::new); concurrentHashMap.put(KEYS[i], (long) i); skipListMap.put(KEYS[i], (long) i); synchronizedHashMap.put(KEYS[i], (long) i); @@ -163,7 +163,7 @@ public Long get_synchronizedHashMap(SharedState s, ThreadState t) { @Benchmark public D1Entry getOrCreate_concurrentHashtable(SharedState s, ThreadState t) { - return s.table.getOrCreate(KEYS[t.next()], D1Entry::new); + return s.table.tryGetOrCreateOrNull(KEYS[t.next()], D1Entry::new); } /** diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java index a8135fe3708..c5b9122ec13 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -196,14 +196,14 @@ public static class SharedState { @Setup(Level.Iteration) public void setUp() { - table = ConcurrentHashtable.D2.createFixedBuckets(D2Entry.class, CAPACITY); + table = ConcurrentHashtable.D2.createCapped(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.getOrCreate(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new); + 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)) { @@ -272,7 +272,7 @@ public Long get_synchronizedHashMap(SharedState s, ThreadState t) { @Benchmark public D2Entry getOrCreate_concurrentHashtable(SharedState s, ThreadState t) { int i = t.next(); - return s.table.getOrCreate(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new); + return s.table.tryGetOrCreateOrNull(SOURCE_K1[i], SOURCE_K2[i], D2Entry::new); } @Benchmark diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index 102a000b567..d7906df834b 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -29,7 +29,7 @@ * synchronization. * *

    The primary advantage over {@link java.util.concurrent.ConcurrentHashMap} for composite-key - * use cases is that {@link D2#get(Object, Object)} and {@link D2#getOrCreate(Object, Object, + * use cases is that {@link D2#get(Object, Object)} and {@link D2#tryGetOrCreate(Object, Object, * BiFunction)} accept key parts directly — no composite key object is allocated for the lookup. * {@code ConcurrentHashMap} requires a wrapper object whose ownership may transfer to the map on * insert; escape analysis must conservatively assume the key escapes even on hit paths, preventing @@ -171,35 +171,38 @@ public static long hash(@Nullable Object key) { } } - private final AtomicReferenceArray buckets; - private final AtomicInteger size = new AtomicInteger(); + private final State state; - private D1(AtomicReferenceArray buckets) { - this.buckets = buckets; + private D1(State state) { + this.state = state; } /** - * Creates a single-key table with a fixed bucket count sized for {@code capacity} entries. The - * {@code entryClass} pins the concrete entry type so the compiler infers both {@code K} and - * {@code TEntry} at the call site — e.g. {@code D1.createFixedBuckets(MyEntry.class, 64)} — and - * keeps the factory symmetric with the rest of the flat-collections family (see {@link - * ConcurrentHashtable#createFixedBuckets(Class, int)} for why the class isn't otherwise - * consumed here). Capacity is fixed; the table does not resize. + * Creates a single-key table capped at {@code maxCapacity} entries: a {@link State} whose + * bucket array is sized with load-factor headroom over {@code maxCapacity} and whose {@link + * SizeManager} enforces {@code maxCapacity} as the strict entry-count limit consulted by {@link + * #tryGetOrCreate}. The {@code entryClass} pins the concrete entry type so the compiler infers + * both {@code K} and {@code TEntry} at the call site — e.g. {@code + * D1.createCapped(MyEntry.class, 64)}. Capacity is fixed; the table does not resize. */ @Nonnull - public static > D1 createFixedBuckets( - @Nonnull Class entryClass, int capacity) { - return new D1<>(ConcurrentHashtable.createFixedBuckets(entryClass, capacity)); + public static > D1 createCapped( + @Nonnull Class entryClass, int maxCapacity) { + return new D1<>(State.createCapped(entryClass, maxCapacity)); } public int size() { - return size.get(); + 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(buckets, keyHash); + for (TEntry curEntry = bucketFor(state, keyHash); curEntry != null; curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key)) { @@ -210,33 +213,100 @@ public TEntry get(@Nullable K key) { } /** - * Returns the entry for {@code key}, creating one via {@code creator} if absent. Lock-free on - * hit; acquires a table-level lock on miss. Re-checks under the lock to avoid duplicate entries - * under concurrent misses. + * 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 TEntry getOrCreate( + public Maybe tryGetOrCreate( + @Nullable K key, @Nonnull Function 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 creator) { long keyHash = D1.Entry.hash(key); - int index = bucketIndex(buckets, keyHash); - for (TEntry curEntry = bucketAt(buckets, index); - curEntry != null; - curEntry = curEntry.next()) { + 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 (getWriteLock(buckets)) { - for (TEntry curEntry = bucketAt(buckets, index); + synchronized (getWriteLock(state)) { + for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key)) { return curEntry; } } + // Deliberately isFull() -> create -> increment, not a pre-reserved slot: creator runs + // between the check and the link and may throw, so reserving up front could leak a slot. + if (state.sizeManager.isFull()) { + return null; + } TEntry newEntry = creator.apply(key); - insertHeadEntryAt(buckets, index, newEntry); - size.incrementAndGet(); + 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 creator, + @Nonnull Predicate 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 creator, + @Nonnull Predicate 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 (getWriteLock(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; } } @@ -249,15 +319,15 @@ public TEntry getOrCreate( @Nullable public TEntry remove(@Nullable K key) { long keyHash = D1.Entry.hash(key); - int index = bucketIndex(buckets, keyHash); - synchronized (getWriteLock(buckets)) { + int index = bucketIndex(state.buckets, keyHash); + synchronized (getWriteLock(state)) { TEntry prev = null; - for (TEntry curEntry = bucketAt(buckets, index); + for (TEntry curEntry = bucketAt(state, index); curEntry != null; prev = curEntry, curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key)) { - unlink(buckets, index, prev, curEntry); - size.decrementAndGet(); + unlink(state, index, prev, curEntry); + state.sizeManager.decrement(); return curEntry; } } @@ -271,7 +341,7 @@ public TEntry remove(@Nullable K key) { * concurrent writers are excluded; lock-free readers continue throughout. */ public boolean removeIf(@Nonnull Predicate predicate) { - return ConcurrentHashtable.removeIf(buckets, size, predicate); + return ConcurrentHashtable.removeIf(state, predicate); } /** @@ -294,10 +364,7 @@ public boolean removeIf(@Nonnull Predicate predicate) { * a path that only matters when the caller is already in error. */ public void drain(@Nonnull Consumer sink) { - synchronized (getWriteLock(buckets)) { - ConcurrentHashtable.drain(buckets, sink); - size.set(0); - } + ConcurrentHashtable.drain(state, sink); } /** @@ -306,22 +373,16 @@ public void drain(@Nonnull Consumer sink) { * event builder) to avoid a capturing-lambda allocation. */ public void drain(C context, @Nonnull BiConsumer sink) { - synchronized (getWriteLock(buckets)) { - ConcurrentHashtable.drain(buckets, context, sink); - size.set(0); - } + ConcurrentHashtable.drain(state, context, sink); } /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ public void clear() { - synchronized (getWriteLock(buckets)) { - ConcurrentHashtable.clear(buckets); - size.set(0); - } + ConcurrentHashtable.clear(state); } public void forEach(@Nonnull Consumer consumer) { - ConcurrentHashtable.forEach(buckets, consumer); + ConcurrentHashtable.forEach(state, consumer); } /** @@ -329,7 +390,7 @@ public void forEach(@Nonnull Consumer consumer) { * BiConsumer} (typically a {@code static final}) plus whatever side-band state it needs. */ public void forEach(C context, @Nonnull BiConsumer consumer) { - ConcurrentHashtable.forEach(buckets, context, consumer); + ConcurrentHashtable.forEach(state, context, consumer); } } @@ -388,35 +449,38 @@ public static long hash(@Nullable Object key1, @Nullable Object key2) { } } - private final AtomicReferenceArray buckets; - private final AtomicInteger size = new AtomicInteger(); + private final State state; - private D2(AtomicReferenceArray buckets) { - this.buckets = buckets; + private D2(State state) { + this.state = state; } /** - * Creates a composite-key table with a fixed bucket count sized for {@code capacity} entries. - * The {@code entryClass} pins the concrete entry type so the compiler infers {@code K1}, {@code - * K2}, and {@code TEntry} at the call site — e.g. {@code D2.createFixedBuckets(MyEntry.class, - * 64)} — and keeps the factory symmetric with the rest of the flat-collections family (see - * {@link ConcurrentHashtable#createFixedBuckets(Class, int)} for why the class isn't otherwise - * consumed here). Capacity is fixed; the table does not resize. + * Creates a composite-key table capped at {@code maxCapacity} entries: a {@link State} whose + * bucket array is sized with load-factor headroom over {@code maxCapacity} and whose {@link + * SizeManager} enforces {@code maxCapacity} as the strict entry-count limit consulted by {@link + * #tryGetOrCreate}. The {@code entryClass} pins the concrete entry type so the compiler infers + * {@code K1}, {@code K2}, and {@code TEntry} at the call site — e.g. {@code + * D2.createCapped(MyEntry.class, 64)}. Capacity is fixed; the table does not resize. */ @Nonnull - public static > D2 createFixedBuckets( - @Nonnull Class entryClass, int capacity) { - return new D2<>(ConcurrentHashtable.createFixedBuckets(entryClass, capacity)); + public static > D2 createCapped( + @Nonnull Class entryClass, int maxCapacity) { + return new D2<>(State.createCapped(entryClass, maxCapacity)); } public int size() { - return size.get(); + 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(buckets, keyHash); + for (TEntry curEntry = bucketFor(state, keyHash); curEntry != null; curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { @@ -427,38 +491,109 @@ public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { } /** - * Returns the entry for {@code (key1, key2)}, creating one via {@code creator} if absent. - * Lock-free on hit; acquires a table-level lock on miss. Re-checks under the lock to avoid - * duplicate entries under concurrent misses. + * 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 TEntry getOrCreate( + public Maybe tryGetOrCreate( + @Nullable K1 key1, + @Nullable K2 key2, + @Nonnull BiFunction 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 creator) { long keyHash = D2.Entry.hash(key1, key2); - int index = bucketIndex(buckets, keyHash); - for (TEntry curEntry = bucketAt(buckets, index); - curEntry != null; - curEntry = curEntry.next()) { + 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 (getWriteLock(state)) { + for (TEntry curEntry = bucketAt(state, index); + curEntry != null; + curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return curEntry; + } + } + // Deliberately isFull() -> create -> increment, not a pre-reserved slot: creator runs + // between the check and the link and may throw, so reserving up front could leak a slot. + 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 creator, + @Nonnull Predicate 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 creator, + @Nonnull Predicate 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 (getWriteLock(buckets)) { - for (TEntry curEntry = bucketAt(buckets, index); + synchronized (getWriteLock(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(buckets, index, newEntry); - size.incrementAndGet(); + insertHeadEntryAt(state, index, newEntry); + state.sizeManager.increment(); return newEntry; } } @@ -471,15 +606,15 @@ public TEntry getOrCreate( @Nullable public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { long keyHash = D2.Entry.hash(key1, key2); - int index = bucketIndex(buckets, keyHash); - synchronized (getWriteLock(buckets)) { + int index = bucketIndex(state.buckets, keyHash); + synchronized (getWriteLock(state)) { TEntry prev = null; - for (TEntry curEntry = bucketAt(buckets, index); + for (TEntry curEntry = bucketAt(state, index); curEntry != null; prev = curEntry, curEntry = curEntry.next()) { if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { - unlink(buckets, index, prev, curEntry); - size.decrementAndGet(); + unlink(state, index, prev, curEntry); + state.sizeManager.decrement(); return curEntry; } } @@ -493,7 +628,7 @@ public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { * concurrent writers are excluded; lock-free readers continue throughout. */ public boolean removeIf(@Nonnull Predicate predicate) { - return ConcurrentHashtable.removeIf(buckets, size, predicate); + return ConcurrentHashtable.removeIf(state, predicate); } /** @@ -516,10 +651,7 @@ public boolean removeIf(@Nonnull Predicate predicate) { * a path that only matters when the caller is already in error. */ public void drain(@Nonnull Consumer sink) { - synchronized (getWriteLock(buckets)) { - ConcurrentHashtable.drain(buckets, sink); - size.set(0); - } + ConcurrentHashtable.drain(state, sink); } /** @@ -528,22 +660,16 @@ public void drain(@Nonnull Consumer sink) { * event builder) to avoid a capturing-lambda allocation. */ public void drain(C context, @Nonnull BiConsumer sink) { - synchronized (getWriteLock(buckets)) { - ConcurrentHashtable.drain(buckets, context, sink); - size.set(0); - } + ConcurrentHashtable.drain(state, context, sink); } /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ public void clear() { - synchronized (getWriteLock(buckets)) { - ConcurrentHashtable.clear(buckets); - size.set(0); - } + ConcurrentHashtable.clear(state); } public void forEach(@Nonnull Consumer consumer) { - ConcurrentHashtable.forEach(buckets, consumer); + ConcurrentHashtable.forEach(state, consumer); } /** @@ -551,7 +677,276 @@ public void forEach(@Nonnull Consumer consumer) { * BiConsumer} (typically a {@code static final}) plus whatever side-band state it needs. */ public void forEach(C context, @Nonnull BiConsumer consumer) { - ConcurrentHashtable.forEach(buckets, context, consumer); + ConcurrentHashtable.forEach(state, context, consumer); + } + } + + /** + * Concurrent counterpart to {@link Hashtable.SizeManager}: manages a table's occupancy against a + * fixed cap in both directions — reserving a slot for an insert and evicting to make room — so a + * caller never has to remember to decrement after unlinking, nor wire up a second object + * alongside the count. + * + *

    {@link D1} and {@link D2} each hold one (via {@link State}) for their strict entry-count + * cap; composers driving an {@link AtomicReferenceArray} through the static building blocks can + * pair one the same way instead of hand-rolling the increment/decrement/cap-check bookkeeping — + * see {@link State#createCapped}. + * + *

    Locking. {@link #estimateSize()}, {@link #capacity()}, and {@link #isFull()} read + * only the atomic counter and need no lock. Every other method walks or mutates the chains (or + * the eviction cursor) and must be called under {@code synchronized (getWriteLock(buckets))} — + * the same monitor guarding the table's other writes — so a scan never races a concurrent insert + * or remove. Unlike {@link Hashtable.SizeManager}'s plain {@code int}, the live count here is an + * {@link AtomicInteger}: {@link #estimateSize()} and {@link #isFull()} are read without the lock + * (e.g. from {@link D1#size()}), which a plain field could not support safely. + */ + @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("getWriteLock(buckets)") + private int cursor; + + 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; + } + + /** + * Reserves a slot for a fresh insert: increments and returns {@code true}, or leaves the count + * unchanged and returns {@code false} if already at capacity. Use this when the entry to link + * is already fully built (nothing between the check and the increment can fail). When building + * the entry is itself fallible, check {@link #isFull()} first, do the fallible work, then call + * {@link #increment()} only once linking actually succeeds — see {@link + * D1#tryGetOrCreateOrNull} for that ordering. + */ + @GuardedBy("getWriteLock(buckets)") + public boolean tryReserve() { + if (isFull()) { + return false; + } + size.incrementAndGet(); + return true; + } + + /** + * {@link #tryReserve()}, falling back to evicting one entry matching {@code evictable} when the + * table is full. Returns {@code true} with a slot reserved, or {@code false} if the table was + * full and nothing was evictable — in which case {@code buckets} is untouched and the caller + * should drop the datum. + */ + @GuardedBy("getWriteLock(buckets)") + public boolean tryReserveOrEvict( + @Nonnull AtomicReferenceArray buckets, + @Nonnull Predicate 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(); + } + + /** Zeroes both the live count and the eviction scan position. */ + @GuardedBy("getWriteLock(buckets)") + public void reset() { + size.set(0); + cursor = 0; + } + + /** + * Scans {@code buckets} for the first entry matching {@code evictable}, starting where the last + * eviction left off and wrapping around if needed. Unlinks and returns the evicted entry, + * decrementing the count; returns {@code null} (count untouched) if nothing matched anywhere. + * + *

    Resuming from the previous position amortizes a sustained eviction stream: no successful + * eviction re-scans the hot prefix more than twice. A call that matches nothing has, by + * definition, tested every live entry, so a table that is full and entirely hot pays a full + * pass per attempt; the cursor still steps on so repeated refusals at least start from a + * different bucket next time. Size the cap to the steady-state working set so this stays the + * rare path, and keep {@code evictable} cheap — it is called once per live entry on every + * refusal. + */ + @GuardedBy("getWriteLock(buckets)") + @Nullable + public TEntry evictOne( + @Nonnull AtomicReferenceArray buckets, + @Nonnull Predicate evictable) { + TEntry evicted = evictOneInRange(buckets, evictable, cursor, buckets.length()); + if (evicted == null && cursor != 0) { + evicted = evictOneInRange(buckets, evictable, 0, cursor); + } + if (evicted != null) { + size.decrementAndGet(); + return evicted; + } + // Nothing matched anywhere; step the cursor on regardless so repeated refusals don't all + // restart the (wasted) scan from the same bucket. + cursor = bucketIndex(buckets, cursor + 1); + return null; + } + + @Nullable + private TEntry evictOneInRange( + @Nonnull AtomicReferenceArray buckets, + @Nonnull Predicate 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); + cursor = 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("getWriteLock(buckets)") + public int evictAll( + @Nonnull AtomicReferenceArray buckets, + @Nonnull Predicate 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; + } + } + } + cursor = 0; + return count; + } + } + + /** + * The mutable state of a caller-driven table: a bucket array and the {@link SizeManager} sized + * and capped to match it. Both halves are stateful and neither is much use without the other, + * which is what the name is getting at — the spine holds the entries, the manager holds how many + * there are and where the last eviction looked. + * + *

    Hold this, rather than unpacking it. Keeping one field instead of two is not just + * tidier: an array and a manager stored separately can drift apart, which is the mistake this + * type exists to prevent. {@link D1} and {@link D2} hold one internally; composers reach through + * it — {@code state.buckets}, {@code state.sizeManager} — when calling the static building blocks + * directly, or use the {@code State}-taking overloads on this class. + * + *

    Same headroom idiom as {@link D1}/{@link D2}: {@code maxCapacity} is the strict cap on live + * entries, and the backing array is sized with load-factor headroom over it. + */ + 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 {@link State}: a bucket array sized with load-factor headroom over {@code + * maxCapacity} (via {@link #createFixedBuckets(Class, int)}), paired with a {@link SizeManager} + * capped at the strict {@code maxCapacity}. {@code entryClass} is a type token only — see + * {@link #createFixedBuckets(Class, int)} for why it's needed despite not being used to + * allocate. + */ + @Nonnull + public static State createCapped( + @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 a slot in {@code state} for a fresh insert, evicting one entry matching {@code + * evictable} if the table is full. {@code false} means full with nothing evictable — the caller + * should drop the datum. Self-locking. + */ + public static boolean tryReserveOrEvict( + @Nonnull State state, @Nonnull Predicate evictable) { + synchronized (getWriteLock(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 evictable) { + synchronized (getWriteLock(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 evictable) { + synchronized (getWriteLock(state)) { + return state.sizeManager.evictAll(state.buckets, evictable); } } @@ -605,6 +1000,12 @@ public static Object getWriteLock(@Nonnull AtomicReferenceArray buckets) { return buckets; } + /** {@link #getWriteLock(AtomicReferenceArray)} over a {@link State}. */ + @Nonnull + public static Object getWriteLock(@Nonnull State state) { + return getWriteLock(state.buckets); + } + public static int bucketIndex(@Nonnull AtomicReferenceArray buckets, long keyHash) { return (int) (keyHash & (buckets.length() - 1)); } @@ -626,6 +1027,13 @@ public static TEntry bucketFor( 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 @@ -638,6 +1046,12 @@ public static TEntry bucketAt( 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); + } + /** * Splices {@code entry} in as the new head of the chain at {@code index}, publishing it with a * volatile {@link AtomicReferenceArray#set} so lock-free readers observe the whole entry (its @@ -661,6 +1075,13 @@ public static void insertHeadEntryAt( buckets.set(index, entry); } + /** {@link #insertHeadEntryAt(AtomicReferenceArray, int, Entry)} over a {@link State}. */ + @GuardedBy("getWriteLock(state)") + 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 @@ -697,6 +1118,13 @@ public static void unlink( } } + /** {@link #unlink(AtomicReferenceArray, int, Entry, Entry)} over a {@link State}. */ + @GuardedBy("getWriteLock(state)") + 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 @@ -726,6 +1154,32 @@ public static boolean removeIf( } } + /** + * {@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 predicate) { + AtomicReferenceArray buckets = state.buckets; + synchronized (getWriteLock(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 every entry, passing each to {@code sink} as its bucket is cleared. Each bucket head is * nulled (a volatile write that publishes the removal) before its chain is fed to {@code sink}, @@ -773,6 +1227,31 @@ public static void drain( } } + /** + * {@link #drain(AtomicReferenceArray, Consumer)} plus the matching bookkeeping: empties {@code + * state} into {@code sink} and resets its {@link SizeManager} to zero. Draining without resetting + * 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 sink) { + synchronized (getWriteLock(state)) { + drain(state.buckets, sink); + state.sizeManager.reset(); + } + } + + /** Context-passing form of {@link #drain(State, Consumer)}. */ + public static void drain( + @Nonnull State state, + C context, + @Nonnull BiConsumer sink) { + synchronized (getWriteLock(state)) { + drain(state.buckets, context, sink); + state.sizeManager.reset(); + } + } + /** Nulls every bucket head. Self-locking: synchronizes on {@code buckets}. */ public static void clear(@Nonnull AtomicReferenceArray buckets) { synchronized (getWriteLock(buckets)) { @@ -782,6 +1261,16 @@ public static void clear(@Nonnull AtomicReferenceArray buckets) { } } + /** + * {@link #clear(AtomicReferenceArray)} over a {@link State}: also resets its {@link SizeManager}. + */ + public static void clear(@Nonnull State state) { + synchronized (getWriteLock(state)) { + clear(state.buckets); + state.sizeManager.reset(); + } + } + public static void forEach( @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer consumer) { for (int i = 0; i < buckets.length(); i++) { @@ -801,4 +1290,18 @@ public static void forEach( } } } + + /** {@link #forEach(AtomicReferenceArray, Consumer)} over a {@link State}. */ + public static void forEach( + @Nonnull State state, @Nonnull Consumer 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 consumer) { + forEach(state.buckets, context, consumer); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java index 49782db69df..f95e0657211 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java @@ -5,6 +5,7 @@ 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; @@ -20,8 +21,8 @@ class ConcurrentHashtableD1Test { @Test void getReturnsMappedEntry() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); - StringEntry e = table.getOrCreate("hello", k -> new StringEntry(k, 42)); + ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + StringEntry e = table.tryGetOrCreateOrNull("hello", k -> new StringEntry(k, 42)); assertSame(e, table.get("hello")); assertNull(table.get("world")); } @@ -29,10 +30,10 @@ void getReturnsMappedEntry() { @Test void getOrCreateOnMissBuildsEntry() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); + ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); int[] createCount = {0}; StringEntry created = - table.getOrCreate( + table.tryGetOrCreateOrNull( "a", k -> { createCount[0]++; @@ -47,11 +48,11 @@ void getOrCreateOnMissBuildsEntry() { @Test void getOrCreateOnHitSkipsCreator() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); - StringEntry seeded = table.getOrCreate("a", k -> new StringEntry(k, 100)); + ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + StringEntry seeded = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 100)); int[] createCount = {0}; StringEntry got = - table.getOrCreate( + table.tryGetOrCreateOrNull( "a", k -> { createCount[0]++; @@ -65,8 +66,8 @@ void getOrCreateOnHitSkipsCreator() { @Test void nullKeyIsSupported() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); - StringEntry e = table.getOrCreate(null, k -> new StringEntry(k, 0)); + ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + StringEntry e = table.tryGetOrCreateOrNull(null, k -> new StringEntry(k, 0)); assertNotNull(e); assertSame(e, table.get(null)); } @@ -74,10 +75,10 @@ void nullKeyIsSupported() { @Test void forEachVisitsAllEntries() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); - table.getOrCreate("a", k -> new StringEntry(k, 1)); - table.getOrCreate("b", k -> new StringEntry(k, 2)); - table.getOrCreate("c", k -> new StringEntry(k, 3)); + ConcurrentHashtable.D1.createCapped(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()); @@ -89,9 +90,9 @@ void forEachVisitsAllEntries() { @Test void forEachWithContextPassesContext() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); - table.getOrCreate("x", k -> new StringEntry(k, 10)); - table.getOrCreate("y", k -> new StringEntry(k, 20)); + ConcurrentHashtable.D1.createCapped(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()); @@ -102,7 +103,7 @@ void forEachWithContextPassesContext() { @Test void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); + ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); int threads = 16; CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -120,7 +121,7 @@ void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException Thread.currentThread().interrupt(); return; } - table.getOrCreate( + table.tryGetOrCreateOrNull( "shared", k -> { createCount.incrementAndGet(); @@ -141,15 +142,15 @@ void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException @Test void chainedEntriesInSameBucketAreAllReachable() { - // 2 buckets: keyHash & 1 determines the slot. Hashes 0 and 2 both land in bucket 0. + // All three keys share hash 0, so they land in the same bucket regardless of table size. ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(CollidingEntry.class, 2); + ConcurrentHashtable.D1.createCapped(CollidingEntry.class, 8); CollidingKey a = new CollidingKey("a", 0); CollidingKey b = new CollidingKey("b", 0); // same bucket as a - CollidingKey c = new CollidingKey("c", 2); // 2 & 1 == 0, same bucket - CollidingEntry ea = table.getOrCreate(a, CollidingEntry::new); - CollidingEntry eb = table.getOrCreate(b, CollidingEntry::new); - CollidingEntry ec = table.getOrCreate(c, CollidingEntry::new); + 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)); @@ -165,7 +166,7 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException keys[i] = "key-" + i; } ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, threads * 2); + ConcurrentHashtable.D1.createCapped(StringEntry.class, threads * 2); CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -182,7 +183,7 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException Thread.currentThread().interrupt(); return; } - table.getOrCreate(key, k -> new StringEntry(k, 1)); + table.tryGetOrCreateOrNull(key, k -> new StringEntry(k, 1)); }); workers[i].start(); } @@ -201,9 +202,9 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException @Test void removeReturnsEntryAndShrinks() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); - StringEntry a = table.getOrCreate("a", k -> new StringEntry(k, 1)); - table.getOrCreate("b", k -> new StringEntry(k, 2)); + ConcurrentHashtable.D1.createCapped(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")); @@ -213,23 +214,23 @@ void removeReturnsEntryAndShrinks() { @Test void removeAbsentKeyReturnsNull() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); - table.getOrCreate("a", k -> new StringEntry(k, 1)); + ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); assertNull(table.remove("missing")); assertEquals(1, table.size()); } @Test void removeHeadMiddleAndTailOfSameBucketChain() { - // Capacity 1 forces every key into a single bucket, so a, b, c form one chain. + // All three keys share hash 0, so a, b, c land in the same bucket and form one chain. ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(CollidingEntry.class, 1); + ConcurrentHashtable.D1.createCapped(CollidingEntry.class, 8); CollidingKey a = new CollidingKey("a", 0); CollidingKey b = new CollidingKey("b", 0); CollidingKey c = new CollidingKey("c", 0); - table.getOrCreate(a, CollidingEntry::new); - table.getOrCreate(b, CollidingEntry::new); - table.getOrCreate(c, CollidingEntry::new); + 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)); @@ -248,10 +249,10 @@ void removeHeadMiddleAndTailOfSameBucketChain() { @Test void removeIfRemovesMatchingEntries() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 16); + ConcurrentHashtable.D1.createCapped(StringEntry.class, 16); for (int i = 0; i < 10; i++) { final int v = i; - table.getOrCreate("k" + i, k -> new StringEntry(k, v)); + 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); @@ -267,8 +268,8 @@ void removeIfRemovesMatchingEntries() { @Test void removeIfReturnsFalseWhenNothingMatches() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); - table.getOrCreate("a", k -> new StringEntry(k, 1)); + ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); assertFalse(table.removeIf(e -> false)); assertEquals(1, table.size()); } @@ -276,14 +277,14 @@ void removeIfReturnsFalseWhenNothingMatches() { @Test void clearEmptiesTableAndLeavesItUsable() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); - table.getOrCreate("a", k -> new StringEntry(k, 1)); - table.getOrCreate("b", k -> new StringEntry(k, 2)); + ConcurrentHashtable.D1.createCapped(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.getOrCreate("c", k -> new StringEntry(k, 3)); + StringEntry c = table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3)); assertSame(c, table.get("c")); assertEquals(1, table.size()); } @@ -291,10 +292,10 @@ void clearEmptiesTableAndLeavesItUsable() { @Test void drainRemovesEveryEntryAndFeedsSink() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); - table.getOrCreate("a", k -> new StringEntry(k, 1)); - table.getOrCreate("b", k -> new StringEntry(k, 2)); - table.getOrCreate("c", k -> new StringEntry(k, 3)); + ConcurrentHashtable.D1.createCapped(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}; @@ -309,7 +310,7 @@ void drainRemovesEveryEntryAndFeedsSink() { assertEquals(0, table.size()); assertNull(table.get("a")); // table remains usable after drain - StringEntry d = table.getOrCreate("d", k -> new StringEntry(k, 4)); + StringEntry d = table.tryGetOrCreateOrNull("d", k -> new StringEntry(k, 4)); assertSame(d, table.get("d")); assertEquals(1, table.size()); } @@ -317,9 +318,9 @@ void drainRemovesEveryEntryAndFeedsSink() { @Test void drainWithContextFeedsSink() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); - table.getOrCreate("a", k -> new StringEntry(k, 1)); - table.getOrCreate("b", k -> new StringEntry(k, 2)); + ConcurrentHashtable.D1.createCapped(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)); @@ -331,7 +332,7 @@ void drainWithContextFeedsSink() { @Test void drainOnEmptyTableInvokesSinkZeroTimes() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createFixedBuckets(StringEntry.class, 8); + ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); int[] count = {0}; table.drain(e -> count[0]++); assertEquals(0, count[0]); @@ -345,14 +346,15 @@ void drainOnEmptyTableInvokesSinkZeroTimes() { */ @Test void concurrentReadsStaySafeWhileOneChainMemberChurns() throws InterruptedException { - // Capacity 1 puts every key in one bucket so removal splices a chain the reader is walking. + // 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.createFixedBuckets(CollidingEntry.class, 1); + ConcurrentHashtable.D1.createCapped(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.getOrCreate(keys[i], CollidingEntry::new); + table.tryGetOrCreateOrNull(keys[i], CollidingEntry::new); } CollidingKey churn = keys[0]; // keys[1..] are stable and must never vanish @@ -372,7 +374,7 @@ void concurrentReadsStaySafeWhileOneChainMemberChurns() throws InterruptedExcept reader.start(); for (int r = 0; r < 100_000; r++) { table.remove(churn); - table.getOrCreate(churn, CollidingEntry::new); + table.tryGetOrCreateOrNull(churn, CollidingEntry::new); } stop.set(true); reader.join(); @@ -380,6 +382,88 @@ void concurrentReadsStaySafeWhileOneChainMemberChurns() throws InterruptedExcept assertEquals(0, missed.get(), "stable chain members must never be unreachable during removal"); } + @Test + void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createCapped(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.createCapped(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.createCapped(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.createCapped(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.createCapped(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; diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java index 76a1321c1b0..ae6978a1ebc 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java @@ -5,6 +5,7 @@ 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; @@ -19,10 +20,10 @@ class ConcurrentHashtableD2Test { @Test void pairKeysParticipateInIdentity() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); - PairEntry ab = table.getOrCreate("a", 1, PairEntry::new); - PairEntry ac = table.getOrCreate("a", 2, PairEntry::new); - PairEntry bb = table.getOrCreate("b", 1, PairEntry::new); + ConcurrentHashtable.D2.createCapped(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)); @@ -33,10 +34,10 @@ void pairKeysParticipateInIdentity() { @Test void getOrCreateOnMissBuildsEntryViaCreator() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); + ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); int[] createCount = {0}; PairEntry created = - table.getOrCreate( + table.tryGetOrCreateOrNull( "a", 1, (k1, k2) -> { @@ -54,11 +55,11 @@ void getOrCreateOnMissBuildsEntryViaCreator() { @Test void getOrCreateOnHitSkipsCreator() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); - PairEntry seeded = table.getOrCreate("a", 1, PairEntry::new); + ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + PairEntry seeded = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); int[] createCount = {0}; PairEntry got = - table.getOrCreate( + table.tryGetOrCreateOrNull( "a", 1, (k1, k2) -> { @@ -73,9 +74,9 @@ void getOrCreateOnHitSkipsCreator() { @Test void forEachVisitsBothPairs() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); - table.getOrCreate("a", 1, PairEntry::new); - table.getOrCreate("b", 2, PairEntry::new); + ConcurrentHashtable.D2.createCapped(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()); @@ -86,9 +87,9 @@ void forEachVisitsBothPairs() { @Test void forEachWithContextPassesContextToConsumer() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); - table.getOrCreate("a", 1, PairEntry::new); - table.getOrCreate("b", 2, PairEntry::new); + ConcurrentHashtable.D2.createCapped(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()); @@ -99,7 +100,7 @@ void forEachWithContextPassesContextToConsumer() { @Test void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); + ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); int threads = 16; CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -117,7 +118,7 @@ void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException Thread.currentThread().interrupt(); return; } - table.getOrCreate( + table.tryGetOrCreateOrNull( "shared", 42, (k1, k2) -> { @@ -139,18 +140,19 @@ void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException @Test void chainedEntriesInSameBucketAreAllReachable() { - // 2 buckets: 4 entries guarantees at least 2 share a bucket by pigeonhole. + // key2 = -31 * key1.hashCode() zeroes the combined hash, so all four land in bucket 0 + // regardless of table size. ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 2); - PairEntry e1 = table.getOrCreate("a", 1, PairEntry::new); - PairEntry e2 = table.getOrCreate("a", 2, PairEntry::new); - PairEntry e3 = table.getOrCreate("b", 1, PairEntry::new); - PairEntry e4 = table.getOrCreate("b", 2, PairEntry::new); + ConcurrentHashtable.D2.createCapped(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", 1)); - assertSame(e2, table.get("a", 2)); - assertSame(e3, table.get("b", 1)); - assertSame(e4, table.get("b", 2)); + 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)); } @@ -164,7 +166,7 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException k2s[i] = i; } ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, threads * 2); + ConcurrentHashtable.D2.createCapped(PairEntry.class, threads * 2); CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -182,7 +184,7 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException Thread.currentThread().interrupt(); return; } - table.getOrCreate(k1, k2, PairEntry::new); + table.tryGetOrCreateOrNull(k1, k2, PairEntry::new); }); workers[i].start(); } @@ -201,9 +203,9 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException @Test void removeReturnsEntryAndShrinks() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); - PairEntry ab = table.getOrCreate("a", 1, PairEntry::new); - table.getOrCreate("a", 2, PairEntry::new); + ConcurrentHashtable.D2.createCapped(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)); @@ -213,8 +215,8 @@ void removeReturnsEntryAndShrinks() { @Test void removeAbsentKeyReturnsNull() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); - table.getOrCreate("a", 1, PairEntry::new); + ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); assertNull(table.remove("a", 99)); assertNull(table.remove("z", 1)); assertEquals(1, table.size()); @@ -222,26 +224,27 @@ void removeAbsentKeyReturnsNull() { @Test void removeMiddleOfSameBucketChainKeepsOthersReachable() { - // Capacity 1 forces every pair into a single bucket chain. + // 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.createFixedBuckets(PairEntry.class, 1); - table.getOrCreate("a", 1, PairEntry::new); - PairEntry mid = table.getOrCreate("a", 2, PairEntry::new); - table.getOrCreate("a", 3, PairEntry::new); - - assertSame(mid, table.remove("a", 2)); - assertNull(table.get("a", 2)); - assertNotNull(table.get("a", 1)); - assertNotNull(table.get("a", 3)); + ConcurrentHashtable.D2.createCapped(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.createFixedBuckets(PairEntry.class, 16); + ConcurrentHashtable.D2.createCapped(PairEntry.class, 16); for (int i = 0; i < 10; i++) { - table.getOrCreate("k", i, PairEntry::new); + table.tryGetOrCreateOrNull("k", i, PairEntry::new); } boolean removed = table.removeIf(e -> e.key2 % 2 == 0); // removes key2 0,2,4,6,8 assertTrue(removed); @@ -254,8 +257,8 @@ void removeIfRemovesMatchingEntries() { @Test void removeIfReturnsFalseWhenNothingMatches() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); - table.getOrCreate("a", 1, PairEntry::new); + ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); assertFalse(table.removeIf(e -> false)); assertEquals(1, table.size()); } @@ -263,13 +266,13 @@ void removeIfReturnsFalseWhenNothingMatches() { @Test void clearEmptiesTableAndLeavesItUsable() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); - table.getOrCreate("a", 1, PairEntry::new); - table.getOrCreate("b", 2, PairEntry::new); + ConcurrentHashtable.D2.createCapped(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.getOrCreate("c", 3, PairEntry::new); + PairEntry c = table.tryGetOrCreateOrNull("c", 3, PairEntry::new); assertSame(c, table.get("c", 3)); assertEquals(1, table.size()); } @@ -277,10 +280,10 @@ void clearEmptiesTableAndLeavesItUsable() { @Test void drainRemovesEveryEntryAndFeedsSink() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); - table.getOrCreate("a", 1, PairEntry::new); - table.getOrCreate("a", 2, PairEntry::new); - table.getOrCreate("b", 1, PairEntry::new); + ConcurrentHashtable.D2.createCapped(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)); @@ -288,7 +291,7 @@ void drainRemovesEveryEntryAndFeedsSink() { assertEquals(new HashSet<>(Arrays.asList("a:1", "a:2", "b:1")), drained); assertEquals(0, table.size()); assertNull(table.get("a", 1)); - PairEntry c = table.getOrCreate("c", 3, PairEntry::new); + PairEntry c = table.tryGetOrCreateOrNull("c", 3, PairEntry::new); assertSame(c, table.get("c", 3)); assertEquals(1, table.size()); } @@ -296,9 +299,9 @@ void drainRemovesEveryEntryAndFeedsSink() { @Test void drainWithContextFeedsSink() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createFixedBuckets(PairEntry.class, 8); - table.getOrCreate("a", 1, PairEntry::new); - table.getOrCreate("b", 2, PairEntry::new); + ConcurrentHashtable.D2.createCapped(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)); @@ -307,6 +310,87 @@ void drainWithContextFeedsSink() { assertEquals(0, table.size()); } + @Test + void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createCapped(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.createCapped(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.createCapped(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.createCapped(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.createCapped(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..c8ea58f7ce7 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java @@ -0,0 +1,301 @@ +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 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.createCapped(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.createCapped(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.createCapped(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 evictOneReturnsNullAndLeavesCountUnchangedWhenNothingMatches() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createCapped(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.createCapped(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.createCapped(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.createCapped(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.createCapped(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 resetZeroesCountAndScanPosition() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createCapped(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 + + state.sizeManager.reset(); + 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 reset() + assertSame(e3, state.buckets.get(3)); + } + + @Test + void stateCreateCappedBundlesBucketsAndSizeManager() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createCapped(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.createCapped(TestEntry.class, 1); + synchronized (ConcurrentHashtable.getWriteLock(state)) { + 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 inserting the entry that occupies it (mirrors the D1/D2 + // tryGetOrCreateOrEvict contract, where the actual insert happens right after). + 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 + synchronized (ConcurrentHashtable.getWriteLock(state)) { + ConcurrentHashtable.insertHeadEntryAt(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.getWriteLock(state)) { + 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)); + } + + 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.getWriteLock(state)) { + 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.getWriteLock(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.getWriteLock(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.getWriteLock(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; + } + } +} From 1e58686e3db645ec48d7f134c3b8e17c693357c3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 17:10:35 -0400 Subject: [PATCH 28/31] Add ConcurrentHashtable.insertReserved static helper Mirrors Hashtable.insertReserved: splices a fully-built entry into an already-reserved slot (from tryReserve()/tryReserveOrEvict) without double-counting. Not used by D1/D2, whose creator is fallible and so increments only after a successful link; documented as the contrast. Co-Authored-By: Claude Sonnet 5 --- .../trace/util/ConcurrentHashtable.java | 26 +++++++++++++++++++ .../ConcurrentHashtableSizeManagerTest.java | 25 +++++++++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index d7906df834b..ac535fea1bc 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -1093,6 +1093,32 @@ public static void insertHeadEntryFor( insertHeadEntryAt(buckets, bucketIndex(buckets, keyHash), entry); } + /** + * Splices {@code entry} in as the new head of its bucket without touching the count, + * because the caller already holds a reservation for it -- from {@link #tryReserveOrEvict} or a + * bare {@link SizeManager#tryReserve()}. Pairing those is the shape of a miss path that wants to + * refuse before it builds anything: + * + *

    {@code
    +   * if (!tryReserveOrEvict(state, evictable)) {
    +   *   return null;                       // refused -- no entry was built
    +   * }
    +   * insertReserved(state, keyHash, buildEntry());
    +   * }
    + * + *

    Distinct from {@link #insertHeadEntryFor(AtomicReferenceArray, long, Entry)}, which reserves + * as it inserts; calling that one here would count the entry twice. {@link D1} and {@link D2} do + * not use this: their {@code creator} is fallible, so they check/evict, build the entry, link it, + * and only then call {@link SizeManager#increment} -- reserving up front could leak a slot if the + * build throws (see {@link D1#tryGetOrCreateOrNull}). Use this only when the entry is already + * fully built before the reservation is taken. + */ + @GuardedBy("getWriteLock(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 diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java index c8ea58f7ce7..acb504180d3 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java @@ -74,6 +74,23 @@ void tryReserveOrEvictFailsAndLeavesTableUntouchedWhenNothingEvictable() { assertSame(existing, state.buckets.get(0)); } + @Test + void insertReservedSplicesWithoutTouchingTheCountAfterATryReserve() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createCapped(TestEntry.class, 2); + assertTrue(state.sizeManager.tryReserve()); + assertEquals(1, state.sizeManager.estimateSize()); + + TestEntry entry = new TestEntry(0, "reserved"); + synchronized (ConcurrentHashtable.getWriteLock(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 = @@ -222,15 +239,15 @@ void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() { 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 inserting the entry that occupies it (mirrors the D1/D2 - // tryGetOrCreateOrEvict contract, where the actual insert happens right after). + // 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. 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 synchronized (ConcurrentHashtable.getWriteLock(state)) { - ConcurrentHashtable.insertHeadEntryAt(state, 0, new TestEntry(0, "reserved")); + ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved")); } int evicted = ConcurrentHashtable.evictAll(state, e -> true); From 2926efb29a89c8757911483a55ffd2e0f8894d4f Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Mon, 31 Aug 2026 12:53:45 +0200 Subject: [PATCH 29/31] fix: make eviction cursor visible across threads --- .../src/main/java/datadog/trace/util/ConcurrentHashtable.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index ac535fea1bc..36631e6c8a2 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -710,7 +710,7 @@ public static final class SizeManager { * eviction stream doesn't repeatedly re-walk the same hot entries clustered near bucket 0. */ @GuardedBy("getWriteLock(buckets)") - private int cursor; + private volatile int cursor; public SizeManager(int capacity) { this.capacity = capacity; From 81b84bf75a9d98d1f25d9ae726408d6137abc4aa Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Mon, 31 Aug 2026 13:27:48 +0200 Subject: [PATCH 30/31] revert: restore lock-guarded eviction cursor --- .../src/main/java/datadog/trace/util/ConcurrentHashtable.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index 36631e6c8a2..ac535fea1bc 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -710,7 +710,7 @@ public static final class SizeManager { * eviction stream doesn't repeatedly re-walk the same hot entries clustered near bucket 0. */ @GuardedBy("getWriteLock(buckets)") - private volatile int cursor; + private int cursor; public SizeManager(int capacity) { this.capacity = capacity; From c5abed7c2afea12806b60349dfe55a8d5511061f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 31 Aug 2026 08:25:33 -0400 Subject: [PATCH 31/31] Keep the reservation and its insert in one critical section tryReserveOrEvict is self-locking, so pairing it with insertReserved across two critical sections lets a drain or clear land in the gap, reset the SizeManager while the reservation is outstanding, and leave the insert linking an entry the count never learns about -- a capped table then drifts silently past its cap. Document the enclosing lock as part of the contract (class level, both tryReserveOrEvict javadocs, and insertReserved's example), fix the test that encoded the racy shape, and add a deterministic test that a concurrent clear cannot interleave. Also give evictOneInRange the @GuardedBy the other cursor writers carry, and suppress AT_STALE_THREAD_WRITE_OF_PRIMITIVE where SpotBugs cannot model the dynamic getWriteLock(buckets) guard. Co-Authored-By: Claude Opus 5 --- .../trace/util/ConcurrentHashtable.java | 56 ++++++++++++++++++- .../ConcurrentHashtableSizeManagerTest.java | 47 ++++++++++++++-- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index ac535fea1bc..92bda67c0c8 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -1,5 +1,6 @@ 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; @@ -88,6 +89,15 @@ * hand-written path; the two mutating ones do not lock, so call them only inside the * caller's {@code synchronized (getWriteLock(buckets))} block. The entry's chain pointer is written * for you by those helpers — custom tables never touch it directly. + * + *

    A sequence of self-locking calls is not atomic. Each self-locking helper takes and + * releases the monitor on its own, so two of them in a row leave a window in between. That matters + * for any multi-step protocol over one table — notably reserving a slot with {@link + * #tryReserveOrEvict} and then filling it with {@link #insertReserved}: a {@link #drain} or {@link + * #clear} landing in the gap resets the {@link SizeManager} while the reservation is outstanding, + * and the later insert then links an entry the count no longer knows about, so a capped table + * drifts silently past its cap. Hold {@code synchronized (getWriteLock(state))} across the whole + * protocol; the monitor is reentrant, so the self-locking calls nest inside it cleanly. */ public final class ConcurrentHashtable { private ConcurrentHashtable() {} @@ -752,6 +762,10 @@ public boolean tryReserve() { * table is full. Returns {@code true} with a slot reserved, or {@code false} if the table was * full and nothing was evictable — in which case {@code buckets} is untouched and the caller * should drop the datum. + * + *

    The write lock must be held across the insert that consumes the reservation, not merely + * across this call: {@link #reset()} (via a table-level drain or clear) zeroes the count, and a + * reservation taken before it is silently voided. */ @GuardedBy("getWriteLock(buckets)") public boolean tryReserveOrEvict( @@ -780,6 +794,11 @@ public void decrement() { /** Zeroes both the live count and the eviction scan position. */ @GuardedBy("getWriteLock(buckets)") + @SuppressFBWarnings( + value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", + justification = + "cursor is read and written only under synchronized (getWriteLock(buckets)); SpotBugs" + + " cannot model that dynamic guard") public void reset() { size.set(0); cursor = 0; @@ -817,6 +836,12 @@ public TEntry evictOne( return null; } + @GuardedBy("getWriteLock(buckets)") + @SuppressFBWarnings( + value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", + justification = + "cursor is read and written only under synchronized (getWriteLock(buckets)); SpotBugs" + + " cannot model that dynamic guard") @Nullable private TEntry evictOneInRange( @Nonnull AtomicReferenceArray buckets, @@ -843,6 +868,11 @@ private TEntry evictOneInRange( * nothing later to resume from. */ @GuardedBy("getWriteLock(buckets)") + @SuppressFBWarnings( + value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", + justification = + "cursor is read and written only under synchronized (getWriteLock(buckets)); SpotBugs" + + " cannot model that dynamic guard") public int evictAll( @Nonnull AtomicReferenceArray buckets, @Nonnull Predicate evictable) { @@ -918,6 +948,12 @@ public static boolean isFull(@Nonnull State state) { * Reserves a slot in {@code state} for a fresh insert, evicting one entry matching {@code * evictable} if the table is full. {@code false} means full with nothing evictable — the caller * should drop the datum. Self-locking. + * + *

    Pairing with an insert: the reservation this takes is only meaningful until the next + * {@link #drain} or {@link #clear}, either of which resets the {@link SizeManager}. Because this + * call releases the monitor before returning, a caller that follows it with {@link + * #insertReserved} must hold {@code synchronized (getWriteLock(state))} across both calls + * — see {@link #insertReserved} for the shape. */ public static boolean tryReserveOrEvict( @Nonnull State state, @Nonnull Predicate evictable) { @@ -1100,12 +1136,26 @@ public static void insertHeadEntryFor( * refuse before it builds anything: * *

    {@code
    -   * if (!tryReserveOrEvict(state, evictable)) {
    -   *   return null;                       // refused -- no entry was built
    +   * synchronized (getWriteLock(state)) {     // ONE critical section for both steps
    +   *   if (!tryReserveOrEvict(state, evictable)) {
    +   *     return null;                         // refused -- no entry was built
    +   *   }
    +   *   insertReserved(state, keyHash, buildEntry());
        * }
    -   * insertReserved(state, keyHash, buildEntry());
        * }
    * + *

    The enclosing block is required, not stylistic. {@link #tryReserveOrEvict} is self-locking + * and releases the monitor before it returns, so without it a {@link #drain} or {@link #clear} + * can land between the reservation and this insert, reset the {@link SizeManager}, and leave this + * insert linking an entry that the count no longer accounts for -- an undercount that never + * heals, and on a {@link State#createCapped} table a cap that is quietly exceeded from then on. + * The monitor is reentrant, so wrapping the self-locking call costs nothing. + * + *

    Because the entry is built inside that block, {@code buildEntry()} must not throw: a throw + * after the reservation is taken leaks a slot for the life of the table. When the build is + * fallible, use the {@link D1#tryGetOrCreateOrNull} shape instead, which checks capacity, builds, + * links, and only then increments. + * *

    Distinct from {@link #insertHeadEntryFor(AtomicReferenceArray, long, Entry)}, which reserves * as it inserts; calling that one here would count the entry twice. {@link D1} and {@link D2} do * not use this: their {@code creator} is fallible, so they check/evict, build the entry, link it, diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java index acb504180d3..dbcd7b794e9 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java @@ -7,6 +7,7 @@ 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; @@ -242,11 +243,13 @@ void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() { // 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. - 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 + // 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.getWriteLock(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")); } @@ -265,6 +268,42 @@ void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() { assertEquals(0, ConcurrentHashtable.estimateSize(state)); } + /** + * Holding the write lock across {@code tryReserveOrEvict} + {@code insertReserved} keeps a + * concurrent {@link ConcurrentHashtable#clear(ConcurrentHashtable.State)} out of the gap. Were + * the clear able to land in between, its {@code SizeManager.reset()} would void the outstanding + * reservation and the insert would link an entry the count never learns about -- an undercount + * that never heals, and a capped table quietly over its cap from then on. + */ + @Test + void clearCannotInterleaveBetweenReservationAndInsert() throws InterruptedException { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createCapped(TestEntry.class, 1); + insertAt(state, 0, "a"); + state.sizeManager.increment(); + assertTrue(ConcurrentHashtable.isFull(state)); + + Thread clearer = new Thread(() -> ConcurrentHashtable.clear(state), "clearer"); + synchronized (ConcurrentHashtable.getWriteLock(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)); + } + private static void assertNotNullLabel( ConcurrentHashtable.State state, int index, String label) { TestEntry e = state.buckets.get(index);