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

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

    + * + *
    {@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 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 keyStrat) { + E[] grown = allocateGrown(table); + for (final E e : table) { + if (e != null) { + placeAt(grown, e, keyStrat.hashOf(e)); + } + } + return grown; + } + + /** + * A new, empty table of twice the capacity, of the same runtime component type as {@code table}. + */ + @SuppressWarnings("unchecked") + private static E[] allocateGrown(E[] table) { + return (E[]) Array.newInstance(table.getClass().getComponentType(), table.length << 1); + } + + /** + * {@link #insert(Entry[], Entry) insert} that grows on demand: adds {@code entry}, {@link + * #resize(Entry[]) resizing} first if the table is full, and returns the table to store back — + * the same array if it fit, a new larger one if it grew: + * + *

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

    Grows unboundedly. Unlike {@code insert}'s {@code false}, this hides the full signal, + * so it is the easiest place to leak memory: use it only for a genuinely bounded key domain, + * never over externally-controlled cardinality. + */ + public static E[] resizingInsert(E[] table, E entry) { + E[] t = table; + while (!insert(t, entry)) { + t = resize(t); // one doubling always suffices; the loop is belt-and-braces + } + return t; + } + + /** + * {@link #resizingInsert(Entry[], Entry)} for any entry type (home via {@link + * KeyStrategy#hashOf}). Same grows-unboundedly caution. + */ + @StrategyConsumer + public static E[] resizingInsert(E[] table, E entry, KeyStrategy keyStrat) { + E[] t = table; + while (!insert(t, entry, keyStrat)) { + t = resize(t, keyStrat); + } + return t; + } + + /** Applies {@code consumer} to every entry in {@code table} (skipping empty slots); any order. */ + public static void forEach(E[] table, Consumer consumer) { + for (final E e : table) { + if (e != null) { + consumer.accept(e); + } + } + } + + /** + * Context-passing {@link #forEach(Object[], Consumer)}: pair a non-capturing {@link BiConsumer} + * (typically a {@code static final}) with side-band {@code context} to avoid a per-call closure. + */ + public static void forEach( + E[] table, C context, BiConsumer consumer) { + for (final E e : table) { + if (e != null) { + consumer.accept(context, e); + } + } + } + + /** + * Read-only iterator over the entries sharing {@code hash} — walks the probe run from {@code + * hash}'s home and yields each entry whose {@link KeyStrategy#hashOf} equals {@code hash}, + * stopping at the first empty slot (the FlatHashtable analogue of walking a chained bucket). The + * key type is irrelevant, so any {@link KeyStrategy} for {@code E} works. + * + *

    Deliberately not a {@link StrategyConsumer}: iteration goes through the {@link + * Iterator} interface and calls {@code hashOf} virtually, so the strategy does not inline here — + * this is a cold traversal, not a hot specialization site. (Still pass a {@code static final} + * strategy to avoid a per-call allocation.) + */ + public static Iterator iterator(E[] table, long hash, KeyStrategy keyStrat) { + return new HashIterator<>(table, hash, keyStrat); + } + + private static final class HashIterator implements Iterator { + private final E[] table; + private final long hash; + private final KeyStrategy keyStrat; + private final int start; + private int i; + private boolean done; + private E lookahead; + + HashIterator(E[] table, long hash, KeyStrategy keyStrat) { + this.table = table; + this.hash = hash; + this.keyStrat = keyStrat; + this.start = 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"); + } + } + } + } +}