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

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

    + * + *
    {@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 consumer) { + for (final E e : table) { + if (e != null) { + consumer.accept(e); + } + } + } + + /** + * Context-passing {@link #forEach(Object[], Consumer)}: pair a non-capturing {@link BiConsumer} + * (typically a {@code static final}) with side-band {@code context} to avoid a per-call closure. + */ + public static void forEach( + E[] table, C context, BiConsumer consumer) { + for (final E e : table) { + if (e != null) { + consumer.accept(context, e); + } + } + } + + /** + * Read-only iterator over the entries sharing {@code hash} — walks the probe run from {@code + * hash}'s home and yields each entry whose {@link 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"); + } + } + } + } +}