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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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); From c8b331c788c93f670904e02ec9935b15dd4ad5a3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 31 Aug 2026 09:10:31 -0400 Subject: [PATCH 32/36] Name the lock by the scope it covers, not "the table's lock" getWriteLock(buckets) had exactly one answer, so every caller asked for the whole table whether or not that was what it needed. That makes the locking granularity part of the API: a striped implementation would have no object to return. Replace it with three accessors that name a scope -- getWriteLock(state, keyHash), getWriteLockAt(state, bucketIndex), and getTableWriteLock(state) -- and point every @GuardedBy, assert, and call site at the one it actually needs. All three still return the same monitor, so behavior is unchanged; only the question each caller asks is different. Two consequences worth having: a caller that holds one key's monitor and mutates another is now visibly wrong rather than accidentally right, and every getTableWriteLock use marks a spot where striping would cost something. The class javadoc records what those spots are -- table-wide capacity accounting and a whole-table eviction scan -- so the analysis does not have to be redone. Co-Authored-By: Claude Opus 5 --- .../trace/util/ThreadSafeMapD2Benchmark.java | 4 +- .../trace/util/ConcurrentHashtable.java | 226 ++++++++++++------ .../ConcurrentHashtableSizeManagerTest.java | 18 +- .../util/ConcurrentHashtableStaticsTest.java | 38 ++- 4 files changed, 191 insertions(+), 95 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 c5b9122ec13..7b52a4d1c14 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -206,7 +206,7 @@ public void setUp() { 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)) { + synchronized (ConcurrentHashtable.getWriteLock(supportBuckets, se.keyHash)) { ConcurrentHashtable.insertHeadEntryFor(supportBuckets, se.keyHash, se); } Key2 key = new Key2(SOURCE_K1[i], SOURCE_K2[i]); @@ -289,7 +289,7 @@ public SupportEntry getOrCreate_support(SharedState s, ThreadState t) { return e; } } - synchronized (ConcurrentHashtable.getWriteLock(s.supportBuckets)) { + synchronized (ConcurrentHashtable.getWriteLockAt(s.supportBuckets, index)) { for (SupportEntry e = ConcurrentHashtable.bucketAt(s.supportBuckets, index); e != null; e = e.next()) { 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 92bda67c0c8..05f98996f8d 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -63,21 +63,23 @@ * 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 #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): + *

    Locking model. Writes are guarded by monitors obtained from this class, never by + * locking on an object the caller picked. Ask for the lock that covers the scope you are + * about to mutate: {@link #getWriteLock(AtomicReferenceArray, long)} (or {@link + * #getWriteLockAt(AtomicReferenceArray, int)}) for one key's bucket, and {@link + * #getTableWriteLock(AtomicReferenceArray)} for anything spanning every bucket. Treat what comes + * back as opaque rather than assuming it is the array. 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, 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, keyHash))} + * so it excludes other writers and the self-locking mutators (they nest cleanly with it): * *

      *
    1. Lock-free pre-check: walk the chain via {@link #bucketFor} / {@link #bucketAt}; return if * found. - *
    2. {@code synchronized (getWriteLock(buckets))} — take the table's write monitor. + *
    3. {@code synchronized (getWriteLock(buckets, keyHash))} — take the monitor covering that key. *
    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 #insertHeadEntryFor} / {@link * #insertHeadEntryAt}. Remove: splice it out with {@link #unlink}. Both are volatile writes @@ -87,8 +89,8 @@ *

      {@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. + * caller's {@code synchronized (getWriteLock(buckets, keyHash))} 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 @@ -96,8 +98,33 @@ * #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. + * drifts silently past its cap. Hold one lock across the whole protocol — {@link + * #getTableWriteLock(State)} here, because a reservation is table-wide; the monitor is reentrant, + * so the self-locking calls nest inside it cleanly. + * + *

      On striping. Every accessor above returns the same monitor today: writes to the whole + * table serialize. The three accessors exist so that granularity is a choice this class can revisit + * without touching its callers — a striped implementation would make {@link + * #getWriteLockAt(AtomicReferenceArray, int)} resolve to a per-stripe monitor and leave {@link + * #getWriteLock(AtomicReferenceArray, long)} unchanged at every call site. Two things would still + * have to be settled first, and every {@code getTableWriteLock} use marks one of them: + * + *

        + *
      • Capacity accounting is table-wide. {@link SizeManager}'s cap check and increment are + * one check-then-act over a shared counter, so a striped table would need per-stripe sub-caps + * — which is a different guarantee than one exact table-wide cap, not just a different lock. + * That is why the capped paths in {@link D1} / {@link D2} take the table lock. + *
      • Eviction scans every bucket. {@link SizeManager#evictOne} walks the whole table from + * a shared cursor, so it needs every stripe. Confining it to the target stripe would make it + * stripeable, at the cost of turning approximate table-wide round-robin into per-stripe + * round-robin. That choice also decides the cursor: per-stripe it stays a plain {@code int} + * guarded by its stripe, while a cursor still shared across stripes becomes a genuine race to + * either accept as a best-effort hint or make {@code volatile}. + *
      + * + *

      The motivation, when it comes, is not write throughput on a read-mostly structure: it is that + * {@link D1#tryGetOrCreateOrNull} runs the caller's {@code creator} inside the lock, so a burst of + * misses on different keys serializes. */ public final class ConcurrentHashtable { private ConcurrentHashtable() {} @@ -249,7 +276,7 @@ public TEntry tryGetOrCreateOrNull( return curEntry; } } - synchronized (getWriteLock(state)) { + synchronized (getTableWriteLock(state)) { for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { @@ -302,7 +329,7 @@ public TEntry tryGetOrCreateOrEvictOrNull( return curEntry; } } - synchronized (getWriteLock(state)) { + synchronized (getTableWriteLock(state)) { for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { @@ -330,7 +357,7 @@ public TEntry tryGetOrCreateOrEvictOrNull( public TEntry remove(@Nullable K key) { long keyHash = D1.Entry.hash(key); int index = bucketIndex(state.buckets, keyHash); - synchronized (getWriteLock(state)) { + synchronized (getTableWriteLock(state)) { TEntry prev = null; for (TEntry curEntry = bucketAt(state, index); curEntry != null; @@ -534,7 +561,7 @@ public TEntry tryGetOrCreateOrNull( return curEntry; } } - synchronized (getWriteLock(state)) { + synchronized (getTableWriteLock(state)) { for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { @@ -589,7 +616,7 @@ public TEntry tryGetOrCreateOrEvictOrNull( return curEntry; } } - synchronized (getWriteLock(state)) { + synchronized (getTableWriteLock(state)) { for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { @@ -617,7 +644,7 @@ public TEntry tryGetOrCreateOrEvictOrNull( public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { long keyHash = D2.Entry.hash(key1, key2); int index = bucketIndex(state.buckets, keyHash); - synchronized (getWriteLock(state)) { + synchronized (getTableWriteLock(state)) { TEntry prev = null; for (TEntry curEntry = bucketAt(state, index); curEntry != null; @@ -704,11 +731,12 @@ public void forEach(C context, @Nonnull BiConsumerLocking. {@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. + * the eviction cursor) and must be called under {@code synchronized (getTableWriteLock(buckets))} + * — the table-wide monitor, not one key's, since the count and the cursor are shared and eviction + * walks every bucket — 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 { @@ -719,7 +747,7 @@ public static final class SizeManager { * 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)") + @GuardedBy("getTableWriteLock(buckets)") private int cursor; public SizeManager(int capacity) { @@ -748,7 +776,7 @@ public boolean isFull() { * {@link #increment()} only once linking actually succeeds — see {@link * D1#tryGetOrCreateOrNull} for that ordering. */ - @GuardedBy("getWriteLock(buckets)") + @GuardedBy("getTableWriteLock(buckets)") public boolean tryReserve() { if (isFull()) { return false; @@ -767,7 +795,7 @@ public boolean tryReserve() { * 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)") + @GuardedBy("getTableWriteLock(buckets)") public boolean tryReserveOrEvict( @Nonnull AtomicReferenceArray buckets, @Nonnull Predicate evictable) { @@ -793,11 +821,11 @@ public void decrement() { } /** Zeroes both the live count and the eviction scan position. */ - @GuardedBy("getWriteLock(buckets)") + @GuardedBy("getTableWriteLock(buckets)") @SuppressFBWarnings( value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", justification = - "cursor is read and written only under synchronized (getWriteLock(buckets)); SpotBugs" + "cursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + " cannot model that dynamic guard") public void reset() { size.set(0); @@ -817,7 +845,7 @@ public void reset() { * rare path, and keep {@code evictable} cheap — it is called once per live entry on every * refusal. */ - @GuardedBy("getWriteLock(buckets)") + @GuardedBy("getTableWriteLock(buckets)") @Nullable public TEntry evictOne( @Nonnull AtomicReferenceArray buckets, @@ -836,11 +864,11 @@ public TEntry evictOne( return null; } - @GuardedBy("getWriteLock(buckets)") + @GuardedBy("getTableWriteLock(buckets)") @SuppressFBWarnings( value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", justification = - "cursor is read and written only under synchronized (getWriteLock(buckets)); SpotBugs" + "cursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + " cannot model that dynamic guard") @Nullable private TEntry evictOneInRange( @@ -867,11 +895,11 @@ private TEntry evictOneInRange( * each, and returns how many were removed. Resets the scan position, since a full pass leaves * nothing later to resume from. */ - @GuardedBy("getWriteLock(buckets)") + @GuardedBy("getTableWriteLock(buckets)") @SuppressFBWarnings( value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", justification = - "cursor is read and written only under synchronized (getWriteLock(buckets)); SpotBugs" + "cursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + " cannot model that dynamic guard") public int evictAll( @Nonnull AtomicReferenceArray buckets, @@ -952,12 +980,12 @@ public static boolean isFull(@Nonnull State state) { *

      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. + * #insertReserved} must hold {@code synchronized (getTableWriteLock(state))} across both + * calls — see {@link #insertReserved} for the shape. */ public static boolean tryReserveOrEvict( @Nonnull State state, @Nonnull Predicate evictable) { - synchronized (getWriteLock(state)) { + synchronized (getTableWriteLock(state)) { return state.sizeManager.tryReserveOrEvict(state.buckets, evictable); } } @@ -970,7 +998,7 @@ public static boolean tryReserveOrEvict( @Nullable public static TEntry evictOne( @Nonnull State state, @Nonnull Predicate evictable) { - synchronized (getWriteLock(state)) { + synchronized (getTableWriteLock(state)) { return state.sizeManager.evictOne(state.buckets, evictable); } } @@ -981,7 +1009,7 @@ public static TEntry evictOne( */ public static int evictAll( @Nonnull State state, @Nonnull Predicate evictable) { - synchronized (getWriteLock(state)) { + synchronized (getTableWriteLock(state)) { return state.sizeManager.evictAll(state.buckets, evictable); } } @@ -991,7 +1019,8 @@ public static int evictAll( // 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. + // not lock and must be called under the caller's own synchronized (getWriteLock(buckets, + // keyHash)) block. // Readers // (bucket walks, forEach) are lock-free. // --------------------------------------------------------------------------------------------- @@ -1024,22 +1053,67 @@ public static int sizeFor(int 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. + * Returns the monitor that guards writes to the bucket {@code keyHash} maps to. A custom table + * locks on this — {@code synchronized (getWriteLock(buckets, keyHash)) { … }} — around its + * scan-then-insert/remove for that one key. + * + *

      Treat the returned object as opaque. It happens to be the bucket array today, and + * every key returns the same monitor, but obtain it here rather than assuming either, so callers + * stay correct if the locking granularity ever changes. Ask for the lock covering the key you are + * about to mutate, not "the table's lock": a caller that holds the monitor for key A and mutates + * key B is correct today only by accident. + * + * @see #getWriteLockAt(AtomicReferenceArray, int) when the bucket index is already computed + * @see #getTableWriteLock(AtomicReferenceArray) for operations that span every bucket + */ + @Nonnull + public static Object getWriteLock(@Nonnull AtomicReferenceArray buckets, long keyHash) { + return getWriteLockAt(buckets, bucketIndex(buckets, keyHash)); + } + + /** {@link #getWriteLock(AtomicReferenceArray, long)} over a {@link State}. */ + @Nonnull + public static Object getWriteLock(@Nonnull State state, long keyHash) { + return getWriteLock(state.buckets, keyHash); + } + + /** + * {@link #getWriteLock(AtomicReferenceArray, long)} for a bucket index that has already been + * computed — the shape a {@code getOrCreate} wants when it reuses the index from its lock-free + * pre-check. This is the primitive; the {@code keyHash} form maps the hash through {@link + * #bucketIndex} and calls it. + */ + @Nonnull + public static Object getWriteLockAt(@Nonnull AtomicReferenceArray buckets, int bucketIndex) { + return buckets; + } + + /** {@link #getWriteLockAt(AtomicReferenceArray, int)} over a {@link State}. */ + @Nonnull + public static Object getWriteLockAt(@Nonnull State state, int bucketIndex) { + return getWriteLockAt(state.buckets, bucketIndex); + } + + /** + * Returns the monitor that excludes writers across every bucket. Needed by anything whose + * effect is not confined to one bucket: capacity accounting ({@link SizeManager}, whose count and + * eviction cursor are table-wide), eviction (which scans every bucket), and the whole-table + * mutators {@link #drain} / {@link #clear} / {@link #removeIf} / {@link #evictAll} (which take it + * themselves). + * + *

      Today this is the same monitor {@link #getWriteLock(AtomicReferenceArray, long)} returns, so + * the blocks nest freely. It is nonetheless the accessor to name when the operation really does + * span the table — see the class javadoc on striping for why the distinction is worth keeping. */ @Nonnull - public static Object getWriteLock(@Nonnull AtomicReferenceArray buckets) { + public static Object getTableWriteLock(@Nonnull AtomicReferenceArray buckets) { return buckets; } - /** {@link #getWriteLock(AtomicReferenceArray)} over a {@link State}. */ + /** {@link #getTableWriteLock(AtomicReferenceArray)} over a {@link State}. */ @Nonnull - public static Object getWriteLock(@Nonnull State state) { - return getWriteLock(state.buckets); + public static Object getTableWriteLock(@Nonnull State state) { + return getTableWriteLock(state.buckets); } public static int bucketIndex(@Nonnull AtomicReferenceArray buckets, long keyHash) { @@ -1092,17 +1166,17 @@ public static TEntry bucketAt(@Nonnull State stat * 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. + * lock, so call it inside the caller's {@code synchronized (getWriteLockAt(buckets, index))} + * 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)") + @GuardedBy("getWriteLockAt(buckets, index)") public static void insertHeadEntryAt( @Nonnull AtomicReferenceArray buckets, int index, @Nonnull TEntry entry) { - assert Thread.holdsLock(getWriteLock(buckets)) - : "insertHeadEntryAt called without holding getWriteLock(buckets)"; + assert Thread.holdsLock(getWriteLockAt(buckets, index)) + : "insertHeadEntryAt called without holding getWriteLockAt(buckets, index)"; assert entry.next() == null : "Entry already linked -- inserting the same Entry instance twice corrupts the chain" + " (unlink() deliberately leaves a removed entry's next intact for in-flight" @@ -1112,7 +1186,7 @@ public static void insertHeadEntryAt( } /** {@link #insertHeadEntryAt(AtomicReferenceArray, int, Entry)} over a {@link State}. */ - @GuardedBy("getWriteLock(state)") + @GuardedBy("getWriteLockAt(state, index)") public static void insertHeadEntryAt( @Nonnull State state, int index, @Nonnull TEntry entry) { insertHeadEntryAt(state.buckets, index, entry); @@ -1123,7 +1197,7 @@ public static void insertHeadEntryAt( * 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)") + @GuardedBy("getWriteLock(buckets, keyHash)") public static void insertHeadEntryFor( @Nonnull AtomicReferenceArray buckets, long keyHash, @Nonnull TEntry entry) { insertHeadEntryAt(buckets, bucketIndex(buckets, keyHash), entry); @@ -1136,7 +1210,7 @@ public static void insertHeadEntryFor( * refuse before it builds anything: * *

      {@code
      -   * synchronized (getWriteLock(state)) {     // ONE critical section for both steps
      +   * synchronized (getTableWriteLock(state)) {     // ONE critical section for both steps
          *   if (!tryReserveOrEvict(state, evictable)) {
          *     return null;                         // refused -- no entry was built
          *   }
      @@ -1163,7 +1237,7 @@ public static  void insertHeadEntryFor(
          * build throws (see {@link D1#tryGetOrCreateOrNull}). Use this only when the entry is already
          * fully built before the reservation is taken.
          */
      -  @GuardedBy("getWriteLock(state)")
      +  @GuardedBy("getTableWriteLock(state)")
         public static  void insertReserved(
             @Nonnull State state, long keyHash, @Nonnull TEntry entry) {
           insertHeadEntryFor(state.buckets, keyHash, entry);
      @@ -1175,17 +1249,17 @@ public static  void insertReserved(
          * (or the bucket head slot) past {@code entry} via a volatile write so lock-free readers see the
          * removal. {@code entry}'s own {@code next} is deliberately left intact so a reader already
          * positioned on it can still traverse forward. This is a single-slot primitive: it does not lock,
      -   * so call it inside the caller's {@code synchronized (getWriteLock(buckets))} block. Does not
      -   * touch size accounting.
      +   * so call it inside the caller's {@code synchronized (getWriteLockAt(buckets, index))} block.
      +   * Does not touch size accounting.
          */
      -  @GuardedBy("getWriteLock(buckets)")
      +  @GuardedBy("getWriteLockAt(buckets, index)")
         public static  void unlink(
             @Nonnull AtomicReferenceArray buckets,
             int index,
             @Nullable TEntry prev,
             @Nonnull TEntry entry) {
      -    assert Thread.holdsLock(getWriteLock(buckets))
      -        : "unlink called without holding getWriteLock(buckets)";
      +    assert Thread.holdsLock(getWriteLockAt(buckets, index))
      +        : "unlink called without holding getWriteLockAt(buckets, index)";
           TEntry next = entry.next();
           if (prev == null) {
             buckets.set(index, next);
      @@ -1195,7 +1269,7 @@ public static  void unlink(
         }
       
         /** {@link #unlink(AtomicReferenceArray, int, Entry, Entry)} over a {@link State}. */
      -  @GuardedBy("getWriteLock(state)")
      +  @GuardedBy("getWriteLockAt(state, index)")
         public static  void unlink(
             @Nonnull State state, int index, @Nullable TEntry prev, @Nonnull TEntry entry) {
           unlink(state.buckets, index, prev, entry);
      @@ -1211,7 +1285,7 @@ public static  boolean removeIf(
             @Nonnull AtomicReferenceArray buckets,
             @Nonnull AtomicInteger size,
             @Nonnull Predicate predicate) {
      -    synchronized (getWriteLock(buckets)) {
      +    synchronized (getTableWriteLock(buckets)) {
             boolean removed = false;
             for (int i = 0; i < buckets.length(); i++) {
               TEntry prev = null;
      @@ -1238,7 +1312,7 @@ public static  boolean removeIf(
         public static  boolean removeIf(
             @Nonnull State state, @Nonnull Predicate predicate) {
           AtomicReferenceArray buckets = state.buckets;
      -    synchronized (getWriteLock(state)) {
      +    synchronized (getTableWriteLock(state)) {
             boolean removed = false;
             for (int i = 0; i < buckets.length(); i++) {
               TEntry prev = null;
      @@ -1262,7 +1336,7 @@ public static  boolean removeIf(
          * so new readers see an empty bucket while the detached chain — whose {@code next} pointers stay
          * 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).
      +   * synchronized (getWriteLock(buckets, keyHash))} block (which nests with this one).
          *
          * 

      {@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 @@ -1270,7 +1344,7 @@ public static boolean removeIf( */ public static void drain( @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer sink) { - synchronized (getWriteLock(buckets)) { + synchronized (getTableWriteLock(buckets)) { for (int i = 0; i < buckets.length(); i++) { TEntry head = buckets.get(i); if (head == null) { @@ -1289,7 +1363,7 @@ public static void drain( @Nonnull AtomicReferenceArray buckets, C context, @Nonnull BiConsumer sink) { - synchronized (getWriteLock(buckets)) { + synchronized (getTableWriteLock(buckets)) { for (int i = 0; i < buckets.length(); i++) { TEntry head = buckets.get(i); if (head == null) { @@ -1311,7 +1385,7 @@ public static void drain( */ public static void drain( @Nonnull State state, @Nonnull Consumer sink) { - synchronized (getWriteLock(state)) { + synchronized (getTableWriteLock(state)) { drain(state.buckets, sink); state.sizeManager.reset(); } @@ -1322,7 +1396,7 @@ public static void drain( @Nonnull State state, C context, @Nonnull BiConsumer sink) { - synchronized (getWriteLock(state)) { + synchronized (getTableWriteLock(state)) { drain(state.buckets, context, sink); state.sizeManager.reset(); } @@ -1330,7 +1404,7 @@ public static void drain( /** Nulls every bucket head. Self-locking: synchronizes on {@code buckets}. */ public static void clear(@Nonnull AtomicReferenceArray buckets) { - synchronized (getWriteLock(buckets)) { + synchronized (getTableWriteLock(buckets)) { for (int i = 0; i < buckets.length(); i++) { buckets.set(i, null); } @@ -1341,7 +1415,7 @@ 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)) { + synchronized (getTableWriteLock(state)) { clear(state.buckets); state.sizeManager.reset(); } 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 dbcd7b794e9..b6f6103f732 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java @@ -83,7 +83,7 @@ void insertReservedSplicesWithoutTouchingTheCountAfterATryReserve() { assertEquals(1, state.sizeManager.estimateSize()); TestEntry entry = new TestEntry(0, "reserved"); - synchronized (ConcurrentHashtable.getWriteLock(state)) { + synchronized (ConcurrentHashtable.getTableWriteLock(state)) { ConcurrentHashtable.insertReserved(state, entry.keyHash, entry); } @@ -234,7 +234,7 @@ void stateCreateCappedBundlesBucketsAndSizeManager() { void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() { ConcurrentHashtable.State state = ConcurrentHashtable.State.createCapped(TestEntry.class, 1); - synchronized (ConcurrentHashtable.getWriteLock(state)) { + synchronized (ConcurrentHashtable.getWriteLockAt(state, 0)) { ConcurrentHashtable.insertHeadEntryAt(state, 0, new TestEntry(0, "a")); } state.sizeManager.increment(); @@ -245,7 +245,7 @@ void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() { // the reservation already happened and a plain insertHeadEntryAt/increment would double-count. // Both steps go in ONE critical section: tryReserveOrEvict is self-locking, so on its own it // leaves a window where a drain/clear could reset the count out from under the reservation. - synchronized (ConcurrentHashtable.getWriteLock(state)) { + synchronized (ConcurrentHashtable.getTableWriteLock(state)) { boolean reserved = ConcurrentHashtable.tryReserveOrEvict(state, e -> true); assertTrue(reserved); assertEquals(1, ConcurrentHashtable.estimateSize(state)); @@ -258,7 +258,7 @@ void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() { assertEquals(0, ConcurrentHashtable.estimateSize(state)); assertFalse(ConcurrentHashtable.isFull(state)); - synchronized (ConcurrentHashtable.getWriteLock(state)) { + synchronized (ConcurrentHashtable.getWriteLockAt(state, 0)) { ConcurrentHashtable.insertHeadEntryAt(state, 0, new TestEntry(0, "b")); } state.sizeManager.increment(); @@ -284,7 +284,7 @@ void clearCannotInterleaveBetweenReservationAndInsert() throws InterruptedExcept assertTrue(ConcurrentHashtable.isFull(state)); Thread clearer = new Thread(() -> ConcurrentHashtable.clear(state), "clearer"); - synchronized (ConcurrentHashtable.getWriteLock(state)) { + synchronized (ConcurrentHashtable.getTableWriteLock(state)) { clearer.start(); // Wait until the clear is definitely queued on the monitor we hold, so the interleaving under // test is the one actually attempted rather than one the scheduler happened to avoid. @@ -315,7 +315,7 @@ private static void assertNotNullLabel( private static TestEntry insertAt( ConcurrentHashtable.State state, int index, String label) { TestEntry entry = new TestEntry(index, label); - synchronized (ConcurrentHashtable.getWriteLock(state)) { + synchronized (ConcurrentHashtable.getWriteLockAt(state, index)) { ConcurrentHashtable.insertHeadEntryAt(state, index, entry); } return entry; @@ -324,7 +324,7 @@ private static TestEntry insertAt( /** {@code sizeManager.tryReserveOrEvict}, taking the write lock {@code @GuardedBy} requires. */ private static boolean tryReserveOrEvict( ConcurrentHashtable.State state, Predicate evictable) { - synchronized (ConcurrentHashtable.getWriteLock(state)) { + synchronized (ConcurrentHashtable.getTableWriteLock(state)) { return state.sizeManager.tryReserveOrEvict(state.buckets, evictable); } } @@ -332,7 +332,7 @@ private static boolean tryReserveOrEvict( /** {@code sizeManager.evictOne}, taking the write lock {@code @GuardedBy} requires. */ private static TestEntry evictOne( ConcurrentHashtable.State state, Predicate evictable) { - synchronized (ConcurrentHashtable.getWriteLock(state)) { + synchronized (ConcurrentHashtable.getTableWriteLock(state)) { return state.sizeManager.evictOne(state.buckets, evictable); } } @@ -340,7 +340,7 @@ private static TestEntry evictOne( /** {@code sizeManager.evictAll}, taking the write lock {@code @GuardedBy} requires. */ private static int evictAll( ConcurrentHashtable.State state, Predicate evictable) { - synchronized (ConcurrentHashtable.getWriteLock(state)) { + synchronized (ConcurrentHashtable.getTableWriteLock(state)) { return state.sizeManager.evictAll(state.buckets, evictable); } } 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 0a2839b6fa7..8191e396e14 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java @@ -46,9 +46,31 @@ void createFixedBucketsAllocatesPowerOfTwoSpine() { void getWriteLockIsStableAndNonNull() { AtomicReferenceArray buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); - Object lock = ConcurrentHashtable.getWriteLock(buckets); + Object lock = ConcurrentHashtable.getWriteLock(buckets, 3L); assertNotNull(lock); - assertSame(lock, ConcurrentHashtable.getWriteLock(buckets)); + assertSame(lock, ConcurrentHashtable.getWriteLock(buckets, 3L)); + // The keyHash form is defined as the index form under bucketIndex. + assertSame( + lock, + ConcurrentHashtable.getWriteLockAt(buckets, ConcurrentHashtable.bucketIndex(buckets, 3L))); + } + + /** + * The three accessors name three scopes, but a single-lock table answers all of them with one + * monitor. Asserting that pins today's granularity as a deliberate choice rather than an + * accident: if it ever changes, this is the test that says so, and callers that asked for the + * scope they actually mutate keep working. + */ + @Test + void allWriteLockScopesResolveToOneMonitorToday() { + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); + Object table = ConcurrentHashtable.getTableWriteLock(buckets); + assertNotNull(table); + for (int index = 0; index < buckets.length(); index++) { + assertSame(table, ConcurrentHashtable.getWriteLockAt(buckets, index)); + assertSame(table, ConcurrentHashtable.getWriteLock(buckets, index)); + } } @Test @@ -85,7 +107,7 @@ 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)) { + synchronized (ConcurrentHashtable.getWriteLock(buckets, e.keyHash)) { ConcurrentHashtable.insertHeadEntryFor(buckets, e.keyHash, e); } assertSame(e, ConcurrentHashtable.bucketFor(buckets, 9L)); // masks keyHash to the bucket index @@ -226,7 +248,7 @@ void unlinkWithoutLockTripsAssertion() { AtomicReferenceArray buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); IntEntry e = new IntEntry(1, 1); - synchronized (ConcurrentHashtable.getWriteLock(buckets)) { + synchronized (ConcurrentHashtable.getWriteLockAt(buckets, 0)) { ConcurrentHashtable.insertHeadEntryAt(buckets, 0, e); } assertThrows(AssertionError.class, () -> ConcurrentHashtable.unlink(buckets, 0, null, e)); @@ -325,7 +347,7 @@ boolean matches(int 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)}. + * getWriteLock(buckets, keyHash)}. */ private static final class IntTable { final AtomicReferenceArray buckets; @@ -353,7 +375,7 @@ IntEntry getOrCreate(int key, int value) { return e; } } - synchronized (ConcurrentHashtable.getWriteLock(buckets)) { + synchronized (ConcurrentHashtable.getWriteLockAt(buckets, index)) { for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { if (e.matches(key)) { return e; @@ -374,7 +396,7 @@ IntEntry getOrCreateCounting(int key, AtomicInteger createCount) { return e; } } - synchronized (ConcurrentHashtable.getWriteLock(buckets)) { + synchronized (ConcurrentHashtable.getWriteLockAt(buckets, index)) { for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { if (e.matches(key)) { return e; @@ -390,7 +412,7 @@ IntEntry getOrCreateCounting(int key, AtomicInteger createCount) { IntEntry remove(int key) { int index = ConcurrentHashtable.bucketIndex(buckets, key); - synchronized (ConcurrentHashtable.getWriteLock(buckets)) { + synchronized (ConcurrentHashtable.getWriteLockAt(buckets, index)) { IntEntry prev = null; for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { if (e.matches(key)) { From 0f82303269aded236a62bcaec3f394f8e788725d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 31 Aug 2026 09:20:26 -0400 Subject: [PATCH 33/36] Make the count honest instead of locking around it SizeManager treated the entry cap as strict, and paid for it twice: a check-then-increment reserve that needed the table write lock, and drain and clear zeroing the count so a concurrent reservation was silently discarded. The second of those was the P1: an undercount no later eviction repairs, since eviction decrements too. An approximate cap is fine here -- the bucket array is fixed-size with load-factor headroom and never rehashes, so overshoot lengthens chains and nothing else. Taking that latitude turns out to buy exactness where it is cheap and delete the locking where it is not: - tryReserve claims a slot and refunds on overshoot. Atomic on its own, so it needs no lock, and concurrent reservers still cannot both pass the cap. - drain and clear subtract what they actually removed (release(int), replacing reset()), so a reservation survives a sweep landing in the gap between reserve and insert. That removes the reason reserve-then-insert had to share one critical section, so insertReserved now documents the single-bucket lock instead. What remains lock-dependent is D1/D2's isFull-then-increment ordering, which exists so a fallible creator cannot leak a slot and can tolerate admitting slightly over the cap. Counting makes clear O(entries) rather than O(buckets); clear is a rare whole-table operation, so an honest count is worth the walk. Co-Authored-By: Claude Opus 5 --- .../trace/util/ConcurrentHashtable.java | 197 ++++++++++++------ .../ConcurrentHashtableSizeManagerTest.java | 90 +++++++- 2 files changed, 212 insertions(+), 75 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 05f98996f8d..3f971082217 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -94,13 +94,18 @@ * *

      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 one lock across the whole protocol — {@link - * #getTableWriteLock(State)} here, because a reservation is table-wide; the monitor is reentrant, - * so the self-locking calls nest inside it cleanly. + * for any multi-step protocol over one table: hold one lock across the whole thing, and note the + * monitor is reentrant, so the self-locking calls nest inside it cleanly. + * + *

      The protocol that used to need that — reserve a slot with {@link #tryReserveOrEvict}, then + * fill it with {@link #insertReserved} — no longer does, and why is worth recording, because the + * reason generalizes. A {@link #drain} or {@link #clear} landing in the gap used to zero the {@link + * SizeManager}, discarding the outstanding reservation and leaving the count permanently below + * reality — drift no later eviction repairs, since eviction decrements too. The repair was not a + * wider lock but honest arithmetic: the sweeps now subtract what they actually removed ({@link + * SizeManager#release(int)}), so a reservation survives one, and {@link SizeManager#tryReserve()} + * claims first and refunds on overshoot, so it needs no lock at all. Prefer making a step + * atomic on its own over holding a lock across steps. * *

      On striping. Every accessor above returns the same monitor today: writes to the whole * table serialize. The three accessors exist so that granularity is a choice this class can revisit @@ -110,10 +115,11 @@ * have to be settled first, and every {@code getTableWriteLock} use marks one of them: * *

        - *
      • Capacity accounting is table-wide. {@link SizeManager}'s cap check and increment are - * one check-then-act over a shared counter, so a striped table would need per-stripe sub-caps - * — which is a different guarantee than one exact table-wide cap, not just a different lock. - * That is why the capped paths in {@link D1} / {@link D2} take the table lock. + *
      • Capacity accounting needs no lock. {@link SizeManager#tryReserve()} claims a slot + * and refunds on overshoot, which is atomic on its own, and the sweeps subtract what they + * removed rather than zeroing. What stays lock-dependent is the {@code isFull()}-then-{@code + * increment()} ordering {@link D1#tryGetOrCreateOrNull} uses so a fallible {@code creator} + * cannot leak a slot — and that one can tolerate admitting slightly over the cap instead. *
      • Eviction scans every bucket. {@link SizeManager#evictOne} walks the whole table from * a shared cursor, so it needs every stripe. Confining it to the target stripe would make it * stripeable, at the cost of turning approximate table-wide round-robin into per-stripe @@ -217,9 +223,9 @@ private D1(State state) { /** * 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 + * SizeManager} enforces {@code maxCapacity} as the approximate 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 @@ -495,9 +501,9 @@ private D2(State state) { /** * 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 + * SizeManager} enforces {@code maxCapacity} as the approximate 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 @@ -724,10 +730,10 @@ public void forEach(C context, @Nonnull BiConsumer{@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}. + *

        {@link D1} and {@link D2} each hold one (via {@link State}) for their approximate + * 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 @@ -769,19 +775,24 @@ public boolean isFull() { } /** - * 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 + * Reserves a slot for a fresh insert: returns {@code true} having claimed one, or {@code false} + * with the count unchanged if the table was already at capacity. Use this when the entry to + * link is already fully built (nothing between the reservation and the link 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. + * + *

        Needs no lock. Claiming first and refunding on overshoot makes the whole + * reservation one atomic step, so concurrent reservers cannot both squeeze past the cap: each + * sees its own post-increment value, and everyone who lands above {@link #capacity()} gives the + * slot back. That is what a check-then-increment could not do without excluding every other + * writer, and it is why capacity accounting does not force a table-wide critical section. */ - @GuardedBy("getTableWriteLock(buckets)") public boolean tryReserve() { - if (isFull()) { + if (size.incrementAndGet() > capacity) { + size.decrementAndGet(); return false; } - size.incrementAndGet(); return true; } @@ -791,9 +802,9 @@ public boolean tryReserve() { * 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. + *

        The reservation is safe to hold across a concurrent sweep: {@link #release(int)} subtracts + * what was removed rather than zeroing, so a drain or clear cannot void it. The caller does + * still owe the insert — an unfilled reservation leaks a slot until the next sweep. */ @GuardedBy("getTableWriteLock(buckets)") public boolean tryReserveOrEvict( @@ -820,15 +831,26 @@ public void decrement() { size.decrementAndGet(); } - /** Zeroes both the live count and the eviction scan position. */ + /** + * Gives back {@code removed} slots after a sweep unlinked that many entries, and restarts the + * eviction scan at bucket 0 (a full pass leaves nothing later to resume from). + * + *

        Subtracting what was actually removed, rather than zeroing, is what lets a sweep run + * concurrently with an outstanding {@link #tryReserve()}: the reservation's claim survives, so + * the count stays tied to the entries that exist. Zeroing would discard it, leaving the count + * permanently one below reality — drift that no later eviction repairs, because eviction + * decrements too. + */ @GuardedBy("getTableWriteLock(buckets)") @SuppressFBWarnings( value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", justification = "cursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + " cannot model that dynamic guard") - public void reset() { - size.set(0); + public void release(int removed) { + if (removed != 0) { + size.addAndGet(-removed); + } cursor = 0; } @@ -934,7 +956,7 @@ public int evictAll( * 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 + *

        Same headroom idiom as {@link D1}/{@link D2}: {@code maxCapacity} is the cap on live * entries, and the backing array is sized with load-factor headroom over it. */ public static final class State { @@ -949,9 +971,8 @@ private State(AtomicReferenceArray buckets, int 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. + * capped at {@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( @@ -977,11 +998,10 @@ public static boolean isFull(@Nonnull State state) { * 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 (getTableWriteLock(state))} across both - * calls — see {@link #insertReserved} for the shape. + *

        Pairing with an insert: the reservation outlives this call and survives a concurrent + * {@link #drain} or {@link #clear}, so the follow-up {@link #insertReserved} need not share a + * critical section with it — see {@link #insertReserved} for the shape. What the caller still + * owes is the insert: a reservation nobody fills leaks a slot until the next sweep. */ public static boolean tryReserveOrEvict( @Nonnull State state, @Nonnull Predicate evictable) { @@ -1210,25 +1230,23 @@ public static void insertHeadEntryFor( * refuse before it builds anything: * *

        {@code
        -   * synchronized (getTableWriteLock(state)) {     // ONE critical section for both steps
        -   *   if (!tryReserveOrEvict(state, evictable)) {
        -   *     return null;                         // refused -- no entry was built
        -   *   }
        +   * if (!tryReserveOrEvict(state, evictable)) {
        +   *   return null;                       // refused -- no entry was built
        +   * }
        +   * synchronized (getWriteLock(state, keyHash)) {
            *   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. + *

        The reservation survives the gap between the two calls, so they do not have to share one + * critical section: {@link #drain} and {@link #clear} subtract what they removed instead of + * zeroing the count (see {@link SizeManager#release(int)}), so a sweep landing in between leaves + * the claim intact. Only the insert itself needs a lock, and only over the one bucket. * - *

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

        {@code buildEntry()} must still not throw once the reservation is taken: an abandoned + * reservation 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 @@ -1344,6 +1362,17 @@ public static boolean removeIf( */ public static void drain( @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer sink) { + drainCounting(buckets, sink); + } + + /** + * {@link #drain(AtomicReferenceArray, Consumer)} returning how many entries it handed to {@code + * sink}, so a {@link State} form can subtract exactly that from its {@link SizeManager} instead + * of zeroing. The count is free here: the sweep already visits every entry. + */ + private static int drainCounting( + @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer sink) { + int removed = 0; synchronized (getTableWriteLock(buckets)) { for (int i = 0; i < buckets.length(); i++) { TEntry head = buckets.get(i); @@ -1352,10 +1381,12 @@ public static void drain( } buckets.set(i, null); for (TEntry e = head; e != null; e = e.next()) { + removed++; sink.accept(e); } } } + return removed; } /** Context-passing variant of {@link #drain(AtomicReferenceArray, Consumer)}. Self-locking. */ @@ -1363,6 +1394,15 @@ public static void drain( @Nonnull AtomicReferenceArray buckets, C context, @Nonnull BiConsumer sink) { + drainCounting(buckets, context, sink); + } + + /** {@link #drainCounting(AtomicReferenceArray, Consumer)}, context-passing form. */ + private static int drainCounting( + @Nonnull AtomicReferenceArray buckets, + C context, + @Nonnull BiConsumer sink) { + int removed = 0; synchronized (getTableWriteLock(buckets)) { for (int i = 0; i < buckets.length(); i++) { TEntry head = buckets.get(i); @@ -1371,23 +1411,24 @@ public static void drain( } buckets.set(i, null); for (TEntry e = head; e != null; e = e.next()) { + removed++; sink.accept(context, e); } } } + return removed; } /** * {@link #drain(AtomicReferenceArray, Consumer)} plus the matching bookkeeping: empties {@code - * state} into {@code sink} and 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. + * state} into {@code sink} and gives its {@link SizeManager} back exactly the slots the sweep + * freed. Draining without that leaves the cap permanently consumed, so the two belong in one call + * rather than as a pair the caller has to remember. */ public static void drain( @Nonnull State state, @Nonnull Consumer sink) { synchronized (getTableWriteLock(state)) { - drain(state.buckets, sink); - state.sizeManager.reset(); + state.sizeManager.release(drainCounting(state.buckets, sink)); } } @@ -1397,8 +1438,7 @@ public static void drain( C context, @Nonnull BiConsumer sink) { synchronized (getTableWriteLock(state)) { - drain(state.buckets, context, sink); - state.sizeManager.reset(); + state.sizeManager.release(drainCounting(state.buckets, context, sink)); } } @@ -1411,13 +1451,36 @@ public static void clear(@Nonnull AtomicReferenceArray buckets) { } } + /** + * {@link #clear(AtomicReferenceArray)} returning how many entries it detached, so a {@link State} + * form can subtract exactly that rather than zeroing — see {@link SizeManager#release(int)} for + * why that distinction matters. Unlike the plain form this walks the chains, making it O(entries) + * rather than O(buckets); clear is a rare, whole-table operation, so the walk is affordable and + * keeping the count honest is worth more than the constant. + */ + private static int clearCounting(@Nonnull AtomicReferenceArray buckets) { + int removed = 0; + synchronized (getTableWriteLock(buckets)) { + for (int i = 0; i < buckets.length(); i++) { + Entry head = buckets.get(i); + if (head == null) { + continue; + } + buckets.set(i, null); + for (Entry e = head; e != null; e = e.next()) { + removed++; + } + } + } + return removed; + } + /** * {@link #clear(AtomicReferenceArray)} over a {@link State}: also resets its {@link SizeManager}. */ public static void clear(@Nonnull State state) { synchronized (getTableWriteLock(state)) { - clear(state.buckets); - state.sizeManager.reset(); + state.sizeManager.release(clearCounting(state.buckets)); } } 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 b6f6103f732..96e6cfdb73c 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java @@ -201,7 +201,7 @@ void evictAllResetsCursorSoSubsequentEvictOneScansFromBucketZero() { } @Test - void resetZeroesCountAndScanPosition() { + void releaseGivesBackRemovedSlotsAndRestartsScan() { ConcurrentHashtable.State state = ConcurrentHashtable.State.createCapped(TestEntry.class, 4); insertAt(state, 2, "a"); @@ -209,7 +209,9 @@ void resetZeroesCountAndScanPosition() { 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(); + // One entry is live and counted; releasing that one slot brings the count back to zero and + // restarts the scan -- unlike a blanket zeroing, this only gives back what was removed. + state.sizeManager.release(1); assertEquals(0, state.sizeManager.estimateSize()); TestEntry e0 = insertAt(state, 0, "e0"); @@ -217,7 +219,7 @@ void resetZeroesCountAndScanPosition() { state.sizeManager.increment(); state.sizeManager.increment(); TestEntry evicted = evictOne(state, e -> true); - assertSame(e0, evicted); // scan restarted from bucket 0, per reset() + assertSame(e0, evicted); // scan restarted from bucket 0, per release() assertSame(e3, state.buckets.get(3)); } @@ -269,11 +271,11 @@ void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() { } /** - * 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. + * Holding the table lock across {@code tryReserveOrEvict} + {@code insertReserved} still keeps a + * concurrent {@link ConcurrentHashtable#clear(ConcurrentHashtable.State)} out of the gap -- the + * belt-and-braces version of the protocol. {@link + * #reservationSurvivesAClearLandingBetweenReserveAndInsert()} covers the case that matters more + * now: the pair does not actually need one critical section. */ @Test void clearCannotInterleaveBetweenReservationAndInsert() throws InterruptedException { @@ -304,6 +306,78 @@ void clearCannotInterleaveBetweenReservationAndInsert() throws InterruptedExcept assertFalse(ConcurrentHashtable.isFull(state)); } + /** + * A clear landing squarely between the reservation and the insert must not void the reservation. + * It cannot, because {@link ConcurrentHashtable.SizeManager#release(int)} subtracts what the + * sweep removed instead of zeroing the count -- so the claim taken before the clear is still a + * claim after it, and the entry the caller then links is accounted for. + * + *

        Zeroing would leave the count one below reality here, permanently: eviction decrements too, + * so nothing later repairs it, and a capped table admits one extra entry from then on. + */ + @Test + void reservationSurvivesAClearLandingBetweenReserveAndInsert() { + ConcurrentHashtable.State state = + ConcurrentHashtable.State.createCapped(TestEntry.class, 2); + insertAt(state, 0, "a"); + state.sizeManager.increment(); + + // Reserve, with no lock held across what follows. + assertTrue(ConcurrentHashtable.tryReserveOrEvict(state, e -> false)); + assertEquals(2, ConcurrentHashtable.estimateSize(state)); // "a" plus our reservation + + // The sweep lands in the gap, removing the one entry that exists. Our slot is not its to give + // back, so the count drops by exactly one. + ConcurrentHashtable.clear(state); + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + + // The reservation is still good, and filling it leaves the count matching the entries present. + synchronized (ConcurrentHashtable.getWriteLockAt(state, 0)) { + ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved")); + } + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + assertNotNullLabel(state, 0, "reserved"); + assertFalse(ConcurrentHashtable.isFull(state)); + } + + /** + * Two reservers racing at the cap cannot both get through: {@code tryReserve} claims first and + * refunds on overshoot, so the count is never left above {@link + * ConcurrentHashtable.SizeManager#capacity()} once both have finished -- and it achieves that + * without a lock. + */ + @Test + void concurrentReserversCannotBothPassTheCap() throws InterruptedException { + for (int attempt = 0; attempt < 200; attempt++) { + ConcurrentHashtable.SizeManager sizeManager = new ConcurrentHashtable.SizeManager(1); + java.util.concurrent.atomic.AtomicInteger granted = + new java.util.concurrent.atomic.AtomicInteger(); + java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1); + Runnable reserve = + () -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + if (sizeManager.tryReserve()) { + granted.incrementAndGet(); + } + }; + Thread t1 = new Thread(reserve, "reserve-1"); + Thread t2 = new Thread(reserve, "reserve-2"); + t1.start(); + t2.start(); + start.countDown(); + t1.join(); + t2.join(); + + assertEquals(1, granted.get(), "exactly one reserver should win a capacity of 1"); + assertEquals(1, sizeManager.estimateSize()); + } + } + private static void assertNotNullLabel( ConcurrentHashtable.State state, int index, String label) { TestEntry e = state.buckets.get(index); From e46a3c507f179554be784eca1b0a96559ca3921c Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 11:33:36 -0400 Subject: [PATCH 34/36] Trim ConcurrentHashtable javadoc and clarify the isFull-before-create comment Condenses the class/method-level Javadoc across ConcurrentHashtable and the ThreadSafeMap* benchmarks down to the load-bearing points, and reworks the isFull()-before-creator comment in D1/D2.tryGetOrCreateOrNull to spell out the leaked-reservation failure mode instead of a terse arrow-notation summary (per bric3's PR review nitpick). Co-Authored-By: Claude Sonnet 5 --- .../util/ThreadSafeMapCounterBenchmark.java | 36 +- .../trace/util/ThreadSafeMapD1Benchmark.java | 41 +- .../trace/util/ThreadSafeMapD2Benchmark.java | 57 +-- .../trace/util/ConcurrentHashtable.java | 450 +++++------------- .../trace/util/ConcurrentHashtableD1Test.java | 48 +- .../trace/util/ConcurrentHashtableD2Test.java | 42 +- .../ConcurrentHashtableSizeManagerTest.java | 28 +- 7 files changed, 222 insertions(+), 480 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 a78a66f6672..bc8bc07b9f5 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java @@ -20,36 +20,24 @@ 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. + * Measures lookup followed by an atomic counter increment in a shared, pre-populated table. Models + * per-class or per-method hit counters in the tracer. * - *

        The 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}. + *

        The {@link ConcurrentHashtable.D1} case embeds a {@code volatile long} in each entry. {@link + * AtomicLongFieldUpdater} updates that field atomically without allocating an {@link AtomicLong} + * per key. The map baselines store a separate {@link AtomicLong} or {@link LongAdder}; {@code + * LongAdder} spreads contention across internal cells at the cost of more memory and a more + * expensive read. * - *

        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()}. - *
        - * - *

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

        Lookups reuse the key instances installed during setup. {@code Objects.equals} therefore + * returns on its identity check without dispatching to {@code equals}, so this measures the + * interned-key pattern used by the tracer rather than distinct-but-equal keys. * *

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

        {@code
          * Benchmark                          Score   Units
        - * increment_longAdder                   79   ops/us  (fastest)
        + * increment_longAdder                   79   ops/us
          * increment_atomicLong                  71   ops/us
          * increment_concurrentHashtable         69   ops/us
          * }
        @@ -112,7 +100,7 @@ public static class SharedState { @Setup(Level.Iteration) public void setUp() { - table = ConcurrentHashtable.D1.createCapped(CounterEntry.class, CAPACITY); + table = ConcurrentHashtable.D1.createBounded(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 fcf5b07c433..8fd07544264 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java @@ -21,42 +21,31 @@ import org.openjdk.jmh.annotations.Warmup; /** - * Compares thread-safe map strategies for shared, concurrent single-key lookups. + * Measures steady-state single-key lookups in a shared, pre-populated table. * - *

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

        Compares {@link ConcurrentHashtable.D1}, {@link ConcurrentHashMap}, {@link + * ConcurrentSkipListMap}, and a synchronized {@link HashMap}. The table is shared across all + * threads ({@link Scope#Benchmark}) and pre-populated before the measurement iteration — modelling + * the steady-state read-mostly pattern that the tracer uses (a per-class or per-method + * instrumentation cache consulted on every invocation). The {@code getOrCreate} methods exercise + * their hit paths because setup installs every key. * - *

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

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

        Lookups reuse the key instances installed during setup. {@code Objects.equals} therefore + * returns on identity before invoking {@code equals}; the benchmark does not include the cost of + * comparing distinct-but-equal keys ({@code ImmutableMapBenchmark} covers that path explicitly via + * its {@code _sameKey} vs default variants). See {@link ThreadSafeMapD2Benchmark} for composite + * keys. * *

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

        {@code
          * Benchmark                             Score   Units
        - * get_concurrentHashtable               1583   ops/us  (fastest)
        + * get_concurrentHashtable               1583   ops/us
          * get_concurrentHashMap                 1145   ops/us
          * get_concurrentSkipListMap              170   ops/us
          * get_synchronizedHashMap                 33   ops/us
          *
        - * getOrCreate_concurrentHashtable       1450   ops/us  (fastest)
        + * getOrCreate_concurrentHashtable       1450   ops/us
          * getOrCreate_concurrentHashMap         1125   ops/us
          * getOrCreate_synchronizedHashMap         31   ops/us
          * }
        @@ -116,7 +105,7 @@ public static class SharedState { @Setup(Level.Iteration) public void setUp() { - table = ConcurrentHashtable.D1.createCapped(D1Entry.class, CAPACITY); + table = ConcurrentHashtable.D1.createBounded(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 7b52a4d1c14..aff30dd0a33 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -22,50 +22,34 @@ import org.openjdk.jmh.annotations.Warmup; /** - * Compares thread-safe map strategies for shared, concurrent composite-key lookups. + * Measures steady-state composite-key lookups in a shared, pre-populated table. * - *

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

        Compares {@link ConcurrentHashtable.D2}, a custom {@link ConcurrentHashtable.Entry} with a + * primitive {@code int} key part, {@link ConcurrentHashMap}, {@link ConcurrentSkipListMap}, and a + * synchronized {@link HashMap}. The table is shared across all threads ({@link Scope#Benchmark}) + * and pre-populated before the measurement iteration — modelling the steady-state read-mostly + * pattern that the tracer uses (a per-class or per-method instrumentation cache consulted on every + * invocation). * - *

        The table is shared across all threads ({@link Scope#Benchmark}) and pre-populated before the - * measurement iteration — modelling the steady-state read-mostly pattern that the tracer uses (a - * per-class or per-method instrumentation cache consulted on every invocation). + *

        The map cases create a {@link Key2} for each lookup. HotSpot may remove that allocation only + * when inlining and escape analysis prove that the wrapper is not retained; a miss that inserts the + * key makes it escape. The concurrent hashtable passes key parts directly, and the custom entry + * also avoids boxing the {@code int}, so neither optimization depends on escape analysis. * - *

        Strategies compared: - * - *

          - *
        • {@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} 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 - * 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. - *
        - * - *

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

        Lookups reuse the key-part instances installed during setup, taking the identity fast path for + * their object comparisons. See {@link ThreadSafeMapD1Benchmark} for single-key lookups. * *

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

        {@code
          * Benchmark                              Score   Units
        - * get_concurrentHashtable                1452   ops/us  (tied fastest)
        - * get_support                            1450   ops/us  (primitive int K2)
        - * get_concurrentHashMap                   777   ops/us  (allocates Key2 wrapper)
        + * get_concurrentHashtable                1452   ops/us
        + * get_support                            1450   ops/us
        + * get_concurrentHashMap                   777   ops/us
          * get_concurrentSkipListMap               146   ops/us
          * get_synchronizedHashMap                  27   ops/us
          *
        - * getOrCreate_support                    1379   ops/us  (fastest)
        + * getOrCreate_support                    1379   ops/us
          * getOrCreate_concurrentHashtable        1119   ops/us
          * getOrCreate_concurrentHashMap           769   ops/us
          * getOrCreate_concurrentSkipListMap       151   ops/us
        @@ -121,9 +105,8 @@ static final class D2Entry extends ConcurrentHashtable.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.
        +   * Entry used with the static helpers. Its primitive second key keeps storage and lookup unboxed,
        +   * independently of {@link Integer} caching or JVM escape analysis.
            */
           static final class SupportEntry extends ConcurrentHashtable.Entry {
             final String k1;
        @@ -196,7 +179,7 @@ public static class SharedState {
         
             @Setup(Level.Iteration)
             public void setUp() {
        -      table = ConcurrentHashtable.D2.createCapped(D2Entry.class, CAPACITY);
        +      table = ConcurrentHashtable.D2.createBounded(D2Entry.class, CAPACITY);
               supportBuckets = ConcurrentHashtable.createFixedBuckets(SupportEntry.class, CAPACITY);
               concurrentHashMap = new ConcurrentHashMap<>(CAPACITY);
               skipListMap = new ConcurrentSkipListMap<>();
        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 3f971082217..4c366d6809e 100644
        --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java
        +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java
        @@ -15,122 +15,26 @@
         import javax.annotation.concurrent.ThreadSafe;
         
         /**
        - * Concurrent hash table providing lock-free reads and locked writes for {@link D1} (single-key) and
        - * {@link D2} (composite-key) tables.
        + * Fixed-capacity concurrent hash tables with lock-free reads and serialized writes.
          *
        - * 

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

        {@link D1} accepts one key. {@link D2} accepts two key parts directly. Both store + * caller-defined {@link Entry} objects in separate-chained buckets and never resize. * - *

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

        Bucket heads live in an {@link AtomicReferenceArray}. Its volatile {@code set}/{@code get} + * semantics publish an inserted entry and its initialized fields to lock-free readers. The volatile + * {@code next} links likewise make chain splices visible. A read racing a removal may observe + * either state; removed entries retain their link so a reader already on the chain can still finish + * traversing it. * - *

        The primary advantage over {@link java.util.concurrent.ConcurrentHashMap} for composite-key - * 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 - * scalar replacement. + *

        {@link D2} structurally avoids a temporary composite key. With a conventional concurrent map, + * that wrapper may be retained on insertion, so HotSpot cannot reliably scalar-replace it through + * escape analysis. Avoiding the wrapper matters on the tracer's hot lookup paths. * - *

        Memory model. Bucket slots are held in an {@link AtomicReferenceArray}, so each {@link - * 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. - * - *

        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 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 #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 monitors obtained from this class, never by - * locking on an object the caller picked. Ask for the lock that covers the scope you are - * about to mutate: {@link #getWriteLock(AtomicReferenceArray, long)} (or {@link - * #getWriteLockAt(AtomicReferenceArray, int)}) for one key's bucket, and {@link - * #getTableWriteLock(AtomicReferenceArray)} for anything spanning every bucket. Treat what comes - * back as opaque rather than assuming it is the array. 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, 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, keyHash))} - * so it excludes other writers and the self-locking mutators (they nest cleanly with it): - * - *

          - *
        1. Lock-free pre-check: walk the chain via {@link #bucketFor} / {@link #bucketAt}; return if - * found. - *
        2. {@code synchronized (getWriteLock(buckets, keyHash))} — take the monitor covering that key. - *
        3. Re-check under the lock (another thread may have inserted between step 1 and step 2). - *
        4. 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 #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, keyHash))} 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: hold one lock across the whole thing, and note the - * monitor is reentrant, so the self-locking calls nest inside it cleanly. - * - *

        The protocol that used to need that — reserve a slot with {@link #tryReserveOrEvict}, then - * fill it with {@link #insertReserved} — no longer does, and why is worth recording, because the - * reason generalizes. A {@link #drain} or {@link #clear} landing in the gap used to zero the {@link - * SizeManager}, discarding the outstanding reservation and leaving the count permanently below - * reality — drift no later eviction repairs, since eviction decrements too. The repair was not a - * wider lock but honest arithmetic: the sweeps now subtract what they actually removed ({@link - * SizeManager#release(int)}), so a reservation survives one, and {@link SizeManager#tryReserve()} - * claims first and refunds on overshoot, so it needs no lock at all. Prefer making a step - * atomic on its own over holding a lock across steps. - * - *

        On striping. Every accessor above returns the same monitor today: writes to the whole - * table serialize. The three accessors exist so that granularity is a choice this class can revisit - * without touching its callers — a striped implementation would make {@link - * #getWriteLockAt(AtomicReferenceArray, int)} resolve to a per-stripe monitor and leave {@link - * #getWriteLock(AtomicReferenceArray, long)} unchanged at every call site. Two things would still - * have to be settled first, and every {@code getTableWriteLock} use marks one of them: - * - *

          - *
        • Capacity accounting needs no lock. {@link SizeManager#tryReserve()} claims a slot - * and refunds on overshoot, which is atomic on its own, and the sweeps subtract what they - * removed rather than zeroing. What stays lock-dependent is the {@code isFull()}-then-{@code - * increment()} ordering {@link D1#tryGetOrCreateOrNull} uses so a fallible {@code creator} - * cannot leak a slot — and that one can tolerate admitting slightly over the cap instead. - *
        • Eviction scans every bucket. {@link SizeManager#evictOne} walks the whole table from - * a shared cursor, so it needs every stripe. Confining it to the target stripe would make it - * stripeable, at the cost of turning approximate table-wide round-robin into per-stripe - * round-robin. That choice also decides the cursor: per-stripe it stays a plain {@code int} - * guarded by its stripe, while a cursor still shared across stripes becomes a genuine race to - * either accept as a best-effort hint or make {@code volatile}. - *
        - * - *

        The motivation, when it comes, is not write throughput on a read-mostly structure: it is that - * {@link D1#tryGetOrCreateOrNull} runs the caller's {@code creator} inside the lock, so a burst of - * misses on different keys serializes. + *

        {@link D1} and {@link D2} manage locking and capacity internally. For primitive or + * higher-arity keys, subclass {@link Entry} and use the static helpers. {@link #bucketFor}, {@link + * #bucketAt}, and {@link #forEach} are lock-free. {@link #removeIf}, {@link #drain}, and {@link + * #clear} acquire the table write lock internally. Follow each mutation helper's locking contract + * and treat the monitor returned by the lock helpers as opaque. */ public final class ConcurrentHashtable { private ConcurrentHashtable() {} @@ -221,17 +125,14 @@ private D1(State state) { } /** - * 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 approximate 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. + * Creates a fixed-size table holding at most {@code maxCapacity} entries. {@code entryClass} is + * used only to infer the concrete entry type; entries are created by the functions passed to + * the insertion methods. The table does not resize. */ @Nonnull - public static > D1 createCapped( + public static > D1 createBounded( @Nonnull Class entryClass, int maxCapacity) { - return new D1<>(State.createCapped(entryClass, maxCapacity)); + return new D1<>(State.createBounded(entryClass, maxCapacity)); } public int size() { @@ -290,8 +191,10 @@ public TEntry tryGetOrCreateOrNull( 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. + // isFull() is checked before creating the entry, not before reserving a slot for it: + // creator.apply() can throw, and if we'd already reserved (incremented) the slot, a + // throwing creator would leak that reservation forever. So we accept the entry only after + // creator succeeds, then increment. if (state.sizeManager.isFull()) { return null; } @@ -388,23 +291,11 @@ public boolean removeIf(@Nonnull Predicate 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. + * Removes all entries and passes each one to {@code sink} while holding the table write lock. + * The sink should be quick and must not throw. If it throws, the partial drain is not rolled + * back and the size is not adjusted. * - *

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

        Use {@link #drain(Object, BiConsumer)} to avoid a capturing lambda. */ public void drain(@Nonnull Consumer sink) { ConcurrentHashtable.drain(state, sink); @@ -438,11 +329,9 @@ public void forEach(C context, @Nonnull BiConsumerKey parts are passed directly to {@link #get} and {@link #getOrCreate}, eliminating the - * per-lookup composite key object allocation that {@code ConcurrentHashMap, V>} - * requires. + * Two-key concurrent hash table. Key parts are passed directly to {@link #get} and {@link + * #tryGetOrCreate}, avoiding a composite wrapper whose allocation would otherwise rely on HotSpot + * escape analysis to disappear. Reads are lock-free; misses and mutations acquire the write lock. * * @param first key type * @param second key type @@ -499,17 +388,14 @@ private D2(State state) { } /** - * 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 approximate 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. + * Creates a fixed-size table holding at most {@code maxCapacity} entries. {@code entryClass} is + * used only to infer the concrete entry type; entries are created by the functions passed to + * the insertion methods. The table does not resize. */ @Nonnull - public static > D2 createCapped( + public static > D2 createBounded( @Nonnull Class entryClass, int maxCapacity) { - return new D2<>(State.createCapped(entryClass, maxCapacity)); + return new D2<>(State.createBounded(entryClass, maxCapacity)); } public int size() { @@ -575,8 +461,10 @@ public TEntry tryGetOrCreateOrNull( 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. + // isFull() is checked before creating the entry, not before reserving a slot for it: + // creator.apply() can throw, and if we'd already reserved (incremented) the slot, a + // throwing creator would leak that reservation forever. So we accept the entry only after + // creator succeeds, then increment. if (state.sizeManager.isFull()) { return null; } @@ -675,23 +563,11 @@ public boolean removeIf(@Nonnull Predicate 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. + * Removes all entries and passes each one to {@code sink} while holding the table write lock. + * The sink should be quick and must not throw. If it throws, the partial drain is not rolled + * back and the size is not adjusted. * - *

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

        Use {@link #drain(Object, BiConsumer)} to avoid a capturing lambda. */ public void drain(@Nonnull Consumer sink) { ConcurrentHashtable.drain(state, sink); @@ -725,24 +601,12 @@ public void forEach(C context, @Nonnull BiConsumer{@link D1} and {@link D2} each hold one (via {@link State}) for their approximate - * 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}. + * Tracks a capped table's occupancy and eviction position. * - *

        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 (getTableWriteLock(buckets))} - * — the table-wide monitor, not one key's, since the count and the cursor are shared and eviction - * walks every bucket — 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. + *

        The count includes live entries and outstanding reservations. Its {@link AtomicInteger} + * provides volatile visibility and atomic updates, allowing size queries and {@link + * #tryReserve()} without the table lock. The plain {@code evictionCursor} is instead protected by + * the table write lock; methods annotated with {@link GuardedBy} require that lock. */ @ThreadSafe public static final class SizeManager { @@ -754,7 +618,7 @@ public static final class SizeManager { * eviction stream doesn't repeatedly re-walk the same hot entries clustered near bucket 0. */ @GuardedBy("getTableWriteLock(buckets)") - private int cursor; + private int evictionCursor; public SizeManager(int capacity) { this.capacity = capacity; @@ -775,18 +639,13 @@ public boolean isFull() { } /** - * Reserves a slot for a fresh insert: returns {@code true} having claimed one, or {@code false} - * with the count unchanged if the table was already at capacity. Use this when the entry to - * link is already fully built (nothing between the reservation and the link 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. + * Atomically reserves one slot without taking the table write lock. It claims first with {@link + * AtomicInteger#incrementAndGet()} and refunds values above capacity, so concurrent callers + * cannot both acquire the last slot. Returns {@code false} with the count unchanged when the + * table is full. * - *

        Needs no lock. Claiming first and refunding on overshoot makes the whole - * reservation one atomic step, so concurrent reservers cannot both squeeze past the cap: each - * sees its own post-increment value, and everyone who lands above {@link #capacity()} gives the - * slot back. That is what a check-then-increment could not do without excluding every other - * writer, and it is why capacity accounting does not force a table-wide critical section. + *

        Build the entry before reserving: there is no cancellation operation, so abandoning a + * successful reservation permanently consumes capacity. */ public boolean tryReserve() { if (size.incrementAndGet() > capacity) { @@ -797,14 +656,11 @@ public boolean tryReserve() { } /** - * {@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. + * Reserves one slot, evicting an entry matching {@code evictable} when the table is full. + * Returns {@code false} without changing the table when no entry can be evicted. * - *

        The reservation is safe to hold across a concurrent sweep: {@link #release(int)} subtracts - * what was removed rather than zeroing, so a drain or clear cannot void it. The caller does - * still owe the insert — an unfilled reservation leaks a slot until the next sweep. + *

        The reservation survives concurrent drain and clear operations. The caller must fill it; + * an abandoned reservation permanently consumes capacity. */ @GuardedBy("getTableWriteLock(buckets)") public boolean tryReserveOrEvict( @@ -832,57 +688,47 @@ public void decrement() { } /** - * Gives back {@code removed} slots after a sweep unlinked that many entries, and restarts the - * eviction scan at bucket 0 (a full pass leaves nothing later to resume from). - * - *

        Subtracting what was actually removed, rather than zeroing, is what lets a sweep run - * concurrently with an outstanding {@link #tryReserve()}: the reservation's claim survives, so - * the count stays tied to the entries that exist. Zeroing would discard it, leaving the count - * permanently one below reality — drift that no later eviction repairs, because eviction - * decrements too. + * Releases {@code removed} slots after a sweep and resets the {@code evictionCursor}. + * Outstanding reservations remain counted. */ @GuardedBy("getTableWriteLock(buckets)") @SuppressFBWarnings( value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", justification = - "cursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + "evictionCursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + " cannot model that dynamic guard") public void release(int removed) { if (removed != 0) { size.addAndGet(-removed); } - cursor = 0; + evictionCursor = 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. + * Removes and returns the first entry matching {@code evictable}, scanning from the previous + * eviction position and wrapping once. Returns {@code null} without changing the count when no + * entry matches. * - *

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

        This operation may inspect every live entry while holding the table write lock, so the + * predicate should be quick. */ @GuardedBy("getTableWriteLock(buckets)") @Nullable public TEntry evictOne( @Nonnull AtomicReferenceArray buckets, @Nonnull Predicate evictable) { - TEntry evicted = evictOneInRange(buckets, evictable, cursor, buckets.length()); - if (evicted == null && cursor != 0) { - evicted = evictOneInRange(buckets, evictable, 0, cursor); + TEntry evicted = evictOneInRange(buckets, evictable, evictionCursor, buckets.length()); + if (evicted == null && evictionCursor != 0) { + evicted = evictOneInRange(buckets, evictable, 0, evictionCursor); } if (evicted != null) { size.decrementAndGet(); return evicted; } - // Nothing matched anywhere; step the cursor on regardless so repeated refusals don't all + // Nothing matched anywhere; step the evictionCursor on regardless so repeated refusals don't + // all // restart the (wasted) scan from the same bucket. - cursor = bucketIndex(buckets, cursor + 1); + evictionCursor = bucketIndex(buckets, evictionCursor + 1); return null; } @@ -890,7 +736,7 @@ public TEntry evictOne( @SuppressFBWarnings( value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", justification = - "cursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + "evictionCursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + " cannot model that dynamic guard") @Nullable private TEntry evictOneInRange( @@ -903,7 +749,7 @@ private TEntry evictOneInRange( for (TEntry e = buckets.get(i); e != null; e = e.next()) { if (evictable.test(e)) { unlink(buckets, i, prev, e); - cursor = i; + evictionCursor = i; return e; } prev = e; @@ -921,7 +767,7 @@ private TEntry evictOneInRange( @SuppressFBWarnings( value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", justification = - "cursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + "evictionCursor is read and written only under synchronized (getTableWriteLock(buckets)); SpotBugs" + " cannot model that dynamic guard") public int evictAll( @Nonnull AtomicReferenceArray buckets, @@ -939,25 +785,14 @@ public int evictAll( } } } - cursor = 0; + evictionCursor = 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 cap on live - * entries, and the backing array is sized with load-factor headroom over it. + * Bucket array and occupancy manager for a caller-defined capped table. Keep them paired and + * prefer the {@code State}-accepting helpers so structural changes update the count consistently. */ public static final class State { public final AtomicReferenceArray buckets; @@ -969,13 +804,11 @@ private State(AtomicReferenceArray buckets, int 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 {@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. + * Creates a bucket array for {@code maxCapacity} entries and pairs it with a manager enforcing + * that cap. {@code entryClass} is used only to infer {@code TEntry}. */ @Nonnull - public static State createCapped( + public static State createBounded( @Nonnull Class entryClass, int maxCapacity) { return new State<>(createFixedBuckets(entryClass, maxCapacity), maxCapacity); } @@ -994,14 +827,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. + * Reserves one slot in {@code state}, evicting an entry matching {@code evictable} when + * necessary. Returns {@code false} if the table is full and nothing can be evicted. This method + * acquires the table write lock. * - *

        Pairing with an insert: the reservation outlives this call and survives a concurrent - * {@link #drain} or {@link #clear}, so the follow-up {@link #insertReserved} need not share a - * critical section with it — see {@link #insertReserved} for the shape. What the caller still - * owes is the insert: a reservation nobody fills leaks a slot until the next sweep. + *

        The reservation survives drain and clear operations. Complete it with {@link + * #insertReserved}; abandoning it permanently consumes capacity. */ public static boolean tryReserveOrEvict( @Nonnull State state, @Nonnull Predicate evictable) { @@ -1046,16 +877,9 @@ public static int evictAll( // --------------------------------------------------------------------------------------------- /** - * Allocates a fixed-size bucket array sized to hold {@code capacity} entries: {@code capacity} - * rounded up to the next power of two. - * - *

        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. + * Creates a bucket array whose length is {@link #sizeFor(int) sizeFor(capacity)}. Because {@link + * AtomicReferenceArray}'s element type is erased at runtime, {@code entryClass} is not used for + * reflective allocation or runtime type checks; it only lets the compiler infer {@code TEntry}. */ @Nonnull public static AtomicReferenceArray createFixedBuckets( @@ -1073,18 +897,11 @@ public static int sizeFor(int requestedSize) { } /** - * Returns the monitor that guards writes to the bucket {@code keyHash} maps to. A custom table - * locks on this — {@code synchronized (getWriteLock(buckets, keyHash)) { … }} — around its - * scan-then-insert/remove for that one key. + * Returns the opaque monitor guarding writes to the bucket selected by {@code keyHash}. Use this + * monitor for a keyed scan-and-mutate operation; do not depend on its identity or granularity. * - *

        Treat the returned object as opaque. It happens to be the bucket array today, and - * every key returns the same monitor, but obtain it here rather than assuming either, so callers - * stay correct if the locking granularity ever changes. Ask for the lock covering the key you are - * about to mutate, not "the table's lock": a caller that holds the monitor for key A and mutates - * key B is correct today only by accident. - * - * @see #getWriteLockAt(AtomicReferenceArray, int) when the bucket index is already computed - * @see #getTableWriteLock(AtomicReferenceArray) for operations that span every bucket + * @see #getWriteLockAt(AtomicReferenceArray, int) + * @see #getTableWriteLock(AtomicReferenceArray) */ @Nonnull public static Object getWriteLock(@Nonnull AtomicReferenceArray buckets, long keyHash) { @@ -1115,15 +932,8 @@ public static Object getWriteLockAt(@Nonnull State state, int bucketIndex) { } /** - * Returns the monitor that excludes writers across every bucket. Needed by anything whose - * effect is not confined to one bucket: capacity accounting ({@link SizeManager}, whose count and - * eviction cursor are table-wide), eviction (which scans every bucket), and the whole-table - * mutators {@link #drain} / {@link #clear} / {@link #removeIf} / {@link #evictAll} (which take it - * themselves). - * - *

        Today this is the same monitor {@link #getWriteLock(AtomicReferenceArray, long)} returns, so - * the blocks nest freely. It is nonetheless the accessor to name when the operation really does - * span the table — see the class javadoc on striping for why the distinction is worth keeping. + * Returns the opaque monitor guarding operations that span all buckets. Whole-table helpers such + * as {@link #drain}, {@link #clear}, and {@link #removeIf} acquire this monitor internally. */ @Nonnull public static Object getTableWriteLock(@Nonnull AtomicReferenceArray buckets) { @@ -1137,6 +947,7 @@ public static Object getTableWriteLock(@Nonnull State state) { } public static int bucketIndex(@Nonnull AtomicReferenceArray buckets, long keyHash) { + // Bucket lengths are powers of two, so masking replaces a more expensive modulo operation. return (int) (keyHash & (buckets.length() - 1)); } @@ -1183,14 +994,13 @@ public static TEntry bucketAt(@Nonnull State stat } /** - * 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 (getWriteLockAt(buckets, index))} - * block, after re-checking the chain for the key under that lock. Does not touch size accounting. + * Publishes {@code entry} as the head of bucket {@code index}. The helper writes the entry's + * {@code next} link before the volatile {@link AtomicReferenceArray#set}; a volatile bucket read + * that observes the new head also sees the initialized entry and its link. The caller must hold + * {@link #getWriteLockAt}; this method does not acquire a lock or update size accounting. * - *

        See {@link #bucketFor} for why this is a distinct name rather than an {@code int} overload - * of {@link #insertHeadEntryFor}. + *

        The entry must be unlinked and must not be reused after removal. Removal intentionally + * retains its {@code next} link for readers already traversing that chain. */ @GuardedBy("getWriteLockAt(buckets, index)") public static void insertHeadEntryAt( @@ -1224,36 +1034,12 @@ public static void insertHeadEntryFor( } /** - * 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
        -   * }
        -   * synchronized (getWriteLock(state, keyHash)) {
        -   *   insertReserved(state, keyHash, buildEntry());
        -   * }
        -   * }
        - * - *

        The reservation survives the gap between the two calls, so they do not have to share one - * critical section: {@link #drain} and {@link #clear} subtract what they removed instead of - * zeroing the count (see {@link SizeManager#release(int)}), so a sweep landing in between leaves - * the claim intact. Only the insert itself needs a lock, and only over the one bucket. - * - *

        {@code buildEntry()} must still not throw once the reservation is taken: an abandoned - * reservation 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. + * Links an already-built entry after a successful {@link #tryReserveOrEvict} or {@link + * SizeManager#tryReserve()} call. This method does not acquire a lock or update the count. * - *

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

        Complete all fallible work before reserving. There is no cancellation operation, so an + * abandoned reservation permanently consumes capacity. Drain and clear do not cancel outstanding + * reservations. */ @GuardedBy("getTableWriteLock(state)") public static void insertReserved( @@ -1349,16 +1135,12 @@ public static boolean removeIf( } /** - * 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. 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, keyHash))} block (which nests with this one). + * Removes all entries while holding the table write lock. Each bucket head is cleared with a + * volatile write before its detached chain is passed to {@code sink}, so subsequent lock-free + * readers observe an empty bucket while readers already on that chain can continue through its + * retained {@code next} links. This overload does not update size accounting. * - *

        {@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. + *

        The sink must not throw. If it does, the partial drain is not rolled back. */ public static void drain( @Nonnull AtomicReferenceArray buckets, @Nonnull Consumer sink) { 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 f95e0657211..540eb036c2f 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java @@ -21,7 +21,7 @@ class ConcurrentHashtableD1Test { @Test void getReturnsMappedEntry() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); StringEntry e = table.tryGetOrCreateOrNull("hello", k -> new StringEntry(k, 42)); assertSame(e, table.get("hello")); assertNull(table.get("world")); @@ -30,7 +30,7 @@ void getReturnsMappedEntry() { @Test void getOrCreateOnMissBuildsEntry() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); int[] createCount = {0}; StringEntry created = table.tryGetOrCreateOrNull( @@ -48,7 +48,7 @@ void getOrCreateOnMissBuildsEntry() { @Test void getOrCreateOnHitSkipsCreator() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); StringEntry seeded = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 100)); int[] createCount = {0}; StringEntry got = @@ -66,7 +66,7 @@ void getOrCreateOnHitSkipsCreator() { @Test void nullKeyIsSupported() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); StringEntry e = table.tryGetOrCreateOrNull(null, k -> new StringEntry(k, 0)); assertNotNull(e); assertSame(e, table.get(null)); @@ -75,7 +75,7 @@ void nullKeyIsSupported() { @Test void forEachVisitsAllEntries() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3)); @@ -90,7 +90,7 @@ void forEachVisitsAllEntries() { @Test void forEachWithContextPassesContext() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); table.tryGetOrCreateOrNull("x", k -> new StringEntry(k, 10)); table.tryGetOrCreateOrNull("y", k -> new StringEntry(k, 20)); Set seen = new HashSet<>(); @@ -103,7 +103,7 @@ void forEachWithContextPassesContext() { @Test void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); int threads = 16; CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -144,7 +144,7 @@ void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException void chainedEntriesInSameBucketAreAllReachable() { // All three keys share hash 0, so they land in the same bucket regardless of table size. ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(CollidingEntry.class, 8); + ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 8); CollidingKey a = new CollidingKey("a", 0); CollidingKey b = new CollidingKey("b", 0); // same bucket as a CollidingKey c = new CollidingKey("c", 0); // same bucket @@ -166,7 +166,7 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException keys[i] = "key-" + i; } ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, threads * 2); + ConcurrentHashtable.D1.createBounded(StringEntry.class, threads * 2); CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -202,7 +202,7 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException @Test void removeReturnsEntryAndShrinks() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); StringEntry a = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); assertSame(a, table.remove("a")); @@ -214,7 +214,7 @@ void removeReturnsEntryAndShrinks() { @Test void removeAbsentKeyReturnsNull() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); assertNull(table.remove("missing")); assertEquals(1, table.size()); @@ -224,7 +224,7 @@ void removeAbsentKeyReturnsNull() { void removeHeadMiddleAndTailOfSameBucketChain() { // All three keys share hash 0, so a, b, c land in the same bucket and form one chain. ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(CollidingEntry.class, 8); + ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 8); CollidingKey a = new CollidingKey("a", 0); CollidingKey b = new CollidingKey("b", 0); CollidingKey c = new CollidingKey("c", 0); @@ -249,7 +249,7 @@ void removeHeadMiddleAndTailOfSameBucketChain() { @Test void removeIfRemovesMatchingEntries() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 16); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 16); for (int i = 0; i < 10; i++) { final int v = i; table.tryGetOrCreateOrNull("k" + i, k -> new StringEntry(k, v)); @@ -268,7 +268,7 @@ void removeIfRemovesMatchingEntries() { @Test void removeIfReturnsFalseWhenNothingMatches() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); assertFalse(table.removeIf(e -> false)); assertEquals(1, table.size()); @@ -277,7 +277,7 @@ void removeIfReturnsFalseWhenNothingMatches() { @Test void clearEmptiesTableAndLeavesItUsable() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); table.clear(); @@ -292,7 +292,7 @@ void clearEmptiesTableAndLeavesItUsable() { @Test void drainRemovesEveryEntryAndFeedsSink() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3)); @@ -318,7 +318,7 @@ void drainRemovesEveryEntryAndFeedsSink() { @Test void drainWithContextFeedsSink() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); @@ -332,7 +332,7 @@ void drainWithContextFeedsSink() { @Test void drainOnEmptyTableInvokesSinkZeroTimes() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); int[] count = {0}; table.drain(e -> count[0]++); assertEquals(0, count[0]); @@ -349,7 +349,7 @@ void concurrentReadsStaySafeWhileOneChainMemberChurns() throws InterruptedExcept // 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.createCapped(CollidingEntry.class, 16); + ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 16); int n = 8; CollidingKey[] keys = new CollidingKey[n]; for (int i = 0; i < n; i++) { @@ -385,7 +385,7 @@ void concurrentReadsStaySafeWhileOneChainMemberChurns() throws InterruptedExcept @Test void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 8); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); Maybe created = table.tryGetOrCreateOrEvict("a", k -> new StringEntry(k, 1), e -> true); assertTrue(created.isPresent()); @@ -396,7 +396,7 @@ void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { @Test void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 1); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); StringEntry a = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); Maybe got = table.tryGetOrCreateOrEvict( @@ -414,7 +414,7 @@ void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() { @Test void tryGetOrCreateOrEvictEvictsWhenFullAndInsertsNewEntry() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 1); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1)); assertTrue(table.isFull()); @@ -430,7 +430,7 @@ void tryGetOrCreateOrEvictEvictsWhenFullAndInsertsNewEntry() { @Test void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 1); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1)); StringEntry result = @@ -444,7 +444,7 @@ void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() { @Test void tryGetOrCreateOrEvictOrNullEvictionRunsBeforeThrowingCreator() { ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createCapped(StringEntry.class, 1); + ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1)); assertThrows( 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 ae6978a1ebc..182cb4c4f25 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java @@ -20,7 +20,7 @@ class ConcurrentHashtableD2Test { @Test void pairKeysParticipateInIdentity() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); PairEntry ab = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); PairEntry ac = table.tryGetOrCreateOrNull("a", 2, PairEntry::new); PairEntry bb = table.tryGetOrCreateOrNull("b", 1, PairEntry::new); @@ -34,7 +34,7 @@ void pairKeysParticipateInIdentity() { @Test void getOrCreateOnMissBuildsEntryViaCreator() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); int[] createCount = {0}; PairEntry created = table.tryGetOrCreateOrNull( @@ -55,7 +55,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() { @Test void getOrCreateOnHitSkipsCreator() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); PairEntry seeded = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); int[] createCount = {0}; PairEntry got = @@ -74,7 +74,7 @@ void getOrCreateOnHitSkipsCreator() { @Test void forEachVisitsBothPairs() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); table.tryGetOrCreateOrNull("a", 1, PairEntry::new); table.tryGetOrCreateOrNull("b", 2, PairEntry::new); Set seen = new HashSet<>(); @@ -87,7 +87,7 @@ void forEachVisitsBothPairs() { @Test void forEachWithContextPassesContextToConsumer() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); table.tryGetOrCreateOrNull("a", 1, PairEntry::new); table.tryGetOrCreateOrNull("b", 2, PairEntry::new); Set seen = new HashSet<>(); @@ -100,7 +100,7 @@ void forEachWithContextPassesContextToConsumer() { @Test void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); int threads = 16; CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -143,7 +143,7 @@ void chainedEntriesInSameBucketAreAllReachable() { // key2 = -31 * key1.hashCode() zeroes the combined hash, so all four land in bucket 0 // regardless of table size. ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); PairEntry e1 = table.tryGetOrCreateOrNull("a", -31 * "a".hashCode(), PairEntry::new); PairEntry e2 = table.tryGetOrCreateOrNull("b", -31 * "b".hashCode(), PairEntry::new); PairEntry e3 = table.tryGetOrCreateOrNull("c", -31 * "c".hashCode(), PairEntry::new); @@ -166,7 +166,7 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException k2s[i] = i; } ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, threads * 2); + ConcurrentHashtable.D2.createBounded(PairEntry.class, threads * 2); CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); @@ -203,7 +203,7 @@ void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException @Test void removeReturnsEntryAndShrinks() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); PairEntry ab = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); table.tryGetOrCreateOrNull("a", 2, PairEntry::new); assertSame(ab, table.remove("a", 1)); @@ -215,7 +215,7 @@ void removeReturnsEntryAndShrinks() { @Test void removeAbsentKeyReturnsNull() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); table.tryGetOrCreateOrNull("a", 1, PairEntry::new); assertNull(table.remove("a", 99)); assertNull(table.remove("z", 1)); @@ -227,7 +227,7 @@ void removeMiddleOfSameBucketChainKeepsOthersReachable() { // key2 = -31 * key1.hashCode() zeroes the combined hash, so all three land in one bucket // chain regardless of table size. ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); table.tryGetOrCreateOrNull("a", -31 * "a".hashCode(), PairEntry::new); PairEntry mid = table.tryGetOrCreateOrNull("b", -31 * "b".hashCode(), PairEntry::new); table.tryGetOrCreateOrNull("c", -31 * "c".hashCode(), PairEntry::new); @@ -242,7 +242,7 @@ void removeMiddleOfSameBucketChainKeepsOthersReachable() { @Test void removeIfRemovesMatchingEntries() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 16); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 16); for (int i = 0; i < 10; i++) { table.tryGetOrCreateOrNull("k", i, PairEntry::new); } @@ -257,7 +257,7 @@ void removeIfRemovesMatchingEntries() { @Test void removeIfReturnsFalseWhenNothingMatches() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); table.tryGetOrCreateOrNull("a", 1, PairEntry::new); assertFalse(table.removeIf(e -> false)); assertEquals(1, table.size()); @@ -266,7 +266,7 @@ void removeIfReturnsFalseWhenNothingMatches() { @Test void clearEmptiesTableAndLeavesItUsable() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); table.tryGetOrCreateOrNull("a", 1, PairEntry::new); table.tryGetOrCreateOrNull("b", 2, PairEntry::new); table.clear(); @@ -280,7 +280,7 @@ void clearEmptiesTableAndLeavesItUsable() { @Test void drainRemovesEveryEntryAndFeedsSink() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); table.tryGetOrCreateOrNull("a", 1, PairEntry::new); table.tryGetOrCreateOrNull("a", 2, PairEntry::new); table.tryGetOrCreateOrNull("b", 1, PairEntry::new); @@ -299,7 +299,7 @@ void drainRemovesEveryEntryAndFeedsSink() { @Test void drainWithContextFeedsSink() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); table.tryGetOrCreateOrNull("a", 1, PairEntry::new); table.tryGetOrCreateOrNull("b", 2, PairEntry::new); @@ -313,7 +313,7 @@ void drainWithContextFeedsSink() { @Test void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 8); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); Maybe created = table.tryGetOrCreateOrEvict("a", 1, PairEntry::new, e -> true); assertTrue(created.isPresent()); assertEquals(1, table.size()); @@ -323,7 +323,7 @@ void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { @Test void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 1); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); PairEntry a = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); Maybe got = table.tryGetOrCreateOrEvict( @@ -342,7 +342,7 @@ void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() { @Test void tryGetOrCreateOrEvictEvictsWhenFullAndInsertsNewEntry() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 1); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); table.tryGetOrCreateOrNull("old", 1, PairEntry::new); assertTrue(table.isFull()); @@ -357,7 +357,7 @@ void tryGetOrCreateOrEvictEvictsWhenFullAndInsertsNewEntry() { @Test void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 1); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); table.tryGetOrCreateOrNull("old", 1, PairEntry::new); PairEntry result = table.tryGetOrCreateOrEvictOrNull("new", 2, PairEntry::new, e -> false); @@ -370,7 +370,7 @@ void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() { @Test void tryGetOrCreateOrEvictOrNullEvictionRunsBeforeThrowingCreator() { ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createCapped(PairEntry.class, 1); + ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); table.tryGetOrCreateOrNull("old", 1, PairEntry::new); assertThrows( 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 96e6cfdb73c..24c581b2380 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java @@ -40,7 +40,7 @@ void tryReserveSucceedsUnderCapacityAndFailsWhenFull() { @Test void tryReserveOrEvictReservesDirectlyWhenUnderCapacity() { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 2); + ConcurrentHashtable.State.createBounded(TestEntry.class, 2); boolean reserved = tryReserveOrEvict(state, e -> true); assertTrue(reserved); @@ -51,7 +51,7 @@ void tryReserveOrEvictReservesDirectlyWhenUnderCapacity() { @Test void tryReserveOrEvictEvictsWhenFullAndSomethingMatches() { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 1); + ConcurrentHashtable.State.createBounded(TestEntry.class, 1); TestEntry existing = insertAt(state, 0, "existing"); assertTrue(state.sizeManager.tryReserve()); assertTrue(state.sizeManager.isFull()); @@ -65,7 +65,7 @@ void tryReserveOrEvictEvictsWhenFullAndSomethingMatches() { @Test void tryReserveOrEvictFailsAndLeavesTableUntouchedWhenNothingEvictable() { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 1); + ConcurrentHashtable.State.createBounded(TestEntry.class, 1); TestEntry existing = insertAt(state, 0, "existing"); assertTrue(state.sizeManager.tryReserve()); @@ -78,7 +78,7 @@ void tryReserveOrEvictFailsAndLeavesTableUntouchedWhenNothingEvictable() { @Test void insertReservedSplicesWithoutTouchingTheCountAfterATryReserve() { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 2); + ConcurrentHashtable.State.createBounded(TestEntry.class, 2); assertTrue(state.sizeManager.tryReserve()); assertEquals(1, state.sizeManager.estimateSize()); @@ -95,7 +95,7 @@ void insertReservedSplicesWithoutTouchingTheCountAfterATryReserve() { @Test void evictOneReturnsNullAndLeavesCountUnchangedWhenNothingMatches() { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 4); + ConcurrentHashtable.State.createBounded(TestEntry.class, 4); insertAt(state, 0, "a"); state.sizeManager.increment(); @@ -107,7 +107,7 @@ void evictOneReturnsNullAndLeavesCountUnchangedWhenNothingMatches() { @Test void evictOneUnlinksMatchAndDecrementsCount() { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 4); + ConcurrentHashtable.State.createBounded(TestEntry.class, 4); TestEntry a = insertAt(state, 0, "a"); TestEntry b = insertAt(state, 1, "b"); state.sizeManager.increment(); @@ -130,7 +130,7 @@ void evictOneUnlinksMatchAndDecrementsCount() { void evictOneResumesFromLastEvictedBucketAndWrapsAround() { // Bucket-array length 4: keyHash i lands in bucket i. ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 4); + ConcurrentHashtable.State.createBounded(TestEntry.class, 4); TestEntry e0 = insertAt(state, 0, "e0"); insertAt(state, 2, "e2"); TestEntry e3 = insertAt(state, 3, "e3"); @@ -159,7 +159,7 @@ void evictOneResumesFromLastEvictedBucketAndWrapsAround() { @Test void evictAllRemovesEveryMatchAndReturnsCount() { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 8); + ConcurrentHashtable.State.createBounded(TestEntry.class, 8); for (int i = 0; i < 6; i++) { insertAt(state, i, "e" + i); state.sizeManager.increment(); @@ -179,7 +179,7 @@ void evictAllRemovesEveryMatchAndReturnsCount() { @Test void evictAllResetsCursorSoSubsequentEvictOneScansFromBucketZero() { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 4); + ConcurrentHashtable.State.createBounded(TestEntry.class, 4); insertAt(state, 2, "a"); state.sizeManager.increment(); // Advance the cursor away from 0 via a successful eviction at bucket 2. @@ -203,7 +203,7 @@ void evictAllResetsCursorSoSubsequentEvictOneScansFromBucketZero() { @Test void releaseGivesBackRemovedSlotsAndRestartsScan() { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 4); + ConcurrentHashtable.State.createBounded(TestEntry.class, 4); insertAt(state, 2, "a"); state.sizeManager.increment(); evictOne(state, e -> true); // advances the cursor to 2, count back to 0 @@ -226,7 +226,7 @@ void releaseGivesBackRemovedSlotsAndRestartsScan() { @Test void stateCreateCappedBundlesBucketsAndSizeManager() { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 3); + ConcurrentHashtable.State.createBounded(TestEntry.class, 3); assertEquals(0, state.sizeManager.estimateSize()); assertEquals(3, state.sizeManager.capacity()); assertTrue(state.buckets.length() >= 3); @@ -235,7 +235,7 @@ void stateCreateCappedBundlesBucketsAndSizeManager() { @Test void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 1); + ConcurrentHashtable.State.createBounded(TestEntry.class, 1); synchronized (ConcurrentHashtable.getWriteLockAt(state, 0)) { ConcurrentHashtable.insertHeadEntryAt(state, 0, new TestEntry(0, "a")); } @@ -280,7 +280,7 @@ void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() { @Test void clearCannotInterleaveBetweenReservationAndInsert() throws InterruptedException { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 1); + ConcurrentHashtable.State.createBounded(TestEntry.class, 1); insertAt(state, 0, "a"); state.sizeManager.increment(); assertTrue(ConcurrentHashtable.isFull(state)); @@ -318,7 +318,7 @@ void clearCannotInterleaveBetweenReservationAndInsert() throws InterruptedExcept @Test void reservationSurvivesAClearLandingBetweenReserveAndInsert() { ConcurrentHashtable.State state = - ConcurrentHashtable.State.createCapped(TestEntry.class, 2); + ConcurrentHashtable.State.createBounded(TestEntry.class, 2); insertAt(state, 0, "a"); state.sizeManager.increment(); From 09134b4730e1cc284824d24761ad2e3e8eedc847 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 15:27:18 -0400 Subject: [PATCH 35/36] Decouple ConcurrentHashtable.sizeFor from Hashtable.Support Inlines the power-of-two rounding and its MAX_BUCKETS cap directly into ConcurrentHashtable instead of delegating to Hashtable.Support.sizeFor, which is being removed as part of the Hashtable/ConcurrentHashtable API unification. Some duplication with Hashtable.sizeFor is accepted in exchange for removing the cross-PR coupling. --- .../trace/util/ConcurrentHashtable.java | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 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 4c366d6809e..f07ae879280 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -887,13 +887,27 @@ public static AtomicReferenceArray createFixedBuc return new AtomicReferenceArray<>(sizeFor(capacity)); } + /** Upper bound on the bucket-array length returned by {@link #sizeFor(int)}. */ + static final int MAX_BUCKETS = 1 << 30; + /** * Returns the bucket-array length to allocate for a table sized to hold {@code requestedSize} - * entries: {@code requestedSize} rounded up to the next power of two. Shares {@link Hashtable}'s - * sizing so the two families round identically. + * entries: {@code requestedSize} rounded up to the next power of two, capped at {@link + * #MAX_BUCKETS}. Throws {@link IllegalArgumentException} for negative inputs or inputs above the + * cap. */ public static int sizeFor(int requestedSize) { - return Hashtable.Support.sizeFor(requestedSize); + if (requestedSize < 0) { + throw new IllegalArgumentException("requestedSize must be non-negative: " + requestedSize); + } + if (requestedSize > MAX_BUCKETS) { + throw new IllegalArgumentException( + "requestedSize exceeds maximum bucket count (" + MAX_BUCKETS + "): " + requestedSize); + } + if (requestedSize <= 1) { + return 1; + } + return Integer.highestOneBit(requestedSize - 1) << 1; } /** From 13671282edbdc3628502113a4a0a4af34e9d5977 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Tue, 1 Sep 2026 22:07:36 +0200 Subject: [PATCH 36/36] perf: reduce LogCollector dedup allocations --- .../api/telemetry/LogCollectorBenchmark.java | 26 ++- .../trace/api/telemetry/LogCollector.java | 131 ++++++++--- .../api/telemetry/LogCollectorTest.groovy | 66 ------ .../trace/api/telemetry/LogCollectorTest.java | 211 ++++++++++++++++++ 4 files changed, 329 insertions(+), 105 deletions(-) delete mode 100644 internal-api/src/test/groovy/datadog/trace/api/telemetry/LogCollectorTest.groovy create mode 100644 internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java diff --git a/internal-api/src/jmh/java/datadog/trace/api/telemetry/LogCollectorBenchmark.java b/internal-api/src/jmh/java/datadog/trace/api/telemetry/LogCollectorBenchmark.java index d7c836b8621..0899c8fecf6 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/telemetry/LogCollectorBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/telemetry/LogCollectorBenchmark.java @@ -2,7 +2,11 @@ import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.Threads; import org.openjdk.jmh.annotations.Warmup; @@ -11,15 +15,25 @@ @Measurement(iterations = 5) @Threads(8) public class LogCollectorBenchmark { + @State(Scope.Benchmark) + public static class CollectorState { + final LogCollector collector = new LogCollector(4); + + @Setup(Level.Trial) + public void setup() { + collector.addLogMessage("error", "ugh!", null); + } + } + @Benchmark - public void noException_before() { - LogCollector.get().addLogMessage("error", "ugh!", null); + public void duplicateWithoutException(CollectorState state) { + state.collector.addLogMessage("error", "ugh!", null); } static final Object NULL = null; @Benchmark - public void nullPointerException() { + public void nullPointerException(CollectorState state) { // Represents the fast throw case where the JVM switches to using // a single Exception instance to handle a hot throw location // of NullPointerException, ArrayIndexOutOfBoundsException, etc. @@ -27,18 +41,18 @@ public void nullPointerException() { try { NULL.hashCode(); } catch (Throwable t) { - LogCollector.get().addLogMessage("error", "npe", t); + state.collector.addLogMessage("error", "npe", t); } } @Benchmark - public void unsupportedOperationException() { + public void unsupportedOperationException(CollectorState state) { // Represents the common case where stack trace is preserved // despite hot throw try { unsupportedOperation(); } catch (Throwable t) { - LogCollector.get().addLogMessage("error", "unsupported", t); + state.collector.addLogMessage("error", "unsupported", t); } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java index b7ad3cb0eb0..39d600a421b 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java @@ -1,16 +1,21 @@ package datadog.trace.api.telemetry; -import datadog.trace.util.HashingUtils; +import static datadog.trace.util.ConcurrentHashtable.bucketAt; +import static datadog.trace.util.ConcurrentHashtable.bucketIndex; +import static datadog.trace.util.ConcurrentHashtable.estimateSize; +import static datadog.trace.util.ConcurrentHashtable.getTableWriteLock; +import static datadog.trace.util.ConcurrentHashtable.insertReserved; +import static datadog.trace.util.ConcurrentHashtable.isFull; +import static datadog.trace.util.LongHashingUtils.hash; + +import datadog.trace.util.ConcurrentHashtable; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.Iterator; import java.util.List; -import java.util.Map; import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; import javax.annotation.Nullable; import org.slf4j.Marker; import org.slf4j.MarkerFactory; @@ -20,8 +25,7 @@ public class LogCollector { public static final Marker EXCLUDE_TELEMETRY = MarkerFactory.getMarker("EXCLUDE_TELEMETRY"); private static final int DEFAULT_MAX_CAPACITY = 10; private static final LogCollector INSTANCE = new LogCollector(); - private final Map rawLogMessages; - private final int maxCapacity; + private final ConcurrentHashtable.State rawLogMessages; public static LogCollector get() { return INSTANCE; @@ -35,8 +39,7 @@ private LogCollector() { value = "SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR", justification = "Usage in tests") LogCollector(int maxCapacity) { - this.maxCapacity = maxCapacity; - this.rawLogMessages = new ConcurrentHashMap<>(maxCapacity); + this.rawLogMessages = ConcurrentHashtable.State.createBounded(RawLogMessage.class, maxCapacity); } public void addLogMessage(String logLevel, String message, @Nullable Throwable throwable) { @@ -54,41 +57,88 @@ public void addLogMessage(String logLevel, String message, @Nullable Throwable t */ public void addLogMessage( String logLevel, String message, @Nullable Throwable throwable, @Nullable String tags) { - if (rawLogMessages.size() >= maxCapacity) { + if (isFull(rawLogMessages)) { // TODO: We could emit a metric for dropped logs. return; } - RawLogMessage rawLogMessage = - new RawLogMessage(logLevel, message, throwable, tags, System.currentTimeMillis() / 1000); - AtomicInteger count = rawLogMessages.computeIfAbsent(rawLogMessage, k -> new AtomicInteger()); - count.incrementAndGet(); + + long keyHash = RawLogMessage.computeHash(logLevel, message, throwable); + int index = bucketIndex(rawLogMessages.buckets, keyHash); + RawLogMessage rawLogMessage = find(index, keyHash, logLevel, message, throwable); + if (rawLogMessage != null) { + rawLogMessage.increment(); + return; + } + + synchronized (getTableWriteLock(rawLogMessages)) { + rawLogMessage = find(index, keyHash, logLevel, message, throwable); + if (rawLogMessage != null) { + rawLogMessage.increment(); + return; + } + if (isFull(rawLogMessages)) { + return; + } + + rawLogMessage = + new RawLogMessage(logLevel, message, throwable, tags, System.currentTimeMillis() / 1000); + if (rawLogMessages.sizeManager.tryReserve()) { + insertReserved(rawLogMessages, keyHash, rawLogMessage); + } + } } public Collection drain() { - if (rawLogMessages.isEmpty()) { + int size = estimateSize(rawLogMessages); + if (size == 0) { return Collections.emptyList(); } - List list = new ArrayList<>(rawLogMessages.size()); - Iterator> iterator = - rawLogMessages.entrySet().iterator(); - - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - RawLogMessage logMessage = entry.getKey(); - // XXX: There might be lost writers to the counters under concurrency if another thread - // increments it - // while we are reading it here. At the moment, we are not overdoing this to prevent some - // counter losses. - logMessage.count = entry.getValue().get(); - iterator.remove(); - list.add(logMessage); - } - + List list = new ArrayList<>(size); + ConcurrentHashtable.drain( + rawLogMessages, + list, + (drained, logMessage) -> { + // A writer that found this entry before drain detached it can still increment too late. + logMessage.snapshotCount(); + drained.add(logMessage); + }); return list; } - public static final class RawLogMessage { + @Nullable + private RawLogMessage find( + int index, long keyHash, String logLevel, String message, @Nullable Throwable throwable) { + StackTraceElement[] stackTrace = null; + for (RawLogMessage entry = bucketAt(rawLogMessages, index); + entry != null; + entry = entry.next()) { + if (entry.keyHash != keyHash + || !Objects.equals(logLevel, entry.logLevel) + || !Objects.equals(message, entry.message)) { + continue; + } + if (throwable == entry.throwable) { + return entry; + } + if (throwable != null + && entry.throwable != null + && throwable.getClass().equals(entry.throwable.getClass())) { + if (stackTrace == null) { + stackTrace = throwable.getStackTrace(); + } + if (Objects.deepEquals(stackTrace, entry.stackTrace())) { + return entry; + } + } + } + return null; + } + + public static final class RawLogMessage extends ConcurrentHashtable.Entry { + private static final AtomicIntegerFieldUpdater DEDUP_COUNT = + AtomicIntegerFieldUpdater.newUpdater(RawLogMessage.class, "dedupCount"); + public final String message; public final String logLevel; public final Throwable throwable; @@ -96,10 +146,12 @@ public static final class RawLogMessage { public final long timestamp; public int count; + private volatile int dedupCount = 1; private StackTraceElement[] cachedStackTrace = null; public RawLogMessage( String logLevel, String message, Throwable throwable, String tags, long timestamp) { + super(computeHash(logLevel, message, throwable)); this.logLevel = logLevel; this.message = message; this.throwable = throwable; @@ -122,6 +174,14 @@ public StackTraceElement[] stackTrace() { return stackTrace; } + private void increment() { + DEDUP_COUNT.incrementAndGet(this); + } + + private void snapshotCount() { + count = DEDUP_COUNT.get(this); + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -149,7 +209,12 @@ public boolean equals(Object o) { @Override public int hashCode() { - return HashingUtils.hash(logLevel, message, throwable == null ? null : throwable.getClass()); + return (int) keyHash; + } + + private static long computeHash( + String logLevel, String message, @Nullable Throwable throwable) { + return hash(logLevel, message, throwable == null ? null : throwable.getClass()); } } } diff --git a/internal-api/src/test/groovy/datadog/trace/api/telemetry/LogCollectorTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/telemetry/LogCollectorTest.groovy deleted file mode 100644 index 4f798f5bf9f..00000000000 --- a/internal-api/src/test/groovy/datadog/trace/api/telemetry/LogCollectorTest.groovy +++ /dev/null @@ -1,66 +0,0 @@ -package datadog.trace.api.telemetry - -import datadog.trace.test.util.DDSpecification - -class LogCollectorTest extends DDSpecification { - - void "tracer time is set"() { - setup: - def logCollector = new LogCollector(1) - - when: - logCollector.addLogMessage("ERROR", "Message 1", null) - - then: - def log = logCollector.drain().toList().get(0) - def ts = log.timestamp - ts > 0L - // Check tracer time is not in millis - ts < 1706529524286L - } - - void "limit log messages in LogCollector"() { - setup: - def logCollector = new LogCollector(3) - - when: - logCollector.addLogMessage("ERROR", "Message 1", null) - logCollector.addLogMessage("ERROR", "Message 2", null) - logCollector.addLogMessage("ERROR", "Message 3", null) - logCollector.addLogMessage("ERROR", "Message 4", null) - - then: - logCollector.rawLogMessages.size() == 3 - } - - void "grouping messages in LogCollector"() { - when: - LogCollector.get().addLogMessage("ERROR", "First Message", null) - LogCollector.get().addLogMessage("ERROR", "Second Message", null) - LogCollector.get().addLogMessage("ERROR", "Third Message", null) - LogCollector.get().addLogMessage("ERROR", "Forth Message", null) - LogCollector.get().addLogMessage("ERROR", "Second Message", null) - LogCollector.get().addLogMessage("ERROR", "Third Message", null) - LogCollector.get().addLogMessage("ERROR", "Forth Message", null) - LogCollector.get().addLogMessage("ERROR", "Third Message", null) - LogCollector.get().addLogMessage("ERROR", "Forth Message", null) - LogCollector.get().addLogMessage("ERROR", "Forth Message", null) - - then: - def list = LogCollector.get().drain() - list.size() == 4 - listContains(list, 'ERROR', "First Message", null, 1) - listContains(list, 'ERROR', "Second Message", null, 2) - listContains(list, 'ERROR', "Third Message", null,3) - listContains(list, 'ERROR', "Forth Message", null, 4) - } - - boolean listContains(Collection list, String logLevel, String message, Throwable t, int count) { - for (final def logMsg in list) { - if (logMsg.logLevel == logLevel && logMsg.message == message && logMsg.throwable == t && logMsg.count == count) { - return true - } - } - return false - } -} diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java new file mode 100644 index 00000000000..34e221b254a --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java @@ -0,0 +1,211 @@ +package datadog.trace.api.telemetry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collection; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; + +class LogCollectorTest { + + @Test + void setsTracerTime() { + LogCollector logCollector = new LogCollector(1); + long before = System.currentTimeMillis() / 1000; + + logCollector.addLogMessage("ERROR", "Message 1", null); + + long after = System.currentTimeMillis() / 1000; + LogCollector.RawLogMessage log = onlyLog(logCollector.drain()); + assertTrue(log.timestamp >= before); + assertTrue(log.timestamp <= after); + } + + @Test + void limitsLogMessages() { + LogCollector logCollector = new LogCollector(3); + + logCollector.addLogMessage("ERROR", "Message 1", null); + logCollector.addLogMessage("ERROR", "Message 2", null); + logCollector.addLogMessage("ERROR", "Message 3", null); + logCollector.addLogMessage("ERROR", "Message 4", null); + + assertEquals(3, logCollector.drain().size()); + } + + @Test + void groupsMessages() { + LogCollector logCollector = new LogCollector(10); + + logCollector.addLogMessage("ERROR", "First Message", null); + logCollector.addLogMessage("ERROR", "Second Message", null); + logCollector.addLogMessage("ERROR", "Third Message", null); + logCollector.addLogMessage("ERROR", "Fourth Message", null); + logCollector.addLogMessage("ERROR", "Second Message", null); + logCollector.addLogMessage("ERROR", "Third Message", null); + logCollector.addLogMessage("ERROR", "Fourth Message", null); + logCollector.addLogMessage("ERROR", "Third Message", null); + logCollector.addLogMessage("ERROR", "Fourth Message", null); + logCollector.addLogMessage("ERROR", "Fourth Message", null); + + Collection logs = logCollector.drain(); + assertEquals(4, logs.size()); + assertLog(logs, "First Message", 1); + assertLog(logs, "Second Message", 2); + assertLog(logs, "Third Message", 3); + assertLog(logs, "Fourth Message", 4); + } + + @Test + void dropsDuplicatesWhenFull() { + LogCollector logCollector = new LogCollector(1); + + logCollector.addLogMessage("ERROR", "Message", null); + logCollector.addLogMessage("ERROR", "Message", null); + + assertEquals(1, onlyLog(logCollector.drain()).count); + } + + @Test + void reusesCapacityAfterDrain() { + LogCollector logCollector = new LogCollector(1); + + logCollector.addLogMessage("ERROR", "First", null); + assertEquals("First", onlyLog(logCollector.drain()).message); + logCollector.addLogMessage("ERROR", "Second", null); + + assertEquals("Second", onlyLog(logCollector.drain()).message); + assertTrue(logCollector.drain().isEmpty()); + } + + @Test + void groupsEquivalentThrowablesAndKeepsFirstMetadata() { + LogCollector logCollector = new LogCollector(2); + Throwable first = throwableAtLine(10); + Throwable second = throwableAtLine(10); + + logCollector.addLogMessage("ERROR", "Message", first, "source:first"); + logCollector.addLogMessage("ERROR", "Message", second, "source:second"); + + LogCollector.RawLogMessage log = onlyLog(logCollector.drain()); + assertEquals(2, log.count); + assertSame(first, log.throwable); + assertEquals("source:first", log.tags); + } + + @Test + void keepsDifferentStackTracesSeparate() { + LogCollector logCollector = new LogCollector(2); + + logCollector.addLogMessage("ERROR", "Message", throwableAtLine(10)); + logCollector.addLogMessage("ERROR", "Message", throwableAtLine(20)); + + assertEquals(2, logCollector.drain().size()); + } + + @Test + void rawLogMessageEqualityMatchesDeduplication() { + LogCollector.RawLogMessage first = + new LogCollector.RawLogMessage("ERROR", "Message", throwableAtLine(10), "first", 1); + LogCollector.RawLogMessage equivalent = + new LogCollector.RawLogMessage("ERROR", "Message", throwableAtLine(10), "second", 2); + LogCollector.RawLogMessage different = + new LogCollector.RawLogMessage("ERROR", "Message", throwableAtLine(20), "first", 1); + + assertEquals(first, equivalent); + assertEquals(first.hashCode(), equivalent.hashCode()); + assertNotEquals(first, different); + } + + @Test + void countsConcurrentDuplicates() throws Exception { + int threadCount = 16; + int messagesPerThread = 1_000; + LogCollector logCollector = new LogCollector(2); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch start = new CountDownLatch(1); + Future[] futures = new Future[threadCount]; + try { + for (int i = 0; i < threadCount; i++) { + futures[i] = + executor.submit( + () -> { + start.await(); + for (int message = 0; message < messagesPerThread; message++) { + logCollector.addLogMessage("ERROR", "Message", null); + } + return null; + }); + } + start.countDown(); + for (Future future : futures) { + future.get(); + } + } finally { + executor.shutdownNow(); + } + + assertEquals(threadCount * messagesPerThread, onlyLog(logCollector.drain()).count); + } + + @Test + void capsConcurrentDistinctMessages() throws Exception { + int capacity = 3; + int threadCount = 16; + LogCollector logCollector = new LogCollector(capacity); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch start = new CountDownLatch(1); + Future[] futures = new Future[threadCount]; + try { + for (int i = 0; i < threadCount; i++) { + String message = "Message " + i; + futures[i] = + executor.submit( + () -> { + start.await(); + logCollector.addLogMessage("ERROR", message, null); + return null; + }); + } + start.countDown(); + for (Future future : futures) { + future.get(); + } + } finally { + executor.shutdownNow(); + } + + assertEquals(capacity, logCollector.drain().size()); + } + + private static Throwable throwableAtLine(int lineNumber) { + Throwable throwable = new IllegalStateException("ignored by deduplication"); + throwable.setStackTrace( + new StackTraceElement[] { + new StackTraceElement("Example", "run", "Example.java", lineNumber) + }); + return throwable; + } + + private static LogCollector.RawLogMessage onlyLog(Collection logs) { + assertEquals(1, logs.size()); + return logs.iterator().next(); + } + + private static void assertLog( + Collection logs, String message, int count) { + LogCollector.RawLogMessage log = + logs.stream() + .filter(candidate -> message.equals(candidate.message)) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing log message: " + message)); + assertEquals("ERROR", log.logLevel); + assertEquals(count, log.count); + } +}