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..9236ae35c79 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 2158595.7 ± 73576.7 ops/s
+ * create_hashMap thrpt 15 944890.9 ± 34398.7 ops/s
+ * create_treeMap thrpt 15 1285085.3 ± 133648.6 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 75350287.4 ± 4128577.5 ops/s
+ * lookup_flatHashtable_lowLoad thrpt 15 77127204.7 ± 2546322.0 ops/s
+ * lookup_hashMap thrpt 15 76615721.1 ± 4615488.0 ops/s
+ * lookup_treeMap thrpt 15 45777645.5 ± 4551223.1 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 537007985.7 ± 21864181.3 ops/s ~0 B/op
+ * lookup_flatHashtable_lowLoad thrpt 15 540434673.5 ± 20451984.4 ops/s ~0 B/op
+ * lookup_hashMap thrpt 15 441875038.1 ± 110408182.2 ops/s 24.0 B/op (129 GCs)
+ * lookup_treeMap thrpt 15 251195415.1 ± 14662568.3 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,90 @@ 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
+ // CaseInsensitiveStringStrategy), the table owns the spread. Only matches/hashOf are bespoke.
+ static final class CaseInsensitiveKeyStrategy
+ extends FlatHashtable.CaseInsensitiveStringStrategy {
+ static final CaseInsensitiveKeyStrategy INSTANCE = new CaseInsensitiveKeyStrategy();
+
+ private CaseInsensitiveKeyStrategy() {}
+
+ @Override
+ public boolean matches(CIEntry entry, String key) {
+ 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
+ }
+ }
+
+ // Never fires here (the mirror loop below only hits already-present keys), so it neither
+ // allocates nor captures; the value is unused. Kept static/non-capturing to avoid per-call cost.
+ static final FlatHashtable.CreateStrategy CI_CREATE =
+ key -> new CIEntry(key, CaseInsensitiveKeyStrategy.INSTANCE.hashKey(key), 0);
+
+ 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.hashKey(key);
+ FlatHashtable.insert(
+ table, new CIEntry(key, hash, suffix), CaseInsensitiveKeyStrategy.INSTANCE);
+ }
+ }
+ // Mirror the HashMap/TreeMap builds' second loop (UPPER_PREFIXES, suffix 0 & 2): 8 case-
+ // insensitive collisions. getOrCreate finds the already-present lower-case entry (a hit -> the
+ // create never fires, nothing allocates), doing the same probe/match work the maps' overwrite
+ // puts do — so all three create arms perform the same 24 operations and are comparable.
+ for (int suffix = 0; suffix < NUM_SUFFIXES; suffix += 2) {
+ for (String prefix : UPPER_PREFIXES) {
+ FlatHashtable.getOrCreate(
+ table, prefix + "-" + suffix, CaseInsensitiveKeyStrategy.INSTANCE, CI_CREATE);
+ }
+ }
+ 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/jmh/java/datadog/trace/util/FlatHashtableIteratorBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/FlatHashtableIteratorBenchmark.java
new file mode 100644
index 00000000000..43d928176b1
--- /dev/null
+++ b/internal-api/src/jmh/java/datadog/trace/util/FlatHashtableIteratorBenchmark.java
@@ -0,0 +1,181 @@
+package datadog.trace.util;
+
+import java.util.Iterator;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Tests the static-polymorphism iterator specialization: walking a shared-hash bucket through the
+ * general {@code iterator(table, hash, hashStrategy)} (strategy held in a field -> {@code
+ * hashOf} dispatched via the shared type profile) vs the Entry-specialized {@code
+ * iterator(table, hash)} (feeds a constant strategy into the shared traversal template -> {@code
+ * hashOf} inlines to {@code entry.hash} by constant type-flow). Both return {@code Iterator} and
+ * reuse the same core; only the strategy call's dispatch differs.
+ *
+ * Why the general path is fragile. When only one strategy type flows through it, C2
+ * devirtualizes {@code hashOf} from the type profile and the two arms tie — type-profile does the
+ * specialization's job. But HotSpot keeps one {@code hashOf} profile per bytecode index,
+ * shared across every inlining context (no context-sensitive profiling), so {@code setUp}
+ * poisons that profile by driving the general iterator with four distinct strategy types. After
+ * that, {@code iterate_general} — though it uses a single strategy and looks monomorphic — pays a
+ * virtual {@code hashOf} per entry, degraded by callers it has nothing to do with. {@code
+ * iterate_specialized} is immune: the constant gives exact-type devirt regardless of the profile.
+ * That contrast is the point — the specialization turns a profile-dependent inline into a
+ * structural one. Confirm with {@code -XX:+PrintInlining}.
+ */
+@Fork(2)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 3, time = 1)
+@Threads(1)
+@State(Scope.Thread)
+public class FlatHashtableIteratorBenchmark {
+ // All entries share this hash -> one contiguous probe-run "bucket" the iterator walks.
+ static final long BUCKET_HASH = 0x123456789ABCDEF0L;
+ static final int BUCKET_SIZE = 16;
+
+ static final class ItEntry extends FlatHashtable.Entry {
+ final int value;
+
+ ItEntry(long hash, int value) {
+ super(hash);
+ this.value = value;
+ }
+ }
+
+ // Four DISTINCT hashOf implementors, all reading entry.hash (identical behavior, different
+ // types).
+ // Loading them defeats CHA; driving all four through the shared traversal in setUp poisons the
+ // hashOf profile to megamorphic, so type-profile can't cleanly devirtualize it for the general
+ // path — the case the Entry-specialized iterator is immune to.
+ //
+ // Measured (MacBook M1, Zulu 21, F5, poisoned profile): iterate_general 18.06M ±0.18 vs
+ // iterate_specialized 37.88M ±0.16 ops/s (2.10x, tight non-overlapping CIs). Unpoisoned, the two
+ // tie (~37.8M) — type-profile does the general path's devirt then; the poisoning is what
+ // type-profile can't survive and the constant can.
+ static final class ItHashStrategyA implements FlatHashtable.HashStrategy {
+ static final ItHashStrategyA INSTANCE = new ItHashStrategyA();
+
+ private ItHashStrategyA() {}
+
+ @Override
+ public long hashOf(ItEntry entry) {
+ return entry.hash;
+ }
+ }
+
+ static final class ItHashStrategyB implements FlatHashtable.HashStrategy {
+ static final ItHashStrategyB INSTANCE = new ItHashStrategyB();
+
+ private ItHashStrategyB() {}
+
+ @Override
+ public long hashOf(ItEntry entry) {
+ return entry.hash;
+ }
+ }
+
+ static final class ItHashStrategyC implements FlatHashtable.HashStrategy {
+ static final ItHashStrategyC INSTANCE = new ItHashStrategyC();
+
+ private ItHashStrategyC() {}
+
+ @Override
+ public long hashOf(ItEntry entry) {
+ return entry.hash;
+ }
+ }
+
+ static final class ItHashStrategyD implements FlatHashtable.HashStrategy {
+ static final ItHashStrategyD INSTANCE = new ItHashStrategyD();
+
+ private ItHashStrategyD() {}
+
+ @Override
+ public long hashOf(ItEntry entry) {
+ return entry.hash;
+ }
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ static final FlatHashtable.HashStrategy[] STRATEGIES =
+ new FlatHashtable.HashStrategy[] {
+ ItHashStrategyA.INSTANCE,
+ ItHashStrategyB.INSTANCE,
+ ItHashStrategyC.INSTANCE,
+ ItHashStrategyD.INSTANCE,
+ };
+
+ ItEntry[] table;
+
+ // Kept live so the profile-poisoning loop below can't be dead-code-eliminated.
+ static long POISON_SINK;
+
+ @Setup(Level.Trial)
+ public void setUp() {
+ // Sparse so the bucket is one contiguous run with a clean terminating empty slot.
+ table = FlatHashtable.create(ItEntry.class, BUCKET_SIZE, FlatHashtable.LOW_LOAD_FACTOR);
+ for (int i = 0; i < BUCKET_SIZE; ++i) {
+ FlatHashtable.insert(table, new ItEntry(BUCKET_HASH, i)); // all share the hash => one bucket
+ }
+ // Poison the shared HashIterator.advanceWith hashOf profile: drive the GENERAL iterator with
+ // four distinct strategy types, hot enough that C2 records the site as megamorphic. HotSpot
+ // keeps one per-bci profile shared across all inlining contexts (no context-sensitive profiling
+ // — tried in Zing, never robust), so those unrelated callers permanently pollute the profile.
+ long sink = 0;
+ for (int r = 0; r < 200_000; ++r) {
+ sink += poison();
+ }
+ POISON_SINK = sink;
+ }
+
+ /** Drives the general iterator once per distinct strategy type — the profile poisoner. */
+ private long poison() {
+ long sink = 0;
+ for (FlatHashtable.HashStrategy s : STRATEGIES) {
+ Iterator it = FlatHashtable.iterator(table, BUCKET_HASH, s);
+ while (it.hasNext()) {
+ sink += it.next().value;
+ }
+ }
+ return sink;
+ }
+
+ /**
+ * General iterator with a SINGLE strategy — looks monomorphic, but its {@code hashOf} still
+ * dispatches virtually because {@code setUp} already poisoned the shared profile with other
+ * strategy types. This is the cross-caller pollution failure mode: a caller degraded by callers
+ * it has nothing to do with. (Unpoisoned, this ties {@code iterate_specialized} — type-profile
+ * then devirtualizes it; the poisoning is what type-profile can't survive and the constant can.)
+ */
+ @Benchmark
+ public long iterate_general() {
+ long sum = 0;
+ Iterator it = FlatHashtable.iterator(table, BUCKET_HASH, ItHashStrategyA.INSTANCE);
+ while (it.hasNext()) {
+ sum += it.next().value;
+ }
+ return sum;
+ }
+
+ /**
+ * Entry-specialized iterator: feeds the constant Entry-hash strategy into the shared template, so
+ * {@code hashOf} inlines to {@code entry.hash} structurally (constant type-flow, not
+ * profile) — immune to the poisoned profile that sinks {@code iterate_general}.
+ */
+ @Benchmark
+ public long iterate_specialized() {
+ long sum = 0;
+ Iterator it = FlatHashtable.iterator(table, BUCKET_HASH); // Entry overload
+ while (it.hasNext()) {
+ sum += it.next().value; // hashOf inlined to entry.hash via the constant strategy
+ }
+ return sum;
+ }
+}
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..cb792cc1ca9 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,108 @@ 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.EntryStrategy {
+ // 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(IntEntry entry, String key) {
+ return key.equals(entry.key);
+ }
+
+ @Override
+ public long hashOf(IntEntry entry) {
+ return entry.key.hashCode(); // consistent with the default hashKey
+ }
+ }
+
+ // --- CHA-defeat decoys ---------------------------------------------------------------------
+ // Never used to build a table; loaded (in setUp) only so MatchingStrategy.matches and .hashKey
+ // each have >=2 concrete implementors. That denies C2 the single-implementor CHA devirtualization
+ // of matchStrat.hashKey/matches inside get(). If the strategy calls still inline afterward, the
+ // win is structural (the constant INSTANCE's exact type propagated through the inlined get), not
+ // a
+ // CHA bet that would deopt when a second subclass loads.
+
+ // Second matches impl -> MatchingStrategy.matches is polymorphic.
+ static final class DecoyMatchStrategy extends FlatHashtable.EntryStrategy {
+ static final DecoyMatchStrategy INSTANCE = new DecoyMatchStrategy();
+
+ private DecoyMatchStrategy() {}
+
+ @Override
+ public boolean matches(IntEntry entry, String key) {
+ return key == entry.key; // deliberately different body from IntEntryKeyStrategy
+ }
+
+ @Override
+ public long hashOf(IntEntry entry) {
+ return entry.key.hashCode();
+ }
+ }
+
+ // Overrides hashKey -> MatchingStrategy.hashKey is polymorphic too (default + this override).
+ static final class DecoyHashKeyStrategy extends FlatHashtable.EntryStrategy {
+ static final DecoyHashKeyStrategy INSTANCE = new DecoyHashKeyStrategy();
+
+ private DecoyHashKeyStrategy() {}
+
+ @Override
+ public long hashKey(String key) {
+ return key.length();
+ }
+
+ @Override
+ public boolean matches(IntEntry entry, String key) {
+ return key.equals(entry.key);
+ }
+
+ @Override
+ public long hashOf(IntEntry entry) {
+ return entry.key.length();
+ }
+ }
+
+ // Referenced only so these three concrete implementors load at benchmark class-init, before the
+ // hot method compiles — see the CHA-defeat note above.
+ @SuppressWarnings("unused")
+ static final Object[] CHA_DEFEAT = {
+ IntEntryKeyStrategy.INSTANCE, DecoyMatchStrategy.INSTANCE, DecoyHashKeyStrategy.INSTANCE
+ };
+
+ static IntEntry[] 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 +199,7 @@ public void setUp() {
linkedHashMap = new LinkedHashMap<>();
fill(linkedHashMap);
tagMap = fillTagMap(TagMap.create());
+ flatTable = newFilledFlat();
}
String nextLookupKey() {
@@ -159,6 +260,11 @@ public TagMap create_tagMap_via_ledger() {
return ledger.build();
}
+ @Benchmark
+ public IntEntry[] create_flatHashtable() {
+ return newFilledFlat();
+ }
+
// ---- copy ----
@Benchmark
@@ -200,6 +306,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 +330,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);
+ });
+ }
}
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..1b686591ea4 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;
@@ -21,6 +23,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)
*
*
*
@@ -65,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"
@@ -84,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];
}
@@ -104,6 +113,99 @@ 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.EntryStrategy {
+ static final IntEntryKeyStrategy INSTANCE = new IntEntryKeyStrategy();
+
+ private IntEntryKeyStrategy() {}
+
+ @Override
+ public boolean matches(IntEntry entry, String key) {
+ return key.equals(entry.key);
+ }
+
+ @Override
+ public long hashOf(IntEntry entry) {
+ return entry.key.hashCode(); // consistent with the default hashKey
+ }
+ }
+
+ // --- CHA-defeat decoys ---------------------------------------------------------------------
+ // These are never used to build a table; they exist only to be *loaded* (see CHA_DEFEAT), so
+ // MatchingStrategy.matches and .hashKey each have >=2 concrete implementors. That denies C2 the
+ // single-implementor CHA devirtualization of matchStrat.hashKey/matches inside get(). If the
+ // strategy calls still inline afterward, the win is structural (the constant INSTANCE's exact
+ // type propagated through the inlined get), not a CHA bet that would deopt on a second subclass.
+
+ // Second matches impl -> MatchingStrategy.matches is polymorphic.
+ static final class DecoyMatchStrategy extends FlatHashtable.EntryStrategy {
+ static final DecoyMatchStrategy INSTANCE = new DecoyMatchStrategy();
+
+ private DecoyMatchStrategy() {}
+
+ @Override
+ public boolean matches(IntEntry entry, String key) {
+ return key == entry.key; // deliberately different body from IntEntryKeyStrategy
+ }
+
+ @Override
+ public long hashOf(IntEntry entry) {
+ return entry.key.hashCode();
+ }
+ }
+
+ // Overrides hashKey -> MatchingStrategy.hashKey is polymorphic too (default + this override).
+ static final class DecoyHashKeyStrategy extends FlatHashtable.EntryStrategy {
+ static final DecoyHashKeyStrategy INSTANCE = new DecoyHashKeyStrategy();
+
+ private DecoyHashKeyStrategy() {}
+
+ @Override
+ public long hashKey(String key) {
+ return key.length();
+ }
+
+ @Override
+ public boolean matches(IntEntry entry, String key) {
+ return key.equals(entry.key);
+ }
+
+ @Override
+ public long hashOf(IntEntry entry) {
+ return entry.key.length();
+ }
+ }
+
+ // Referenced only so these three concrete implementors load at benchmark class-init, before the
+ // hot method compiles — see the CHA-defeat note above.
+ @SuppressWarnings("unused")
+ static final Object[] CHA_DEFEAT = {
+ IntEntryKeyStrategy.INSTANCE, DecoyMatchStrategy.INSTANCE, DecoyHashKeyStrategy.INSTANCE
+ };
+
+ static IntEntry[] _create_flat() {
+ // Sized to the key count (FlatHashtable is fixed-capacity, no resize): load factor <= 0.5.
+ IntEntry[] table = FlatHashtable.create(IntEntry.class, INSERTION_KEYS.length);
+ for (int i = 0; i < INSERTION_KEYS.length; ++i) {
+ FlatHashtable.insert(table, new IntEntry(INSERTION_KEYS[i], i), IntEntryKeyStrategy.INSTANCE);
+ }
+ return table;
+ }
+
static final HashMap _create_hashMap() {
HashMap map = new HashMap<>();
fill(map);
@@ -177,4 +279,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);
+ }
}
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..2cbb334f8c3
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java
@@ -0,0 +1,589 @@
+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
+ * 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).
+ *
+ * Bounded by construction — and that is a feature, not just a limit. {@link #create}
+ * takes a cardinality budget, so you cannot build one without deciding how big it may get —
+ * the question whose unasked version becomes an unbounded-growth leak in a long-lived agent living
+ * in someone else's process. A regular {@code Map}'s auto-resize lets you forget that (fine when
+ * you own the heap; the wrong default when you are a guest in one). This table never grows on its
+ * own: {@link #get} / {@link #getOrCreate} / {@link #insert} cap rather than churn — a full
+ * table degrades to recompute-on-miss with bounded memory and no reallocation — and growth is an
+ * explicit, deliberate {@link #resize} / {@link #resizingInsert}. So it defaults to the
+ * bounded-footprint posture the agent needs, with unbounded growth an opt-in you have to reach for
+ * (and one that, over externally-controlled keys, is the leak this structure otherwise prevents —
+ * see {@link #resizingInsert}). The trade only pays when a miss is benign (a cache / interner), not
+ * for a must-hold-everything map.
+ *
+ *
Strategy roles, split by concern. The per-use policy is a small set of {@link Strategy
+ * strategy} objects rather than one, so a caller supplies only what an operation needs:
+ *
+ *
+ * - a {@link MatchingStrategy} — the key side: {@link MatchingStrategy#hashKey hash a
+ * lookup key} (defaults to {@code hashCode}) and {@link MatchingStrategy#matches match} it
+ * against a stored entry. Used by {@link #get} / {@link #getOrCreate}.
+ *
- a {@link HashStrategy} — the entry side: {@link HashStrategy#hashOf hash a stored
+ * entry}. Used by {@link #insert} / {@link #iterator} / {@link #resize} (which have an entry,
+ * not a key). For {@link Entry}-based tables this is just the cached {@link Entry#hash}, so
+ * those get dedicated overloads that need no strategy at all.
+ *
- an {@link EntryStrategy} — both of the above, for a user that does lookups and
+ * inserts; extend this one abstract class and you have the whole policy.
+ *
- a {@link CreateStrategy} — how to mint an entry for a key. Cold (once per key, at warmup),
+ * so it is a {@link FunctionalInterface} you can supply as a non-capturing lambda.
+ *
+ *
+ * {@code
+ * private static final MyStrategy S = new MyStrategy(); // concrete type => exact type pinned
+ * ...
+ * E e = FlatHashtable.getOrCreate(table, key, S, MyEntry::new); // non-capturing create
+ * }
+ *
+ * Contract: {@code table.length} must be a power of two ({@link #capacityFor}). Both
+ * {@link MatchingStrategy#hashKey} and {@link HashStrategy#hashOf} may return a plain {@code
+ * hashCode} — the table owns the spread ({@link #home}) — but they must be consistent:
+ * {@code hashKey(key)} must equal {@code hashOf(entry)} for that key's entry, so a lookup lands
+ * where the entry was placed (trivially true when both default to {@code hashCode}). 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() {}
+
+ /**
+ * 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 a {@link HashStrategy} yourself instead. Caller
+ * contract: {@code hash} must equal the table's {@link MatchingStrategy#hashKey} for this entry's
+ * 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;
+
+ protected Entry(long hash) {
+ this.hash = hash;
+ }
+ }
+
+ /**
+ * Entry-side strategy: the hash of a stored {@code entry} — its home is {@link #home}{@code
+ * (hashOf(entry))}. Used by {@link #insert} / {@link #iterator} / {@link #resize}, which have an
+ * entry rather than a lookup key. Must be consistent with {@link MatchingStrategy#hashKey} (see
+ * the class contract). Supply a {@code static final} constant or a non-capturing lambda so it
+ * stays a single monomorphic instance (see {@link Strategy}); for {@link Entry}-based tables use
+ * the strategy-free overloads, which read {@link Entry#hash} directly.
+ *
+ * @param stored entry
+ */
+ @Strategy
+ @FunctionalInterface
+ public interface HashStrategy {
+ long hashOf(E entry);
+ }
+
+ /**
+ * Key-side strategy: whether a stored {@code entry} is the one for a lookup {@code key} ({@link
+ * #matches}), and how to hash that key ({@link #hashKey}). {@code hashKey} defaults to {@code
+ * key.hashCode()} — override it only when the key's identity needs different hashing (e.g.
+ * case-insensitive), and then keep it consistent with the table's {@link HashStrategy#hashOf}.
+ * Used by {@link #get} / {@link #getOrCreate}.
+ *
+ * A {@link FunctionalInterface} ({@code matches} is the sole abstract method), so the common
+ * case can be a non-capturing lambda; a strategy that also customizes hashing is a named class
+ * that overrides {@code hashKey}.
+ *
+ * @param stored entry
+ * @param lookup key
+ */
+ @Strategy
+ @FunctionalInterface
+ public interface MatchingStrategy {
+ /** Whether the stored {@code entry} is the one for {@code key}. */
+ boolean matches(E entry, K key);
+
+ /**
+ * Hash of {@code key}; defaults to {@code key.hashCode()} (the table applies its own spread).
+ */
+ default long hashKey(K key) {
+ return key.hashCode();
+ }
+ }
+
+ /**
+ * The whole policy for a table you both look up in and insert into: a {@link MatchingStrategy}
+ * (key side) and a {@link HashStrategy} (entry side) in one. Extend as a stateless final
+ * class held in a concrete-typed {@code static final} singleton so the JIT specializes each call
+ * site (see {@link Strategy}); an abstract class (not an interface) so a specialization
+ * miss falls back to {@code invokevirtual} rather than the costlier megamorphic {@code
+ * invokeinterface}.
+ *
+ * @param stored entry
+ * @param lookup key
+ */
+ @Strategy
+ public abstract static class EntryStrategy
+ implements HashStrategy, MatchingStrategy {}
+
+ /**
+ * {@link EntryStrategy} for {@code String} keys compared case-insensitively: seals {@link
+ * #hashKey} to {@link Strings#caseInsensitiveHashCode} (consistent with {@link
+ * String#equalsIgnoreCase}, which callers use in {@link #matches}). Callers still supply {@link
+ * #matches} (typically {@code key.equalsIgnoreCase(entry.key)}) and {@link #hashOf} (the same
+ * case-insensitive hash of the entry's key, or the cached {@link Entry#hash}).
+ *
+ * @param stored entry — self-contained (carries its own key)
+ */
+ public abstract static class CaseInsensitiveStringStrategy extends EntryStrategy {
+ @Override
+ public final long hashKey(String key) {
+ return Strings.caseInsensitiveHashCode(key); // raw; the table spreads before masking
+ }
+ }
+
+ /**
+ * 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 stored entry to create
+ * @param lookup key
+ */
+ @Strategy
+ @FunctionalInterface
+ public interface CreateStrategy {
+ E create(K key);
+ }
+
+ /**
+ * 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);
+ }
+ 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;
+ }
+
+ /**
+ * 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 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.
+ */
+ @StrategyConsumer
+ public static E get(E[] table, K key, MatchingStrategy matchStrat) {
+ final int mask = table.length - 1;
+ final int start = home(matchStrat.hashKey(key), mask);
+ int i = start;
+ for (; ; ) {
+ final E e = table[i];
+ if (e == null) {
+ return null; // empty slot terminates the probe (no tombstones)
+ }
+ if (matchStrat.matches(e, key)) {
+ return e;
+ }
+ i = (i + 1) & mask;
+ if (i == start) {
+ return null; // wrapped ⇒ full, absent
+ }
+ }
+ }
+
+ /**
+ * 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).
+ */
+ @StrategyConsumer
+ public static E getOrCreate(
+ E[] table, K key, MatchingStrategy matchStrat, CreateStrategy createStrat) {
+ final int mask = table.length - 1;
+ final int start = home(matchStrat.hashKey(key), mask);
+ int i = start;
+ for (; ; ) {
+ final E e = table[i];
+ if (e == null) {
+ final E created = createStrat.create(key);
+ table[i] = created; // single-reference publish; benign clobber (see class doc)
+ return created;
+ }
+ if (matchStrat.matches(e, key)) {
+ return e;
+ }
+ i = (i + 1) & mask;
+ if (i == start) {
+ return null; // wrapped ⇒ full
+ }
+ }
+ }
+
+ /**
+ * 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 HashStrategy}-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
+ * HashStrategy#hashOf}. Same comparison-free, caller-ensures-absence contract.
+ */
+ @StrategyConsumer
+ public static boolean insert(E[] table, E entry, HashStrategy hashStrat) {
+ return placeAt(table, entry, hashStrat.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 = home(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
+ }
+ }
+ }
+
+ /**
+ * 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 strategy 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 HashStrategy}-taking overload for {@link Entry}-based entries
+ * (the home comes from {@link Entry#hash}). See {@link #resizingInsert(Entry[], Entry)} 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
+ * HashStrategy#hashOf}. Not a {@link StrategyConsumer} — the rehash is a cold, one-off traversal,
+ * not a hot specialization site.
+ */
+ public static E[] resize(E[] table, HashStrategy hashStrat) {
+ E[] grown = allocateGrown(table);
+ for (final E e : table) {
+ if (e != null) {
+ placeAt(grown, e, hashStrat.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
+ * HashStrategy#hashOf}). Same grows-unboundedly caution.
+ */
+ @StrategyConsumer
+ public static E[] resizingInsert(E[] table, E entry, HashStrategy hashStrat) {
+ E[] t = table;
+ while (!insert(t, entry, hashStrat)) {
+ t = resize(t, hashStrat);
+ }
+ return t;
+ }
+
+ /** Applies {@code consumer} to every entry in {@code table} (skipping empty slots); any order. */
+ public static void forEach(E[] table, Consumer super E> 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 super C, ? super E> 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 HashStrategy#hashOf} equals {@code hash},
+ * stopping at the first empty slot (the FlatHashtable analogue of walking a chained bucket).
+ *
+ * This general overload holds the strategy in a field, so {@code hashOf} is called virtually
+ * (not inlined). For {@link Entry}-based tables prefer {@link #iterator(Entry[], long)}, which
+ * specializes the traversal so {@code hashOf} inlines. (Still pass a {@code static final}
+ * strategy to avoid a per-call allocation.)
+ */
+ public static Iterator iterator(E[] table, long hash, HashStrategy hashStrat) {
+ return new StrategyHashIterator<>(table, hash, hashStrat);
+ }
+
+ /**
+ * {@link #iterator(Object[], long, HashStrategy)} for {@link Entry}-based tables: the filter hash
+ * comes from {@link Entry#hash}, so no strategy is needed. Returns a specialized iterator that
+ * feeds the {@link Entry}-hash strategy singleton into the shared traversal template as a
+ * constant, so {@code hashOf} devirtualizes to {@code entry.hash} and inlines — a monomorphic
+ * call site thus gets a devirtualized pull-based traversal while keeping the plain {@link
+ * Iterator} API and reusing the same core (see the {@link HashIterator} base). Same {@code
+ * Iterator} return type as the general overload; the call site specializes by its own
+ * monomorphism.
+ */
+ public static Iterator iterator(E[] table, long hash) {
+ return new EntryHashIterator<>(table, hash);
+ }
+
+ /**
+ * Shared iterator core. The traversal lives in {@code final} template methods parameterized by
+ * the strategy ({@code advanceWith}/{@code nextWith}); each concrete subclass implements {@link
+ * #next} by handing in its strategy source — a field (general) or a {@code static final} constant
+ * (Entry). Feeding a constant into the {@code final} template is what lets the specialized
+ * subclass inline {@code hashOf} (the CacheHelper static-polymorphism move), so the two share all
+ * the mechanism yet differ only in whether the strategy call devirtualizes.
+ */
+ private abstract static class HashIterator implements Iterator {
+ final E[] table;
+ final long hash;
+ final int start;
+ int i;
+ boolean done;
+ E lookahead;
+
+ HashIterator(E[] table, long hash) {
+ this.table = table;
+ this.hash = hash;
+ this.start = home(hash, table.length - 1);
+ this.i = this.start;
+ // Priming advance() is left to the concrete ctor: it needs the subclass's strategy source,
+ // which isn't set until after super().
+ }
+
+ /**
+ * Template traversal core, parameterized by the strategy. {@code final} so that a subclass
+ * passing a constant strategy inlines this and devirtualizes {@code hashOf}.
+ */
+ final void advanceWith(HashStrategy hashStrat) {
+ 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 = hashStrat.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;
+ }
+ }
+ }
+
+ final E nextWith(HashStrategy hashStrat) {
+ final E e = lookahead;
+ if (e == null) {
+ throw new NoSuchElementException();
+ }
+ advanceWith(hashStrat);
+ return e;
+ }
+
+ @Override
+ public final boolean hasNext() {
+ return lookahead != null;
+ }
+
+ // Abstract so each subclass injects its own strategy source into nextWith(); that binding is
+ // what lets the Entry variant inline hashOf while the general one stays virtual.
+ @Override
+ public abstract E next();
+ }
+
+ /** General iterator: strategy held in a field, so {@code hashOf} stays a virtual call. */
+ private static final class StrategyHashIterator extends HashIterator {
+ private final HashStrategy hashStrat;
+
+ StrategyHashIterator(E[] table, long hash, HashStrategy hashStrat) {
+ super(table, hash);
+ this.hashStrat = hashStrat;
+ advanceWith(hashStrat); // prime
+ }
+
+ @Override
+ public E next() {
+ return nextWith(hashStrat);
+ }
+ }
+
+ /**
+ * Entry iterator: feeds the constant {@link #ENTRY_HASH} singleton, so {@code hashOf} inlines to
+ * {@code entry.hash}.
+ */
+ private static final class EntryHashIterator extends HashIterator {
+ EntryHashIterator(E[] table, long hash) {
+ super(table, hash);
+ advanceWith(entryHash()); // prime with the constant
+ }
+
+ @Override
+ public E next() {
+ return nextWith(entryHash());
+ }
+ }
+
+ /**
+ * Stateless {@link HashStrategy} that reads the cached {@link Entry#hash} — the constant fed into
+ * the Entry iterator template so {@code hashOf} devirtualizes.
+ */
+ private static final HashStrategy ENTRY_HASH = entry -> entry.hash;
+
+ @SuppressWarnings("unchecked")
+ private static HashStrategy entryHash() {
+ return (HashStrategy) ENTRY_HASH; // safe: hashOf only reads Entry.hash, present on all E
+ }
+}
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..bf862c44802 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,28 @@ 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.
+ *
+ *
Folds per {@code char} (UTF-16 unit), which is exactly what {@code equalsIgnoreCase} itself
+ * does — so a supplementary case pair (e.g. U+10400 / U+10428) is treated as distinct by
+ * both, and the two remain consistent. This mirrors {@code equalsIgnoreCase} rather than doing
+ * full code-point case folding; a code-point fold would unify pairs {@code equalsIgnoreCase} does
+ * not, making the hash inconsistent with it.
+ */
+ 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
new file mode 100644
index 00000000000..666f7520fb9
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java
@@ -0,0 +1,538 @@
+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 TestEntry {
+ final String key;
+
+ TestEntry(String key) {
+ this.key = key;
+ }
+ }
+
+ /**
+ * Stateless concrete strategy over String keys, exposed as a canonical {@code INSTANCE} singleton
+ * (private ctor) so the JIT can specialize each call site. {@code hashKey} defaults to {@code
+ * key.hashCode()}; {@code hashOf} recomputes from the entry's key (this entry doesn't cache it),
+ * consistently with that default.
+ */
+ static final class TestEntryStrategy extends FlatHashtable.EntryStrategy {
+ static final TestEntryStrategy INSTANCE = new TestEntryStrategy();
+
+ private TestEntryStrategy() {}
+
+ @Override
+ public boolean matches(TestEntry entry, String key) {
+ return key.equals(entry.key);
+ }
+
+ @Override
+ public long hashOf(TestEntry entry) {
+ return entry.key.hashCode(); // consistent with the default hashKey (key.hashCode())
+ }
+ }
+
+ /** Non-capturing create strategy (a constructor method ref => singleton-cached, alloc-free). */
+ private static final FlatHashtable.CreateStrategy CREATE = TestEntry::new;
+
+ /** All keys/entries hash to slot 0, so inserts chain by linear probing — exercises the probe. */
+ static final class TestCollidingStrategy extends FlatHashtable.EntryStrategy {
+ static final TestCollidingStrategy INSTANCE = new TestCollidingStrategy();
+
+ private TestCollidingStrategy() {}
+
+ @Override
+ public long hashKey(String key) {
+ return 0;
+ }
+
+ @Override
+ public boolean matches(TestEntry entry, String key) {
+ return key.equals(entry.key);
+ }
+
+ @Override
+ public long hashOf(TestEntry entry) {
+ return 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 TestLastSlotStrategy extends FlatHashtable.EntryStrategy {
+ static final TestLastSlotStrategy INSTANCE = new TestLastSlotStrategy();
+ private static final long LAST_SLOT_HASH = hashLandingOn(1, 1); // slot 1 of a 2-slot table
+
+ private TestLastSlotStrategy() {}
+
+ @Override
+ public long hashKey(String key) {
+ return LAST_SLOT_HASH;
+ }
+
+ @Override
+ public boolean matches(TestEntry entry, String key) {
+ return key.equals(entry.key);
+ }
+
+ @Override
+ public long hashOf(TestEntry entry) {
+ return LAST_SLOT_HASH;
+ }
+ }
+
+ /**
+ * 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(key.hashCode()); // cache the default hashKey (key.hashCode())
+ this.key = key;
+ }
+ }
+
+ /** Strategy for {@link TestHashedEntry}: {@code hashOf} reads the cached hash. */
+ static final class TestHashedStrategy
+ extends FlatHashtable.EntryStrategy {
+ static final TestHashedStrategy INSTANCE = new TestHashedStrategy();
+
+ private TestHashedStrategy() {}
+
+ @Override
+ public boolean matches(TestHashedEntry entry, String key) {
+ return key.equals(entry.key);
+ }
+
+ @Override
+ public long hashOf(TestHashedEntry entry) {
+ return entry.hash;
+ }
+ }
+
+ /**
+ * Case-insensitive strategy: {@code hashKey} sealed by the CI base, {@code matches} folds case.
+ */
+ static final class TestCaseInsensitiveStrategy
+ extends FlatHashtable.CaseInsensitiveStringStrategy {
+ static final TestCaseInsensitiveStrategy INSTANCE = new TestCaseInsensitiveStrategy();
+
+ private TestCaseInsensitiveStrategy() {}
+
+ @Override
+ public boolean matches(TestEntry entry, String key) {
+ return key.equalsIgnoreCase(entry.key);
+ }
+
+ @Override
+ public long hashOf(TestEntry entry) {
+ return Strings.caseInsensitiveHashCode(entry.key);
+ }
+ }
+
+ @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() {
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 4);
+ assertEquals(8, table.length);
+ assertEquals(TestEntry.class, table.getClass().getComponentType());
+ }
+
+ @Test
+ void getOrCreate_insertsOnceAndReturnsTheExistingEntry() {
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 8);
+ TestEntry first = FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.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", TestEntryStrategy.INSTANCE, CREATE));
+ assertSame(first, FlatHashtable.get(table, "a", TestEntryStrategy.INSTANCE));
+ }
+
+ @Test
+ void get_returnsNullForAbsentKey() {
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 8);
+ assertNull(FlatHashtable.get(table, "missing", TestEntryStrategy.INSTANCE));
+ FlatHashtable.getOrCreate(table, "present", TestEntryStrategy.INSTANCE, CREATE);
+ assertNull(FlatHashtable.get(table, "still-missing", TestEntryStrategy.INSTANCE));
+ }
+
+ @Test
+ void getOrCreate_returnsNullWhenTableIsFull() {
+ // capacityFor(1) == 2 slots.
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 1);
+ assertTrue(FlatHashtable.getOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE) != null);
+ assertTrue(FlatHashtable.getOrCreate(table, "k1", TestEntryStrategy.INSTANCE, CREATE) != null);
+ // Both slots occupied by distinct keys -> a third distinct key finds no room.
+ assertNull(FlatHashtable.getOrCreate(table, "k2", TestEntryStrategy.INSTANCE, CREATE));
+ // ...but an existing key still resolves even when full.
+ assertSame(
+ FlatHashtable.get(table, "k0", TestEntryStrategy.INSTANCE),
+ FlatHashtable.getOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE));
+ }
+
+ @Test
+ void hashKey_isStableForEqualKeys() {
+ assertEquals(
+ TestEntryStrategy.INSTANCE.hashKey("route"),
+ TestEntryStrategy.INSTANCE.hashKey(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", TestCollidingStrategy.INSTANCE, CREATE);
+ // slot 0 taken -> 1
+ TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE);
+ // -> slot 2
+ TestEntry c = FlatHashtable.getOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE);
+
+ assertNotSame(a, b);
+ assertNotSame(b, c);
+
+ // each resolves via probe-past-occupied + match-after-probe
+ assertSame(a, FlatHashtable.get(table, "a", TestCollidingStrategy.INSTANCE));
+ assertSame(b, FlatHashtable.get(table, "b", TestCollidingStrategy.INSTANCE));
+ assertSame(c, FlatHashtable.get(table, "c", TestCollidingStrategy.INSTANCE));
+
+ // existing colliding key: found after probing, no new entry minted
+ assertSame(b, FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE));
+
+ // absent key: probe past the 3 occupied slots, hit an empty slot -> null
+ assertNull(FlatHashtable.get(table, "absent", TestCollidingStrategy.INSTANCE));
+ }
+
+ @Test
+ void collision_probeWrapsAroundToFront() {
+ // 2 slots (0,1), mask=1; LAST_SLOT starts at 1
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 1);
+ // -> slot 1
+ TestEntry k0 = FlatHashtable.getOrCreate(table, "k0", TestLastSlotStrategy.INSTANCE, CREATE);
+ // taken -> wraps to 0
+ TestEntry k1 = FlatHashtable.getOrCreate(table, "k1", TestLastSlotStrategy.INSTANCE, CREATE);
+
+ assertNotSame(k0, k1);
+ assertSame(k0, FlatHashtable.get(table, "k0", TestLastSlotStrategy.INSTANCE));
+ // start slot 1 is occupied (no match) -> probe wraps to slot 0 -> match
+ assertSame(k1, FlatHashtable.get(table, "k1", TestLastSlotStrategy.INSTANCE));
+ }
+
+ @Test
+ void get_returnsNullWhenTableFullAndKeyAbsent() {
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots
+ FlatHashtable.getOrCreate(table, "k0", TestCollidingStrategy.INSTANCE, CREATE);
+ // fills slots 0 and 1
+ FlatHashtable.getOrCreate(table, "k1", TestCollidingStrategy.INSTANCE, CREATE);
+
+ // get() probes both occupied slots, wraps back to start -> null (get's full-wrap branch)
+ assertNull(FlatHashtable.get(table, "absent", TestCollidingStrategy.INSTANCE));
+ }
+
+ @Test
+ void insert_generalFlavor_placesViaHashOfAndResolves() {
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 8);
+ TestEntry e = new TestEntry("a");
+ // flavor 2: the home comes from TestEntryStrategy.INSTANCE.hashOf(e)
+ assertTrue(FlatHashtable.insert(table, e, TestEntryStrategy.INSTANCE));
+ assertSame(e, FlatHashtable.get(table, "a", TestEntryStrategy.INSTANCE));
+ }
+
+ @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", TestHashedStrategy.INSTANCE));
+ }
+
+ @Test
+ void insert_returnsFalseWhenFull() {
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots
+ assertTrue(FlatHashtable.insert(table, new TestEntry("k0"), TestEntryStrategy.INSTANCE));
+ assertTrue(FlatHashtable.insert(table, new TestEntry("k1"), TestEntryStrategy.INSTANCE));
+ // no room
+ assertFalse(FlatHashtable.insert(table, new TestEntry("k2"), TestEntryStrategy.INSTANCE));
+ }
+
+ @Test
+ void forEach_visitsEveryEntry() {
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 8);
+ FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE);
+ FlatHashtable.getOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE);
+ FlatHashtable.getOrCreate(table, "c", TestEntryStrategy.INSTANCE, 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", TestEntryStrategy.INSTANCE, CREATE);
+ FlatHashtable.getOrCreate(table, "b", TestEntryStrategy.INSTANCE, 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", TestCollidingStrategy.INSTANCE, CREATE);
+ TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE);
+ TestEntry c = FlatHashtable.getOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE);
+
+ Set seen = new HashSet<>();
+ Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE);
+ 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", TestCollidingStrategy.INSTANCE, CREATE);
+ FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE);
+
+ // 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, TestCollidingStrategy.INSTANCE);
+ assertFalse(it.hasNext());
+ }
+
+ @Test
+ void iterator_emptyRunHasNoNext() {
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 4);
+ Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE);
+ assertFalse(it.hasNext());
+ assertThrows(NoSuchElementException.class, it::next);
+ }
+
+ @Test
+ void iterator_fullTable_yieldsMatchesIncludingTheWrappingSlot() {
+ // 2 slots, both filled by colliding (hash 0) entries -> the probe has no empty slot to stop at,
+ // so the traversal must yield the entry on the wrapping slot and then terminate on wrap.
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 1);
+ TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE);
+ TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE);
+
+ Set seen = new HashSet<>();
+ Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE);
+ while (it.hasNext()) {
+ seen.add(it.next());
+ }
+ assertEquals(new HashSet<>(Arrays.asList(a, b)), seen);
+ }
+
+ @Test
+ void iterator_fullTable_absentHash_terminatesOnWrap() {
+ // Full table, iterating a hash no stored entry has (all hashOf == 0) -> the traversal walks
+ // every slot and wraps without ever hitting an empty one, then reports no elements.
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 1);
+ FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE);
+ FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE);
+
+ Iterator it = FlatHashtable.iterator(table, 5, TestCollidingStrategy.INSTANCE);
+ assertFalse(it.hasNext());
+ }
+
+ @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() {
+ // 8 slots with only 2 entries -> the rehash loop skips empty slots as well as copying entries.
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 4);
+ FlatHashtable.insert(table, new TestEntry("a"), TestEntryStrategy.INSTANCE);
+ FlatHashtable.insert(table, new TestEntry("b"), TestEntryStrategy.INSTANCE);
+
+ TestEntry[] grown = FlatHashtable.resize(table, TestEntryStrategy.INSTANCE);
+ assertEquals(16, grown.length);
+ assertNotSame(table, grown);
+ assertEquals("a", FlatHashtable.get(grown, "a", TestEntryStrategy.INSTANCE).key);
+ assertEquals("b", FlatHashtable.get(grown, "b", TestEntryStrategy.INSTANCE).key);
+ }
+
+ @Test
+ void resize_entryFlavor_growsAndKeepsEveryEntryFindable() {
+ TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 4); // 8 slots, 2 entries
+ FlatHashtable.insert(table, new TestHashedEntry("a"));
+ FlatHashtable.insert(table, new TestHashedEntry("b"));
+
+ TestHashedEntry[] grown = FlatHashtable.resize(table);
+ assertEquals(16, grown.length);
+ assertEquals("a", FlatHashtable.get(grown, "a", TestHashedStrategy.INSTANCE).key);
+ assertEquals("b", FlatHashtable.get(grown, "b", TestHashedStrategy.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), TestEntryStrategy.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, TestEntryStrategy.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, TestHashedStrategy.INSTANCE).key);
+ }
+ }
+
+ /** Entry with an explicitly-set cached hash — lets a test force a shared-hash bucket. */
+ static final class TestHashEntry extends FlatHashtable.Entry {
+ final String key;
+
+ TestHashEntry(String key, long hash) {
+ super(hash);
+ this.key = key;
+ }
+ }
+
+ @Test
+ void entryIterator_yieldsEveryEntrySharingTheHash() {
+ TestHashEntry[] table = FlatHashtable.create(TestHashEntry.class, 4);
+ TestHashEntry a = new TestHashEntry("a", 0);
+ TestHashEntry b = new TestHashEntry("b", 0);
+ TestHashEntry c = new TestHashEntry("c", 0);
+ FlatHashtable.insert(table, a);
+ FlatHashtable.insert(table, b);
+ FlatHashtable.insert(table, c);
+
+ Set seen = new HashSet<>();
+ // 2-arg Entry overload -> the specialized iterator (constant strategy, inlined hashOf).
+ Iterator it = FlatHashtable.iterator(table, 0);
+ while (it.hasNext()) {
+ seen.add(it.next());
+ }
+ assertEquals(new HashSet<>(Arrays.asList(a, b, c)), seen);
+ }
+
+ @Test
+ void entryIterator_filtersOutEntriesWithADifferentHash() {
+ TestHashEntry[] table = FlatHashtable.create(TestHashEntry.class, 4); // 8 slots, mask 7
+ FlatHashtable.insert(table, new TestHashEntry("a", 0));
+ FlatHashtable.insert(table, new TestHashEntry("b", 0));
+ // shares the home slot (0) but a different hash -> must be filtered out
+ long otherHash = hashLandingOn(0, table.length - 1);
+ FlatHashtable.insert(table, new TestHashEntry("c", otherHash));
+
+ int yielded = 0;
+ Iterator it = FlatHashtable.iterator(table, 0);
+ while (it.hasNext()) {
+ assertEquals(0, it.next().hash);
+ yielded++;
+ }
+ assertEquals(2, yielded);
+ }
+
+ @Test
+ void entryIterator_emptyRunHasNoNext() {
+ TestHashEntry[] table = FlatHashtable.create(TestHashEntry.class, 4);
+ Iterator it = FlatHashtable.iterator(table, 0);
+ assertFalse(it.hasNext());
+ assertThrows(NoSuchElementException.class, it::next);
+ }
+
+ @Test
+ void caseInsensitiveStrategy_matchesRegardlessOfCase() {
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 4);
+ TestEntry stored =
+ FlatHashtable.getOrCreate(
+ table, "Content-Type", TestCaseInsensitiveStrategy.INSTANCE, CREATE);
+
+ // Look-ups in any case resolve to the same stored entry, allocation-free.
+ assertSame(
+ stored, FlatHashtable.get(table, "content-type", TestCaseInsensitiveStrategy.INSTANCE));
+ assertSame(
+ stored, FlatHashtable.get(table, "CONTENT-TYPE", TestCaseInsensitiveStrategy.INSTANCE));
+ assertSame(
+ stored, FlatHashtable.get(table, "cOnTeNt-TyPe", TestCaseInsensitiveStrategy.INSTANCE));
+ // getOrCreate with a differently-cased key does not mint a second entry.
+ assertSame(
+ stored,
+ FlatHashtable.getOrCreate(
+ table, "CONTENT-TYPE", TestCaseInsensitiveStrategy.INSTANCE, CREATE));
+ assertNull(FlatHashtable.get(table, "content-length", TestCaseInsensitiveStrategy.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");
+ }
+ }
+ }
+ }
+}