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/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java
index 11572aa923d..6409bf045e8 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,107 @@ 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);
+ }
+ }
+
+ // --- 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);
+ 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 +198,7 @@ public void setUp() {
linkedHashMap = new LinkedHashMap<>();
fill(linkedHashMap);
tagMap = fillTagMap(TagMap.create());
+ flatTable = newFilledFlat();
}
String nextLookupKey() {
@@ -159,6 +259,11 @@ public TagMap create_tagMap_via_ledger() {
return ledger.build();
}
+ @Benchmark
+ public IntEntry[] create_flatHashtable() {
+ return newFilledFlat();
+ }
+
// ---- copy ----
@Benchmark
@@ -200,6 +305,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 +329,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..94925664938 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.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);
+ }
+ }
+
+ // --- 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);
+ 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..fbc9cdc4597
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java
@@ -0,0 +1,511 @@
+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).
+ *
+ * Two strategies, split by concern. The per-use policy is two {@link Strategy strategy}
+ * objects rather than one:
+ *
+ *
+ * - a {@link KeyStrategy} — how to {@link KeyStrategy#hash hash} a lookup key and {@link
+ * KeyStrategy#matches match} it against a stored entry. Key-identity is intrinsic to the key
+ * type, so this is shared and reused (one {@link StringKeyStrategy} serves every
+ * String-keyed table); it is on the hot path (every probe), so it is an abstract class held
+ * as a concrete-typed {@code static final} constant to specialize (see {@link Strategy}).
+ *
- a {@link CreateStrategy} — how to mint an entry for a key. Creation varies per use case and
+ * is on the cold path (once per key, at warmup), so it is a {@link FunctionalInterface} you
+ * can supply as a non-capturing lambda.
+ *
+ *
+ * {@code
+ * private static final MyKeyStrategy KEYS = new MyKeyStrategy(); // concrete type => exact type pinned
+ * ...
+ * E e = FlatHashtable.getOrCreate(table, key, KEYS, MyEntry::new); // non-capturing create
+ * }
+ *
+ * Contract: {@code table.length} must be a power of two ({@link #capacityFor}). {@code
+ * 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() {}
+
+ /**
+ * 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 (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;
+ }
+ }
+
+ /**
+ * 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 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}. 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)
+ */
+ @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). 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);
+
+ /** Whether the stored {@code entry} is the one for {@code key}. */
+ public abstract boolean matches(K key, E entry);
+
+ /**
+ * 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 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) {
+ 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
+ }
+ }
+
+ /**
+ * {@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);
+ }
+
+ /**
+ * 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, KeyStrategy keyStrat) {
+ final int mask = table.length - 1;
+ final int start = home(keyStrat.hash(key), mask);
+ int i = start;
+ for (; ; ) {
+ final E e = table[i];
+ if (e == null) {
+ return null; // empty slot terminates the probe (no tombstones)
+ }
+ if (keyStrat.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 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, KeyStrategy keyStrat, CreateStrategy createStrat) {
+ final int mask = table.length - 1;
+ final int start = home(keyStrat.hash(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 (keyStrat.matches(key, e)) {
+ 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 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, E> 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 = 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 {@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, E> 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, E> 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 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 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, E> 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, E> keyStrat;
+ private final int start;
+ private int i;
+ private boolean done;
+ private E lookahead;
+
+ HashIterator(E[] table, long hash, KeyStrategy, E> keyStrat) {
+ this.table = table;
+ this.hash = hash;
+ this.keyStrat = keyStrat;
+ this.start = home(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/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
new file mode 100644
index 00000000000..7bf39c7f78f
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java
@@ -0,0 +1,458 @@
+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 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);
+ }
+
+ @Override
+ public long hashOf(TestEntry entry) {
+ return hash(entry.key);
+ }
+ }
+
+ /** 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;
+ }
+
+ @Override
+ public boolean matches(String key, TestEntry entry) {
+ return key.equals(entry.key);
+ }
+
+ @Override
+ public long hashOf(TestEntry entry) {
+ return hash(entry.key);
+ }
+ }
+
+ /**
+ * 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 LAST_SLOT_HASH;
+ }
+
+ @Override
+ public boolean matches(String key, TestEntry entry) {
+ return key.equals(entry.key);
+ }
+
+ @Override
+ public long hashOf(TestEntry entry) {
+ return hash(entry.key);
+ }
+ }
+
+ /**
+ * 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) {
+ // cache the same hash TestEntryKeyStrategy uses for lookups
+ super(TestEntryKeyStrategy.INSTANCE.hash(key));
+ this.key = key;
+ }
+ }
+
+ /** 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 TestEntryKeyStrategy.INSTANCE.hash(key);
+ }
+
+ @Override
+ public boolean matches(String key, TestHashedEntry entry) {
+ return key.equals(entry.key);
+ }
+ }
+
+ /**
+ * 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));
+ 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", 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", 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", 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", 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", TestEntryKeyStrategy.INSTANCE, CREATE));
+ // ...but an existing key still resolves even when full.
+ assertSame(
+ FlatHashtable.get(table, "k0", TestEntryKeyStrategy.INSTANCE),
+ FlatHashtable.getOrCreate(table, "k0", TestEntryKeyStrategy.INSTANCE, CREATE));
+ }
+
+ @Test
+ void stringKeyStrategy_hashIsStableForEqualKeys() {
+ 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", 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", 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", TestCollidingKeyStrategy.INSTANCE, CREATE));
+
+ // absent key: probe past the 3 occupied slots, hit an empty slot -> null
+ 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);
+ // -> 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", TestLastSlotKeyStrategy.INSTANCE));
+ // start slot 1 is occupied (no match) -> probe wraps to slot 0 -> match
+ 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", 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", 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 TestEntryKeyStrategy.INSTANCE.hashOf(e)
+ assertTrue(FlatHashtable.insert(table, e, TestEntryKeyStrategy.INSTANCE));
+ assertSame(e, FlatHashtable.get(table, "a", TestEntryKeyStrategy.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", TestHashedKeyStrategy.INSTANCE));
+ }
+
+ @Test
+ void insert_returnsFalseWhenFull() {
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots
+ 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", 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));
+ 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", TestEntryKeyStrategy.INSTANCE, CREATE);
+ FlatHashtable.getOrCreate(table, "b", TestEntryKeyStrategy.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", 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, TestCollidingKeyStrategy.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", TestCollidingKeyStrategy.INSTANCE, CREATE);
+ FlatHashtable.getOrCreate(table, "b", TestCollidingKeyStrategy.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, TestCollidingKeyStrategy.INSTANCE);
+ assertFalse(it.hasNext());
+ }
+
+ @Test
+ void iterator_emptyRunHasNoNext() {
+ TestEntry[] table = FlatHashtable.create(TestEntry.class, 4);
+ Iterator it = FlatHashtable.iterator(table, 0, TestCollidingKeyStrategy.INSTANCE);
+ 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");
+ }
+ }
+ }
+ }
+}