diff --git a/internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java
new file mode 100644
index 00000000000..10cd0a0faea
--- /dev/null
+++ b/internal-api/src/jmh/java/datadog/trace/util/LightMapGrowBenchmark.java
@@ -0,0 +1,314 @@
+package datadog.trace.util;
+
+import datadog.trace.util.LightMap.EmbeddingSupport;
+import de.thetaphi.forbiddenapis.SuppressForbidden;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * Measures three candidate grow triggers for {@code LightMap}, to decide whether to reframe "when
+ * to grow" from a load-factor threshold to a maximum-probe bound.
+ *
+ *
+ *
{@code FULL} -- grow only when the table is physically full (the pre-existing spine
+ * behavior; the probe walks the whole table before giving up).
+ *
{@code LOAD_FACTOR} -- grow before the live count would exceed 0.75 of the slots (the
+ * currently committed object-tier policy; needs a maintained live count).
+ *
{@code MAX_PROBES} -- grow when an insertion would land more than {@code K} slots from its
+ * home slot (stateless; derived from the probe walk, so it can live entirely in the spine).
+ *
+ *
+ *
All three arms drive the same real {@code EmbeddingSupport} primitives (this
+ * benchmark lives in {@code datadog.trace.util} so it can call the package-private spine directly),
+ * so the only difference between arms is the grow decision.
+ *
+ *
The {@code @Benchmark} methods answer the hot-path tax question (does the extra probe
+ * distance check cost anything on the tiny, miss-dominated majority case?). The {@link #main}
+ * method runs a deterministic, timing-free analysis of the behavior question (grow frequency
+ * and the probe-length distribution each trigger produces), including the tail that a mean would
+ * hide.
+ */
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Fork(1)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 3, time = 1)
+@State(Scope.Thread)
+public class LightMapGrowBenchmark {
+
+ static final int SEED_SLOTS = 8;
+ static final int LOAD_FACTOR_RESERVE_SHIFT = 2; // 1/4 reserve => 0.75 trigger
+ static final int MAX_PROBES = 8;
+
+ public enum Trigger {
+ FULL,
+ LOAD_FACTOR,
+ MAX_PROBES
+ }
+
+ public enum Regime {
+ // springweb6 localAttributes: a handful of interned keys, get-heavy, some maps stay empty.
+ TINY,
+ // where triggers begin to diverge.
+ MEDIUM,
+ // keys crafted to collide to one home slot -- where MAX_PROBES should bound the tail that
+ // LOAD_FACTOR / FULL let run long.
+ ADVERSARIAL
+ }
+
+ // ---- key sets -------------------------------------------------------------------------------
+
+ static String[] tinyKeys() {
+ return new String[] {
+ "org.springframework.web.servlet.HandlerMapping.bestMatchingPattern",
+ "org.springframework.web.servlet.HandlerMapping.pathWithinHandlerMapping",
+ "org.springframework.web.servlet.HandlerMapping.uriTemplateVariables",
+ "org.springframework.web.servlet.HandlerMapping.producibleMediaTypes",
+ "org.springframework.web.servlet.View.selectedContentType"
+ };
+ }
+
+ static String[] mediumKeys() {
+ String[] keys = new String[64];
+ for (int i = 0; i < keys.length; i++) {
+ keys[i] = "attribute.key.number." + i;
+ }
+ return keys;
+ }
+
+ // Strings whose home slot (after the spine's hash spread) collides mod 64, and therefore also mod
+ // every smaller power-of-two size the map passes through while growing from the 8-slot seed.
+ static String[] adversarialKeys(int count) {
+ List keys = new ArrayList<>(count);
+ int target = EmbeddingSupport.preferredSlot(64, "seed".hashCode());
+ for (int i = 0; keys.size() < count; i++) {
+ String candidate = "adv" + i;
+ if (EmbeddingSupport.preferredSlot(64, candidate.hashCode()) == target) {
+ keys.add(candidate);
+ }
+ }
+ return keys.toArray(new String[0]);
+ }
+
+ static String[] keysFor(Regime regime) {
+ switch (regime) {
+ case TINY:
+ return tinyKeys();
+ case MEDIUM:
+ return mediumKeys();
+ case ADVERSARIAL:
+ return adversarialKeys(48);
+ default:
+ throw new IllegalStateException();
+ }
+ }
+
+ // ---- the shared, trigger-parameterized insert over the real spine ---------------------------
+
+ /** Per-map mutable state so LOAD_FACTOR can consult a live count and we can tally grows. */
+ static final class Cursor {
+ Object[] data;
+ int size;
+ int grows;
+ }
+
+ static void put(Trigger trigger, int k, Cursor c, String key, Object value) {
+ Object[] data = c.data;
+ if (data == null) {
+ c.data = EmbeddingSupport.newMapData(SEED_SLOTS, key, value);
+ c.size = 1;
+ return;
+ }
+
+ int numSlots = EmbeddingSupport.numSlots(data);
+ int slot = EmbeddingSupport.findInsertionSlot(data, numSlots, key);
+ if (slot >= 0) {
+ // overwrite -- no size change, no grow
+ data[slot + numSlots] = value;
+ return;
+ }
+
+ boolean grow;
+ if (slot == EmbeddingSupport.SLOT_CAPACITY_REACHED) {
+ grow = true;
+ } else {
+ int available = EmbeddingSupport.flip(slot);
+ switch (trigger) {
+ case FULL:
+ grow = false;
+ break;
+ case LOAD_FACTOR:
+ grow = c.size + 1 > numSlots - (numSlots >> LOAD_FACTOR_RESERVE_SHIFT);
+ break;
+ case MAX_PROBES:
+ {
+ int home = EmbeddingSupport.preferredSlot(numSlots, key.hashCode());
+ int distance = (available - home) & (numSlots - 1);
+ grow = distance >= k;
+ break;
+ }
+ default:
+ grow = false;
+ }
+ if (!grow) {
+ data[available] = key;
+ data[available + numSlots] = value;
+ c.size++;
+ return;
+ }
+ }
+
+ data = EmbeddingSupport.expandMapData(data);
+ c.grows++;
+ EmbeddingSupport.newMapUncheckedInsert(data, EmbeddingSupport.numSlots(data), key, value);
+ c.data = data;
+ c.size++;
+ }
+
+ static Object[] build(Trigger trigger, int k, String[] keys) {
+ Cursor c = new Cursor();
+ for (int i = 0; i < keys.length; i++) {
+ put(trigger, k, c, keys[i], i);
+ }
+ return c.data;
+ }
+
+ // ---- JMH state ------------------------------------------------------------------------------
+
+ @Param({"FULL", "LOAD_FACTOR", "MAX_PROBES"})
+ Trigger trigger;
+
+ @Param({"TINY", "MEDIUM", "ADVERSARIAL"})
+ Regime regime;
+
+ String[] insertionKeys;
+ String[] lookupKeys; // distinct instances (equals path) plus misses
+ Object[] prebuilt;
+ int index;
+
+ @Setup(Level.Trial)
+ public void setUp() {
+ insertionKeys = keysFor(regime);
+
+ // Lookup mix: distinct-instance copies of half the present keys (hits, via equals) interleaved
+ // with absent keys (misses) -- the miss-dominated read pattern of the wrapper.
+ lookupKeys = new String[insertionKeys.length];
+ for (int i = 0; i < insertionKeys.length; i++) {
+ lookupKeys[i] = (i % 2 == 0) ? new String(insertionKeys[i]) : ("absent." + i);
+ }
+
+ prebuilt = build(trigger, MAX_PROBES, insertionKeys);
+ }
+
+ String nextLookupKey() {
+ if (++index >= lookupKeys.length) index = 0;
+ return lookupKeys[index];
+ }
+
+ @Benchmark
+ public Object[] build() {
+ return build(trigger, MAX_PROBES, insertionKeys);
+ }
+
+ @Benchmark
+ public Object get() {
+ return EmbeddingSupport.get(prebuilt, nextLookupKey());
+ }
+
+ @Benchmark
+ public void buildThenReadAll(Blackhole bh) {
+ Object[] data = build(trigger, MAX_PROBES, insertionKeys);
+ for (int i = 0; i < lookupKeys.length; i++) {
+ bh.consume(EmbeddingSupport.get(data, lookupKeys[i]));
+ }
+ bh.consume(data);
+ }
+
+ // ---- deterministic behavior analysis (run via main, no timing) ------------------------------
+
+ static int[] displacements(Object[] data) {
+ int numSlots = EmbeddingSupport.numSlots(data);
+ List ds = new ArrayList<>();
+ for (int slot = 0; slot < numSlots; slot++) {
+ String key = (String) data[slot];
+ if (key == null || EmbeddingSupport.isRemoved(key)) continue;
+ int home = EmbeddingSupport.preferredSlot(numSlots, key.hashCode());
+ ds.add((slot - home) & (numSlots - 1));
+ }
+ int[] out = new int[ds.size()];
+ for (int i = 0; i < out.length; i++) out[i] = ds.get(i);
+ Arrays.sort(out);
+ return out;
+ }
+
+ static int pct(int[] sorted, double p) {
+ if (sorted.length == 0) return 0;
+ int idx = (int) Math.ceil(p * sorted.length) - 1;
+ if (idx < 0) idx = 0;
+ if (idx >= sorted.length) idx = sorted.length - 1;
+ return sorted[idx];
+ }
+
+ static double mean(int[] xs) {
+ if (xs.length == 0) return 0;
+ long sum = 0;
+ for (int x : xs) sum += x;
+ return (double) sum / xs.length;
+ }
+
+ @SuppressForbidden // System.out: this is a CLI analysis report, not agent logging.
+ public static void main(String[] args) {
+ int[] ks = {4, 8};
+ System.out.printf(
+ "%-12s %-12s %6s %6s %6s %6s %6s %6s %6s %6s%n",
+ "regime", "trigger", "n", "slots", "LF", "grows", "meanP", "p90", "p99", "maxP");
+ for (Regime regime : Regime.values()) {
+ String[] keys = keysFor(regime);
+ // FULL and LOAD_FACTOR ignore K; MAX_PROBES reported for each K.
+ reportRow(regime, Trigger.FULL, 0, keys);
+ reportRow(regime, Trigger.LOAD_FACTOR, 0, keys);
+ for (int k : ks) {
+ reportRow(regime, Trigger.MAX_PROBES, k, keys);
+ }
+ System.out.println();
+ }
+ }
+
+ @SuppressForbidden // System.out: this is a CLI analysis report, not agent logging.
+ static void reportRow(Regime regime, Trigger trigger, int k, String[] keys) {
+ Cursor c = new Cursor();
+ for (int i = 0; i < keys.length; i++) {
+ put(trigger, k, c, keys[i], i);
+ }
+ int[] ds = displacements(c.data);
+ int slots = EmbeddingSupport.numSlots(c.data);
+ String label = trigger == Trigger.MAX_PROBES ? "MAX_PROBES(" + k + ")" : trigger.name();
+ System.out.printf(
+ "%-12s %-12s %6d %6d %6.2f %6d %6.2f %6d %6d %6d%n",
+ regime.name(),
+ label,
+ ds.length,
+ slots,
+ slots == 0 ? 0.0 : (double) ds.length / slots,
+ c.grows,
+ mean(ds),
+ pct(ds, 0.90),
+ pct(ds, 0.99),
+ ds.length == 0 ? 0 : ds[ds.length - 1]);
+ }
+}
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..b80792221c8 100644
--- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java
+++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java
@@ -1,6 +1,9 @@
package datadog.trace.util;
import datadog.trace.api.TagMap;
+import datadog.trace.util.LightMap.AdaptiveSizingHint;
+import datadog.trace.util.LightMap.EmbeddingSupport;
+import datadog.trace.util.LightMap.EntryReader;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -33,11 +36,26 @@
*
(RECOMMENDED) TagMap — preferred for storing tags; excels at primitives, copying, and
* builder idioms
+ *
LightMap — a tiny, entry-less open-addressed map for short-lived, miss-dominated maps that
+ * do not need the {@code java.util.Map} interface; allocation-light, and its for-each
+ * iteration is shaped so escape analysis eliminates the iterator (see {@code
+ * iterate_lightMap})
*
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
*
*
+ *
Allocation-free iteration. {@code iterate_lightMap} exercises the entry-less {@link
+ * LightMap} for-each: the flyweight iterator is both the {@code Iterator} and the {@code
+ * EntryReader} it yields, so with a concretely-typed map that lets it stay non-escaping, escape
+ * analysis scalar-replaces it. Measured (JDK 17, {@code -prof gc}, {@code -t 1}) at {@code
+ * gc.alloc.rate.norm ≈ 10^-5 B/op} -- i.e. zero. The point is not that this beats {@code
+ * iterate_hashMap}: HashMap's iterator likewise scalar-replaces and reuses its stored {@code Node}
+ * as the entry, so it is also ~0 B/op. The point is that LightMap's entry-less layout (no per-entry
+ * object to hand out) costs nothing at iteration time -- the flyweight matches HashMap's zero-alloc
+ * traversal rather than paying to materialize entries. Re-run with {@code -prof gc} to confirm both
+ * arms stay at ~0 B/op.
+ *
*
Uncontended synchronization tax. A {@link Collections#synchronizedMap} case is included
* to measure what synchronization costs when there is no contention: because each thread
* owns its synchronized map, the monitor is only ever locked by one thread. On JVMs with biased
@@ -81,12 +99,47 @@ static TagMap fillTagMap(TagMap map) {
return map;
}
+ static LightMap fillLightMap(LightMap map) {
+ for (int i = 0; i < INSERTION_KEYS.length; ++i) {
+ map.set(INSERTION_KEYS[i], i);
+ }
+ return map;
+ }
+
+ // The "embedded" analog of fillLightMap: no LightMap wrapper object at all -- the caller owns a
+ // raw Object[] spine and drives EmbeddingSupport static functions over it. set() returns the
+ // (possibly grown) array, mirroring how a consumer that has "dropped a level" would hold it.
+ static Object[] fillLightMapEmbedded() {
+ Object[] data = null;
+ for (int i = 0; i < INSERTION_KEYS.length; ++i) {
+ data = EmbeddingSupport.set(data, INSERTION_KEYS[i], i);
+ }
+ return data;
+ }
+
+ // Embedded fill seeded from a self-tuning hint (spine counterpart of LightMap.create(hint)).
+ static Object[] fillLightMapEmbedded(AdaptiveSizingHint hint) {
+ Object[] data = null;
+ for (int i = 0; i < INSERTION_KEYS.length; ++i) {
+ data = EmbeddingSupport.set(hint, data, INSERTION_KEYS[i], i);
+ }
+ return data;
+ }
+
// 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;
+ LightMap lightMap;
+ // Minted once and reused across every create_lightMap_adaptive invocation, mirroring the intended
+ // static-final-per-site usage. Warmup iterations let it converge to the fill size, so the
+ // measured
+ // creates seed a right-sized table instead of resizing up from the small createUncapped() seed.
+ AdaptiveSizingHint lightMapSizingHint;
+ // Prebuilt raw spine (no wrapper) for the embedded get / iterate arms.
+ Object[] lightMapData;
int index = 0;
@Setup(Level.Trial)
@@ -99,6 +152,9 @@ public void setUp() {
linkedHashMap = new LinkedHashMap<>();
fill(linkedHashMap);
tagMap = fillTagMap(TagMap.create());
+ lightMap = fillLightMap(LightMap.createUncapped());
+ lightMapSizingHint = LightMap.createUncappedAdaptiveSizingHint();
+ lightMapData = fillLightMapEmbedded();
}
String nextLookupKey() {
@@ -159,6 +215,32 @@ public TagMap create_tagMap_via_ledger() {
return ledger.build();
}
+ @Benchmark
+ public LightMap create_lightMap() {
+ return fillLightMap(LightMap.createUncapped());
+ }
+
+ @Benchmark
+ public LightMap create_lightMap_adaptive() {
+ // Same fill, but seeded from the self-tuning hint held across invocations -- isolates how much
+ // of create_lightMap's cost is resize churn from the small createUncapped() seed.
+ return fillLightMap(LightMap.create(lightMapSizingHint));
+ }
+
+ @Benchmark
+ public Object[] create_lightMap_embedded() {
+ // No wrapper object -- build straight over a raw Object[] spine. The delta to create_lightMap
+ // is
+ // the LightMap wrapper's own allocation/overhead.
+ return fillLightMapEmbedded();
+ }
+
+ @Benchmark
+ public Object[] create_lightMap_embedded_adaptive() {
+ // Embedded + hint-seeded: the leanest create path (no wrapper, right-sized first table).
+ return fillLightMapEmbedded(lightMapSizingHint);
+ }
+
// ---- copy ----
@Benchmark
@@ -200,6 +282,16 @@ public Integer get_synchronizedHashMap() {
return synchronizedHashMap.get(nextLookupKey());
}
+ @Benchmark
+ public Integer get_lightMap() {
+ return lightMap.get(nextLookupKey());
+ }
+
+ @Benchmark
+ public Object get_lightMap_embedded() {
+ return EmbeddingSupport.get(lightMapData, nextLookupKey());
+ }
+
@Benchmark
public void iterate_hashMap(Blackhole blackhole) {
for (Map.Entry entry : hashMap.entrySet()) {
@@ -208,6 +300,31 @@ public void iterate_hashMap(Blackhole blackhole) {
}
}
+ @Benchmark
+ public void iterate_lightMap(Blackhole blackhole) {
+ // LightMap is concretely typed here so the for-each call sites devirtualize and inline; the
+ // flyweight iterator never escapes this method, so escape analysis scalar-replaces it entirely
+ // -- measured ~0 B/op under -prof gc. iterate_hashMap is the peer: it too is ~0 B/op (its
+ // iterator scalar-replaces and reuses the stored Node as the entry), so this arm confirms the
+ // entry-less flyweight matches that zero-alloc traversal rather than paying to materialize.
+ for (LightMap.EntryReader entry : lightMap) {
+ blackhole.consume(entry.key());
+ blackhole.consume(entry.value());
+ }
+ }
+
+ @Benchmark
+ public void iterate_lightMap_embedded(Blackhole blackhole) {
+ // Embedded for-each over the raw spine via EmbeddingSupport.iterable(). This adds an Iterable
+ // lambda on top of the flyweight iterator versus the object tier's direct iterator(); -prof gc
+ // shows whether escape analysis still folds both away to ~0 B/op.
+ for (EntryReader entry :
+ EmbeddingSupport.iterable(lightMapData)) {
+ blackhole.consume(entry.key());
+ blackhole.consume(entry.value());
+ }
+ }
+
@Benchmark
public void iterate_synchronizedHashMap(Blackhole blackhole) {
// Collections.synchronizedMap requires the caller to synchronize during iteration; this is the
diff --git a/internal-api/src/main/java/datadog/trace/util/LightMap.java b/internal-api/src/main/java/datadog/trace/util/LightMap.java
new file mode 100644
index 00000000000..dbc1d1ba121
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/util/LightMap.java
@@ -0,0 +1,1061 @@
+package datadog.trace.util;
+
+import datadog.trace.api.function.TriConsumer;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.function.BiConsumer;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * A lightweight map, keyed by any type with a stable {@code hashCode}/{@code equals} (typically
+ * {@link String}), designed to be small and fast for tiny maps.
+ *
+ *
Supports the common map operations -- {@code set}, {@code get}, {@code remove}, {@code
+ * containsKey}, {@code forEach}, and for-each iteration ({@link Iterable} of {@link EntryReader})
+ * -- as an easy, largely footgun-free stand-in wherever a small {@code Map} is needed. It
+ * deliberately does not implement {@link java.util.Map}; the surface is intentionally
+ * small.
+ *
+ *
Neither null keys nor null values are supported: {@code set} rejects a null value, so a null
+ * {@code get} result unambiguously means "absent". Use {@link #containsKey} if you need to probe
+ * for presence separately. Interned/literal keys resolve slightly faster but are not assumed.
+ *
+ *
This class is not thread-safe.
+ *
+ *
Map data is stored in a single flat array that can be embedded into another object via {@link
+ * EmbeddingSupport} as a further optimization -- an expert hatch that drops the {@code LightMap}
+ * wrapper and drives the caller-owned array directly. It buys only the wrapper object back (about
+ * one object header, ~32 B per map) and nothing else: lookup and iteration are otherwise identical.
+ * Reach for it only when that per-map word matters at scale -- see the {@code *_embedded} arms in
+ * {@code SingleThreadedMapBenchmark} for the (marginal) delta.
+ */
+/*
+ * Keys are stored in the first half of the array and values in the second half.
+ * Key collisions are resolved via linear probing.
+ *
+ * This layout is intended to optimize scanning for available and matching slots.
+ *
+ * Key removal is handled by placing a poison key in the previously occupied slot.
+ * This approach was chosen so that linear probing can break out of the loop
+ * when an empty slot is encountered.
+ *
+ * Insertions after the a removal can fill the emptied slot.
+ * In the event of resizing, removal markers are discarded while assigning
+ * slots in the new data array.
+ *
+ * Why a single Object[] and not two typed arrays (K[] keys, V[] values)?
+ * Under erasure "properly typed" arrays would still be (K[]) new Object[] and
+ * (V[]) new Object[], so no real type safety is gained. Meanwhile the single
+ * array buys two concrete things:
+ * - The removal tombstone (a non-K sentinel) can live directly in the array.
+ * A reified K[] could not hold it (ArrayStoreException), forcing a separate
+ * parallel deleted-marker structure.
+ * - Embedding is one Object[] field in the host object instead of two.
+ */
+public final class LightMap implements Iterable> {
+ public static final int DEFAULT_CAPACITY = 8;
+
+ // Slots a fresh (un-tuned) hint seeds -- a reasonable default so a cold site behaves like a
+ // plain LightMap.createUncapped(); it then self-tunes up or down from here.
+ static final int DEFAULT_HINT_SLOTS = DEFAULT_CAPACITY;
+ // Floor step-down never drops a hint below (in slots), so a genuinely tiny site can still tune
+ // below the default. Must stay >= 1 (a zero-slot table is degenerate).
+ static final int MIN_HINT_SLOTS = 1;
+ // Step the learned estimate down one power-of-two class every this-many constructions. A
+ // power of two so the tick test is a bit-mask. Large => decay is a slow background correction,
+ // not something that fights a steady workload.
+ static final int DECAY_INTERVAL = 1024;
+ // Safety ceiling on how large a hint will pre-provision (in slots). Bounds the shared hint's
+ // over-provision from an outlier; a map without a maxCapacity still grows past this on its own.
+ static final int MAX_HINT_SLOTS = 1024;
+
+ // Sentinel maxSlots meaning "no hard cap": the map grows freely (numSlots < NO_MAX_SLOTS always
+ // holds, so the cap check never fires and set() always stores).
+ static final int NO_MAX_SLOTS = Integer.MAX_VALUE;
+
+ private final int initialCapacity;
+ // Hard cap on slots (a power of two), or NO_MAX_SLOTS when uncapped. Comes from the sizing hint's
+ // maxCapacity so every map at a construction site shares one bound.
+ private final int maxSlots;
+ @Nullable private final AdaptiveSizingHint sizingHint;
+ private Object[] data = EmbeddingSupport.EMPTY_DATA;
+
+ private LightMap(int capacity, int maxSlots) {
+ this.initialCapacity = capacity;
+ this.sizingHint = null;
+ this.maxSlots = maxSlots;
+ }
+
+ private LightMap(@Nonnull AdaptiveSizingHint hint) {
+ this.sizingHint = hint;
+ this.initialCapacity = hint.seedSlots();
+ this.maxSlots = hint.maxSlots();
+ }
+
+ /**
+ * A new, uncapped map seeded at the default capacity ({@value #DEFAULT_CAPACITY} slots). The
+ * "just give me a map" front door, sized for a handful of entries.
+ *
+ *
If the map will routinely hold more than about {@value #DEFAULT_CAPACITY} entries, prefer
+ * {@link #createUncapped(int)} with a rough size, or better a reused {@link
+ * #createUncappedAdaptiveSizingHint() AdaptiveSizingHint}. Left at the default seed, a larger map
+ * takes a resize as it grows, which roughly halves build throughput -- see {@code
+ * SingleThreadedMapBenchmark} ({@code create_lightMap} vs {@code create_lightMap_adaptive}).
+ */
+ @Nonnull
+ public static LightMap createUncapped() {
+ return new LightMap<>(DEFAULT_CAPACITY, NO_MAX_SLOTS);
+ }
+
+ /**
+ * A new, uncapped map seeded at {@code capacity} (rounded up to a power of two on first write).
+ * Use when the caller already knows the rough size; otherwise prefer {@link #createUncapped()} or
+ * a {@link #createUncappedAdaptiveSizingHint()}.
+ */
+ @Nonnull
+ public static LightMap createUncapped(int capacity) {
+ return new LightMap<>(capacity, NO_MAX_SLOTS);
+ }
+
+ /**
+ * A new map hard-capped at {@code maxCapacity} slots (rounded up to a power of two), seeded at
+ * the default capacity (clamped to the cap). Once the table is physically full at the cap, {@link
+ * #set} rejects a genuinely new key (returns {@code false}) instead of growing further -- a
+ * thought-free way to bound worst-case memory without minting an {@link AdaptiveSizingHint}.
+ *
+ *
Like {@link #createUncapped()}, this seeds for a handful of entries; if the map will
+ * routinely hold more, give an explicit seed via {@link #createCapped(int, int)} or reuse a
+ * {@link #createCappedAdaptiveSizingHint(int)} to avoid a resize on the way up.
+ */
+ @Nonnull
+ public static LightMap createCapped(int maxCapacity) {
+ int max = EmbeddingSupport.roundUpToPow2(maxCapacity);
+ int seed = Math.min(DEFAULT_CAPACITY, max);
+ return new LightMap<>(seed, max);
+ }
+
+ /**
+ * A new map seeded at {@code initialCapacity} and hard-capped at {@code maxCapacity} (both in
+ * slots, each rounded up to a power of two). Like {@link #createCapped(int)} but with an explicit
+ * seed. Throws {@link IllegalArgumentException} if the rounded seed exceeds the rounded cap.
+ */
+ @Nonnull
+ public static LightMap createCapped(int initialCapacity, int maxCapacity) {
+ int seed = EmbeddingSupport.roundUpToPow2(initialCapacity);
+ int max = EmbeddingSupport.roundUpToPow2(maxCapacity);
+ if (seed > max) {
+ throw new IllegalArgumentException(
+ "initialCapacity (" + seed + " slots) exceeds maxCapacity (" + max + " slots)");
+ }
+ return new LightMap<>(seed, max);
+ }
+
+ /**
+ * A new map sized (and, if the hint carries a {@code maxCapacity}, capped) from {@code hint}.
+ * Mint the hint once per construction site via {@link #createUncappedAdaptiveSizingHint()},
+ * {@link #createCappedAdaptiveSizingHint(int)}, or {@link AdaptiveSizingHint#builder()}, hold it
+ * in a {@code static final} field, and pass it to every map at that site.
+ */
+ @Nonnull
+ public static LightMap create(@Nonnull AdaptiveSizingHint hint) {
+ return new LightMap<>(hint);
+ }
+
+ /**
+ * Mints an uncapped self-tuning {@link AdaptiveSizingHint} for a single construction site. Hold
+ * it in a {@code static final} field and pass it to every {@link #create(AdaptiveSizingHint)} at
+ * that site; the map sizes itself from the hint and tunes the hint back on its own. The caller
+ * never touches the hint again.
+ */
+ @Nonnull
+ public static AdaptiveSizingHint createUncappedAdaptiveSizingHint() {
+ return AdaptiveSizingHint.builder().build();
+ }
+
+ /**
+ * Mints a self-tuning {@link AdaptiveSizingHint} hard-capped at {@code maxCapacity} slots
+ * (rounded up to a power of two). Like {@link #createUncappedAdaptiveSizingHint()} but every map
+ * built from the hint is bounded: once a map is physically full at the cap, {@link #set} rejects
+ * a new key (returns {@code false}) instead of growing past it. To also set an explicit initial
+ * capacity, use {@link AdaptiveSizingHint#builder()}.
+ */
+ @Nonnull
+ public static AdaptiveSizingHint createCappedAdaptiveSizingHint(int maxCapacity) {
+ return AdaptiveSizingHint.builder().maxCapacity(maxCapacity).build();
+ }
+
+ /**
+ * Stores {@code value} under {@code key}, growing the backing table if the probe-bound grow
+ * trigger fires. Returns {@code true} if the mapping was stored (or overwrote an existing one).
+ *
+ *
Returns {@code false} only for a capped map (one built via {@link #createCapped(int)} /
+ * {@link #createCapped(int, int)}, or from a {@link AdaptiveSizingHint#builder()}.{@code
+ * maxCapacity(...)} hint): once the table is physically full at its cap, a genuinely new key is
+ * rejected rather than growing past the cap. The rejection is non-fatal -- the map is unchanged
+ * and the caller may ignore the return. An uncapped map always returns {@code true}.
+ */
+ public boolean set(@Nonnull K key, @Nonnull V value) {
+ // Null-value rejection is enforced centrally in EmbeddingSupport.setOrReject (the shared insert
+ // core), so it holds for the spine entry points too -- not just this object-tier front door.
+ // A thin delegate over the shared spine orchestration: it passes this map's cap (NO_MAX_SLOTS
+ // when uncapped) and does the two things only the object tier can -- swap in the new backing
+ // array and teach the sizing hint. A null result is the spine's non-fatal rejection signal.
+ Object[] before = this.data;
+ int beforeSlots = EmbeddingSupport.numSlots(before);
+ Object[] after =
+ EmbeddingSupport.setOrReject(this.initialCapacity, this.maxSlots, before, key, value);
+ if (after == null) {
+ return false; // capped, physically full, key is new
+ }
+ this.data = after;
+ recordGrowth(beforeSlots, after);
+ return true;
+ }
+
+ // Teach the sizing hint after a genuine grow (not the lazy first allocation, which seeds from
+ // beforeSlots == 0) so it learns this site's high-water mark. seedSlots()/newMapData never feed
+ // the hint.
+ private void recordGrowth(int beforeSlots, @Nullable Object[] after) {
+ if (this.sizingHint != null && beforeSlots != 0) {
+ int afterSlots = EmbeddingSupport.numSlots(after);
+ if (afterSlots > beforeSlots) {
+ this.sizingHint.recordSlots(afterSlots);
+ }
+ }
+ }
+
+ @Nullable
+ public V get(@Nonnull K key) {
+ return EmbeddingSupport.get(this.data, key);
+ }
+
+ public void remove(@Nonnull K key) {
+ EmbeddingSupport.remove(this.data, key);
+ }
+
+ /** The number of live entries in this map (tombstones excluded). */
+ public int size() {
+ return EmbeddingSupport.size(this.data);
+ }
+
+ public boolean containsKey(@Nonnull K key) {
+ return EmbeddingSupport.containsKey(this.data, key);
+ }
+
+ @SuppressWarnings("unchecked")
+ public void forEach(@Nonnull BiConsumer super K, ? super V> consumer) {
+ Object[] mapData = this.data;
+ if (mapData == null) return;
+ int numSlots = mapData.length >> 1;
+ for (int slot = 0; slot < numSlots; slot++) {
+ Object key = mapData[slot];
+ if (key == null || EmbeddingSupport.isRemoved(key)) continue;
+ consumer.accept((K) key, (V) mapData[slot + numSlots]);
+ }
+ }
+
+ /**
+ * Returns an iterator over the live entries, for use with a for-each loop. Delegates to the spine
+ * ({@link EmbeddingSupport#iterator(Object[])}); the returned object is a reused flyweight -- see
+ * {@link EntryReader} for the retention caveat. Read-only: {@link Iterator#remove()} is
+ * unsupported (throws {@link UnsupportedOperationException}) -- mutate via {@link
+ * #remove(Object)} instead. Not thread-safe; behavior is undefined if the map is structurally
+ * modified during iteration.
+ */
+ @Override
+ @Nonnull
+ public Iterator> iterator() {
+ return EmbeddingSupport.iterator(this.data);
+ }
+
+ /**
+ * A read-only view of one entry, yielded by {@link #iterator()} (and the spine's {@link
+ * EmbeddingSupport#iterator(Object[])} / {@link EmbeddingSupport#iterable(Object[])}).
+ *
+ *
Reused cursor -- do not retain. To stay entry-less and allocation-free the
+ * iterator hands back itself, repositioned onto each entry in turn, rather than a fresh
+ * object per entry. A returned reader is therefore valid only until the next {@code next()}:
+ * never stash one or collect the readers into a collection -- every reference would point at the
+ * same flyweight showing the last entry visited. Read {@link #key()}/{@link #value()} into your
+ * own fields if you need a durable copy. The upside of the single reused object is that escape
+ * analysis can eliminate it entirely when a for-each loop does not let it escape.
+ */
+ public interface EntryReader {
+ K key();
+
+ V value();
+ }
+
+ // Visible for testing: the backing spine (null until the first set).
+ @Nullable
+ Object[] dataForTesting() {
+ return this.data;
+ }
+
+ /**
+ * A self-tuning, per-construction-site sizing estimate. Mint one via {@link
+ * LightMap#createUncappedAdaptiveSizingHint()}, {@link
+ * LightMap#createCappedAdaptiveSizingHint(int)}, or {@link #builder()}, hold it in a {@code
+ * static final} field, and pass it to {@link LightMap#create(AdaptiveSizingHint)}; the map reads
+ * it to size itself and tunes it back as it grows. The caller never updates it.
+ *
+ *
Opaque by design (no public members). The estimate self-tunes on two events the map already
+ * observes -- a new map is started ({@link #seedSlots()}) and a map grows ({@link
+ * #recordSlots(int)}) -- so it is tier-agnostic: the same hint can drive both this object and the
+ * static {@link EmbeddingSupport} spine.
+ *
+ *
Tuning is racy by design and needs no synchronization: {@code slots}/{@code constructs} are
+ * plain ints, whose reads and writes are atomic (JLS 17.7), so a reader never sees a half-written
+ * value. The only races are a lost update (an interleaved increment or grow dropped) and a stale
+ * read (a write not yet visible to another thread); either just mis-sizes a future array by a
+ * class (over/under-provision) for an instance or two, which the next grow corrects. Map data is
+ * never touched by this state, so there is no corruption path.
+ */
+ public static final class AdaptiveSizingHint {
+ // Learned seed capacity in slots (always a power of two). Additive-increase on grow (with one
+ // class of headroom), multiplicative-decrease on the decay tick.
+ private int slots;
+ // Approximate count of maps started from this hint; drives the periodic step-down decay.
+ private int constructs;
+ // Hard cap on slots for maps built from this hint (a power of two), or NO_MAX_SLOTS when
+ // uncapped. Immutable; the learned seed is clamped to it so a hint never over-provisions past
+ // the cap.
+ private final int maxSlots;
+
+ private AdaptiveSizingHint(int seedSlots, int maxSlots) {
+ this.slots = seedSlots;
+ this.maxSlots = maxSlots;
+ }
+
+ /**
+ * Opens a builder for an {@link AdaptiveSizingHint} that carries an explicit initial capacity
+ * and/or a hard {@code maxCapacity}. Use this instead of {@link
+ * LightMap#createUncappedAdaptiveSizingHint()} / {@link
+ * LightMap#createCappedAdaptiveSizingHint(int)} when a site wants to seed the initial capacity
+ * as well. A {@code maxCapacity} bounds worst-case memory: every map built from the returned
+ * hint shares the same cap, and {@link LightMap#set} rejects (returns {@code false}) once a map
+ * is physically full at that cap. The hint still self-tunes its seed capacity within the cap.
+ */
+ @Nonnull
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ // The hard slot cap for maps built from this hint (NO_MAX_SLOTS when uncapped).
+ int maxSlots() {
+ return this.maxSlots;
+ }
+
+ /**
+ * The seed capacity (in slots) for a map just started from this hint. Advances the decay clock
+ * and, every {@link #DECAY_INTERVAL} maps, steps the estimate down one class so a stale
+ * high-water from a past spike self-corrects; if that made it too tight, the next grow snaps it
+ * back.
+ */
+ int seedSlots() {
+ int n = ++this.constructs; // racy; a lost increment only jitters the decay cadence
+ if ((n & (DECAY_INTERVAL - 1)) == 0) {
+ int reduced = this.slots >> 1;
+ this.slots = (reduced < MIN_HINT_SLOTS) ? MIN_HINT_SLOTS : reduced;
+ }
+ return this.slots;
+ }
+
+ /**
+ * Records that a map grew to {@code grownSlots}. Reserves one extra power-of-two class so a
+ * reseeded map starts with slack and is unlikely to immediately re-trip the grow trigger for
+ * the same workload. Monotonic-max, clamped to {@link #MAX_HINT_SLOTS} and to this hint's hard
+ * {@code maxSlots} cap so a capped hint never seeds a map larger than its cap.
+ */
+ void recordSlots(int grownSlots) {
+ int candidate = grownSlots << 1;
+ int ceiling = Math.min(MAX_HINT_SLOTS, this.maxSlots);
+ if (candidate > ceiling) {
+ candidate = ceiling;
+ }
+ if (candidate > this.slots) {
+ this.slots = candidate;
+ }
+ }
+
+ // Visible for testing: the current learned seed capacity in slots.
+ int currentSeedSlots() {
+ return this.slots;
+ }
+
+ /** Builds an {@link AdaptiveSizingHint} with an initial and/or maximum capacity. */
+ public static final class Builder {
+ private int initCapacity = DEFAULT_HINT_SLOTS;
+ private int maxCapacity = NO_MAX_SLOTS;
+
+ private Builder() {}
+
+ /** Seed capacity in slots for a cold map (rounded up to a power of two). */
+ @Nonnull
+ public Builder initCapacity(int slots) {
+ this.initCapacity = slots;
+ return this;
+ }
+
+ /**
+ * Hard cap, in slots (rounded up to a power of two), on how large any map built from this
+ * hint may grow. Once a map is physically full at this many slots, {@link LightMap#set}
+ * rejects a new key (returns {@code false}) instead of growing further -- bounding worst-case
+ * memory.
+ */
+ @Nonnull
+ public Builder maxCapacity(int slots) {
+ this.maxCapacity = slots;
+ return this;
+ }
+
+ @Nonnull
+ public AdaptiveSizingHint build() {
+ int seed = EmbeddingSupport.roundUpToPow2(this.initCapacity);
+ int max =
+ (this.maxCapacity == NO_MAX_SLOTS)
+ ? NO_MAX_SLOTS
+ : EmbeddingSupport.roundUpToPow2(this.maxCapacity);
+ if (max != NO_MAX_SLOTS && seed > max) {
+ throw new IllegalArgumentException(
+ "initCapacity (" + seed + " slots) exceeds maxCapacity (" + max + " slots)");
+ }
+ return new AdaptiveSizingHint(seed, max);
+ }
+ }
+ }
+
+ public static final class EmbeddingSupport {
+ // findSlot is a pure lookup in the String.indexOf idiom: a non-negative slot on a hit, or this
+ // sentinel on any miss (null map or absent key).
+ public static final int SLOT_NOT_FOUND = -1;
+ // findInsertionSlot is a locate-or-reserve in the Arrays.binarySearch idiom, so its return
+ // lives
+ // in a different numeric space than findSlot's: a non-negative slot when the key is already
+ // present, a flip()-encoded free slot when it is absent, or this sentinel when the table is
+ // physically full. The two sentinels are deliberately named apart so a return from one method
+ // is
+ // never compared against the other's contract.
+ static final int SLOT_CAPACITY_REACHED = Integer.MIN_VALUE;
+
+ // Grow trigger: an insertion that would land this many slots or more from its home slot forces
+ // a resize, rather than waiting for the table to fill completely. This bounds the worst-case
+ // probe length (and therefore lookup cost) directly -- a load-factor threshold cannot, because
+ // it is blind to local clustering (colliding keys pile into one chain long before global
+ // occupancy is high). The check is derived entirely from the probe walk, so it is stateless and
+ // lives here in the spine rather than needing a maintained live count in the object tier.
+ //
+ // Chosen as a power-of-two-friendly 8 from measurement (LightMapGrowBenchmark): it caps
+ // the worst-case probe at 8 while keeping the memory over-provision modest on well-spread keys.
+ // Because a table never has more than (numSlots - 1) probe distance, this is inert for tables
+ // of
+ // 8 slots or fewer -- tiny maps still grow only when physically full, exactly as before.
+ static final int MAX_PROBES = 8;
+
+ // Backstop on probe-bound over-growth, so a hashCode() collision set cannot exhaust the heap.
+ // Keys that share a hashCode() collapse onto one home slot in EVERY table size, so growing can
+ // never shorten their probe chain. A pure probe-bound trigger would then double the table every
+ // insert past MAX_PROBES -- a handful of colliding keys could balloon an uncapped map to
+ // hundreds of millions of slots (an adversarial-input OOM). We therefore refuse a probe-bound
+ // grow once the table already holds this many slots per live entry: past that point the long
+ // chain is a genuine collision cluster no resize can spread, so we accept the chain (bounded
+ // lookup cost) rather than grow (unbounded memory). Growth to make physical room is never gated
+ // by this -- only the probe-bound trigger is. Memory stays O(live entries); the MAX_PROBES
+ // probe-length bound still holds for well-distributed keys and degrades gracefully, not
+ // catastrophically, only under genuine hashCode collisions.
+ static final int MAX_SLOTS_PER_LIVE_ENTRY = 8;
+
+ // The deletion tombstone. A dedicated singleton *type* rather than a magic String so it is
+ // unmistakable in a heap dump or debugger (a Tombstone instance, not a String of NUL bytes) and
+ // can never collide with a real key of any type. Compared only by identity (==).
+ private static final class RemovedTombstone {
+ static final RemovedTombstone INSTANCE = new RemovedTombstone();
+
+ private RemovedTombstone() {}
+
+ @Override
+ public String toString() {
+ return "--REMOVED--";
+ }
+ }
+
+ static final Object REMOVED = RemovedTombstone.INSTANCE;
+
+ @Nullable public static final Object[] EMPTY_DATA = null;
+
+ public static boolean isDefinitelyEmpty(@Nullable Object[] mapData) {
+ return (mapData == null);
+ }
+
+ public static int numSlots(@Nullable Object[] mapData) {
+ return mapData == null ? 0 : mapData.length >> 1;
+ }
+
+ public static int size(@Nullable Object[] mapData) {
+ if (mapData == null) return 0;
+
+ int size = 0;
+ int numSlots = numSlots(mapData);
+ for (int slot = 0; slot < numSlots; ++slot) {
+ Object key = mapData[slot];
+ if (key != null && key != REMOVED) size += 1;
+ }
+ return size;
+ }
+
+ @Nullable
+ public static Object[] clear(@Nullable Object[] mapData) {
+ if (mapData != null) {
+ Arrays.fill(mapData, null);
+ }
+ return mapData;
+ }
+
+ @Nullable
+ public static Object[] copy(@Nullable Object[] mapData) {
+ return mapData == null ? null : mapData.clone();
+ }
+
+ public static boolean isPresent(int slotIndex) {
+ return (slotIndex >= 0);
+ }
+
+ public static boolean isRemoved(@Nullable Object key) {
+ return (key == REMOVED);
+ }
+
+ @Nullable
+ public static Object keyAt(@Nullable Object[] mapData, int slotIndex) {
+ if (mapData == null) return null;
+ if (slotIndex < 0) return null;
+
+ Object key = mapData[slotIndex];
+ return (key == REMOVED) ? null : key;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Nullable
+ public static V valueAt(@Nullable Object[] mapData, int slotIndex) {
+ if (mapData == null || slotIndex < 0) return null;
+
+ return (V) mapData[slotIndex + numSlots(mapData)];
+ }
+
+ public static final boolean containsKey(@Nullable Object[] mapData, @Nonnull Object key) {
+ if (mapData == null) return false;
+
+ int numSlots = numSlots(mapData);
+
+ int hash = key.hashCode();
+ int preferredSlot = preferredSlot(numSlots, hash);
+
+ // Identity fast path before equals(), same rationale as findSlot.
+ for (int slot = preferredSlot; slot < numSlots; ++slot) {
+ Object curKey = mapData[slot];
+ if (curKey == null) return false;
+ if (curKey == key) return true;
+ if (curKey != REMOVED && key.equals(curKey)) return true;
+ }
+ for (int slot = 0; slot < preferredSlot; ++slot) {
+ Object curKey = mapData[slot];
+ if (curKey == null) return false;
+ if (curKey == key) return true;
+ if (curKey != REMOVED && key.equals(curKey)) return true;
+ }
+ return false;
+ }
+
+ public static final boolean containsValue(@Nullable Object[] mapData, @Nonnull Object value) {
+ if (mapData == null) return false;
+
+ for (int valueIndex = numSlots(mapData); valueIndex < mapData.length; ++valueIndex) {
+ Object curValue = mapData[valueIndex];
+ if (value.equals(curValue)) return true;
+ }
+ return false;
+ }
+
+ @Nonnull
+ public static Object[] set(
+ @Nullable Object[] mapData, @Nonnull Object key, @Nonnull V value) {
+ return set(DEFAULT_CAPACITY, mapData, key, value);
+ }
+
+ @Nonnull
+ public static Object[] set(
+ int initialCapacity, @Nullable Object[] mapData, @Nonnull Object key, @Nonnull V value) {
+ // Uncapped: NO_MAX_SLOTS makes the cap check inert, so setOrReject grows on demand and never
+ // rejects -- the result is always non-null.
+ return setOrReject(initialCapacity, NO_MAX_SLOTS, mapData, key, value);
+ }
+
+ /**
+ * Hint-aware spine insert: the migration counterpart of {@link LightMap#set} for a map that has
+ * been dropped to the embedded spine but wants to keep the self-tuning it had as an object.
+ * Seeds a fresh table from {@code hint.seedSlots()} and teaches the hint back on a genuine
+ * grow, exactly as the object tier does -- so graduating a map to the spine is a strict
+ * superset (it gains the embedding win without losing its sizing).
+ *
+ *
Unlike the object tier, the hint's {@code maxCapacity} cap does not bound growth
+ * here: a returned {@code Object[]} cannot signal a rejection, so this always stores and always
+ * returns non-null. Taking the spine means you own bounding your own growth; only the sizing
+ * tuning follows down, not the cap guardrail.
+ */
+ @Nonnull
+ public static Object[] set(
+ @Nonnull AdaptiveSizingHint hint,
+ @Nullable Object[] mapData,
+ @Nonnull Object key,
+ @Nonnull V value) {
+ int beforeSlots = numSlots(mapData);
+ // Seed a fresh table from the hint (mirrors LightMap(hint)); initialCapacity is only
+ // read on the null-array branch, so the 0 below is never consumed when mapData is non-null.
+ int seedCapacity = (mapData == null) ? hint.seedSlots() : 0;
+ Object[] after = setOrReject(seedCapacity, NO_MAX_SLOTS, mapData, key, value);
+ // Teach the hint on a genuine grow only (not the lazy first allocation, which already seeded
+ // from the hint) -- mirrors LightMap.recordGrowth.
+ if (beforeSlots != 0) {
+ int afterSlots = numSlots(after);
+ if (afterSlots > beforeSlots) {
+ hint.recordSlots(afterSlots);
+ }
+ }
+ return after;
+ }
+
+ // The single insert orchestration shared by the uncapped spine set() above and the capped
+ // object-tier LightMap.set(): probe, then either overwrite in place, fill a free slot, or
+ // grow. Stores {@code key -> value} and returns the (possibly new) backing array.
+ //
+ // Returns null ONLY when {@code maxSlots} is a finite cap, the table is physically full at it,
+ // and the key is genuinely new -- the caller's non-fatal rejection signal. With {@code maxSlots
+ // == NO_MAX_SLOTS} the cap check is inert (numSlots is always below it), so a grow is always
+ // available and the result is never null.
+ @Nullable
+ static Object[] setOrReject(
+ int initialCapacity,
+ int maxSlots,
+ @Nullable Object[] mapData,
+ @Nonnull Object key,
+ @Nonnull V value) {
+ // The map contract forbids null values (a null get() unambiguously means "absent"), so reject
+ // one here -- the single chokepoint every set path (object tier and spine) flows through.
+ Objects.requireNonNull(value, "value");
+ if (mapData == null) {
+ return newMapData(initialCapacity, key, value);
+ }
+
+ int numSlots = numSlots(mapData);
+ // Compute the home slot once and thread it into the probe (findInsertionSlot) and the
+ // probe-bound distance check below, rather than re-deriving it from key.hashCode() twice.
+ int home = preferredSlot(numSlots, key.hashCode());
+ int slot = findInsertionSlot(mapData, numSlots, key, home);
+ if (slot >= 0) {
+ // Key already present -- overwrite in place, no growth.
+ mapData[slot + numSlots] = value;
+ return mapData;
+ }
+
+ if (slot == SLOT_CAPACITY_REACHED) {
+ // Physically full (no null and no reclaimable tombstone). We must grow to make room, unless
+ // a finite cap blocks it -- then reject the new key (non-fatal, map unchanged).
+ if (numSlots < maxSlots) {
+ mapData = expandMapData(mapData);
+ newMapUncheckedInsert(mapData, numSlots(mapData), key, value);
+ return mapData;
+ }
+ return null;
+ }
+
+ // A free slot the probe walk found. Grow only if the insertion is past the probe bound AND a
+ // grow could actually help. Keys sharing a hashCode() cluster onto one chain in every table
+ // size, so once the table already holds MAX_SLOTS_PER_LIVE_ENTRY slots per live entry no
+ // resize can spread them -- we accept the long chain instead of doubling the table forever
+ // (the adversarial-input OOM backstop). At a finite cap the bound is likewise relaxed: we
+ // cannot grow, so we fill past MAX_PROBES until physically full.
+ int availableSlot = flip(slot);
+ int distance = (availableSlot - home) & (numSlots - 1);
+ if (distance >= MAX_PROBES
+ && numSlots < maxSlots
+ && (long) numSlots < (long) size(mapData) * MAX_SLOTS_PER_LIVE_ENTRY) {
+ // Grow, then insert into the fresh (tombstone-free, better-spread) table.
+ mapData = expandMapData(mapData);
+ newMapUncheckedInsert(mapData, numSlots(mapData), key, value);
+ return mapData;
+ }
+ mapData[availableSlot] = key;
+ mapData[availableSlot + numSlots] = value;
+ return mapData;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Nullable
+ public static V get(@Nullable Object[] mapData, @Nonnull Object key) {
+ if (mapData == null) return null;
+
+ int numSlots = numSlots(mapData);
+ int foundIndex = findSlot(mapData, numSlots, key);
+
+ return (foundIndex >= 0) ? (V) mapData[numSlots + foundIndex] : null;
+ }
+
+ public static boolean remove(@Nullable Object[] mapData, @Nonnull Object key) {
+ if (mapData == null) return false;
+
+ int numSlots = numSlots(mapData);
+ int foundIndex = findSlot(mapData, numSlots, key);
+ if (foundIndex >= 0) {
+ mapData[foundIndex] = REMOVED;
+ mapData[foundIndex + numSlots] = null;
+
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public static int findInsertionSlot(@Nullable Object[] mapData, @Nonnull Object key) {
+ if (mapData == null) return SLOT_CAPACITY_REACHED;
+
+ return findInsertionSlot(mapData, numSlots(mapData), key);
+ }
+
+ @Nonnull
+ public static final Object[] insertAt(
+ int initialCapacity,
+ @Nullable Object[] mapData,
+ int insertionSlot,
+ @Nonnull Object key,
+ @Nonnull Object value) {
+ // Same null-value invariant as setOrReject; insertAt is a separate spine write path that does
+ // not flow through it, so it needs its own guard.
+ Objects.requireNonNull(value, "value");
+ if (mapData == null) {
+ return newMapData(initialCapacity, key, value);
+ }
+
+ if (insertionSlot == SLOT_CAPACITY_REACHED) {
+ mapData = expandMapData(mapData);
+ newMapUncheckedInsert(mapData, numSlots(mapData), key, value);
+ } else {
+ if (insertionSlot < 0) insertionSlot = flip(insertionSlot);
+
+ mapData[insertionSlot] = key;
+ mapData[insertionSlot + numSlots(mapData)] = value;
+ }
+
+ return mapData;
+ }
+
+ static final int findInsertionSlot(
+ @Nonnull Object[] mapData, int numSlots, @Nonnull Object key) {
+ return findInsertionSlot(mapData, numSlots, key, preferredSlot(numSlots, key.hashCode()));
+ }
+
+ static final int findInsertionSlot(
+ @Nonnull Object[] mapData, int numSlots, @Nonnull Object key, int preferredSlot) {
+ int availableIndex = SLOT_CAPACITY_REACHED;
+ for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) {
+ Object curKey = mapData[keyIndex];
+ // A reclaimable tombstone seen earlier in the probe order beats this null: it is closer to
+ // the home slot (shorter displacement, so less likely to trip the probe-bound grow) and
+ // reusing it clears a tombstone. The key is confirmed absent either way (we reached a
+ // null).
+ if (curKey == null)
+ return (availableIndex != SLOT_CAPACITY_REACHED) ? availableIndex : flip(keyIndex);
+ if (curKey == key) return keyIndex;
+ if (curKey == REMOVED) {
+ if (availableIndex == SLOT_CAPACITY_REACHED) availableIndex = flip(keyIndex);
+ } else if (key.equals(curKey)) return keyIndex;
+ }
+ for (int keyIndex = 0; keyIndex < preferredSlot; ++keyIndex) {
+ Object curKey = mapData[keyIndex];
+ if (curKey == null)
+ return (availableIndex != SLOT_CAPACITY_REACHED) ? availableIndex : flip(keyIndex);
+ if (curKey == key) return keyIndex;
+ if (curKey == REMOVED) {
+ if (availableIndex == SLOT_CAPACITY_REACHED) availableIndex = flip(keyIndex);
+ } else if (key.equals(curKey)) return keyIndex;
+ }
+ return availableIndex;
+ }
+
+ // Encodes a free slot index as a negative number so it is distinguishable from a "key present
+ // at this slot" hit (which is >= 0), letting one int carry both outcomes without an out-param.
+ // This is the same convention java.util.Arrays.binarySearch uses to return an insertion point:
+ // slot i maps to -i-1, so slot 0 (which cannot be negated to a distinct value) becomes -1.
+ // Self-inverse: flip(flip(i)) == i.
+ static int flip(int keyIndex) {
+ return -keyIndex - 1;
+ }
+
+ public static final void removeAt(@Nullable Object[] mapData, int slot) {
+ if (mapData == null || slot < 0) return;
+
+ mapData[slot] = REMOVED;
+ mapData[slot + numSlots(mapData)] = null;
+ }
+
+ @Nullable
+ public static final Object getAndRemoveAt(@Nullable Object[] mapData, int slot) {
+ if (mapData == null || slot < 0) return null;
+
+ mapData[slot] = REMOVED;
+
+ int valueIndex = slot + numSlots(mapData);
+ Object prev = mapData[valueIndex];
+ mapData[valueIndex] = null;
+
+ return prev;
+ }
+
+ public static final int findSlot(@Nullable Object[] mapData, @Nonnull Object key) {
+ if (mapData == null) return SLOT_NOT_FOUND;
+
+ return findSlot(mapData, numSlots(mapData), key);
+ }
+
+ static final int findSlot(@Nonnull Object[] mapData, int numSlots, @Nonnull Object key) {
+ int hash = key.hashCode();
+ int preferredSlot = preferredSlot(numSlots, hash);
+
+ // A single probe that terminates at the first null slot. Each live slot is checked by
+ // identity (curKey == key) before equals(): interned or reused keys -- the common case for a
+ // String-keyed map here -- hit without any virtual equals() call, and the guard is a wash
+ // when equals() inlines and a win when it does not (keys erase to Object, so equals() is not
+ // guaranteed monomorphic across callers of the shared spine). We compare key.equals(curKey),
+ // not curKey.equals(key), so the receiver stays the caller's key type and C2 can devirtualize
+ // it. This is a per-slot guard, not a whole-array pre-pass: a miss still touches only the
+ // probe chain. A live key is never REMOVED, so the identity hit needs no tombstone check.
+ for (int keyIndex = preferredSlot; keyIndex < numSlots; ++keyIndex) {
+ Object curKey = mapData[keyIndex];
+ if (curKey == null) return SLOT_NOT_FOUND;
+ if (curKey == key) return keyIndex;
+ if (curKey != REMOVED && key.equals(curKey)) return keyIndex;
+ }
+ for (int keyIndex = 0; keyIndex < preferredSlot; ++keyIndex) {
+ Object curKey = mapData[keyIndex];
+ if (curKey == null) return SLOT_NOT_FOUND;
+ if (curKey == key) return keyIndex;
+ if (curKey != REMOVED && key.equals(curKey)) return keyIndex;
+ }
+ return SLOT_NOT_FOUND;
+ }
+
+ @Nonnull
+ static final Object[] newMapData(
+ int initialCapacity, @Nonnull Object key, @Nonnull Object value) {
+ int numSlots = roundUpToPow2(initialCapacity);
+ Object[] mapData = new Object[numSlots << 1];
+
+ int slotIndex = preferredSlot(numSlots, key.hashCode());
+ mapData[slotIndex] = key;
+ mapData[slotIndex + numSlots] = value;
+ return mapData;
+ }
+
+ @Nonnull
+ public static final Object[] expandMapData(@Nonnull Object[] mapData) {
+ // Subtle - capacity is in terms of slots - not array size, so passing length is
+ // asking to double capacity
+ return expandMapData(mapData, mapData.length);
+ }
+
+ @Nonnull
+ public static Object[] expandMapData(@Nonnull Object[] origMapData, int newCapacity) {
+ newCapacity = roundUpToPow2(newCapacity);
+ int newSize = newCapacity << 1;
+ // Don't try to optimize by returning origMapData if big enough to contain
+ // the newCapacity. There's subtle invariant that new maps also don't
+ // contain remove markers that must also be maintained.
+
+ int origNumSlots = numSlots(origMapData);
+
+ Object[] newMapData = new Object[newSize];
+ for (int slot = 0; slot < origNumSlots; ++slot) {
+ Object key = origMapData[slot];
+ if (key == null || key == REMOVED) continue;
+
+ Object value = origMapData[slot + origNumSlots];
+ newMapUncheckedInsert(newMapData, newCapacity, key, value);
+ }
+ return newMapData;
+ }
+
+ @SuppressWarnings("unchecked")
+ public static void forEach(
+ @Nullable Object[] mapData, @Nonnull BiConsumer super K, ? super V> entryConsumer) {
+ if (mapData == null) return;
+
+ int numSlots = numSlots(mapData);
+ for (int slot = 0; slot < numSlots; ++slot) {
+ Object key = mapData[slot];
+ if (key == null || key == REMOVED) continue;
+
+ Object value = mapData[slot + numSlots];
+ entryConsumer.accept((K) key, (V) value);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ public static void forEach(
+ @Nullable Object[] mapData,
+ @Nullable C ctx,
+ @Nonnull TriConsumer super C, ? super K, ? super V> entryConsumer) {
+ if (mapData == null) return;
+
+ int numSlots = numSlots(mapData);
+ for (int slot = 0; slot < numSlots; ++slot) {
+ Object key = mapData[slot];
+ if (key == null || key == REMOVED) continue;
+
+ Object value = mapData[slot + numSlots];
+ entryConsumer.accept(ctx, (K) key, (V) value);
+ }
+ }
+
+ /**
+ * A read-only iterator over the live entries of a caller-owned spine array -- the spine
+ * counterpart of {@link LightMap#iterator()}, for a caller that embeds the {@code Object[]}
+ * directly. The returned object is a reused flyweight; see {@link EntryReader} for the
+ * retention caveat. {@link Iterator#remove()} is unsupported.
+ */
+ @Nonnull
+ public static Iterator> iterator(@Nullable Object[] mapData) {
+ return new SlotIterator<>(mapData);
+ }
+
+ /**
+ * An {@link Iterable} view of a caller-owned spine array so the spine can be driven by a
+ * for-each loop directly: {@code for (EntryReader e : EmbeddingSupport.iterable(data))}.
+ * Each {@link Iterable#iterator()} call mints a fresh flyweight, so the view is re-iterable.
+ * The wrapper is a tiny object escape analysis can eliminate when the loop does not let it
+ * escape.
+ */
+ @Nonnull
+ public static Iterable> iterable(@Nullable Object[] mapData) {
+ return () -> iterator(mapData);
+ }
+
+ // Flyweight that is both the Iterator and the EntryReader it yields: next() advances to the
+ // next
+ // live slot and returns this. No per-entry object, so a for-each loop that does not let it
+ // escape scalar-replaces the whole thing away. Fields are one array reference plus two ints for
+ // exactly that reason. Read-only: the inherited Iterator.remove() default throws.
+ private static final class SlotIterator
+ implements Iterator>, EntryReader {
+ private final Object[] mapData;
+ private final int numSlots;
+ // Index of the next live key slot (== numSlots once exhausted), and the slot the reader
+ // currently points at (set by next()).
+ private int nextSlot;
+ private int slot;
+
+ SlotIterator(@Nullable Object[] mapData) {
+ this.mapData = mapData;
+ this.numSlots = numSlots(mapData);
+ this.nextSlot = nextLiveSlot(0);
+ }
+
+ // The first live slot at or after `from`, skipping empty (null) and tombstone (REMOVED)
+ // slots.
+ private int nextLiveSlot(int from) {
+ Object[] data = this.mapData;
+ int n = this.numSlots;
+ for (int i = from; i < n; ++i) {
+ Object key = data[i];
+ if (key != null && key != REMOVED) return i;
+ }
+ return n;
+ }
+
+ @Override
+ public boolean hasNext() {
+ return this.nextSlot < this.numSlots;
+ }
+
+ @Override
+ public EntryReader next() {
+ int cur = this.nextSlot;
+ if (cur >= this.numSlots) throw new NoSuchElementException();
+ this.slot = cur;
+ this.nextSlot = nextLiveSlot(cur + 1);
+ return this;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public K key() {
+ return (K) this.mapData[this.slot];
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public V value() {
+ return (V) this.mapData[this.slot + this.numSlots];
+ }
+ }
+
+ @Nonnull
+ public static String toInternalString(@Nullable Object[] mapData) {
+ StringBuilder builder = new StringBuilder(128);
+ builder.append('{');
+
+ int numSlots = numSlots(mapData);
+ for (int slot = 0; slot < numSlots; ++slot) {
+ Object key = mapData[slot];
+ if (key == null) continue;
+
+ builder.append('[').append(slot).append("]=");
+
+ if (key == REMOVED) {
+ builder.append("--REMOVED--");
+ } else {
+ Object value = mapData[slot + numSlots];
+ builder.append(key).append(':').append(value);
+ }
+ builder.append('\n');
+ }
+ builder.append('}');
+ return builder.toString();
+ }
+
+ static void newMapUncheckedInsert(
+ @Nonnull Object[] mapData, int numSlots, @Nonnull Object key, @Nonnull Object value) {
+ int hash = key.hashCode();
+ int preferredSlot = preferredSlot(numSlots, hash);
+
+ for (int slot = preferredSlot; slot < numSlots; ++slot) {
+ Object curKey = mapData[slot];
+ if (curKey == null) {
+ mapData[slot] = key;
+ mapData[slot + numSlots] = value;
+
+ return;
+ }
+ }
+ for (int slot = 0; slot < preferredSlot; ++slot) {
+ Object curKey = mapData[slot];
+ if (curKey == null) {
+ mapData[slot] = key;
+ mapData[slot + numSlots] = value;
+
+ return;
+ }
+ }
+ }
+
+ static int preferredSlot(int numSlots, int hash) {
+ // numSlots is required to be a power of 2 (allocation sites round up via roundUpToPow2).
+ // The bit mask is ~20x faster than a modulo divide on typical hardware.
+ return (hash ^ (hash >>> 16)) & (numSlots - 1);
+ }
+
+ static int roundUpToPow2(int n) {
+ return n <= 1 ? 1 : Integer.highestOneBit(n - 1) << 1;
+ }
+ }
+}
diff --git a/internal-api/src/test/java/datadog/trace/util/LightMapTest.java b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java
new file mode 100644
index 00000000000..208fb5d9174
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/util/LightMapTest.java
@@ -0,0 +1,1119 @@
+package datadog.trace.util;
+
+import static datadog.trace.util.LightMap.EmbeddingSupport;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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 datadog.trace.util.LightMap.EntryReader;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class LightMapTest {
+
+ // ============ Instance API ============
+
+ @Nested
+ class InstanceTests {
+
+ @Test
+ void getOnEmptyMapReturnsNull() {
+ LightMap map = LightMap.createUncapped(LightMap.DEFAULT_CAPACITY);
+ assertNull(map.get("absent"));
+ }
+
+ @Test
+ void setThenGet() {
+ LightMap map = LightMap.createUncapped(8);
+ map.set("a", 1);
+ map.set("b", 2);
+ assertEquals(1, map.get("a"));
+ assertEquals(2, map.get("b"));
+ assertNull(map.get("c"));
+ }
+
+ @Test
+ void noArgCreateUncappedProducesUsableMap() {
+ // The parameterless front door seeds the default capacity; exercise it end-to-end so the
+ // convenience factory doesn't rot uncovered.
+ LightMap map = LightMap.createUncapped();
+ map.set("a", 1);
+ map.set("b", 2);
+ assertEquals(1, map.get("a"));
+ assertEquals(2, map.get("b"));
+ assertEquals(2, map.size());
+ }
+
+ @Test
+ void setOverwritesExistingKey() {
+ LightMap map = LightMap.createUncapped(8);
+ map.set("a", 1);
+ map.set("a", 42);
+ assertEquals(42, map.get("a"));
+ }
+
+ @Test
+ void removeMakesKeyAbsent() {
+ LightMap map = LightMap.createUncapped(8);
+ map.set("a", 1);
+ map.set("b", 2);
+ map.remove("a");
+ assertNull(map.get("a"));
+ assertEquals(2, map.get("b"));
+ }
+
+ @Test
+ void containsKeyDistinguishesPresenceFromAbsence() {
+ LightMap map = LightMap.createUncapped(8);
+ assertFalse(map.containsKey("a"));
+ map.set("a", 1);
+ assertTrue(map.containsKey("a"));
+ assertFalse(map.containsKey("b"));
+ map.remove("a");
+ assertFalse(map.containsKey("a"));
+ }
+
+ @Test
+ void setRejectsNullValue() {
+ LightMap map = LightMap.createUncapped(8);
+ assertThrows(NullPointerException.class, () -> map.set("a", null));
+ assertFalse(map.containsKey("a"));
+ }
+
+ @Test
+ void sizeTracksLiveEntries() {
+ LightMap map = LightMap.createUncapped(8);
+ assertEquals(0, map.size());
+ map.set("a", 1);
+ map.set("b", 2);
+ assertEquals(2, map.size());
+ map.set("a", 11); // overwrite does not change size
+ assertEquals(2, map.size());
+ map.remove("a");
+ assertEquals(1, map.size());
+ map.remove("missing"); // no-op does not change size
+ assertEquals(1, map.size());
+ }
+
+ @Test
+ void tinyMapGrowsOnlyWhenPhysicallyFull() {
+ // The probe-bound grow trigger (MAX_PROBES == 8) cannot fire on an 8-slot table -- a probe
+ // can never travel 8 slots there -- so a seed-8 map fills all 8 slots before it grows,
+ // exactly as it did before the trigger existed. This is the property that keeps the tiny,
+ // miss-dominated consumer (springweb6 localAttributes) behaviorally unchanged.
+ LightMap map = LightMap.createUncapped(8);
+ for (int i = 0; i < 8; i++) {
+ map.set("k" + i, i);
+ }
+ assertEquals(
+ 8, EmbeddingSupport.numSlots(map.dataForTesting()), "fills to full before growing");
+ map.set("k8", 8);
+ assertEquals(16, EmbeddingSupport.numSlots(map.dataForTesting()), "9th key forces the grow");
+ assertEquals(9, map.size());
+ for (int i = 0; i < 9; i++) {
+ assertEquals(i, map.get("k" + i));
+ }
+ }
+
+ @Test
+ void clusteredKeysGrowEarlierThanWellSpreadKeys() {
+ // The whole point of the probe bound: it responds to local clustering that a load factor is
+ // blind to. The same number of keys, all colliding onto one home slot, must drive the table
+ // strictly larger than a well-spread set would -- the trigger is firing on long probe chains
+ // well before the table is anywhere near full. Both sets stay fully retrievable.
+ int count = 32;
+
+ LightMap spread = LightMap.createUncapped(8);
+ for (int i = 0; i < count; i++) {
+ spread.set("spread." + i, i);
+ }
+
+ LightMap clustered = LightMap.createUncapped(8);
+ List colliding = collidingKeys(count);
+ for (int i = 0; i < count; i++) {
+ clustered.set(colliding.get(i), i);
+ }
+
+ int spreadSlots = EmbeddingSupport.numSlots(spread.dataForTesting());
+ int clusteredSlots = EmbeddingSupport.numSlots(clustered.dataForTesting());
+ assertTrue(
+ clusteredSlots > spreadSlots,
+ "clustered keys should over-grow (" + clusteredSlots + " vs spread " + spreadSlots + ")");
+
+ for (int i = 0; i < count; i++) {
+ assertEquals(i, spread.get("spread." + i));
+ assertEquals(i, clustered.get(colliding.get(i)));
+ }
+ }
+
+ @Test
+ void collidingHashCodesDoNotExplodeMemory() {
+ // Regression: keys sharing an identical hashCode() land on the same home slot in EVERY table
+ // size, so growing never shortens their probe chain. A pure probe-bound grow trigger would
+ // double the table on every insert past MAX_PROBES, ballooning an uncapped map to hundreds of
+ // millions of slots from a few dozen keys (an adversarial-input OOM). The
+ // MAX_SLOTS_PER_LIVE_ENTRY backstop must keep the table O(live entries) while every key stays
+ // retrievable.
+ List keys = identicalHashCodeKeys(32);
+ int sharedHash = keys.get(0).hashCode();
+ for (String key : keys) {
+ assertEquals(sharedHash, key.hashCode(), "fixture keys must share one hashCode: " + key);
+ }
+
+ LightMap map = LightMap.createUncapped(8);
+ for (int i = 0; i < keys.size(); i++) {
+ map.set(keys.get(i), i);
+ }
+
+ int slots = EmbeddingSupport.numSlots(map.dataForTesting());
+ assertTrue(
+ slots <= keys.size() * 16,
+ "colliding keys must stay bounded, not explode: " + slots + " slots for " + keys.size());
+ assertEquals(keys.size(), map.size());
+ for (int i = 0; i < keys.size(); i++) {
+ assertEquals(i, map.get(keys.get(i)), keys.get(i));
+ }
+ }
+
+ @Test
+ void insertReclaimsEarlierTombstoneInsteadOfExtendingChain() {
+ // A chain of same-home-slot keys, with one removed mid-chain, leaves a tombstone. A later
+ // insert of a new key on that chain must reclaim the (earlier, shorter-displacement)
+ // tombstone
+ // rather than walk past it to a fresh null -- keeping the chain short and avoiding a needless
+ // grow.
+ List colliding = collidingKeys(5);
+ LightMap map = LightMap.createUncapped(16);
+ for (int i = 0; i < 4; i++) {
+ map.set(colliding.get(i), i);
+ }
+ Object[] data = map.dataForTesting();
+ int numSlots = EmbeddingSupport.numSlots(data);
+ int reclaimedSlot = slotOf(data, numSlots, colliding.get(1));
+ assertTrue(reclaimedSlot >= 0, "second key should be present before removal");
+
+ map.remove(colliding.get(1)); // tombstone mid-chain
+ map.set(colliding.get(4), 4); // new key on the same chain
+
+ Object[] after = map.dataForTesting();
+ assertEquals(16, EmbeddingSupport.numSlots(after), "reusing the tombstone avoids a grow");
+ assertEquals(
+ colliding.get(4),
+ after[reclaimedSlot],
+ "the new key should reuse the earlier tombstone slot, not extend the chain");
+ assertEquals(4, map.get(colliding.get(4)));
+ assertNull(map.get(colliding.get(1)));
+ }
+
+ @Test
+ void growsAndPreservesAllEntries() {
+ // initial capacity 2 forces several resizes as we insert well past it.
+ LightMap map = LightMap.createUncapped(2);
+ int n = 100;
+ for (int i = 0; i < n; i++) {
+ map.set("key-" + i, i);
+ }
+ for (int i = 0; i < n; i++) {
+ assertEquals(i, map.get("key-" + i), "key-" + i);
+ }
+ }
+
+ @Test
+ void growsAndPreservesAllEntriesWithNonStringKeys() {
+ // A non-String key type must survive resizing: expandMapData copies keys back generically,
+ // so it must not assume String. initial capacity 2 forces several resizes.
+ LightMap map = LightMap.createUncapped(2);
+ int n = 100;
+ for (int i = 0; i < n; i++) {
+ map.set(i, "value-" + i);
+ }
+ for (int i = 0; i < n; i++) {
+ assertEquals("value-" + i, map.get(i), "key " + i);
+ }
+ }
+
+ @Test
+ void forEachVisitsEveryLiveEntry() {
+ LightMap map = LightMap.createUncapped(4);
+ for (int i = 0; i < 20; i++) {
+ map.set("k" + i, i);
+ }
+ map.remove("k5");
+ map.remove("k12");
+
+ Map seen = new HashMap<>();
+ map.forEach(seen::put);
+
+ assertEquals(18, seen.size());
+ assertFalse(seen.containsKey("k5"));
+ assertFalse(seen.containsKey("k12"));
+ assertEquals(7, seen.get("k7"));
+ }
+
+ @Test
+ void nonLiteralKeyResolvesViaEqualsFallback() {
+ LightMap map = LightMap.createUncapped(8);
+ map.set("hello", 1);
+ // Distinct String instance with the same content -- must be found via the equals pass.
+ String lookup = new String("hello");
+ assertEquals(1, map.get(lookup));
+ }
+
+ // Strings whose home slot collides in a large reference table (2^16 slots), and therefore in
+ // every smaller power-of-two table the map passes through while growing -- so they pile onto a
+ // single probe chain no matter how the map is sized here.
+ private List collidingKeys(int count) {
+ int reference = 1 << 16;
+ int target = EmbeddingSupport.preferredSlot(reference, "anchor".hashCode());
+ List keys = new ArrayList<>(count);
+ for (int i = 0; keys.size() < count; i++) {
+ String candidate = "collide." + i;
+ if (EmbeddingSupport.preferredSlot(reference, candidate.hashCode()) == target) {
+ keys.add(candidate);
+ }
+ }
+ return keys;
+ }
+
+ // Distinct strings that all share ONE hashCode() (not merely one home slot): the equal-hashCode
+ // blocks "Aa" and "BB" both hash to 2112, and any concatenation of them yields the same
+ // hashCode -- so these collide in every table size and can never be spread apart by growing.
+ private List identicalHashCodeKeys(int count) {
+ String[] blocks = {"Aa", "BB"};
+ List keys = new ArrayList<>(count);
+ for (int i = 0; keys.size() < count; i++) {
+ StringBuilder sb = new StringBuilder();
+ int bits = i;
+ for (int b = 0; b < 5; b++) { // 2^5 = 32 distinct combinations
+ sb.append(blocks[bits & 1]);
+ bits >>>= 1;
+ }
+ keys.add(sb.toString());
+ }
+ return keys;
+ }
+
+ private int slotOf(Object[] data, int numSlots, String key) {
+ for (int slot = 0; slot < numSlots; slot++) {
+ if (key.equals(data[slot])) return slot;
+ }
+ return -1;
+ }
+
+ @Test
+ void removeThenReinsertSameKey() {
+ LightMap map = LightMap.createUncapped(4);
+ for (int i = 0; i < 4; i++) {
+ map.set("k" + i, i);
+ }
+ map.remove("k1");
+ assertNull(map.get("k1"));
+ map.set("k1", 99);
+ assertEquals(99, map.get("k1"));
+ }
+
+ @Test
+ void forEachLoopVisitsAllLiveEntries() {
+ LightMap map = LightMap.createUncapped(8);
+ map.set("a", 1);
+ map.set("b", 2);
+ map.set("c", 3);
+
+ Map seen = new HashMap<>();
+ for (EntryReader e : map) {
+ seen.put(e.key(), e.value());
+ }
+ assertEquals(3, seen.size());
+ assertEquals(1, seen.get("a"));
+ assertEquals(2, seen.get("b"));
+ assertEquals(3, seen.get("c"));
+ }
+
+ @Test
+ void iterationSkipsRemovedAndEmptySlots() {
+ LightMap map = LightMap.createUncapped(8);
+ for (int i = 0; i < 5; i++) {
+ map.set("k" + i, i);
+ }
+ map.remove("k2"); // leaves a tombstone the iterator must skip
+
+ Map seen = new HashMap<>();
+ for (EntryReader e : map) {
+ seen.put(e.key(), e.value());
+ }
+ assertEquals(4, seen.size());
+ assertNull(seen.get("k2"));
+ assertEquals(0, seen.get("k0"));
+ assertEquals(4, seen.get("k4"));
+ }
+
+ @Test
+ void iterationOverEmptyMapYieldsNothing() {
+ LightMap map = LightMap.createUncapped(8);
+ Iterator> it = map.iterator();
+ assertFalse(it.hasNext());
+ assertThrows(NoSuchElementException.class, it::next);
+ }
+
+ @Test
+ void iteratorReusesASingleFlyweight() {
+ // The reader handed back is the iterator itself, repositioned in place -- the same object
+ // each
+ // step. This pins the deliberate zero-allocation contract (and the "do not retain" caveat).
+ LightMap map = LightMap.createUncapped(8);
+ map.set("a", 1);
+ map.set("b", 2);
+
+ Iterator> it = map.iterator();
+ EntryReader first = it.next();
+ EntryReader second = it.next();
+ assertSame(first, second);
+ assertSame(it, first);
+ assertFalse(it.hasNext());
+ }
+
+ @Test
+ void iteratorRemoveIsUnsupported() {
+ LightMap map = LightMap.createUncapped(8);
+ map.set("a", 1);
+ Iterator> it = map.iterator();
+ it.next();
+ assertThrows(UnsupportedOperationException.class, it::remove);
+ }
+ }
+
+ // ============ EmbeddingSupport spine ============
+
+ @Nested
+ class EmbeddingSupportTests {
+
+ @Test
+ void emptyMapProbes() {
+ assertTrue(EmbeddingSupport.isDefinitelyEmpty(EmbeddingSupport.EMPTY_DATA));
+ assertEquals(0, EmbeddingSupport.numSlots(null));
+ assertEquals(0, EmbeddingSupport.size(null));
+ assertNull(EmbeddingSupport.get(null, "x"));
+ assertFalse(EmbeddingSupport.remove(null, "x"));
+ assertFalse(EmbeddingSupport.containsKey(null, "x"));
+ assertFalse(EmbeddingSupport.containsValue(null, "x"));
+ assertEquals(EmbeddingSupport.SLOT_NOT_FOUND, EmbeddingSupport.findSlot(null, "x"));
+ }
+
+ @Test
+ void removedTombstoneRendersDistinctlyForHeapInspection() {
+ // The deletion sentinel is only ever observed by a human reading a heap dump / debugger,
+ // never by production code, so its toString() has no code-path caller. Pin the rendering
+ // here so the debug aid can't silently regress.
+ assertEquals("--REMOVED--", EmbeddingSupport.REMOVED.toString());
+ }
+
+ @Test
+ void setGrowsFromNullAndReadsBack() {
+ Object[] data = null;
+ data = EmbeddingSupport.set(4, data, "a", "A");
+ data = EmbeddingSupport.set(4, data, "b", "B");
+ assertEquals("A", EmbeddingSupport.get(data, "a"));
+ assertEquals("B", EmbeddingSupport.get(data, "b"));
+ assertEquals(2, EmbeddingSupport.size(data));
+ }
+
+ @Test
+ void defaultCapacitySetOverload() {
+ Object[] data = EmbeddingSupport.set(null, "a", "A");
+ assertEquals(LightMap.DEFAULT_CAPACITY, EmbeddingSupport.numSlots(data));
+ assertEquals("A", EmbeddingSupport.get(data, "a"));
+ }
+
+ @Test
+ void containsKeyAndContainsValue() {
+ Object[] data = null;
+ data = EmbeddingSupport.set(8, data, "a", "A");
+ data = EmbeddingSupport.set(8, data, "b", "B");
+ assertTrue(EmbeddingSupport.containsKey(data, "a"));
+ assertFalse(EmbeddingSupport.containsKey(data, "z"));
+ assertTrue(EmbeddingSupport.containsValue(data, "B"));
+ assertFalse(EmbeddingSupport.containsValue(data, "Z"));
+ }
+
+ @Test
+ void removeLeavesTombstoneButKeepsProbeChainIntact() {
+ // Insert many keys into a small table, remove a middle one, ensure the rest still resolve.
+ Object[] data = null;
+ for (int i = 0; i < 16; i++) {
+ data = EmbeddingSupport.set(2, data, "k" + i, i);
+ }
+ assertTrue(EmbeddingSupport.remove(data, "k8"));
+ assertNull(EmbeddingSupport.get(data, "k8"));
+ for (int i = 0; i < 16; i++) {
+ if (i == 8) continue;
+ assertEquals(i, (Object) EmbeddingSupport.get(data, "k" + i), "k" + i);
+ }
+ assertEquals(15, EmbeddingSupport.size(data));
+ }
+
+ // The following three tests drive the wraparound (second) probe loop in the spine, the path
+ // where a probe runs off the end of the table and resumes at slot 0. Integer keys make the home
+ // slot deterministic: for a value v < 2^16, key.hashCode() == v and its high half is zero, so
+ // preferredSlot(8, v) == (v & 7). Keys 7, 15, 23 all home to slot 7 (the last slot), so a probe
+ // starting there must wrap. Distances stay under MAX_PROBES, so an 8-slot table never resizes.
+ @Test
+ void findSlotWrapsPastEndToLocateKey() {
+ Object[] data = null;
+ data = EmbeddingSupport.set(8, data, 7, "A"); // home slot 7
+ data = EmbeddingSupport.set(8, data, 15, "B"); // home slot 7 taken -> wraps to slot 0
+ assertEquals(8, EmbeddingSupport.numSlots(data)); // no resize
+
+ // 15's home (7) is occupied by 7, so the lookup must run off the end and resume the scan at
+ // slot 0 -- the wraparound loop -- to find it. get()/remove() both route through findSlot.
+ assertEquals(0, EmbeddingSupport.findSlot(data, 15));
+ assertTrue(EmbeddingSupport.containsKey(data, 15));
+ assertEquals("B", EmbeddingSupport.get(data, 15));
+ }
+
+ @Test
+ void missTerminatesInWraparoundLoopAtNull() {
+ Object[] data = null;
+ data = EmbeddingSupport.set(8, data, 7, "A"); // home 7 -> slot 7
+ data = EmbeddingSupport.set(8, data, 15, "B"); // home 7 -> wraps to slot 0
+
+ // 23 also homes to slot 7; the probe wraps (slot 7 occupied, no match) and terminates at the
+ // first null in the second loop -- an absent-key miss reached only via wraparound.
+ assertEquals(EmbeddingSupport.SLOT_NOT_FOUND, EmbeddingSupport.findSlot(data, 23));
+ assertFalse(EmbeddingSupport.containsKey(data, 23));
+ assertNull(EmbeddingSupport.get(data, 23));
+ }
+
+ @Test
+ void insertReusesTombstoneFoundAfterWraparound() {
+ Object[] data = null;
+ data = EmbeddingSupport.set(8, data, 7, "A"); // home 7 -> slot 7
+ data = EmbeddingSupport.set(8, data, 15, "B"); // home 7 -> wraps to slot 0
+ assertTrue(EmbeddingSupport.remove(data, 15)); // slot 0 becomes a tombstone
+
+ // 23 homes to slot 7 (occupied), wraps, and meets the tombstone at slot 0 before any null.
+ // findInsertionSlot must reclaim that tombstone in the wraparound loop rather than walk on.
+ data = EmbeddingSupport.set(8, data, 23, "C");
+ assertEquals(8, EmbeddingSupport.numSlots(data)); // reclaimed in place, no resize
+ assertEquals(0, EmbeddingSupport.findSlot(data, 23)); // reused the very slot 15 vacated
+ assertEquals("C", EmbeddingSupport.get(data, 23));
+ assertEquals(2, EmbeddingSupport.size(data));
+ }
+
+ @Test
+ void spineIteratorVisitsAllLiveEntriesAndSkipsTombstones() {
+ Object[] data = null;
+ for (int i = 0; i < 5; i++) {
+ data = EmbeddingSupport.set(8, data, "k" + i, i);
+ }
+ EmbeddingSupport.remove(data, "k3"); // tombstone to skip
+
+ Map seen = new HashMap<>();
+ Iterator> it = EmbeddingSupport.iterator(data);
+ while (it.hasNext()) {
+ EntryReader e = it.next();
+ seen.put(e.key(), e.value());
+ }
+ assertEquals(4, seen.size());
+ assertNull(seen.get("k3"));
+ assertEquals(0, seen.get("k0"));
+ }
+
+ @Test
+ void spineIterableDrivesForEachLoopAndIsReIterable() {
+ Object[] data = null;
+ data = EmbeddingSupport.set(8, data, "a", 1);
+ data = EmbeddingSupport.set(8, data, "b", 2);
+
+ Iterable> view = EmbeddingSupport.iterable(data);
+ // A fresh flyweight per iterator() call, so the view survives a second pass.
+ for (int pass = 0; pass < 2; pass++) {
+ Map seen = new HashMap<>();
+ for (EntryReader e : view) {
+ seen.put(e.key(), e.value());
+ }
+ assertEquals(2, seen.size(), "pass " + pass);
+ assertEquals(1, seen.get("a"));
+ assertEquals(2, seen.get("b"));
+ }
+ }
+
+ @Test
+ void spineIteratorOverNullDataYieldsNothing() {
+ Iterator> it = EmbeddingSupport.iterator(null);
+ assertFalse(it.hasNext());
+ assertThrows(NoSuchElementException.class, it::next);
+ }
+
+ @Test
+ void expandDiscardsTombstones() {
+ Object[] data = null;
+ for (int i = 0; i < 4; i++) {
+ data = EmbeddingSupport.set(4, data, "k" + i, i);
+ }
+ EmbeddingSupport.remove(data, "k0");
+ EmbeddingSupport.remove(data, "k1");
+ // A live tombstone exists now; expanding must purge them (size unchanged, no REMOVED slots).
+ Object[] expanded = EmbeddingSupport.expandMapData(data);
+ assertEquals(2, EmbeddingSupport.size(expanded));
+ int numSlots = EmbeddingSupport.numSlots(expanded);
+ for (int slot = 0; slot < numSlots; slot++) {
+ assertFalse(
+ EmbeddingSupport.isRemoved((String) expanded[slot]),
+ "expanded table must not carry tombstones");
+ }
+ assertEquals(2, (Object) EmbeddingSupport.get(expanded, "k2"));
+ assertEquals(3, (Object) EmbeddingSupport.get(expanded, "k3"));
+ }
+
+ @Test
+ void copyIsIndependent() {
+ Object[] data = EmbeddingSupport.set(4, null, "a", "A");
+ Object[] copy = EmbeddingSupport.copy(data);
+ EmbeddingSupport.set(4, copy, "b", "B");
+ assertNull(EmbeddingSupport.get(data, "b"));
+ assertEquals("B", EmbeddingSupport.get(copy, "b"));
+ assertNull(EmbeddingSupport.copy(null));
+ }
+
+ @Test
+ void clearNullsEverything() {
+ Object[] data = EmbeddingSupport.set(4, null, "a", "A");
+ EmbeddingSupport.clear(data);
+ assertEquals(0, EmbeddingSupport.size(data));
+ assertNull(EmbeddingSupport.get(data, "a"));
+ }
+
+ @Test
+ void keyAtValueAtAndPresence() {
+ Object[] data = EmbeddingSupport.set(8, null, "a", "A");
+ int slot = EmbeddingSupport.findSlot(data, "a");
+ assertTrue(EmbeddingSupport.isPresent(slot));
+ assertEquals("a", EmbeddingSupport.keyAt(data, slot));
+ assertEquals("A", EmbeddingSupport.valueAt(data, slot));
+ // negative slot -> absent
+ assertNull(EmbeddingSupport.keyAt(data, -1));
+ assertFalse(EmbeddingSupport.isPresent(-1));
+ }
+
+ @Test
+ void findSlotReportsSameMissSentinelForNullMapAndAbsentKey() {
+ // findSlot follows the String.indexOf idiom: every miss returns SLOT_NOT_FOUND, whether the
+ // map is null or simply lacks the key. The two cases must not diverge (they used to).
+ Object[] data = EmbeddingSupport.set(8, null, "a", "A");
+ assertEquals(EmbeddingSupport.SLOT_NOT_FOUND, EmbeddingSupport.findSlot(data, "absent"));
+ assertEquals(EmbeddingSupport.SLOT_NOT_FOUND, EmbeddingSupport.findSlot(null, "absent"));
+ assertEquals(-1, EmbeddingSupport.SLOT_NOT_FOUND);
+ }
+
+ @Test
+ void removeAtAndGetAndRemoveAt() {
+ Object[] data = EmbeddingSupport.set(8, null, "a", "A");
+ int slot = EmbeddingSupport.findSlot(data, "a");
+ Object prev = EmbeddingSupport.getAndRemoveAt(data, slot);
+ assertEquals("A", prev);
+ assertNull(EmbeddingSupport.get(data, "a"));
+
+ Object[] data2 = EmbeddingSupport.set(8, null, "b", "B");
+ int slot2 = EmbeddingSupport.findSlot(data2, "b");
+ EmbeddingSupport.removeAt(data2, slot2);
+ assertNull(EmbeddingSupport.get(data2, "b"));
+ }
+
+ @Test
+ void insertAtUsingFoundInsertionSlot() {
+ Object[] data = EmbeddingSupport.set(8, null, "a", "A");
+ int insertionSlot = EmbeddingSupport.findInsertionSlot(data, "b");
+ data = EmbeddingSupport.insertAt(8, data, insertionSlot, "b", "B");
+ assertEquals("A", EmbeddingSupport.get(data, "a"));
+ assertEquals("B", EmbeddingSupport.get(data, "b"));
+ }
+
+ @Test
+ void insertAtFromNullBuildsNewMap() {
+ Object[] data =
+ EmbeddingSupport.insertAt(4, null, EmbeddingSupport.SLOT_CAPACITY_REACHED, "a", "A");
+ assertEquals("A", EmbeddingSupport.get(data, "a"));
+ }
+
+ @Test
+ void forEachStaticVisitsLiveEntries() {
+ Object[] data = null;
+ for (int i = 0; i < 6; i++) {
+ data = EmbeddingSupport.set(4, data, "k" + i, i);
+ }
+ EmbeddingSupport.remove(data, "k3");
+
+ Map seen = new HashMap<>();
+ EmbeddingSupport.forEach(data, seen::put);
+ assertEquals(5, seen.size());
+ assertFalse(seen.containsKey("k3"));
+ }
+
+ @Test
+ void forEachStaticWithContext() {
+ Object[] data = null;
+ for (int i = 0; i < 4; i++) {
+ data = EmbeddingSupport.set(4, data, "k" + i, i);
+ }
+ Map