From 45915773189054c1a158eaf5e920f2396fc145f3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 16 Jul 2026 16:40:42 -0400 Subject: [PATCH 1/9] Add FlatHashtable: open-addressed find-or-create with static-polymorphism helpers A reusable open-addressed table over self-contained entries (one reference per slot), designed so callers specialize it via a concrete-typed static-final Helper that the JIT devirtualizes and inlines (C++-template-style static polymorphism). Cardinality cap / overflow / size are caller policy; this class is pure mechanism (capacityFor, create, get, getOrCreate). Includes a String-key StringHelper that seals a spread hash. Intended as the shared backing for several open-addressed caches/tables (per-operation sizing hints, UTF8BytesString caches, etc.). Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/FlatHashtable.java | 152 ++++++++++++++++++ .../datadog/trace/util/FlatHashtableTest.java | 92 +++++++++++ 2 files changed, 244 insertions(+) create mode 100644 internal-api/src/main/java/datadog/trace/util/FlatHashtable.java create mode 100644 internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java diff --git a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java new file mode 100644 index 00000000000..ac9abf37522 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java @@ -0,0 +1,152 @@ +package datadog.trace.util; + +import java.lang.reflect.Array; + +/** + * Open-addressed, single-array find-or-create over self-contained entries — each slot is one + * reference to an entry that carries its own key (and, typically, a cached hash). One array, one + * reference per slot: entry publication is a single reference store, so a reader sees {@code null} or + * a complete entry (never a torn one), and {@code final} identity fields on the entry are visible + * under racy publication. That sidesteps the memory-ordering / visibility problems parallel + * key/hash/value arrays would create — no {@code volatile}, no atomics — as long as the payload is + * one where a stale/lost read is benign (miss → recreate; clobber → one wins). + * + *

Static polymorphism (C++-template-style). The per-use policy is a {@link Helper} — a + * stateless subclass held by each caller as a {@code static final} field declared with the + * concrete helper type (not the {@code Helper} base): + * + *

{@code
+ * private static final MyHelper HELPER = new MyHelper();  // concrete type => exact type pinned
+ * ...
+ * V v = FlatHashtable.getOrCreate(table, key, HELPER);
+ * }
+ * + * Because {@code HELPER} is a compile-time-constant of an exact type at the call site, once these + * small {@code Support} methods inline the JIT devirtualizes and inlines {@code hash}/{@code + * matches}/{@code create} — each call site specializes to straight-line code, one instantiation per + * helper, with no CHA/type-profiling dependence. Keep the methods small so they inline; verify with + * {@code -XX:+PrintInlining} (the failure mode is silent: it compiles and runs, just stays + * megamorphic and slow). {@code Helper} is an abstract class, so a distinct final subclass is required + * anyway — an exact type gives the inliner an unambiguous receiver. + * + *

Contract: {@code table.length} must be a power of two ({@link #capacityFor}). {@code + * helper.hash} should be well-distributed (this class masks it directly). Cardinality cap / overflow + * / a live-size counter are caller policy (this class is pure mechanism): a capped caller does + * {@link #get} first, and only on a miss checks its budget before {@link #getOrCreate} (so hits stay + * a single probe and the create path is warmup-rare). + */ +public final class FlatHashtable { + private FlatHashtable() {} + + /** + * Per-use policy. Extend as a stateless final class and hold a {@code static final} singleton + * of the concrete type (see class doc) so the JIT can specialize each call site. + * + *

An abstract class (not an interface) on purpose: it forces a named helper type (no + * lambdas, which can blur the receiver the inliner needs), and if specialization ever misses, the + * fallback dispatches via {@code invokevirtual} rather than the costlier megamorphic {@code + * invokeinterface}. On the specialized (inlined) path the choice is a wash — this just hedges the + * fallback and lets shared bits be {@code final}-sealed later. + * + * @param lookup key + * @param stored entry — self-contained (carries its own key, ideally a cached hash) + */ + public abstract static class Helper { + /** Hash of {@code key}; should be well-distributed (this table masks it directly). */ + public abstract int hash(K key); + + /** Whether the stored {@code value} entry is the one for {@code key}. */ + public abstract boolean matches(K key, V value); + + /** Mint a new entry for {@code key} (called once, on insert). */ + public abstract V create(K key); + } + + /** + * {@link Helper} specialized for {@code String} keys: seals a spread {@link #hash} so String-key + * callers write only {@link #matches} and {@link #create}. Extend as a stateless final class held in + * a concrete-typed {@code static final} singleton, exactly like {@link Helper} — the {@code final} + * hash resolves directly and the concrete subclass still specializes the same at each call site, so + * there's no cost to the extra layer. + * + * @param stored entry — self-contained (carries its own key, ideally a cached hash) + */ + public abstract static class StringHelper extends Helper { + @Override + public final int hash(String key) { + final int h = key.hashCode(); + return h ^ (h >>> 16); // spread; FlatHashtable masks this directly + } + } + + /** Power-of-two capacity for a cardinality budget: {@code >= 2 * limit} (load factor <= 0.5). */ + public static int capacityFor(int cardinalityLimit) { + if (cardinalityLimit <= 0) { + throw new IllegalArgumentException("cardinalityLimit must be positive: " + cardinalityLimit); + } + return Integer.highestOneBit(cardinalityLimit * 2 - 1) << 1; + } + + /** + * Allocates a correctly-typed table for a cardinality budget ({@link #capacityFor} slots). Passing + * {@code type} makes the array's runtime component type {@code T} rather than {@code Object[]} — + * typed reads, real array-store checks, and a monomorphic element type for the JIT. Callers can't + * {@code new T[]} themselves under erasure; this does the one reflective allocation at construction + * (off any hot path). Note: this {@code create} mints the backing array; {@link Helper#create} + * mints an entry — different types, no ambiguity at the call site. + */ + @SuppressWarnings("unchecked") + public static T[] create(Class type, int cardinalityLimit) { + return (T[]) Array.newInstance(type, capacityFor(cardinalityLimit)); + } + + /** + * Existing entry for {@code key}, or {@code null}. Read-only — never creates. Single probe on a + * hit; walks to the first empty slot (or all the way around) on a miss. + */ + public static V get(V[] table, K key, Helper helper) { + final int mask = table.length - 1; + final int start = helper.hash(key) & mask; + int i = start; + for (; ; ) { + final V e = table[i]; + if (e == null) { + return null; // empty slot terminates the probe (no tombstones) + } + if (helper.matches(key, e)) { + return e; + } + i = (i + 1) & mask; + if (i == start) { + return null; // wrapped ⇒ full, absent + } + } + } + + /** + * Existing entry for {@code key}, or a freshly {@link Helper#create created} + inserted one. + * Returns {@code null} only if the table is full (no empty slot) — the caller supplies its overflow + * default. The insert is a single plain reference store: a concurrent clobber / double-create is + * acceptable only when the payload makes it benign (see class doc). + */ + public static V getOrCreate(V[] table, K key, Helper helper) { + final int mask = table.length - 1; + final int start = helper.hash(key) & mask; + int i = start; + for (; ; ) { + final V e = table[i]; + if (e == null) { + final V created = helper.create(key); + table[i] = created; // single-reference publish; benign clobber (see class doc) + return created; + } + if (helper.matches(key, e)) { + return e; + } + i = (i + 1) & mask; + if (i == start) { + return null; // wrapped ⇒ full + } + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java new file mode 100644 index 00000000000..e6dba7391dc --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java @@ -0,0 +1,92 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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 org.junit.jupiter.api.Test; + +class FlatHashtableTest { + + /** Self-contained entry: carries its own key (the identity FlatHashtable relies on). */ + static final class Entry { + final String key; + + Entry(String key) { + this.key = key; + } + } + + /** Stateless concrete helper, held as a concrete-typed static final singleton (the JIT idiom). */ + static final class EntryHelper extends FlatHashtable.StringHelper { + @Override + public boolean matches(String key, Entry value) { + return key.equals(value.key); + } + + @Override + public Entry create(String key) { + return new Entry(key); + } + } + + private static final EntryHelper HELPER = new EntryHelper(); + + @Test + void capacityFor_roundsToPowerOfTwoAtLeastTwiceLimit() { + assertEquals(2, FlatHashtable.capacityFor(1)); + assertEquals(8, FlatHashtable.capacityFor(4)); + assertEquals(16, FlatHashtable.capacityFor(6)); // 6*2-1=11 -> 8 -> 16 + } + + @Test + void capacityFor_rejectsNonPositive() { + assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(0)); + assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(-1)); + } + + @Test + void create_allocatesTypedTableOfCapacity() { + Entry[] table = FlatHashtable.create(Entry.class, 4); + assertEquals(8, table.length); + assertEquals(Entry.class, table.getClass().getComponentType()); + } + + @Test + void getOrCreate_insertsOnceAndReturnsTheExistingEntry() { + Entry[] table = FlatHashtable.create(Entry.class, 8); + Entry first = FlatHashtable.getOrCreate(table, "a", HELPER); + assertEquals("a", first.key); + // A second call must return the SAME instance, not mint a new one. + assertSame(first, FlatHashtable.getOrCreate(table, "a", HELPER)); + assertSame(first, FlatHashtable.get(table, "a", HELPER)); + } + + @Test + void get_returnsNullForAbsentKey() { + Entry[] table = FlatHashtable.create(Entry.class, 8); + assertNull(FlatHashtable.get(table, "missing", HELPER)); + FlatHashtable.getOrCreate(table, "present", HELPER); + assertNull(FlatHashtable.get(table, "still-missing", HELPER)); + } + + @Test + void getOrCreate_returnsNullWhenTableIsFull() { + // capacityFor(1) == 2 slots. + Entry[] table = FlatHashtable.create(Entry.class, 1); + assertTrue(FlatHashtable.getOrCreate(table, "k0", HELPER) != null); + assertTrue(FlatHashtable.getOrCreate(table, "k1", HELPER) != null); + // Both slots occupied by distinct keys -> a third distinct key finds no room. + assertNull(FlatHashtable.getOrCreate(table, "k2", HELPER)); + // ...but an existing key still resolves even when full. + assertSame( + FlatHashtable.get(table, "k0", HELPER), FlatHashtable.getOrCreate(table, "k0", HELPER)); + } + + @Test + void stringHelper_hashIsStableForEqualKeys() { + assertEquals(HELPER.hash("route"), HELPER.hash(new String("route"))); + } +} From c1d7b16d42e3cf992507a5e05402a4cfb0741c2f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 16 Jul 2026 19:31:50 -0400 Subject: [PATCH 2/9] Add FlatHashtable collision/probe coverage tests Force hash collisions via fixed-hash helpers to exercise the linear-probe paths the original tests missed: probe-past-occupied + match-after-probe (in both get and getOrCreate), wraparound to the front, and get()'s full-table wrap-to-null branch. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/FlatHashtableTest.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java index e6dba7391dc..9bebfa9c45a 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java @@ -1,6 +1,7 @@ package datadog.trace.util; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -34,6 +35,46 @@ public Entry create(String key) { private static final EntryHelper HELPER = new EntryHelper(); + /** All keys hash to slot 0, so inserts chain by linear probing — exercises the probe path. */ + static final class CollidingHelper extends FlatHashtable.Helper { + @Override + public int hash(String key) { + return 0; + } + + @Override + public boolean matches(String key, Entry value) { + return key.equals(value.key); + } + + @Override + public Entry create(String key) { + return new Entry(key); + } + } + + private static final CollidingHelper COLLIDING = new CollidingHelper(); + + /** All keys hash to the last slot ({@code -1 & mask}), so probing wraps around to index 0. */ + static final class LastSlotHelper extends FlatHashtable.Helper { + @Override + public int hash(String key) { + return -1; + } + + @Override + public boolean matches(String key, Entry value) { + return key.equals(value.key); + } + + @Override + public Entry create(String key) { + return new Entry(key); + } + } + + private static final LastSlotHelper LAST_SLOT = new LastSlotHelper(); + @Test void capacityFor_roundsToPowerOfTwoAtLeastTwiceLimit() { assertEquals(2, FlatHashtable.capacityFor(1)); @@ -89,4 +130,49 @@ void getOrCreate_returnsNullWhenTableIsFull() { void stringHelper_hashIsStableForEqualKeys() { assertEquals(HELPER.hash("route"), HELPER.hash(new String("route"))); } + + @Test + void collision_probesPastOccupiedSlots_andResolvesEach() { + Entry[] table = FlatHashtable.create(Entry.class, 4); // 8 slots; COLLIDING sends all to slot 0 + Entry a = FlatHashtable.getOrCreate(table, "a", COLLIDING); + Entry b = FlatHashtable.getOrCreate(table, "b", COLLIDING); // slot 0 taken -> probes to slot 1 + Entry c = FlatHashtable.getOrCreate(table, "c", COLLIDING); // -> slot 2 + + assertNotSame(a, b); + assertNotSame(b, c); + + // each resolves via probe-past-occupied + match-after-probe + assertSame(a, FlatHashtable.get(table, "a", COLLIDING)); + assertSame(b, FlatHashtable.get(table, "b", COLLIDING)); + assertSame(c, FlatHashtable.get(table, "c", COLLIDING)); + + // existing colliding key: found after probing, no new entry minted + assertSame(b, FlatHashtable.getOrCreate(table, "b", COLLIDING)); + + // absent key: probe past the 3 occupied slots, hit an empty slot -> null + assertNull(FlatHashtable.get(table, "absent", COLLIDING)); + } + + @Test + void collision_probeWrapsAroundToFront() { + Entry[] table = + FlatHashtable.create(Entry.class, 1); // 2 slots (0,1), mask=1; LAST_SLOT starts at 1 + Entry k0 = FlatHashtable.getOrCreate(table, "k0", LAST_SLOT); // -> slot 1 + Entry k1 = FlatHashtable.getOrCreate(table, "k1", LAST_SLOT); // slot 1 taken -> wraps to slot 0 + + assertNotSame(k0, k1); + assertSame(k0, FlatHashtable.get(table, "k0", LAST_SLOT)); + // start slot 1 is occupied (no match) -> probe wraps to slot 0 -> match + assertSame(k1, FlatHashtable.get(table, "k1", LAST_SLOT)); + } + + @Test + void get_returnsNullWhenTableFullAndKeyAbsent() { + Entry[] table = FlatHashtable.create(Entry.class, 1); // 2 slots + FlatHashtable.getOrCreate(table, "k0", COLLIDING); + FlatHashtable.getOrCreate(table, "k1", COLLIDING); // fills slots 0 and 1 + + // get() probes both occupied slots, wraps back to start -> null (get's full-wrap branch) + assertNull(FlatHashtable.get(table, "absent", COLLIDING)); + } } From f39ac32557dfa2d9316046f6f851e47156f8d3e5 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 17 Jul 2026 10:55:41 -0400 Subject: [PATCH 3/9] Reshape FlatHashtable into KeyStrategy/CreateStrategy strategies KeyStrategy (abstract class): hash/matches/hashOf, long hashes for family consistency with Hashtable/ConcurrentHashtable. StringKeyStrategy seals the spread hash; EntryKeyStrategy seals hashOf to a cached Entry.hash (Entry is an optional structure-free base). CreateStrategy (bespoke @FunctionalInterface): create, cold+per-use, lambda-able. @Strategy on both strategy types; @StrategyConsumer on the inlining consumers (get/getOrCreate/insert). Surface: get/getOrCreate (key-taking) + two insert flavors (Entry-based / KeyStrategy-based) over a shared comparison-free placement core; forEach (+ context variant); hash-filtered read-only iterator (not a StrategyConsumer -- interface-dispatched cold traversal). No removal. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/FlatHashtable.java | 342 ++++++++++++++---- .../datadog/trace/util/FlatHashtableTest.java | 252 ++++++++++--- 2 files changed, 471 insertions(+), 123 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java index ac9abf37522..6a934167f2e 100644 --- a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java @@ -1,84 +1,153 @@ package datadog.trace.util; +import datadog.trace.api.function.Strategy; +import datadog.trace.api.function.StrategyConsumer; import java.lang.reflect.Array; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.function.BiConsumer; +import java.util.function.Consumer; /** * Open-addressed, single-array find-or-create over self-contained entries — each slot is one * reference to an entry that carries its own key (and, typically, a cached hash). One array, one - * reference per slot: entry publication is a single reference store, so a reader sees {@code null} or - * a complete entry (never a torn one), and {@code final} identity fields on the entry are visible - * under racy publication. That sidesteps the memory-ordering / visibility problems parallel + * reference per slot: entry publication is a single reference store, so a reader sees {@code null} + * or a complete entry (never a torn one), and {@code final} identity fields on the entry are + * visible under racy publication. That sidesteps the memory-ordering / visibility problems parallel * key/hash/value arrays would create — no {@code volatile}, no atomics — as long as the payload is * one where a stale/lost read is benign (miss → recreate; clobber → one wins). * - *

Static polymorphism (C++-template-style). The per-use policy is a {@link Helper} — a - * stateless subclass held by each caller as a {@code static final} field declared with the - * concrete helper type (not the {@code Helper} base): + *

Two strategies, split by concern. The per-use policy is two {@link Strategy strategy} + * objects rather than one: + * + *

* *
{@code
- * private static final MyHelper HELPER = new MyHelper();  // concrete type => exact type pinned
+ * private static final MyKeyStrategy KEYS = new MyKeyStrategy();   // concrete type => exact type pinned
  * ...
- * V v = FlatHashtable.getOrCreate(table, key, HELPER);
+ * E e = FlatHashtable.getOrCreate(table, key, KEYS, MyEntry::new); // non-capturing create
  * }
* - * Because {@code HELPER} is a compile-time-constant of an exact type at the call site, once these - * small {@code Support} methods inline the JIT devirtualizes and inlines {@code hash}/{@code - * matches}/{@code create} — each call site specializes to straight-line code, one instantiation per - * helper, with no CHA/type-profiling dependence. Keep the methods small so they inline; verify with - * {@code -XX:+PrintInlining} (the failure mode is silent: it compiles and runs, just stays - * megamorphic and slow). {@code Helper} is an abstract class, so a distinct final subclass is required - * anyway — an exact type gives the inliner an unambiguous receiver. - * *

Contract: {@code table.length} must be a power of two ({@link #capacityFor}). {@code - * helper.hash} should be well-distributed (this class masks it directly). Cardinality cap / overflow - * / a live-size counter are caller policy (this class is pure mechanism): a capped caller does - * {@link #get} first, and only on a miss checks its budget before {@link #getOrCreate} (so hits stay - * a single probe and the create path is warmup-rare). + * KeyStrategy.hash} should be well-distributed (this class masks it directly). Cardinality cap / + * overflow / a live-size counter are caller policy (this class is pure mechanism): a capped + * caller does {@link #get} first, and only on a miss checks its budget before {@link #getOrCreate} + * (so hits stay a single probe and the create path is warmup-rare). */ public final class FlatHashtable { private FlatHashtable() {} /** - * Per-use policy. Extend as a stateless final class and hold a {@code static final} singleton - * of the concrete type (see class doc) so the JIT can specialize each call site. + * Optional structure-free entry base carrying only a cached {@code hash} — an + * optimization, not plumbing (open addressing needs no {@code next}), so extending it is + * never required: bring any entry type and supply {@link KeyStrategy#hashOf} yourself instead. + * Caller contract: {@code hash} must equal the table's {@link KeyStrategy#hash} for this entry's + * key, so the entry lands where {@link #get} looks. + */ + public abstract static class Entry { + public final long hash; + + protected Entry(long hash) { + this.hash = hash; + } + } + + /** + * Key-identity strategy: how to {@link #hash} a lookup key and {@link #matches} it against a + * stored entry. Extend as a stateless final class and hold a {@code static final} + * singleton of the concrete type so the JIT can specialize each call site (see {@link Strategy}). * - *

An abstract class (not an interface) on purpose: it forces a named helper type (no - * lambdas, which can blur the receiver the inliner needs), and if specialization ever misses, the + *

An abstract class (not an interface) on purpose: it forces a named strategy type (no + * lambdas, which can blur the receiver the inliner needs), and if specialization ever misses the * fallback dispatches via {@code invokevirtual} rather than the costlier megamorphic {@code - * invokeinterface}. On the specialized (inlined) path the choice is a wash — this just hedges the - * fallback and lets shared bits be {@code final}-sealed later. + * invokeinterface}. Key-identity is the hot strategy (every probe), so it takes the + * abstract-class rigor; creation is the cold one, hence {@link CreateStrategy} is a lambda-able + * interface. * * @param lookup key - * @param stored entry — self-contained (carries its own key, ideally a cached hash) + * @param stored entry — self-contained (carries its own key) */ - public abstract static class Helper { - /** Hash of {@code key}; should be well-distributed (this table masks it directly). */ - public abstract int hash(K key); + @Strategy + public abstract static class KeyStrategy { + /** + * Hash of {@code key} ({@code long} for family-wide consistency with Hashtable / + * ConcurrentHashtable and to leave room for composite keys); should be well-distributed (this + * table masks it directly). + */ + public abstract long hash(K key); - /** Whether the stored {@code value} entry is the one for {@code key}. */ - public abstract boolean matches(K key, V value); + /** Whether the stored {@code entry} is the one for {@code key}. */ + public abstract boolean matches(K key, E entry); - /** Mint a new entry for {@code key} (called once, on insert). */ - public abstract V create(K key); + /** + * Hash of a stored {@code entry} — must equal {@link #hash}{@code (key)} for that entry's key, + * so the entry lands where {@link #get} would look for it. Used by the entry-taking {@link + * #insert(Object[], Object, KeyStrategy)} and {@link #iterator} (which have an entry, not a + * key); {@link #get} lookups never call it. When the entry can surface its key this is + * typically {@code hash(entry.key)}; when it caches its own hash (see {@link Entry}), {@link + * EntryKeyStrategy} seals it to that field. + */ + public abstract long hashOf(E entry); } /** - * {@link Helper} specialized for {@code String} keys: seals a spread {@link #hash} so String-key - * callers write only {@link #matches} and {@link #create}. Extend as a stateless final class held in - * a concrete-typed {@code static final} singleton, exactly like {@link Helper} — the {@code final} - * hash resolves directly and the concrete subclass still specializes the same at each call site, so - * there's no cost to the extra layer. + * {@link KeyStrategy} specialized for {@code String} keys: seals a spread {@link #hash} so + * String-key callers write only {@link #matches}. Extend as a stateless final class held in a + * concrete-typed {@code static final} singleton, exactly like {@link KeyStrategy} — the {@code + * final} hash resolves directly and the concrete subclass still specializes the same at each call + * site, so there's no cost to the extra layer. * - * @param stored entry — self-contained (carries its own key, ideally a cached hash) + * @param stored entry — self-contained (carries its own key) */ - public abstract static class StringHelper extends Helper { + public abstract static class StringKeyStrategy extends KeyStrategy { @Override - public final int hash(String key) { + public final long hash(String key) { final int h = key.hashCode(); - return h ^ (h >>> 16); // spread; FlatHashtable masks this directly + return h ^ (h >>> 16); // spread the int entropy; widened to the family-wide long hash width } } + /** + * {@link KeyStrategy} for entries that extend {@link Entry}: seals {@link #hashOf} to the entry's + * cached {@code hash}. Callers still supply {@link #hash} and {@link #matches} (or start from + * {@link StringKeyStrategy} for the {@code hash} seal too). + * + * @param lookup key + * @param stored entry — must extend {@link Entry} + */ + public abstract static class EntryKeyStrategy extends KeyStrategy { + @Override + public final long hashOf(E entry) { + return entry.hash; + } + } + + /** + * Creation strategy: mint a new entry for {@code key} (called once, on insert). A {@link + * FunctionalInterface} — supply a {@code static final} constant or a non-capturing lambda + * (e.g. {@code MyEntry::new}) so it stays a single monomorphic, allocation-free instance; a + * capturing lambda silently re-allocates per call and can de-monomorphize the site (see {@link + * Strategy}). Bespoke rather than {@link java.util.function.Function} so it carries the {@link + * Strategy} contract and reads as {@code create} at the call site. + * + * @param lookup key + * @param stored entry to create + */ + @Strategy + @FunctionalInterface + public interface CreateStrategy { + E create(K key); + } + /** Power-of-two capacity for a cardinality budget: {@code >= 2 * limit} (load factor <= 0.5). */ public static int capacityFor(int cardinalityLimit) { if (cardinalityLimit <= 0) { @@ -88,32 +157,33 @@ public static int capacityFor(int cardinalityLimit) { } /** - * Allocates a correctly-typed table for a cardinality budget ({@link #capacityFor} slots). Passing - * {@code type} makes the array's runtime component type {@code T} rather than {@code Object[]} — - * typed reads, real array-store checks, and a monomorphic element type for the JIT. Callers can't - * {@code new T[]} themselves under erasure; this does the one reflective allocation at construction - * (off any hot path). Note: this {@code create} mints the backing array; {@link Helper#create} - * mints an entry — different types, no ambiguity at the call site. + * Allocates a correctly-typed table for a cardinality budget ({@link #capacityFor} slots). + * Passing {@code type} makes the array's runtime component type {@code E} rather than {@code + * Object[]} — typed reads, real array-store checks, and a monomorphic element type for the JIT. + * Callers can't {@code new E[]} themselves under erasure; this does the one reflective allocation + * at construction (off any hot path). Note: this {@code create} mints the backing array; {@link + * CreateStrategy#create} mints an entry — different types, no ambiguity at the call site. */ @SuppressWarnings("unchecked") - public static T[] create(Class type, int cardinalityLimit) { - return (T[]) Array.newInstance(type, capacityFor(cardinalityLimit)); + public static E[] create(Class type, int cardinalityLimit) { + return (E[]) Array.newInstance(type, capacityFor(cardinalityLimit)); } /** * Existing entry for {@code key}, or {@code null}. Read-only — never creates. Single probe on a * hit; walks to the first empty slot (or all the way around) on a miss. */ - public static V get(V[] table, K key, Helper helper) { + @StrategyConsumer + public static E get(E[] table, K key, KeyStrategy keyStrat) { final int mask = table.length - 1; - final int start = helper.hash(key) & mask; + final int start = (int) (keyStrat.hash(key) & mask); int i = start; for (; ; ) { - final V e = table[i]; + final E e = table[i]; if (e == null) { return null; // empty slot terminates the probe (no tombstones) } - if (helper.matches(key, e)) { + if (keyStrat.matches(key, e)) { return e; } i = (i + 1) & mask; @@ -124,23 +194,25 @@ public static V get(V[] table, K key, Helper helper) { } /** - * Existing entry for {@code key}, or a freshly {@link Helper#create created} + inserted one. - * Returns {@code null} only if the table is full (no empty slot) — the caller supplies its overflow - * default. The insert is a single plain reference store: a concurrent clobber / double-create is - * acceptable only when the payload makes it benign (see class doc). + * Existing entry for {@code key}, or a freshly {@link CreateStrategy#create created} + inserted + * one. Returns {@code null} only if the table is full (no empty slot) — the caller supplies its + * overflow default. The insert is a single plain reference store: a concurrent clobber / + * double-create is acceptable only when the payload makes it benign (see class doc). */ - public static V getOrCreate(V[] table, K key, Helper helper) { + @StrategyConsumer + public static E getOrCreate( + E[] table, K key, KeyStrategy keyStrat, CreateStrategy createStrat) { final int mask = table.length - 1; - final int start = helper.hash(key) & mask; + final int start = (int) (keyStrat.hash(key) & mask); int i = start; for (; ; ) { - final V e = table[i]; + final E e = table[i]; if (e == null) { - final V created = helper.create(key); + final E created = createStrat.create(key); table[i] = created; // single-reference publish; benign clobber (see class doc) return created; } - if (helper.matches(key, e)) { + if (keyStrat.matches(key, e)) { return e; } i = (i + 1) & mask; @@ -149,4 +221,146 @@ public static V getOrCreate(V[] table, K key, Helper helper) { } } } + + /** + * Unconditionally adds {@code entry} at the first empty slot from its {@link Entry#hash home}; + * {@code false} if the table is full. Convenience over the {@link KeyStrategy}-taking overload + * for {@link Entry}-based entries (the home comes from the entry, so no strategy is needed). + * + *

Comparison-free and caller-responsible. It does not check for an existing key, so the + * caller must ensure {@code entry}'s key is absent. A duplicate lands shadowed further + * along the probe run — unreachable by {@link #get}, wasting a slot, and (if the key is later + * removed) able to resurrect stale data. Reach for it only from the expert tier, with that + * contract in hand. + */ + public static boolean insert(E[] table, E entry) { + return placeAt(table, entry, entry.hash); + } + + /** + * {@link #insert(Entry[], Entry)} for any entry type: the home comes from {@link + * KeyStrategy#hashOf}. Same comparison-free, caller-ensures-absence contract (the key type is + * irrelevant here — insert never hashes or matches a key). + */ + @StrategyConsumer + public static boolean insert(E[] table, E entry, KeyStrategy keyStrat) { + return placeAt(table, entry, keyStrat.hashOf(entry)); + } + + /** + * Shared placement core: probe from {@code hash}'s home to the first empty slot; false if full. + */ + private static boolean placeAt(E[] table, E entry, long hash) { + final int mask = table.length - 1; + final int start = (int) (hash & mask); + int i = start; + for (; ; ) { + if (table[i] == null) { + table[i] = entry; // single-reference publish (see class doc) + return true; + } + i = (i + 1) & mask; + if (i == start) { + return false; // wrapped ⇒ full + } + } + } + + /** Applies {@code consumer} to every entry in {@code table} (skipping empty slots); any order. */ + public static void forEach(E[] table, Consumer consumer) { + for (final E e : table) { + if (e != null) { + consumer.accept(e); + } + } + } + + /** + * Context-passing {@link #forEach(Object[], Consumer)}: pair a non-capturing {@link BiConsumer} + * (typically a {@code static final}) with side-band {@code context} to avoid a per-call closure. + */ + public static void forEach( + E[] table, C context, BiConsumer consumer) { + for (final E e : table) { + if (e != null) { + consumer.accept(context, e); + } + } + } + + /** + * Read-only iterator over the entries sharing {@code hash} — walks the probe run from {@code + * hash}'s home and yields each entry whose {@link KeyStrategy#hashOf} equals {@code hash}, + * stopping at the first empty slot (the FlatHashtable analogue of walking a chained bucket). The + * key type is irrelevant, so any {@link KeyStrategy} for {@code E} works. + * + *

Deliberately not a {@link StrategyConsumer}: iteration goes through the {@link + * Iterator} interface and calls {@code hashOf} virtually, so the strategy does not inline here — + * this is a cold traversal, not a hot specialization site. (Still pass a {@code static final} + * strategy to avoid a per-call allocation.) + */ + public static Iterator iterator(E[] table, long hash, KeyStrategy keyStrat) { + return new HashIterator<>(table, hash, keyStrat); + } + + private static final class HashIterator implements Iterator { + private final E[] table; + private final long hash; + private final KeyStrategy keyStrat; + private final int start; + private int i; + private boolean done; + private E lookahead; + + HashIterator(E[] table, long hash, KeyStrategy keyStrat) { + this.table = table; + this.hash = hash; + this.keyStrat = keyStrat; + this.start = (int) (hash & (table.length - 1)); + this.i = this.start; + advance(); + } + + private void advance() { + lookahead = null; + if (done) { + return; + } + final int mask = table.length - 1; + for (; ; ) { + final E e = table[i]; + if (e == null) { + done = true; // probe run ends at the first empty slot + return; + } + final boolean match = keyStrat.hashOf(e) == hash; + i = (i + 1) & mask; + final boolean wrapped = (i == start); + if (match) { + lookahead = e; + done = wrapped; + return; + } + if (wrapped) { + done = true; // walked the whole table without an empty slot + return; + } + } + } + + @Override + public boolean hasNext() { + return lookahead != null; + } + + @Override + public E next() { + final E e = lookahead; + if (e == null) { + throw new NoSuchElementException(); + } + advance(); + return e; + } + } } diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java index 9bebfa9c45a..551a83b874b 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java @@ -1,79 +1,123 @@ 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.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Set; import org.junit.jupiter.api.Test; class FlatHashtableTest { /** Self-contained entry: carries its own key (the identity FlatHashtable relies on). */ - static final class Entry { + static final class TestEntry { final String key; - Entry(String key) { + TestEntry(String key) { this.key = key; } } - /** Stateless concrete helper, held as a concrete-typed static final singleton (the JIT idiom). */ - static final class EntryHelper extends FlatHashtable.StringHelper { + /** + * Stateless concrete key strategy over String keys, held as a concrete-typed static final + * singleton (the JIT idiom). {@code hash} is sealed by {@link FlatHashtable.StringKeyStrategy}; + * {@code hashOf} recomputes from the entry's key (this entry doesn't cache its hash). + */ + static final class TestEntryKeyStrategy extends FlatHashtable.StringKeyStrategy { @Override - public boolean matches(String key, Entry value) { - return key.equals(value.key); + public boolean matches(String key, TestEntry entry) { + return key.equals(entry.key); } @Override - public Entry create(String key) { - return new Entry(key); + public long hashOf(TestEntry entry) { + return hash(entry.key); } } - private static final EntryHelper HELPER = new EntryHelper(); + private static final TestEntryKeyStrategy ENTRY_KEY_STRATEGY = new TestEntryKeyStrategy(); + + /** Non-capturing create strategy (a constructor method ref => singleton-cached, alloc-free). */ + private static final FlatHashtable.CreateStrategy CREATE = TestEntry::new; /** All keys hash to slot 0, so inserts chain by linear probing — exercises the probe path. */ - static final class CollidingHelper extends FlatHashtable.Helper { + static final class TestCollidingKeyStrategy extends FlatHashtable.KeyStrategy { @Override - public int hash(String key) { + public long hash(String key) { return 0; } @Override - public boolean matches(String key, Entry value) { - return key.equals(value.key); + public boolean matches(String key, TestEntry entry) { + return key.equals(entry.key); } @Override - public Entry create(String key) { - return new Entry(key); + public long hashOf(TestEntry entry) { + return hash(entry.key); } } - private static final CollidingHelper COLLIDING = new CollidingHelper(); + private static final TestCollidingKeyStrategy COLLIDING_KEY_STRATEGY = + new TestCollidingKeyStrategy(); /** All keys hash to the last slot ({@code -1 & mask}), so probing wraps around to index 0. */ - static final class LastSlotHelper extends FlatHashtable.Helper { + static final class TestLastSlotKeyStrategy extends FlatHashtable.KeyStrategy { @Override - public int hash(String key) { + public long hash(String key) { return -1; } @Override - public boolean matches(String key, Entry value) { - return key.equals(value.key); + public boolean matches(String key, TestEntry entry) { + return key.equals(entry.key); + } + + @Override + public long hashOf(TestEntry entry) { + return hash(entry.key); + } + } + + private static final TestLastSlotKeyStrategy LAST_SLOT_KEY_STRATEGY = + new TestLastSlotKeyStrategy(); + + /** + * Entry that caches its own hash (extends the Entry base) — for the entry-taking insert flavor. + */ + static final class TestHashedEntry extends FlatHashtable.Entry { + final String key; + + TestHashedEntry(String key) { + super( + ENTRY_KEY_STRATEGY.hash(key)); // cache the same hash ENTRY_KEY_STRATEGY uses for lookups + this.key = key; + } + } + + /** Key strategy for {@link TestHashedEntry}: {@code hashOf} is sealed to the cached hash. */ + static final class TestHashedKeyStrategy + extends FlatHashtable.EntryKeyStrategy { + @Override + public long hash(String key) { + return ENTRY_KEY_STRATEGY.hash(key); } @Override - public Entry create(String key) { - return new Entry(key); + public boolean matches(String key, TestHashedEntry entry) { + return key.equals(entry.key); } } - private static final LastSlotHelper LAST_SLOT = new LastSlotHelper(); + private static final TestHashedKeyStrategy HASHED_KEY_STRATEGY = new TestHashedKeyStrategy(); @Test void capacityFor_roundsToPowerOfTwoAtLeastTwiceLimit() { @@ -90,89 +134,179 @@ void capacityFor_rejectsNonPositive() { @Test void create_allocatesTypedTableOfCapacity() { - Entry[] table = FlatHashtable.create(Entry.class, 4); + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); assertEquals(8, table.length); - assertEquals(Entry.class, table.getClass().getComponentType()); + assertEquals(TestEntry.class, table.getClass().getComponentType()); } @Test void getOrCreate_insertsOnceAndReturnsTheExistingEntry() { - Entry[] table = FlatHashtable.create(Entry.class, 8); - Entry first = FlatHashtable.getOrCreate(table, "a", HELPER); + TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + TestEntry first = FlatHashtable.getOrCreate(table, "a", ENTRY_KEY_STRATEGY, CREATE); assertEquals("a", first.key); // A second call must return the SAME instance, not mint a new one. - assertSame(first, FlatHashtable.getOrCreate(table, "a", HELPER)); - assertSame(first, FlatHashtable.get(table, "a", HELPER)); + assertSame(first, FlatHashtable.getOrCreate(table, "a", ENTRY_KEY_STRATEGY, CREATE)); + assertSame(first, FlatHashtable.get(table, "a", ENTRY_KEY_STRATEGY)); } @Test void get_returnsNullForAbsentKey() { - Entry[] table = FlatHashtable.create(Entry.class, 8); - assertNull(FlatHashtable.get(table, "missing", HELPER)); - FlatHashtable.getOrCreate(table, "present", HELPER); - assertNull(FlatHashtable.get(table, "still-missing", HELPER)); + TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + assertNull(FlatHashtable.get(table, "missing", ENTRY_KEY_STRATEGY)); + FlatHashtable.getOrCreate(table, "present", ENTRY_KEY_STRATEGY, CREATE); + assertNull(FlatHashtable.get(table, "still-missing", ENTRY_KEY_STRATEGY)); } @Test void getOrCreate_returnsNullWhenTableIsFull() { // capacityFor(1) == 2 slots. - Entry[] table = FlatHashtable.create(Entry.class, 1); - assertTrue(FlatHashtable.getOrCreate(table, "k0", HELPER) != null); - assertTrue(FlatHashtable.getOrCreate(table, "k1", HELPER) != null); + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); + assertTrue(FlatHashtable.getOrCreate(table, "k0", ENTRY_KEY_STRATEGY, CREATE) != null); + assertTrue(FlatHashtable.getOrCreate(table, "k1", ENTRY_KEY_STRATEGY, CREATE) != null); // Both slots occupied by distinct keys -> a third distinct key finds no room. - assertNull(FlatHashtable.getOrCreate(table, "k2", HELPER)); + assertNull(FlatHashtable.getOrCreate(table, "k2", ENTRY_KEY_STRATEGY, CREATE)); // ...but an existing key still resolves even when full. assertSame( - FlatHashtable.get(table, "k0", HELPER), FlatHashtable.getOrCreate(table, "k0", HELPER)); + FlatHashtable.get(table, "k0", ENTRY_KEY_STRATEGY), + FlatHashtable.getOrCreate(table, "k0", ENTRY_KEY_STRATEGY, CREATE)); } @Test - void stringHelper_hashIsStableForEqualKeys() { - assertEquals(HELPER.hash("route"), HELPER.hash(new String("route"))); + void stringKeyStrategy_hashIsStableForEqualKeys() { + assertEquals(ENTRY_KEY_STRATEGY.hash("route"), ENTRY_KEY_STRATEGY.hash(new String("route"))); } @Test void collision_probesPastOccupiedSlots_andResolvesEach() { - Entry[] table = FlatHashtable.create(Entry.class, 4); // 8 slots; COLLIDING sends all to slot 0 - Entry a = FlatHashtable.getOrCreate(table, "a", COLLIDING); - Entry b = FlatHashtable.getOrCreate(table, "b", COLLIDING); // slot 0 taken -> probes to slot 1 - Entry c = FlatHashtable.getOrCreate(table, "c", COLLIDING); // -> slot 2 + // 8 slots; COLLIDING sends all to slot 0 + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + TestEntry a = FlatHashtable.getOrCreate(table, "a", COLLIDING_KEY_STRATEGY, CREATE); + TestEntry b = + FlatHashtable.getOrCreate(table, "b", COLLIDING_KEY_STRATEGY, CREATE); // slot 0 taken -> 1 + TestEntry c = + FlatHashtable.getOrCreate(table, "c", COLLIDING_KEY_STRATEGY, CREATE); // -> slot 2 assertNotSame(a, b); assertNotSame(b, c); // each resolves via probe-past-occupied + match-after-probe - assertSame(a, FlatHashtable.get(table, "a", COLLIDING)); - assertSame(b, FlatHashtable.get(table, "b", COLLIDING)); - assertSame(c, FlatHashtable.get(table, "c", COLLIDING)); + assertSame(a, FlatHashtable.get(table, "a", COLLIDING_KEY_STRATEGY)); + assertSame(b, FlatHashtable.get(table, "b", COLLIDING_KEY_STRATEGY)); + assertSame(c, FlatHashtable.get(table, "c", COLLIDING_KEY_STRATEGY)); // existing colliding key: found after probing, no new entry minted - assertSame(b, FlatHashtable.getOrCreate(table, "b", COLLIDING)); + assertSame(b, FlatHashtable.getOrCreate(table, "b", COLLIDING_KEY_STRATEGY, CREATE)); // absent key: probe past the 3 occupied slots, hit an empty slot -> null - assertNull(FlatHashtable.get(table, "absent", COLLIDING)); + assertNull(FlatHashtable.get(table, "absent", COLLIDING_KEY_STRATEGY)); } @Test void collision_probeWrapsAroundToFront() { - Entry[] table = - FlatHashtable.create(Entry.class, 1); // 2 slots (0,1), mask=1; LAST_SLOT starts at 1 - Entry k0 = FlatHashtable.getOrCreate(table, "k0", LAST_SLOT); // -> slot 1 - Entry k1 = FlatHashtable.getOrCreate(table, "k1", LAST_SLOT); // slot 1 taken -> wraps to slot 0 + // 2 slots (0,1), mask=1; LAST_SLOT starts at 1 + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); + TestEntry k0 = + FlatHashtable.getOrCreate(table, "k0", LAST_SLOT_KEY_STRATEGY, CREATE); // -> slot 1 + TestEntry k1 = + FlatHashtable.getOrCreate( + table, "k1", LAST_SLOT_KEY_STRATEGY, CREATE); // taken -> wraps to 0 assertNotSame(k0, k1); - assertSame(k0, FlatHashtable.get(table, "k0", LAST_SLOT)); + assertSame(k0, FlatHashtable.get(table, "k0", LAST_SLOT_KEY_STRATEGY)); // start slot 1 is occupied (no match) -> probe wraps to slot 0 -> match - assertSame(k1, FlatHashtable.get(table, "k1", LAST_SLOT)); + assertSame(k1, FlatHashtable.get(table, "k1", LAST_SLOT_KEY_STRATEGY)); } @Test void get_returnsNullWhenTableFullAndKeyAbsent() { - Entry[] table = FlatHashtable.create(Entry.class, 1); // 2 slots - FlatHashtable.getOrCreate(table, "k0", COLLIDING); - FlatHashtable.getOrCreate(table, "k1", COLLIDING); // fills slots 0 and 1 + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots + FlatHashtable.getOrCreate(table, "k0", COLLIDING_KEY_STRATEGY, CREATE); + FlatHashtable.getOrCreate(table, "k1", COLLIDING_KEY_STRATEGY, CREATE); // fills slots 0 and 1 // get() probes both occupied slots, wraps back to start -> null (get's full-wrap branch) - assertNull(FlatHashtable.get(table, "absent", COLLIDING)); + assertNull(FlatHashtable.get(table, "absent", COLLIDING_KEY_STRATEGY)); + } + + @Test + void insert_generalFlavor_placesViaHashOfAndResolves() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + TestEntry e = new TestEntry("a"); + // flavor 2: the home comes from ENTRY_KEY_STRATEGY.hashOf(e) + assertTrue(FlatHashtable.insert(table, e, ENTRY_KEY_STRATEGY)); + assertSame(e, FlatHashtable.get(table, "a", ENTRY_KEY_STRATEGY)); + } + + @Test + void insert_entryFlavor_placesViaCachedHashAndResolves() { + TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 8); + TestHashedEntry e = new TestHashedEntry("a"); + // flavor 1: the home comes from the Entry's own cached hash, no strategy needed + assertTrue(FlatHashtable.insert(table, e)); + assertSame(e, FlatHashtable.get(table, "a", HASHED_KEY_STRATEGY)); + } + + @Test + void insert_returnsFalseWhenFull() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots + assertTrue(FlatHashtable.insert(table, new TestEntry("k0"), ENTRY_KEY_STRATEGY)); + assertTrue(FlatHashtable.insert(table, new TestEntry("k1"), ENTRY_KEY_STRATEGY)); + assertFalse(FlatHashtable.insert(table, new TestEntry("k2"), ENTRY_KEY_STRATEGY)); // no room + } + + @Test + void forEach_visitsEveryEntry() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + FlatHashtable.getOrCreate(table, "a", ENTRY_KEY_STRATEGY, CREATE); + FlatHashtable.getOrCreate(table, "b", ENTRY_KEY_STRATEGY, CREATE); + FlatHashtable.getOrCreate(table, "c", ENTRY_KEY_STRATEGY, CREATE); + + Set seen = new HashSet<>(); + FlatHashtable.forEach(table, e -> seen.add(e.key)); + assertEquals(new HashSet<>(Arrays.asList("a", "b", "c")), seen); + } + + @Test + void forEach_contextVariant_passesContextWithoutCapture() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + FlatHashtable.getOrCreate(table, "a", ENTRY_KEY_STRATEGY, CREATE); + FlatHashtable.getOrCreate(table, "b", ENTRY_KEY_STRATEGY, CREATE); + + Set seen = new HashSet<>(); + FlatHashtable.forEach(table, seen, (ctx, e) -> ctx.add(e.key)); + assertEquals(new HashSet<>(Arrays.asList("a", "b")), seen); + } + + @Test + void iterator_yieldsEveryEntrySharingTheHash() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // COLLIDING sends all to slot 0 + TestEntry a = FlatHashtable.getOrCreate(table, "a", COLLIDING_KEY_STRATEGY, CREATE); + TestEntry b = FlatHashtable.getOrCreate(table, "b", COLLIDING_KEY_STRATEGY, CREATE); + TestEntry c = FlatHashtable.getOrCreate(table, "c", COLLIDING_KEY_STRATEGY, CREATE); + + Set seen = new HashSet<>(); + Iterator it = FlatHashtable.iterator(table, 0, COLLIDING_KEY_STRATEGY); + while (it.hasNext()) { + seen.add(it.next()); + } + assertEquals(new HashSet<>(Arrays.asList(a, b, c)), seen); + } + + @Test + void iterator_filtersOutEntriesWithADifferentHash() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // entries at slot 0, hashOf == 0 + FlatHashtable.getOrCreate(table, "a", COLLIDING_KEY_STRATEGY, CREATE); + FlatHashtable.getOrCreate(table, "b", COLLIDING_KEY_STRATEGY, CREATE); + + // hash 8 shares the home slot (8 & 7 == 0) but no stored entry has hashOf == 8 + Iterator it = FlatHashtable.iterator(table, 8, COLLIDING_KEY_STRATEGY); + assertFalse(it.hasNext()); + } + + @Test + void iterator_emptyRunHasNoNext() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + Iterator it = FlatHashtable.iterator(table, 0, COLLIDING_KEY_STRATEGY); + assertFalse(it.hasNext()); + assertThrows(NoSuchElementException.class, it::next); } } From fb84b625622c67ef81e0e3bf95825e638f72b6d8 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 17 Jul 2026 13:03:53 -0400 Subject: [PATCH 4/9] Benchmark FlatHashtable in the single-threaded map comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds FlatHashtable to SingleThreadedMapBenchmark on the ops it supports (build via comparison-free insert, get, iterate) — its self-contained entry holds the value unboxed vs HashMap. Fixed-capacity so the table is sized to the key count. Uses the INSTANCE-singleton strategy style (private ctor, lazy class-init). Co-Authored-By: Claude Opus 4.8 --- .../util/SingleThreadedMapBenchmark.java | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java index 11572aa923d..51d3e4c8b12 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java @@ -36,6 +36,10 @@ *

  • TreeMap — when a custom Comparator is needed (see CaseInsensitiveMapBenchmark) *
  • LinkedHashMap — only when insertion-order iteration is required; cost is paid at * construction and in per-entry memory + *
  • FlatHashtable — a find-or-create table over self-contained entries (not a general Map: no + * arbitrary put/remove). Compared here on the ops it does support — build, get, iterate — + * where its self-contained entry stores the value unboxed (one object per key, no + * {@code Integer}). Fixed-capacity, so it must be sized to the working set up front. * * *

    Uncontended synchronization tax. A {@link Collections#synchronizedMap} case is included @@ -81,12 +85,54 @@ static TagMap fillTagMap(TagMap map) { return map; } + // FlatHashtable is a find-or-create table over self-contained entries — no arbitrary put/remove, + // so only the comparable ops appear here: build (via the comparison-free insert of distinct + // keys), + // get, and iterate. Its entry carries the value UNBOXED (no Integer), one object per key. + static final class IntEntry { + final String key; + final int value; + + IntEntry(String key, int value) { + this.key = key; + this.value = value; + } + } + + static final class IntEntryKeyStrategy extends FlatHashtable.StringKeyStrategy { + // Canonical exact-typed singleton: one instance, private ctor => the static-poly discipline is + // enforced by the class, not left to each caller to declare correctly. + static final IntEntryKeyStrategy INSTANCE = new IntEntryKeyStrategy(); + + private IntEntryKeyStrategy() {} + + @Override + public boolean matches(String key, IntEntry entry) { + return key.equals(entry.key); + } + + @Override + public long hashOf(IntEntry entry) { + return hash(entry.key); + } + } + + static IntEntry[] newFilledFlat() { + // Sized to the key count (FlatHashtable is fixed-capacity, no resize): load factor <= 0.5. + IntEntry[] table = FlatHashtable.create(IntEntry.class, INSERTION_KEYS.length); + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + FlatHashtable.insert(table, new IntEntry(INSERTION_KEYS[i], i), IntEntryKeyStrategy.INSTANCE); + } + return table; + } + // Per-thread prebuilt maps for the read + clone benchmarks (built once per trial, per thread). HashMap hashMap; Map synchronizedHashMap; TreeMap treeMap; LinkedHashMap linkedHashMap; TagMap tagMap; + IntEntry[] flatTable; int index = 0; @Setup(Level.Trial) @@ -99,6 +145,7 @@ public void setUp() { linkedHashMap = new LinkedHashMap<>(); fill(linkedHashMap); tagMap = fillTagMap(TagMap.create()); + flatTable = newFilledFlat(); } String nextLookupKey() { @@ -159,6 +206,11 @@ public TagMap create_tagMap_via_ledger() { return ledger.build(); } + @Benchmark + public IntEntry[] create_flatHashtable() { + return newFilledFlat(); + } + // ---- copy ---- @Benchmark @@ -200,6 +252,11 @@ public Integer get_synchronizedHashMap() { return synchronizedHashMap.get(nextLookupKey()); } + @Benchmark + public IntEntry get_flatHashtable() { + return FlatHashtable.get(flatTable, nextLookupKey(), IntEntryKeyStrategy.INSTANCE); + } + @Benchmark public void iterate_hashMap(Blackhole blackhole) { for (Map.Entry entry : hashMap.entrySet()) { @@ -219,4 +276,16 @@ public void iterate_synchronizedHashMap(Blackhole blackhole) { } } } + + @Benchmark + public void iterate_flatHashtable(Blackhole blackhole) { + // Context-passing forEach: blackhole rides through as context, so the lambda doesn't capture. + FlatHashtable.forEach( + flatTable, + blackhole, + (bh, e) -> { + bh.consume(e.key); + bh.consume(e.value); + }); + } } From f92aab4abd658ef62296efbe264fa1122d73100f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 17 Jul 2026 13:52:57 -0400 Subject: [PATCH 5/9] Benchmark FlatHashtable's lock-free read in the thread-safe map comparison Adds FlatHashtable to ThreadSafeMapBenchmark: build + concurrent get on a shared, once-published table (lock-free, no volatile) alongside ConcurrentHashMap / volatile-HashMap / synchronizedHashMap. Fixture mirrors SingleThreadedMapBenchmark (self-contained per benchmark). Co-Authored-By: Claude Opus 4.8 --- .../trace/util/ThreadSafeMapBenchmark.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java index 793627a37e6..51d50bb6b0c 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java @@ -21,6 +21,8 @@ *

  • ConcurrentMap - only when there are simultaneously readers & writers in multiple threads *
  • HashMap via volatile - preferred for background thread updates *
  • synchronized HashMap - when simultaneous readers & writers are uncommon (e.g. tags) + *
  • FlatHashtable - lock-free reads (no lock, no volatile; benign-race) of a fixed, once-built + * keyed set; a find-or-create table, not a general concurrent Map (no arbitrary put/remove) * * *

    @@ -104,6 +106,46 @@ static void fill(Map map) { } } + // FlatHashtable's contribution here is the lock-free concurrent read: get() is a plain array + // probe + // with no lock and no volatile — safe under concurrency because the table is published once (a + // final static field) and each entry's identity fields are final. (Fixture mirrors the one in + // SingleThreadedMapBenchmark; the benchmarks are self-contained.) + static final class IntEntry { + final String key; + final int value; + + IntEntry(String key, int value) { + this.key = key; + this.value = value; + } + } + + static final class IntEntryKeyStrategy extends FlatHashtable.StringKeyStrategy { + static final IntEntryKeyStrategy INSTANCE = new IntEntryKeyStrategy(); + + private IntEntryKeyStrategy() {} + + @Override + public boolean matches(String key, IntEntry entry) { + return key.equals(entry.key); + } + + @Override + public long hashOf(IntEntry entry) { + return hash(entry.key); + } + } + + static IntEntry[] _create_flat() { + // Sized to the key count (FlatHashtable is fixed-capacity, no resize): load factor <= 0.5. + IntEntry[] table = FlatHashtable.create(IntEntry.class, INSERTION_KEYS.length); + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + FlatHashtable.insert(table, new IntEntry(INSERTION_KEYS[i], i), IntEntryKeyStrategy.INSTANCE); + } + return table; + } + static final HashMap _create_hashMap() { HashMap map = new HashMap<>(); fill(map); @@ -177,4 +219,17 @@ public ConcurrentSkipListMap create_concSkipListMap() { public Integer get_concSkipListMap() { return CONC_SKIP_LIST_MAP.get(nextLookupKey()); } + + @Benchmark + public IntEntry[] create_flatHashtable() { + return _create_flat(); + } + + static final IntEntry[] FLAT_TABLE = _create_flat(); + + @Benchmark + public IntEntry get_flatHashtable() { + // Lock-free concurrent read of the shared, once-published table. + return FlatHashtable.get(FLAT_TABLE, nextLookupKey(), IntEntryKeyStrategy.INSTANCE); + } } From 779de806fa67c099cac7fae216604e73cddb4a88 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 17 Jul 2026 14:07:50 -0400 Subject: [PATCH 6/9] Make ThreadSafeMapBenchmark lookup index per-thread (remove shared-counter contention) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nextLookupKey used a shared static counter incremented from all @Threads(8) — a cache-line-contended race that floors the fastest reads and masks the very differences the benchmark compares (mirrors the per-thread index SingleThreadedMapBenchmark already uses, and the set-benchmark fix in #11721). Maps stay static/shared; only the lookup index goes per-thread via @State(Scope.Thread). Existing Javadoc numbers predate this and need a rerun. Co-Authored-By: Claude Opus 4.8 --- .../trace/util/ThreadSafeMapBenchmark.java | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java index 51d50bb6b0c..9b66e727455 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java @@ -9,6 +9,8 @@ import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Fork; import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.Threads; import org.openjdk.jmh.annotations.Warmup; @@ -67,6 +69,7 @@ @Warmup(iterations = 2) @Measurement(iterations = 3) @Threads(8) +@State(Scope.Thread) public class ThreadSafeMapBenchmark { static final String[] INSERTION_KEYS = { "foo", "bar", "baz", "quux", "foobar", "foobaz", "key0", "key1", "key2", "key3" @@ -86,16 +89,20 @@ static T init(Supplier supplier) { return supplier.get(); } - static int sharedLookupIndex = 0; + // Per-thread (@State(Scope.Thread)) so cycling the lookup key doesn't contend a shared counter. + // The maps below stay static/shared (the point — concurrent reads of one map); only the index is + // per-thread. A shared counter's cache-line ping-pong would otherwise floor the fastest reads + // (e.g. FlatHashtable's lock-free probe), hiding exactly the differences this benchmark compares. + int lookupIndex = 0; - static String nextLookupKey() { + String nextLookupKey() { return nextLookupKey(EQUAL_KEYS); } - static String nextLookupKey(String[] keys) { - int localIndex = ++sharedLookupIndex; + String nextLookupKey(String[] keys) { + int localIndex = ++lookupIndex; if (localIndex >= keys.length) { - sharedLookupIndex = localIndex = 0; + lookupIndex = localIndex = 0; } return keys[localIndex]; } From e5674eb8020ed923d6718bc4f418d0ac29643684 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 17 Jul 2026 14:44:55 -0400 Subject: [PATCH 7/9] Adopt INSTANCE-singleton convention in FlatHashtable test strategies Each concrete key strategy carries a static final INSTANCE of its exact type + a private ctor (lazy class-init singleton); call sites use X.INSTANCE, dropping the separate module-level constants. Matches the benchmark fixture and the strategy-class style. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/FlatHashtableTest.java | 148 ++++++++++-------- 1 file changed, 80 insertions(+), 68 deletions(-) diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java index 551a83b874b..24a74084e1d 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java @@ -27,11 +27,16 @@ static final class TestEntry { } /** - * Stateless concrete key strategy over String keys, held as a concrete-typed static final - * singleton (the JIT idiom). {@code hash} is sealed by {@link FlatHashtable.StringKeyStrategy}; - * {@code hashOf} recomputes from the entry's key (this entry doesn't cache its hash). + * Stateless concrete key strategy over String keys, exposed as a canonical {@code INSTANCE} + * singleton (private ctor) so the JIT can specialize each call site. {@code hash} is sealed by + * {@link FlatHashtable.StringKeyStrategy}; {@code hashOf} recomputes from the entry's key (this + * entry doesn't cache its hash). */ static final class TestEntryKeyStrategy extends FlatHashtable.StringKeyStrategy { + static final TestEntryKeyStrategy INSTANCE = new TestEntryKeyStrategy(); + + private TestEntryKeyStrategy() {} + @Override public boolean matches(String key, TestEntry entry) { return key.equals(entry.key); @@ -43,13 +48,15 @@ public long hashOf(TestEntry entry) { } } - private static final TestEntryKeyStrategy ENTRY_KEY_STRATEGY = new TestEntryKeyStrategy(); - /** Non-capturing create strategy (a constructor method ref => singleton-cached, alloc-free). */ private static final FlatHashtable.CreateStrategy CREATE = TestEntry::new; /** All keys hash to slot 0, so inserts chain by linear probing — exercises the probe path. */ static final class TestCollidingKeyStrategy extends FlatHashtable.KeyStrategy { + static final TestCollidingKeyStrategy INSTANCE = new TestCollidingKeyStrategy(); + + private TestCollidingKeyStrategy() {} + @Override public long hash(String key) { return 0; @@ -66,11 +73,12 @@ public long hashOf(TestEntry entry) { } } - private static final TestCollidingKeyStrategy COLLIDING_KEY_STRATEGY = - new TestCollidingKeyStrategy(); - /** All keys hash to the last slot ({@code -1 & mask}), so probing wraps around to index 0. */ static final class TestLastSlotKeyStrategy extends FlatHashtable.KeyStrategy { + static final TestLastSlotKeyStrategy INSTANCE = new TestLastSlotKeyStrategy(); + + private TestLastSlotKeyStrategy() {} + @Override public long hash(String key) { return -1; @@ -87,9 +95,6 @@ public long hashOf(TestEntry entry) { } } - private static final TestLastSlotKeyStrategy LAST_SLOT_KEY_STRATEGY = - new TestLastSlotKeyStrategy(); - /** * Entry that caches its own hash (extends the Entry base) — for the entry-taking insert flavor. */ @@ -97,8 +102,8 @@ static final class TestHashedEntry extends FlatHashtable.Entry { final String key; TestHashedEntry(String key) { - super( - ENTRY_KEY_STRATEGY.hash(key)); // cache the same hash ENTRY_KEY_STRATEGY uses for lookups + // cache the same hash TestEntryKeyStrategy uses for lookups + super(TestEntryKeyStrategy.INSTANCE.hash(key)); this.key = key; } } @@ -106,9 +111,13 @@ static final class TestHashedEntry extends FlatHashtable.Entry { /** Key strategy for {@link TestHashedEntry}: {@code hashOf} is sealed to the cached hash. */ static final class TestHashedKeyStrategy extends FlatHashtable.EntryKeyStrategy { + static final TestHashedKeyStrategy INSTANCE = new TestHashedKeyStrategy(); + + private TestHashedKeyStrategy() {} + @Override public long hash(String key) { - return ENTRY_KEY_STRATEGY.hash(key); + return TestEntryKeyStrategy.INSTANCE.hash(key); } @Override @@ -117,8 +126,6 @@ public boolean matches(String key, TestHashedEntry entry) { } } - private static final TestHashedKeyStrategy HASHED_KEY_STRATEGY = new TestHashedKeyStrategy(); - @Test void capacityFor_roundsToPowerOfTwoAtLeastTwiceLimit() { assertEquals(2, FlatHashtable.capacityFor(1)); @@ -142,98 +149,102 @@ void create_allocatesTypedTableOfCapacity() { @Test void getOrCreate_insertsOnceAndReturnsTheExistingEntry() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - TestEntry first = FlatHashtable.getOrCreate(table, "a", ENTRY_KEY_STRATEGY, CREATE); + TestEntry first = FlatHashtable.getOrCreate(table, "a", TestEntryKeyStrategy.INSTANCE, CREATE); assertEquals("a", first.key); // A second call must return the SAME instance, not mint a new one. - assertSame(first, FlatHashtable.getOrCreate(table, "a", ENTRY_KEY_STRATEGY, CREATE)); - assertSame(first, FlatHashtable.get(table, "a", ENTRY_KEY_STRATEGY)); + assertSame(first, FlatHashtable.getOrCreate(table, "a", TestEntryKeyStrategy.INSTANCE, CREATE)); + assertSame(first, FlatHashtable.get(table, "a", TestEntryKeyStrategy.INSTANCE)); } @Test void get_returnsNullForAbsentKey() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - assertNull(FlatHashtable.get(table, "missing", ENTRY_KEY_STRATEGY)); - FlatHashtable.getOrCreate(table, "present", ENTRY_KEY_STRATEGY, CREATE); - assertNull(FlatHashtable.get(table, "still-missing", ENTRY_KEY_STRATEGY)); + assertNull(FlatHashtable.get(table, "missing", TestEntryKeyStrategy.INSTANCE)); + FlatHashtable.getOrCreate(table, "present", TestEntryKeyStrategy.INSTANCE, CREATE); + assertNull(FlatHashtable.get(table, "still-missing", TestEntryKeyStrategy.INSTANCE)); } @Test void getOrCreate_returnsNullWhenTableIsFull() { // capacityFor(1) == 2 slots. TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); - assertTrue(FlatHashtable.getOrCreate(table, "k0", ENTRY_KEY_STRATEGY, CREATE) != null); - assertTrue(FlatHashtable.getOrCreate(table, "k1", ENTRY_KEY_STRATEGY, CREATE) != null); + assertTrue( + FlatHashtable.getOrCreate(table, "k0", TestEntryKeyStrategy.INSTANCE, CREATE) != null); + assertTrue( + FlatHashtable.getOrCreate(table, "k1", TestEntryKeyStrategy.INSTANCE, CREATE) != null); // Both slots occupied by distinct keys -> a third distinct key finds no room. - assertNull(FlatHashtable.getOrCreate(table, "k2", ENTRY_KEY_STRATEGY, CREATE)); + assertNull(FlatHashtable.getOrCreate(table, "k2", TestEntryKeyStrategy.INSTANCE, CREATE)); // ...but an existing key still resolves even when full. assertSame( - FlatHashtable.get(table, "k0", ENTRY_KEY_STRATEGY), - FlatHashtable.getOrCreate(table, "k0", ENTRY_KEY_STRATEGY, CREATE)); + FlatHashtable.get(table, "k0", TestEntryKeyStrategy.INSTANCE), + FlatHashtable.getOrCreate(table, "k0", TestEntryKeyStrategy.INSTANCE, CREATE)); } @Test void stringKeyStrategy_hashIsStableForEqualKeys() { - assertEquals(ENTRY_KEY_STRATEGY.hash("route"), ENTRY_KEY_STRATEGY.hash(new String("route"))); + assertEquals( + TestEntryKeyStrategy.INSTANCE.hash("route"), + TestEntryKeyStrategy.INSTANCE.hash(new String("route"))); } @Test void collision_probesPastOccupiedSlots_andResolvesEach() { // 8 slots; COLLIDING sends all to slot 0 TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); - TestEntry a = FlatHashtable.getOrCreate(table, "a", COLLIDING_KEY_STRATEGY, CREATE); - TestEntry b = - FlatHashtable.getOrCreate(table, "b", COLLIDING_KEY_STRATEGY, CREATE); // slot 0 taken -> 1 - TestEntry c = - FlatHashtable.getOrCreate(table, "c", COLLIDING_KEY_STRATEGY, CREATE); // -> slot 2 + TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingKeyStrategy.INSTANCE, CREATE); + // slot 0 taken -> 1 + TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingKeyStrategy.INSTANCE, CREATE); + // -> slot 2 + TestEntry c = FlatHashtable.getOrCreate(table, "c", TestCollidingKeyStrategy.INSTANCE, CREATE); assertNotSame(a, b); assertNotSame(b, c); // each resolves via probe-past-occupied + match-after-probe - assertSame(a, FlatHashtable.get(table, "a", COLLIDING_KEY_STRATEGY)); - assertSame(b, FlatHashtable.get(table, "b", COLLIDING_KEY_STRATEGY)); - assertSame(c, FlatHashtable.get(table, "c", COLLIDING_KEY_STRATEGY)); + assertSame(a, FlatHashtable.get(table, "a", TestCollidingKeyStrategy.INSTANCE)); + assertSame(b, FlatHashtable.get(table, "b", TestCollidingKeyStrategy.INSTANCE)); + assertSame(c, FlatHashtable.get(table, "c", TestCollidingKeyStrategy.INSTANCE)); // existing colliding key: found after probing, no new entry minted - assertSame(b, FlatHashtable.getOrCreate(table, "b", COLLIDING_KEY_STRATEGY, CREATE)); + assertSame(b, FlatHashtable.getOrCreate(table, "b", TestCollidingKeyStrategy.INSTANCE, CREATE)); // absent key: probe past the 3 occupied slots, hit an empty slot -> null - assertNull(FlatHashtable.get(table, "absent", COLLIDING_KEY_STRATEGY)); + assertNull(FlatHashtable.get(table, "absent", TestCollidingKeyStrategy.INSTANCE)); } @Test void collision_probeWrapsAroundToFront() { // 2 slots (0,1), mask=1; LAST_SLOT starts at 1 TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); - TestEntry k0 = - FlatHashtable.getOrCreate(table, "k0", LAST_SLOT_KEY_STRATEGY, CREATE); // -> slot 1 - TestEntry k1 = - FlatHashtable.getOrCreate( - table, "k1", LAST_SLOT_KEY_STRATEGY, CREATE); // taken -> wraps to 0 + // -> slot 1 + TestEntry k0 = FlatHashtable.getOrCreate(table, "k0", TestLastSlotKeyStrategy.INSTANCE, CREATE); + // taken -> wraps to 0 + TestEntry k1 = FlatHashtable.getOrCreate(table, "k1", TestLastSlotKeyStrategy.INSTANCE, CREATE); assertNotSame(k0, k1); - assertSame(k0, FlatHashtable.get(table, "k0", LAST_SLOT_KEY_STRATEGY)); + assertSame(k0, FlatHashtable.get(table, "k0", TestLastSlotKeyStrategy.INSTANCE)); // start slot 1 is occupied (no match) -> probe wraps to slot 0 -> match - assertSame(k1, FlatHashtable.get(table, "k1", LAST_SLOT_KEY_STRATEGY)); + assertSame(k1, FlatHashtable.get(table, "k1", TestLastSlotKeyStrategy.INSTANCE)); } @Test void get_returnsNullWhenTableFullAndKeyAbsent() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots - FlatHashtable.getOrCreate(table, "k0", COLLIDING_KEY_STRATEGY, CREATE); - FlatHashtable.getOrCreate(table, "k1", COLLIDING_KEY_STRATEGY, CREATE); // fills slots 0 and 1 + FlatHashtable.getOrCreate(table, "k0", TestCollidingKeyStrategy.INSTANCE, CREATE); + // fills slots 0 and 1 + FlatHashtable.getOrCreate(table, "k1", TestCollidingKeyStrategy.INSTANCE, CREATE); // get() probes both occupied slots, wraps back to start -> null (get's full-wrap branch) - assertNull(FlatHashtable.get(table, "absent", COLLIDING_KEY_STRATEGY)); + assertNull(FlatHashtable.get(table, "absent", TestCollidingKeyStrategy.INSTANCE)); } @Test void insert_generalFlavor_placesViaHashOfAndResolves() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); TestEntry e = new TestEntry("a"); - // flavor 2: the home comes from ENTRY_KEY_STRATEGY.hashOf(e) - assertTrue(FlatHashtable.insert(table, e, ENTRY_KEY_STRATEGY)); - assertSame(e, FlatHashtable.get(table, "a", ENTRY_KEY_STRATEGY)); + // flavor 2: the home comes from TestEntryKeyStrategy.INSTANCE.hashOf(e) + assertTrue(FlatHashtable.insert(table, e, TestEntryKeyStrategy.INSTANCE)); + assertSame(e, FlatHashtable.get(table, "a", TestEntryKeyStrategy.INSTANCE)); } @Test @@ -242,23 +253,24 @@ void insert_entryFlavor_placesViaCachedHashAndResolves() { TestHashedEntry e = new TestHashedEntry("a"); // flavor 1: the home comes from the Entry's own cached hash, no strategy needed assertTrue(FlatHashtable.insert(table, e)); - assertSame(e, FlatHashtable.get(table, "a", HASHED_KEY_STRATEGY)); + assertSame(e, FlatHashtable.get(table, "a", TestHashedKeyStrategy.INSTANCE)); } @Test void insert_returnsFalseWhenFull() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots - assertTrue(FlatHashtable.insert(table, new TestEntry("k0"), ENTRY_KEY_STRATEGY)); - assertTrue(FlatHashtable.insert(table, new TestEntry("k1"), ENTRY_KEY_STRATEGY)); - assertFalse(FlatHashtable.insert(table, new TestEntry("k2"), ENTRY_KEY_STRATEGY)); // no room + assertTrue(FlatHashtable.insert(table, new TestEntry("k0"), TestEntryKeyStrategy.INSTANCE)); + assertTrue(FlatHashtable.insert(table, new TestEntry("k1"), TestEntryKeyStrategy.INSTANCE)); + // no room + assertFalse(FlatHashtable.insert(table, new TestEntry("k2"), TestEntryKeyStrategy.INSTANCE)); } @Test void forEach_visitsEveryEntry() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - FlatHashtable.getOrCreate(table, "a", ENTRY_KEY_STRATEGY, CREATE); - FlatHashtable.getOrCreate(table, "b", ENTRY_KEY_STRATEGY, CREATE); - FlatHashtable.getOrCreate(table, "c", ENTRY_KEY_STRATEGY, CREATE); + FlatHashtable.getOrCreate(table, "a", TestEntryKeyStrategy.INSTANCE, CREATE); + FlatHashtable.getOrCreate(table, "b", TestEntryKeyStrategy.INSTANCE, CREATE); + FlatHashtable.getOrCreate(table, "c", TestEntryKeyStrategy.INSTANCE, CREATE); Set seen = new HashSet<>(); FlatHashtable.forEach(table, e -> seen.add(e.key)); @@ -268,8 +280,8 @@ void forEach_visitsEveryEntry() { @Test void forEach_contextVariant_passesContextWithoutCapture() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - FlatHashtable.getOrCreate(table, "a", ENTRY_KEY_STRATEGY, CREATE); - FlatHashtable.getOrCreate(table, "b", ENTRY_KEY_STRATEGY, CREATE); + FlatHashtable.getOrCreate(table, "a", TestEntryKeyStrategy.INSTANCE, CREATE); + FlatHashtable.getOrCreate(table, "b", TestEntryKeyStrategy.INSTANCE, CREATE); Set seen = new HashSet<>(); FlatHashtable.forEach(table, seen, (ctx, e) -> ctx.add(e.key)); @@ -279,12 +291,12 @@ void forEach_contextVariant_passesContextWithoutCapture() { @Test void iterator_yieldsEveryEntrySharingTheHash() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // COLLIDING sends all to slot 0 - TestEntry a = FlatHashtable.getOrCreate(table, "a", COLLIDING_KEY_STRATEGY, CREATE); - TestEntry b = FlatHashtable.getOrCreate(table, "b", COLLIDING_KEY_STRATEGY, CREATE); - TestEntry c = FlatHashtable.getOrCreate(table, "c", COLLIDING_KEY_STRATEGY, CREATE); + TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingKeyStrategy.INSTANCE, CREATE); + TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingKeyStrategy.INSTANCE, CREATE); + TestEntry c = FlatHashtable.getOrCreate(table, "c", TestCollidingKeyStrategy.INSTANCE, CREATE); Set seen = new HashSet<>(); - Iterator it = FlatHashtable.iterator(table, 0, COLLIDING_KEY_STRATEGY); + Iterator it = FlatHashtable.iterator(table, 0, TestCollidingKeyStrategy.INSTANCE); while (it.hasNext()) { seen.add(it.next()); } @@ -294,18 +306,18 @@ void iterator_yieldsEveryEntrySharingTheHash() { @Test void iterator_filtersOutEntriesWithADifferentHash() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // entries at slot 0, hashOf == 0 - FlatHashtable.getOrCreate(table, "a", COLLIDING_KEY_STRATEGY, CREATE); - FlatHashtable.getOrCreate(table, "b", COLLIDING_KEY_STRATEGY, CREATE); + FlatHashtable.getOrCreate(table, "a", TestCollidingKeyStrategy.INSTANCE, CREATE); + FlatHashtable.getOrCreate(table, "b", TestCollidingKeyStrategy.INSTANCE, CREATE); // hash 8 shares the home slot (8 & 7 == 0) but no stored entry has hashOf == 8 - Iterator it = FlatHashtable.iterator(table, 8, COLLIDING_KEY_STRATEGY); + Iterator it = FlatHashtable.iterator(table, 8, TestCollidingKeyStrategy.INSTANCE); assertFalse(it.hasNext()); } @Test void iterator_emptyRunHasNoNext() { TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); - Iterator it = FlatHashtable.iterator(table, 0, COLLIDING_KEY_STRATEGY); + Iterator it = FlatHashtable.iterator(table, 0, TestCollidingKeyStrategy.INSTANCE); assertFalse(it.hasNext()); assertThrows(NoSuchElementException.class, it::next); } From 68791d6d62df49e53e901a82723395c4a2b0e7bb Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 17 Jul 2026 15:06:59 -0400 Subject: [PATCH 8/9] Defeat CHA in the FlatHashtable map benchmarks to prove structural devirt Load two decoy KeyStrategy implementors alongside the real one in both map benchmarks so KeyStrategy.hash and KeyStrategy.matches each have >=2 concrete implementors before the hot method compiles. This denies C2 the single- implementor CHA devirtualization of keyStrat.hash/matches inside get(), so the steady-state numbers reflect the no-CHA regime rather than a deopt-guarded bet. Verified with -XX:+PrintInlining (Zulu 21; Java 8/ARM64 is fine for inlining- decision inspection but not for throughput): with CHA impossible, the concrete StringKeyStrategy::hash / IntEntryKeyStrategy::matches still inline (hot) and no type-profile/morphic markers appear on any FlatHashtable/KeyStrategy method. The devirtualization is therefore exact-type, from the constant INSTANCE propagated through the inlined get -- the structural monomorphization the @Strategy contract promises, not a CHA or type-profile speculation. Co-Authored-By: Claude Opus 4.8 --- .../util/SingleThreadedMapBenchmark.java | 53 +++++++++++++++++++ .../trace/util/ThreadSafeMapBenchmark.java | 53 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java index 51d3e4c8b12..6409bf045e8 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java @@ -117,6 +117,59 @@ public long hashOf(IntEntry entry) { } } + // --- CHA-defeat decoys --------------------------------------------------------------------- + // Never used to build a table; loaded (in setUp) only so KeyStrategy.hash and KeyStrategy.matches + // each have >=2 concrete implementors. That denies C2 the single-implementor CHA devirtualization + // of keyStrat.hash/matches inside get(). If the strategy calls still inline afterward, the win is + // structural (the constant INSTANCE's exact type propagated through the inlined get), not a CHA + // bet that would deopt when a second subclass loads. + + // Second StringKeyStrategy impl -> KeyStrategy.matches is now polymorphic. + static final class DecoyStringKeyStrategy extends FlatHashtable.StringKeyStrategy { + static final DecoyStringKeyStrategy INSTANCE = new DecoyStringKeyStrategy(); + + private DecoyStringKeyStrategy() {} + + @Override + public boolean matches(String key, IntEntry entry) { + return key == entry.key; // deliberately different body from IntEntryKeyStrategy + } + + @Override + public long hashOf(IntEntry entry) { + return hash(entry.key); + } + } + + // Direct KeyStrategy impl with its own hash -> KeyStrategy.hash is now polymorphic too. + static final class DecoyKeyStrategy extends FlatHashtable.KeyStrategy { + static final DecoyKeyStrategy INSTANCE = new DecoyKeyStrategy(); + + private DecoyKeyStrategy() {} + + @Override + public long hash(String key) { + return key.length(); + } + + @Override + public boolean matches(String key, IntEntry entry) { + return key.equals(entry.key); + } + + @Override + public long hashOf(IntEntry entry) { + return entry.key.length(); + } + } + + // Referenced only so these three concrete KeyStrategy implementors load at benchmark class-init, + // before the hot method compiles — see the CHA-defeat note above. + @SuppressWarnings("unused") + static final Object[] CHA_DEFEAT = { + IntEntryKeyStrategy.INSTANCE, DecoyStringKeyStrategy.INSTANCE, DecoyKeyStrategy.INSTANCE + }; + static IntEntry[] newFilledFlat() { // Sized to the key count (FlatHashtable is fixed-capacity, no resize): load factor <= 0.5. IntEntry[] table = FlatHashtable.create(IntEntry.class, INSERTION_KEYS.length); diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java index 9b66e727455..94925664938 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java @@ -144,6 +144,59 @@ public long hashOf(IntEntry entry) { } } + // --- CHA-defeat decoys --------------------------------------------------------------------- + // These are never used to build a table; they exist only to be *loaded* (see loadStrategies), + // so KeyStrategy.hash and KeyStrategy.matches each have >=2 concrete implementors. That denies + // C2 the single-implementor CHA devirtualization of keyStrat.hash/matches inside get(). If the + // strategy calls still inline afterward, the win is structural (the constant INSTANCE's exact + // type propagated through the inlined get), not a CHA bet that would deopt on a second subclass. + + // Second StringKeyStrategy impl -> KeyStrategy.matches is now polymorphic. + static final class DecoyStringKeyStrategy extends FlatHashtable.StringKeyStrategy { + static final DecoyStringKeyStrategy INSTANCE = new DecoyStringKeyStrategy(); + + private DecoyStringKeyStrategy() {} + + @Override + public boolean matches(String key, IntEntry entry) { + return key == entry.key; // deliberately different body from IntEntryKeyStrategy + } + + @Override + public long hashOf(IntEntry entry) { + return hash(entry.key); + } + } + + // Direct KeyStrategy impl with its own hash -> KeyStrategy.hash is now polymorphic too. + static final class DecoyKeyStrategy extends FlatHashtable.KeyStrategy { + static final DecoyKeyStrategy INSTANCE = new DecoyKeyStrategy(); + + private DecoyKeyStrategy() {} + + @Override + public long hash(String key) { + return key.length(); + } + + @Override + public boolean matches(String key, IntEntry entry) { + return key.equals(entry.key); + } + + @Override + public long hashOf(IntEntry entry) { + return entry.key.length(); + } + } + + // Referenced only so these three concrete KeyStrategy implementors load at benchmark class-init, + // before the hot method compiles — see the CHA-defeat note above. + @SuppressWarnings("unused") + static final Object[] CHA_DEFEAT = { + IntEntryKeyStrategy.INSTANCE, DecoyStringKeyStrategy.INSTANCE, DecoyKeyStrategy.INSTANCE + }; + static IntEntry[] _create_flat() { // Sized to the key count (FlatHashtable is fixed-capacity, no resize): load factor <= 0.5. IntEntry[] table = FlatHashtable.create(IntEntry.class, INSERTION_KEYS.length); From f9f9d224c9c27a5f5045fc3e7f9681ccc9972aa5 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 17 Jul 2026 16:56:33 -0400 Subject: [PATCH 9/9] Add case-insensitive strategy, load-factor control, and resize to FlatHashtable Rounds out the FlatHashtable toolbox and adds a case-insensitive map arm to the comparison benchmark: - Strings.caseInsensitiveHashCode: a hashCode consistent with String.equalsIgnoreCase (same two-way fold as regionMatches(ignoreCase)), allocation-free. Reusable primitive; the strategy below composes it. - FlatHashtable.CaseInsensitiveStringKeyStrategy: case-insensitive sibling of StringKeyStrategy, sealing hash to the primitive. - The table now owns the spread (home()): a golden-ratio Fibonacci mix robust to weak/int-derived and full 64-bit hashes alike, so a KeyStrategy returns a plain hashCode without pre-mixing. StringKeyStrategy/CI/Entry now carry raw hashes. - Load-factor control: DEFAULT_LOAD_FACTOR (0.5) / LOW_LOAD_FACTOR (0.25) constants plus create(Class,int,float) / capacityFor(int,float); the 2-arg forms are kept and delegate to the default. - resize(...) and resizingInsert(...) (Entry and KeyStrategy flavors): explicit, caller-invoked growth for the rare full case, keeping get/insert resize-free on the hot path. resizingInsert returns the (possibly new) array either way. Benchmark: CaseInsensitiveMapBenchmark gains a FlatHashtable arm (dogfooding the shared strategy + primitive) and a DEFAULT-vs-LOW load-factor pair, and moves the lookup index per-thread (@State(Scope.Thread)) so the shared counter doesn't floor the fastest reads. On Zulu 21 the CI FlatHashtable is ~2x the (previously recommended) TreeMap at the same zero allocation, and matches HashMap's throughput without HashMap's per-lookup folded-String garbage; LOW_LOAD_FACTOR is a wash for the fold-dominated CI lookup. Co-Authored-By: Claude Opus 4.8 --- .../util/CaseInsensitiveMapBenchmark.java | 141 ++++++++++--- .../datadog/trace/util/FlatHashtable.java | 185 ++++++++++++++++-- .../main/java/datadog/trace/util/Strings.java | 18 ++ .../datadog/trace/util/FlatHashtableTest.java | 142 +++++++++++++- .../StringsCaseInsensitiveHashCodeTest.java | 57 ++++++ 5 files changed, 492 insertions(+), 51 deletions(-) create mode 100644 internal-api/src/test/java/datadog/trace/util/StringsCaseInsensitiveHashCodeTest.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java index 48500669cd5..514bd7df0cc 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java @@ -7,6 +7,8 @@ import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Fork; import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.Threads; import org.openjdk.jmh.annotations.Warmup; import org.openjdk.jmh.infra.Blackhole; @@ -15,42 +17,49 @@ * * *

      - * Benchmark to illustrate the trade-offs around case-insensitive Map look-ups - using either... - *
    • (RECOMMENDED) TreeMap with Comparator of String::compareToIgnoreCase - *
    • HashMap with look-ups using String::toCase + * Benchmark for the trade-offs around case-insensitive Map look-ups, comparing: + *
    • TreeMap with a {@code String::compareToIgnoreCase} comparator — allocation-free, O(log n) + *
    • HashMap keyed on {@code toLowerCase()} — O(1) but allocates a folded String per look-up + *
    • FlatHashtable with a {@link FlatHashtable.CaseInsensitiveStringKeyStrategy} — O(1) probe, + * allocation-free (case folded inside hash/matches), value stored unboxed *
    * - *

    For case-insensitive lookups, TreeMap map creation is consistently faster because it avoids - * String::toCase calls. + *

    Takeaways. FlatHashtable is ~2x the (previously recommended) TreeMap at the same zero + * allocation, and matches HashMap's look-up throughput without HashMap's per-look-up folded + * String (which drives the multi-threaded GC pressure). The case-insensitive hash is the + * consistent-for-all-inputs two-way fold ({@link + * datadog.trace.util.Strings#caseInsensitiveHashCode} — see its note); a cheaper ASCII-only fold + * would recover a few percent for header-name-only hot paths, deliberately not the default. {@code + * LOW_LOAD_FACTOR} makes no difference here (the fold, not the probe count, dominates), so the + * default 0.5 is used. * - *

    Despite calls to String::toCase, HashMap lookups are faster in single threaded - * microbenchmark by 50% but are worse when frequently called in a multi-threaded system. + *

    Numbers below: MacBook M1, Zulu 21, per-thread lookup index, @Fork(5). + * 1 thread * - *

    With many threads, the extra allocation from calling String::toCase leads to frequent GCs - * which has adverse impacts on the whole system. - * MacBook M1 with 1 thread (Java 21) + * Benchmark Mode Cnt Score Error Units + * create_flatHashtable thrpt 15 3723141.4 ± 63717.9 ops/s + * create_hashMap thrpt 15 905452.5 ± 16561.3 ops/s + * create_treeMap thrpt 15 1208339.4 ± 84364.2 ops/s * - * Benchmark Mode Cnt Score Error Units - * CaseInsensitiveMapBenchmark.create_hashMap thrpt 6 994213.041 ± 15718.903 ops/s - * CaseInsensitiveMapBenchmark.create_treeMap thrpt 6 1522900.015 ± 21646.688 ops/s - * - * CaseInsensitiveMapBenchmark.get_hashMap thrpt 6 69149862.293 ± 9168648.566 ops/s - * CaseInsensitiveMapBenchmark.get_treeMap thrpt 6 42796699.230 ± 9029447.805 ops/s + * lookup_flatHashtable thrpt 15 75874505.0 ± 3722582.3 ops/s + * lookup_flatHashtable_lowLoad thrpt 15 75686682.1 ± 1879579.4 ops/s + * lookup_hashMap thrpt 15 80319813.9 ± 7410634.9 ops/s + * lookup_treeMap thrpt 15 45926358.7 ± 1917349.2 ops/s * - * MacBook M1 with 8 threads (Java 21) - * - * Benchmark Mode Cnt Score Error Units - * CaseInsensitiveMapBenchmark.create_hashMap thrpt 6 6641003.483 ± 543210.409 ops/s - * CaseInsensitiveMapBenchmark.create_treeMap thrpt 6 10030191.764 ± 1308865.113 ops/s + * 8 threads (with -prof gc; alloc = gc.alloc.rate.norm) * - * CaseInsensitiveMapBenchmark.get_hashMap thrpt 6 38748031.837 ± 9012072.804 ops/s - * CaseInsensitiveMapBenchmark.get_treeMap thrpt 6 173495470.789 ± 27824904.999 ops/s + * Benchmark Mode Cnt Score Error Units alloc + * lookup_flatHashtable thrpt 15 558144937.2 ± 22797680.7 ops/s ~0 B/op + * lookup_flatHashtable_lowLoad thrpt 15 564984154.1 ± 25899687.5 ops/s ~0 B/op + * lookup_hashMap thrpt 15 529773720.8 ± 82928000.6 ops/s 24.0 B/op (151 GCs) + * lookup_treeMap thrpt 15 262110611.8 ± 30486484.7 ops/s ~0 B/op * */ @Fork(2) @Warmup(iterations = 2) @Measurement(iterations = 3) @Threads(8) +@State(Scope.Thread) public class CaseInsensitiveMapBenchmark { static final String[] PREFIXES = {"foo", "bar", "baz", "quux"}; @@ -87,12 +96,17 @@ static T init(Supplier supplier) { return keys; }); - static int sharedLookupIndex = 0; + // Per-thread (@State(Scope.Thread)) so cycling the lookup key doesn't contend a shared counter. + // The maps stay static/shared (read-only after class-init); only the index is per-thread. A + // shared + // counter's cache-line ping-pong would floor the fastest lookups (the flat probe) at @Threads(8), + // masking exactly the differences this benchmark compares. + int lookupIndex = 0; - static String nextLookupKey() { - int localIndex = ++sharedLookupIndex; + String nextLookupKey() { + int localIndex = ++lookupIndex; if (localIndex >= LOOKUP_KEYS.length) { - sharedLookupIndex = localIndex = 0; + lookupIndex = localIndex = 0; } return LOOKUP_KEYS[localIndex]; } @@ -178,5 +192,78 @@ public Integer lookup_treeMap() { return TREE_MAP.get(nextLookupKey()); } + // FlatHashtable with a case-insensitive KeyStrategy: the strategy folds case inside hash/matches, + // so lookups are O(1) (single probe) AND allocation-free (no String::toCase) — TreeMap's zero- + // alloc property without TreeMap's O(log n) comparison walk. Value is stored unboxed. Read-only + // after build, so reads are lock-free (see FlatHashtable / ThreadSafeMapBenchmark). + static final class CIEntry extends FlatHashtable.Entry { + final String key; // original case preserved + final int value; + + CIEntry(String key, long hash, int value) { + super(hash); // cache the (char-by-char) case-insensitive hash + this.key = key; + this.value = value; + } + } + + // Dogfoods the shared toolbox pieces: the CI hash is Strings.caseInsensitiveHashCode (sealed by + // CaseInsensitiveStringKeyStrategy), the table owns the spread. Only matches/hashOf are bespoke. + static final class CaseInsensitiveKeyStrategy + extends FlatHashtable.CaseInsensitiveStringKeyStrategy { + static final CaseInsensitiveKeyStrategy INSTANCE = new CaseInsensitiveKeyStrategy(); + + private CaseInsensitiveKeyStrategy() {} + + @Override + public boolean matches(String key, CIEntry entry) { + return key.equalsIgnoreCase(entry.key); // case-folded, allocation-free + } + + @Override + public long hashOf(CIEntry entry) { + return entry.hash; // CIEntry caches its (raw, case-insensitive) hash + } + } + + static CIEntry[] _create_flat(float loadFactor) { + // 16 distinct case-insensitive keys (foo-0..quux-3). + CIEntry[] table = + FlatHashtable.create(CIEntry.class, PREFIXES.length * NUM_SUFFIXES, loadFactor); + for (int suffix = 0; suffix < NUM_SUFFIXES; ++suffix) { + for (String prefix : PREFIXES) { + String key = prefix + "-" + suffix; + long hash = CaseInsensitiveKeyStrategy.INSTANCE.hash(key); + FlatHashtable.insert( + table, new CIEntry(key, hash, suffix), CaseInsensitiveKeyStrategy.INSTANCE); + } + } + // The HashMap/TreeMap builds' second loop (UPPER_PREFIXES, suffix 0 & 2) only OVERWRITES values + // case-insensitively — it adds no new keys, and values don't affect lookup throughput — so the + // read set is these same 16 keys. + return table; + } + + @Benchmark + public CIEntry[] create_flatHashtable() { + return _create_flat(FlatHashtable.DEFAULT_LOAD_FACTOR); + } + + static final CIEntry[] FLAT_TABLE = _create_flat(FlatHashtable.DEFAULT_LOAD_FACTOR); + static final CIEntry[] FLAT_TABLE_LOW = _create_flat(FlatHashtable.LOW_LOAD_FACTOR); + + @Benchmark + public CIEntry lookup_flatHashtable() { + // Lock-free, allocation-free, single-probe case-insensitive lookup. + return FlatHashtable.get(FLAT_TABLE, nextLookupKey(), CaseInsensitiveKeyStrategy.INSTANCE); + } + + @Benchmark + public CIEntry lookup_flatHashtable_lowLoad() { + // Same, but at LOW_LOAD_FACTOR (4x): does the sparser table shave probes for the (mostly + // hash-fold-dominated) CI lookup, or is it a wash? — see the delta to lookup_flatHashtable. + return FlatHashtable.get(FLAT_TABLE_LOW, nextLookupKey(), CaseInsensitiveKeyStrategy.INSTANCE); + } + // TODO: Add ConcurrentSkipListMap & synchronized HashMap & TreeMap } diff --git a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java index 6a934167f2e..fbc9cdc4597 100644 --- a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java @@ -38,10 +38,10 @@ * } * *

    Contract: {@code table.length} must be a power of two ({@link #capacityFor}). {@code - * KeyStrategy.hash} should be well-distributed (this class masks it directly). Cardinality cap / - * overflow / a live-size counter are caller policy (this class is pure mechanism): a capped - * caller does {@link #get} first, and only on a miss checks its budget before {@link #getOrCreate} - * (so hits stay a single probe and the create path is warmup-rare). + * KeyStrategy.hash} may return a plain {@code hashCode} — the table owns the spread ({@link + * #home}). Cardinality cap / overflow / a live-size counter are caller policy (this class is + * pure mechanism): a capped caller does {@link #get} first, and only on a miss checks its budget + * before {@link #getOrCreate} (so hits stay a single probe and the create path is warmup-rare). */ public final class FlatHashtable { private FlatHashtable() {} @@ -51,7 +51,8 @@ private FlatHashtable() {} * optimization, not plumbing (open addressing needs no {@code next}), so extending it is * never required: bring any entry type and supply {@link KeyStrategy#hashOf} yourself instead. * Caller contract: {@code hash} must equal the table's {@link KeyStrategy#hash} for this entry's - * key, so the entry lands where {@link #get} looks. + * key (the raw hash — the table applies its own spread), so the entry lands where {@link + * #get} looks. */ public abstract static class Entry { public final long hash; @@ -80,8 +81,8 @@ protected Entry(long hash) { public abstract static class KeyStrategy { /** * Hash of {@code key} ({@code long} for family-wide consistency with Hashtable / - * ConcurrentHashtable and to leave room for composite keys); should be well-distributed (this - * table masks it directly). + * ConcurrentHashtable and to leave room for composite keys). Return a plain {@code hashCode} — + * the table {@linkplain #home spreads} it before masking, so there is no need to pre-mix. */ public abstract long hash(K key); @@ -100,19 +101,35 @@ public abstract static class KeyStrategy { } /** - * {@link KeyStrategy} specialized for {@code String} keys: seals a spread {@link #hash} so - * String-key callers write only {@link #matches}. Extend as a stateless final class held in a - * concrete-typed {@code static final} singleton, exactly like {@link KeyStrategy} — the {@code - * final} hash resolves directly and the concrete subclass still specializes the same at each call - * site, so there's no cost to the extra layer. + * {@link KeyStrategy} specialized for {@code String} keys: seals {@link #hash} to {@link + * String#hashCode} so String-key callers write only {@link #matches}. Extend as a stateless final + * class held in a concrete-typed {@code static final} singleton, exactly like {@link KeyStrategy} + * — the {@code final} hash resolves directly and the concrete subclass still specializes the same + * at each call site, so there's no cost to the extra layer. (No spread here — the table + * {@linkplain #home spreads} the raw hashCode itself.) * * @param stored entry — self-contained (carries its own key) */ public abstract static class StringKeyStrategy extends KeyStrategy { @Override public final long hash(String key) { - final int h = key.hashCode(); - return h ^ (h >>> 16); // spread the int entropy; widened to the family-wide long hash width + return key.hashCode(); // raw; the table spreads before masking + } + } + + /** + * {@link KeyStrategy} for {@code String} keys compared case-insensitively — the case-insensitive + * sibling of {@link StringKeyStrategy}. Seals {@link #hash} to {@link + * Strings#caseInsensitiveHashCode}, which is consistent with {@link String#equalsIgnoreCase} + * (callers implement {@link #matches} with {@code equalsIgnoreCase}). Extend as a stateless final + * class held in a concrete-typed {@code static final} singleton. + * + * @param stored entry — self-contained (carries its own key) + */ + public abstract static class CaseInsensitiveStringKeyStrategy extends KeyStrategy { + @Override + public final long hash(String key) { + return Strings.caseInsensitiveHashCode(key); // raw; the table spreads before masking } } @@ -148,12 +165,42 @@ public interface CreateStrategy { E create(K key); } - /** Power-of-two capacity for a cardinality budget: {@code >= 2 * limit} (load factor <= 0.5). */ + /** + * Balanced default load factor — target fill {@code <= 0.5} ({@code >= 2x} capacity). Linear + * probing then costs ~1.5 probes on a hit, ~2.5 on a miss (Knuth); the general-purpose sweet + * spot. + */ + public static final float DEFAULT_LOAD_FACTOR = 0.5f; + + /** + * Sparse load factor — target fill {@code <= 0.25} ({@code >= 4x} capacity): ~1.2 probes on a + * hit, ~1.4 on a miss. For miss-heavy hot paths (membership checks) where the extra empty slots + * are cheap and shaving the (quadratic-in-load) miss cost is worth the memory. Measure before + * preferring it to {@link #DEFAULT_LOAD_FACTOR}. There is deliberately no higher-than-default + * constant — open addressing degrades sharply past 0.5 (~8.5 probes/miss at 0.75). + */ + public static final float LOW_LOAD_FACTOR = 0.25f; + + /** Power-of-two capacity for a cardinality budget at the {@link #DEFAULT_LOAD_FACTOR}. */ public static int capacityFor(int cardinalityLimit) { + return capacityFor(cardinalityLimit, DEFAULT_LOAD_FACTOR); + } + + /** + * Power-of-two capacity for a cardinality budget at {@code loadFactor}: the smallest power of two + * {@code >= ceil(cardinalityLimit / loadFactor)}. Because it rounds up to a power of two, the + * achieved fill is often below {@code loadFactor} (never above) — you always get at least the + * headroom you asked for. + */ + public static int capacityFor(int cardinalityLimit, float loadFactor) { if (cardinalityLimit <= 0) { throw new IllegalArgumentException("cardinalityLimit must be positive: " + cardinalityLimit); } - return Integer.highestOneBit(cardinalityLimit * 2 - 1) << 1; + if (!(loadFactor > 0f && loadFactor < 1f)) { + throw new IllegalArgumentException("loadFactor must be in (0, 1): " + loadFactor); + } + int min = (int) Math.ceil(cardinalityLimit / (double) loadFactor); + return Integer.highestOneBit(min - 1) << 1; } /** @@ -169,6 +216,15 @@ public static E[] create(Class type, int cardinalityLimit) { return (E[]) Array.newInstance(type, capacityFor(cardinalityLimit)); } + /** + * {@link #create(Class, int)} at an explicit {@code loadFactor} (see {@link #capacityFor(int, + * float)} and the {@link #DEFAULT_LOAD_FACTOR} / {@link #LOW_LOAD_FACTOR} constants). + */ + @SuppressWarnings("unchecked") + public static E[] create(Class type, int cardinalityLimit, float loadFactor) { + return (E[]) Array.newInstance(type, capacityFor(cardinalityLimit, loadFactor)); + } + /** * Existing entry for {@code key}, or {@code null}. Read-only — never creates. Single probe on a * hit; walks to the first empty slot (or all the way around) on a miss. @@ -176,7 +232,7 @@ public static E[] create(Class type, int cardinalityLimit) { @StrategyConsumer public static E get(E[] table, K key, KeyStrategy keyStrat) { final int mask = table.length - 1; - final int start = (int) (keyStrat.hash(key) & mask); + final int start = home(keyStrat.hash(key), mask); int i = start; for (; ; ) { final E e = table[i]; @@ -203,7 +259,7 @@ public static E get(E[] table, K key, KeyStrategy keyStrat) { public static E getOrCreate( E[] table, K key, KeyStrategy keyStrat, CreateStrategy createStrat) { final int mask = table.length - 1; - final int start = (int) (keyStrat.hash(key) & mask); + final int start = home(keyStrat.hash(key), mask); int i = start; for (; ; ) { final E e = table[i]; @@ -252,7 +308,7 @@ public static boolean insert(E[] table, E entry, KeyStrategy keyStrat) */ private static boolean placeAt(E[] table, E entry, long hash) { final int mask = table.length - 1; - final int start = (int) (hash & mask); + final int start = home(hash, mask); int i = start; for (; ; ) { if (table[i] == null) { @@ -266,6 +322,95 @@ private static boolean placeAt(E[] table, E entry, long hash) { } } + /** + * Placement slot for {@code hash} in a table of {@code mask + 1} slots. The table owns the + * spread: a golden-ratio (Fibonacci) multiply diffuses the hash across all bits — robust to weak + * or {@code int}-derived {@code hashCode}s and to full 64-bit composite hashes alike — then the + * low index bits are taken. So a {@link KeyStrategy} may return a plain {@code hashCode} without + * pre-mixing. Package-private so tests can predict slots. + */ + static int home(long hash, int mask) { + long z = hash * 0x9E3779B97F4A7C15L; // 2^64 / golden ratio; odd ⇒ a bijection (loses no bits) + z ^= z >>> 32; // fold the well-mixed high half down into the low bits the mask keeps + return (int) z & mask; + } + + /** + * Doubles capacity and rehashes every entry into a new table — call when {@link #insert} returns + * {@code false} and you want to grow rather than reject; the caller stores the returned array + * back. Convenience over the {@link KeyStrategy}-taking overload for {@link Entry}-based entries + * (the home comes from {@link Entry#hash}). See {@link #resizingInsert(Object[], Object)} to do + * both in one call, and its note on growing over unbounded key domains. + */ + public static E[] resize(E[] table) { + E[] grown = allocateGrown(table); + for (final E e : table) { + if (e != null) { + placeAt(grown, e, e.hash); + } + } + return grown; + } + + /** + * {@link #resize(Entry[])} for any entry type: each entry's home comes from {@link + * KeyStrategy#hashOf}. Not a {@link StrategyConsumer} — the rehash is a cold, one-off traversal, + * not a hot specialization site. + */ + public static E[] resize(E[] table, KeyStrategy keyStrat) { + E[] grown = allocateGrown(table); + for (final E e : table) { + if (e != null) { + placeAt(grown, e, keyStrat.hashOf(e)); + } + } + return grown; + } + + /** + * A new, empty table of twice the capacity, of the same runtime component type as {@code table}. + */ + @SuppressWarnings("unchecked") + private static E[] allocateGrown(E[] table) { + return (E[]) Array.newInstance(table.getClass().getComponentType(), table.length << 1); + } + + /** + * {@link #insert(Entry[], Entry) insert} that grows on demand: adds {@code entry}, {@link + * #resize(Entry[]) resizing} first if the table is full, and returns the table to store back — + * the same array if it fit, a new larger one if it grew: + * + *

    {@code
    +   * table = FlatHashtable.resizingInsert(table, entry); // always reassign
    +   * }
    + * + * Same comparison-free, caller-ensures-absence contract as {@link #insert}. + * + *

    Grows unboundedly. Unlike {@code insert}'s {@code false}, this hides the full signal, + * so it is the easiest place to leak memory: use it only for a genuinely bounded key domain, + * never over externally-controlled cardinality. + */ + public static E[] resizingInsert(E[] table, E entry) { + E[] t = table; + while (!insert(t, entry)) { + t = resize(t); // one doubling always suffices; the loop is belt-and-braces + } + return t; + } + + /** + * {@link #resizingInsert(Entry[], Entry)} for any entry type (home via {@link + * KeyStrategy#hashOf}). Same grows-unboundedly caution. + */ + @StrategyConsumer + public static E[] resizingInsert(E[] table, E entry, KeyStrategy keyStrat) { + E[] t = table; + while (!insert(t, entry, keyStrat)) { + t = resize(t, keyStrat); + } + return t; + } + /** Applies {@code consumer} to every entry in {@code table} (skipping empty slots); any order. */ public static void forEach(E[] table, Consumer consumer) { for (final E e : table) { @@ -316,7 +461,7 @@ private static final class HashIterator implements Iterator { this.table = table; this.hash = hash; this.keyStrat = keyStrat; - this.start = (int) (hash & (table.length - 1)); + this.start = home(hash, table.length - 1); this.i = this.start; advance(); } diff --git a/internal-api/src/main/java/datadog/trace/util/Strings.java b/internal-api/src/main/java/datadog/trace/util/Strings.java index 603a7665c04..5ea52a0ce14 100644 --- a/internal-api/src/main/java/datadog/trace/util/Strings.java +++ b/internal-api/src/main/java/datadog/trace/util/Strings.java @@ -353,4 +353,22 @@ public static boolean regionContains(String s, int beginIndex, int endIndex, Str int idx = s.indexOf(needle, beginIndex); return idx >= 0 && idx + needle.length() <= endIndex; } + + /** + * A {@code hashCode} consistent with {@link String#equalsIgnoreCase}: any two strings that are + * equal ignoring case produce the same value. Same polynomial as {@link String#hashCode} but over + * the case-folded characters, so it never allocates (no {@code toLowerCase} copy). + * + *

    Uses the same two-way fold {@code String.equalsIgnoreCase} / {@code + * String.regionMatches(ignoreCase)} use ({@code toLowerCase(toUpperCase(c))}), so the two stay + * consistent for all inputs, not just ASCII — pairing a one-way fold here with {@code + * equalsIgnoreCase} would risk silent false misses on the Unicode characters where they diverge. + */ + public static int caseInsensitiveHashCode(String s) { + int h = 0; + for (int i = 0, len = s.length(); i < len; ++i) { + h = 31 * h + Character.toLowerCase(Character.toUpperCase(s.charAt(i))); + } + return h; + } } diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java index 24a74084e1d..7bf39c7f78f 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java @@ -73,15 +73,32 @@ public long hashOf(TestEntry entry) { } } - /** All keys hash to the last slot ({@code -1 & mask}), so probing wraps around to index 0. */ + /** + * Smallest hash {@code >= 1} that the table places on {@code slot} of a {@code mask + 1} table. + */ + private static long hashLandingOn(int slot, int mask) { + for (long h = 1; h < 1_000_000L; ++h) { + if (FlatHashtable.home(h, mask) == slot) { + return h; + } + } + throw new AssertionError("no hash found landing on slot " + slot); + } + + /** + * All keys hash to the last slot of a 2-slot table (so probing wraps around to index 0). The + * table owns the spread now, so we compute a hash that lands there rather than assuming {@code -1 + * & mask}. + */ static final class TestLastSlotKeyStrategy extends FlatHashtable.KeyStrategy { static final TestLastSlotKeyStrategy INSTANCE = new TestLastSlotKeyStrategy(); + private static final long LAST_SLOT_HASH = hashLandingOn(1, 1); // slot 1 of a 2-slot table private TestLastSlotKeyStrategy() {} @Override public long hash(String key) { - return -1; + return LAST_SLOT_HASH; } @Override @@ -126,6 +143,26 @@ public boolean matches(String key, TestHashedEntry entry) { } } + /** + * Case-insensitive key strategy: {@code hash} sealed by the CI base, {@code matches} folds case. + */ + static final class TestCaseInsensitiveKeyStrategy + extends FlatHashtable.CaseInsensitiveStringKeyStrategy { + static final TestCaseInsensitiveKeyStrategy INSTANCE = new TestCaseInsensitiveKeyStrategy(); + + private TestCaseInsensitiveKeyStrategy() {} + + @Override + public boolean matches(String key, TestEntry entry) { + return key.equalsIgnoreCase(entry.key); + } + + @Override + public long hashOf(TestEntry entry) { + return hash(entry.key); + } + } + @Test void capacityFor_roundsToPowerOfTwoAtLeastTwiceLimit() { assertEquals(2, FlatHashtable.capacityFor(1)); @@ -309,8 +346,10 @@ void iterator_filtersOutEntriesWithADifferentHash() { FlatHashtable.getOrCreate(table, "a", TestCollidingKeyStrategy.INSTANCE, CREATE); FlatHashtable.getOrCreate(table, "b", TestCollidingKeyStrategy.INSTANCE, CREATE); - // hash 8 shares the home slot (8 & 7 == 0) but no stored entry has hashOf == 8 - Iterator it = FlatHashtable.iterator(table, 8, TestCollidingKeyStrategy.INSTANCE); + // a hash that shares the entries' home slot (0) but that no stored entry has as its hashOf + long sameHomeOtherHash = hashLandingOn(0, table.length - 1); + Iterator it = + FlatHashtable.iterator(table, sameHomeOtherHash, TestCollidingKeyStrategy.INSTANCE); assertFalse(it.hasNext()); } @@ -321,4 +360,99 @@ void iterator_emptyRunHasNoNext() { assertFalse(it.hasNext()); assertThrows(NoSuchElementException.class, it::next); } + + @Test + void capacityFor_honorsLoadFactor() { + // default 0.5 -> >= 2x; LOW 0.25 -> >= 4x. + assertEquals(8, FlatHashtable.capacityFor(4, FlatHashtable.DEFAULT_LOAD_FACTOR)); + assertEquals(16, FlatHashtable.capacityFor(4, FlatHashtable.LOW_LOAD_FACTOR)); + // capacityFor(cardinalityLimit) delegates to the default. + assertEquals(FlatHashtable.capacityFor(6), FlatHashtable.capacityFor(6, 0.5f)); + } + + @Test + void capacityFor_rejectsLoadFactorOutOfRange() { + assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(4, 0f)); + assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(4, 1f)); + assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(4, -0.1f)); + } + + @Test + void create_honorsLoadFactor() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4, FlatHashtable.LOW_LOAD_FACTOR); + assertEquals(16, table.length); + } + + @Test + void resize_generalFlavor_growsAndKeepsEveryEntryFindable() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots + FlatHashtable.insert(table, new TestEntry("a"), TestEntryKeyStrategy.INSTANCE); + FlatHashtable.insert(table, new TestEntry("b"), TestEntryKeyStrategy.INSTANCE); + + TestEntry[] grown = FlatHashtable.resize(table, TestEntryKeyStrategy.INSTANCE); + assertEquals(4, grown.length); + assertNotSame(table, grown); + assertEquals("a", FlatHashtable.get(grown, "a", TestEntryKeyStrategy.INSTANCE).key); + assertEquals("b", FlatHashtable.get(grown, "b", TestEntryKeyStrategy.INSTANCE).key); + } + + @Test + void resize_entryFlavor_growsAndKeepsEveryEntryFindable() { + TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 1); // 2 slots + FlatHashtable.insert(table, new TestHashedEntry("a")); + FlatHashtable.insert(table, new TestHashedEntry("b")); + + TestHashedEntry[] grown = FlatHashtable.resize(table); + assertEquals(4, grown.length); + assertEquals("a", FlatHashtable.get(grown, "a", TestHashedKeyStrategy.INSTANCE).key); + assertEquals("b", FlatHashtable.get(grown, "b", TestHashedKeyStrategy.INSTANCE).key); + } + + @Test + void resizingInsert_generalFlavor_growsPastCapacityAndReturnsTheTable() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots + for (int i = 0; i < 10; ++i) { + table = + FlatHashtable.resizingInsert( + table, new TestEntry("k" + i), TestEntryKeyStrategy.INSTANCE); + } + assertTrue(table.length >= 16); // grew from 2 to hold 10 at load factor <= 0.5 + for (int i = 0; i < 10; ++i) { + assertEquals("k" + i, FlatHashtable.get(table, "k" + i, TestEntryKeyStrategy.INSTANCE).key); + } + } + + @Test + void resizingInsert_entryFlavor_growsPastCapacityAndReturnsTheTable() { + TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 1); // 2 slots + for (int i = 0; i < 10; ++i) { + table = FlatHashtable.resizingInsert(table, new TestHashedEntry("k" + i)); + } + assertTrue(table.length >= 16); + for (int i = 0; i < 10; ++i) { + assertEquals("k" + i, FlatHashtable.get(table, "k" + i, TestHashedKeyStrategy.INSTANCE).key); + } + } + + @Test + void caseInsensitiveStrategy_matchesRegardlessOfCase() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + TestEntry stored = + FlatHashtable.getOrCreate( + table, "Content-Type", TestCaseInsensitiveKeyStrategy.INSTANCE, CREATE); + + // Look-ups in any case resolve to the same stored entry, allocation-free. + assertSame( + stored, FlatHashtable.get(table, "content-type", TestCaseInsensitiveKeyStrategy.INSTANCE)); + assertSame( + stored, FlatHashtable.get(table, "CONTENT-TYPE", TestCaseInsensitiveKeyStrategy.INSTANCE)); + assertSame( + stored, FlatHashtable.get(table, "cOnTeNt-TyPe", TestCaseInsensitiveKeyStrategy.INSTANCE)); + // getOrCreate with a differently-cased key does not mint a second entry. + assertSame( + stored, + FlatHashtable.getOrCreate( + table, "CONTENT-TYPE", TestCaseInsensitiveKeyStrategy.INSTANCE, CREATE)); + assertNull(FlatHashtable.get(table, "content-length", TestCaseInsensitiveKeyStrategy.INSTANCE)); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/StringsCaseInsensitiveHashCodeTest.java b/internal-api/src/test/java/datadog/trace/util/StringsCaseInsensitiveHashCodeTest.java new file mode 100644 index 00000000000..94295c44a43 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/StringsCaseInsensitiveHashCodeTest.java @@ -0,0 +1,57 @@ +package datadog.trace.util; + +import static datadog.trace.util.Strings.caseInsensitiveHashCode; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import org.junit.jupiter.api.Test; + +class StringsCaseInsensitiveHashCodeTest { + + @Test + void equalIgnoringCaseProducesEqualHash() { + assertEquals(caseInsensitiveHashCode("Content-Type"), caseInsensitiveHashCode("content-type")); + assertEquals(caseInsensitiveHashCode("Content-Type"), caseInsensitiveHashCode("CONTENT-TYPE")); + assertEquals(caseInsensitiveHashCode("Content-Type"), caseInsensitiveHashCode("cOnTeNt-TyPe")); + } + + @Test + void emptyStringHashesToZero() { + // Matches String.hashCode("") == 0. + assertEquals(0, caseInsensitiveHashCode("")); + } + + @Test + void distinctContentHashesDiffer() { + // Not a guarantee in general, but these representative keys must not collide. + assertNotEquals(caseInsensitiveHashCode("foo"), caseInsensitiveHashCode("bar")); + assertNotEquals(caseInsensitiveHashCode("Accept"), caseInsensitiveHashCode("Host")); + } + + @Test + void staysConsistentWithEqualsIgnoreCase() { + // For any pair, equalsIgnoreCase => equal hash. (The converse — unequal hash implies not + // equalsIgnoreCase — is what a table relies on to never miss a present key.) + String[] samples = { + "Accept", + "accept", + "ACCEPT", + "Accept-Encoding", + "accept-encoding", + "X-Forwarded-For", + "x-forwarded-for", + "Host", + "host" + }; + for (String a : samples) { + for (String b : samples) { + if (a.equalsIgnoreCase(b)) { + assertEquals( + caseInsensitiveHashCode(a), + caseInsensitiveHashCode(b), + () -> "hash mismatch for equalIgnoreCase pair"); + } + } + } + } +}