diff --git a/dd-trace-core/src/jmh/java/datadog/trace/common/metrics/CardinalityLimitReporterBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/common/metrics/CardinalityLimitReporterBenchmark.java new file mode 100644 index 00000000000..6566216153f --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/common/metrics/CardinalityLimitReporterBenchmark.java @@ -0,0 +1,61 @@ +package datadog.trace.common.metrics; + +import java.util.concurrent.ThreadLocalRandom; +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.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Steady-state {@code record()} acceptance check: once every tag in the working set has an entry, + * every call should be a lookup + in-place count bump through the {@link + * datadog.trace.util.Hashtable.D1#tryGetOrCreate} {@code Maybe}, with no per-call allocation. Run + * with {@code -prof gc} -- B/op should read ~0. + * + *
Not thread-safe by design (see {@link CardinalityLimitReporter}'s class javadoc), so each
+ * thread gets its own reporter and tag pool rather than sharing one instance.
+ */
+@State(Scope.Thread)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@Warmup(iterations = 5, time = 2)
+@Measurement(iterations = 5, time = 2)
+@Fork(3)
+@Threads(8)
+public class CardinalityLimitReporterBenchmark {
+
+ private static final int DISTINCT_TAGS = 32;
+
+ private CardinalityLimitReporter reporter;
+ private String[] tags;
+ private int cursor;
+
+ @Setup(Level.Trial)
+ public void setup() {
+ this.reporter = new CardinalityLimitReporter();
+ this.tags = new String[DISTINCT_TAGS];
+ for (int i = 0; i < DISTINCT_TAGS; i++) {
+ tags[i] = "tag-" + i;
+ }
+ // Pre-populate every entry so the measured path is pure lookup + update, not creation.
+ for (String tag : tags) {
+ reporter.record(tag, 1);
+ }
+ }
+
+ @Benchmark
+ public void record() {
+ String tag = tags[cursor++ & (DISTINCT_TAGS - 1)];
+ long count = 1L + (ThreadLocalRandom.current().nextLong() & 0xFF);
+ reporter.record(tag, count);
+ }
+}
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateEntry.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateEntry.java
index 1214b246470..41f280faee7 100644
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateEntry.java
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateEntry.java
@@ -244,6 +244,14 @@ public int getHitCount() {
return hitCount;
}
+ /**
+ * {@code true} if nothing hit this entry in the current reporting cycle, making it the first
+ * thing worth evicting when the table is full.
+ */
+ public boolean isStale() {
+ return hitCount == 0;
+ }
+
public int getErrorCount() {
return errorCount;
}
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateTable.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateTable.java
index b120cb8b915..1984e8f4f20 100644
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateTable.java
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateTable.java
@@ -2,7 +2,6 @@
import datadog.trace.core.monitor.HealthMetrics;
import datadog.trace.util.Hashtable;
-import datadog.trace.util.Hashtable.MutatingTableIterator;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
@@ -25,17 +24,9 @@
*/
final class AggregateTable {
- private final Hashtable.Entry[] buckets;
- private final int maxAggregates;
- private final AggregateEntry.Canonical canonical;
- private int size;
+ private final Hashtable.State Cardinality limiting (see {@link MetricCardinalityLimits#USE_BLOCKED_SENTINEL}) reduces how
+ * often eviction fires but doesn't eliminate it. Over-cap values for a single field collapse into
+ * the shared {@code tracer_blocked_value} sentinel, so no one field can fill the table on its
+ * own. But distinct in-budget combinations across fields (resource x service x operation x ...)
+ * can still drive the entry count to {@code maxAggregates}, so eviction remains the backstop.
+ *
+ * The scan that finds a stale entry, and its resume-where-it-left-off amortization, live in
+ * {@link Hashtable#tryReserveOrEvict} -- this class only supplies {@link AggregateEntry#isStale}.
*/
AggregateEntry findOrInsert(SpanSnapshot snapshot) {
canonical.populateFrom(snapshot);
long keyHash = canonical.keyHash;
- for (AggregateEntry candidate = Hashtable.Support.bucket(buckets, keyHash);
+ for (AggregateEntry candidate = Hashtable.bucketFor(state, keyHash);
candidate != null;
candidate = candidate.next()) {
if (candidate.keyHash == keyHash && canonical.matches(candidate)) {
return candidate;
}
}
- // Miss path.
- if (size >= maxAggregates && !evictOneStale()) {
+ // Miss path. Reserve before building the entry so a refused insert costs no allocation; the
+ // reservation evicts a stale entry to make room if the table is already full.
+ if (!Hashtable.tryReserveOrEvict(state, AggregateEntry::isStale)) {
return null;
}
AggregateEntry entry = canonical.createEntry();
- Hashtable.Support.insertHeadEntry(buckets, keyHash, entry);
- size++;
+ Hashtable.insertReserved(state, keyHash, entry);
return entry;
}
- /**
- * Unlinks the first entry whose {@code getHitCount() == 0}, resuming the scan from {@link
- * #evictCursor} so consecutive evictions amortize to O(1) per call. Worst case for a single call
- * is still O(N) when nearly every entry is hot, but a sustained eviction stream never re-scans
- * the hot prefix more than twice across N evictions.
- *
- * If the table is full and every entry was used in this cycle, drop the new key (reported via
- * {@code onStatsAggregateDropped}) rather than evicting an established one. Cap is sized to the
- * steady-state working set, so eviction is rare in the common case.
- *
- * Cardinality limiting (see {@link MetricCardinalityLimits#USE_BLOCKED_SENTINEL}) reduces how
- * often this fires but doesn't eliminate it. Over-cap values for a single field collapse into the
- * shared {@code tracer_blocked_value} sentinel, so no one field can fill the table on its own.
- * But distinct in-budget combinations across fields (resource x service x operation x ...) can
- * still drive the entry count to {@code maxAggregates}, so this cursor-resumed scan remains the
- * backstop.
- */
- private boolean evictOneStale() {
- // Two passes -- [cursor, length) then [0, cursor) -- using the half-open-range iterator. The
- // second pass is naturally empty when cursor==0, so no extra check needed.
- return evictOneStaleInRange(evictCursor, buckets.length)
- || evictOneStaleInRange(0, evictCursor);
- }
-
- /** Scans {@code [startBucket, endBucket)} for the first stale entry and unlinks it. */
- private boolean evictOneStaleInRange(int startBucket, int endBucket) {
- MutatingTableIterator A refused create -- the tag table is itself at capacity -- is deliberately ignored: this is
+ * a log sink, and the durable counts still reach {@code onTagCardinalityBlocked}.
+ */
void record(String tag, long count) {
if (count > 0) {
- TagBlockEntry entry = blockedByTag.tryGetOrCreate(tag, TagBlockEntry::new);
- if (entry != null) {
- entry.count += count;
- }
+ blockedByTag.tryGetOrCreate(tag, TagBlockEntry::new).update(count, TagBlockEntry::inc);
}
}
@@ -106,5 +108,9 @@ private static final class TagBlockEntry extends Hashtable.D1.Entry The boxed-context pair is the sharper illustration of that phrase than it first looks
+ * like. {@code badBoxedContextUpdateInlined} was expected to allocate the boxed {@code Long}
+ * and, measured here, does not -- with the whole {@code update} call inlined, C2 scalar-replaces
+ * the box the same as it would any other short-lived object. That is exactly the "EA-dependent"
+ * half of that phrase: {@link Maybe#update(long, ObjLongConsumer)} has no box to eliminate
+ * in the first place, so it reads 0 B/op regardless of whether the mutator lambda's own inlining
+ * holds; the generic-context form's 0 B/op is contingent on that specific inlining, which {@code
+ * badBoxedContextUpdateUninlined} demonstrates by taking it away via the same {@code
+ * -XX:CompileCommand=dontinline} technique {@code EscapeShapeBenchmark} uses for its {@code
+ * UninlinedStrategy} arm. This is narrower than immunity to every inlining failure: if the
+ * producing method or the {@code update} call itself fails to inline -- a different boundary,
+ * exercised by {@code EscapeShapeBenchmark}'s {@code passedToUninlinedStrategy} arm (24 B/op) --
+ * the {@code Maybe} wrapper itself becomes a real allocation for either overload.
+ */
+@Fork(
+ value = 2,
+ jvmArgsAppend = {
+ "-XX:CompileCommand=dontinline,datadog.trace.util.MaybeUsagePatternsBenchmark$UninlinedBoxedAdder::accept"
+ })
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(java.util.concurrent.TimeUnit.NANOSECONDS)
+@State(Scope.Thread)
+public class MaybeUsagePatternsBenchmark {
+
+ static final class Widget {
+ long count;
+ }
+
+ /** A non-capturing updater, as {@link Maybe#update(long, ObjLongConsumer)} expects. */
+ static final ObjLongConsumer The underlying idea, before the terminology: the compiler can sometimes prove a short-lived
+ * object never needs to outlive the method that created it, and when it can, it skips putting that
+ * object on the heap at all -- it keeps the object's fields as plain local values instead. Three
+ * terms for that recur below. Escape analysis (EA) is the compiler's proof step -- showing
+ * an allocated object's lifetime is confined to the method (or thread) that created it, i.e. it
+ * never escapes into a field, a return value visible outside, or a call the compiler cannot see
+ * into. Scalar replacement is what C2 (HotSpot's JIT) does once that proof holds: the object
+ * itself disappears, and its individual fields live in registers or on the stack instead, so no
+ * heap allocation happens -- the arms below that read 0 B/op are exactly the ones EA proved safe.
+ * {@code ReduceAllocationMerges} (JDK-8287061) extends that same proof to one harder case:
+ * an if/else (or similar branch) where each side allocates its own object -- say {@code x = new
+ * Foo()} in one branch and {@code x = new Bar()} in the other -- and the code after the branch
+ * reads {@code x} without knowing which allocation actually ran. Before JDK 21, C2 could not
+ * scalar-replace either allocation once they were merged like this, even if each individually would
+ * have qualified on its own; {@code ReduceAllocationMerges} is what lets it do so starting at JDK
+ * 21, which is why a few rows below only drop to 0 starting at JDK 21/25 rather than on every JDK.
+ * All of this is specific to HotSpot's C2 JIT; none of it has been checked against OpenJ9 or
+ * GraalVM, which use different compilers with different heuristics and may not scalar-replace the
+ * same shapes.
+ *
+ * Every arm consumes the object's fields rather than the object. Handing the reference
+ * to a {@link Blackhole} would make it escape by construction and every row would read the same.
+ *
+ * Bytes per operation, one machine, {@code -Pjmh.forks=1}. A 16-byte object allocated on half
+ * the operations reads as 8. Columns are the JDK the fork ran on, which is not necessarily
+ * the JDK on the shell's path — take it from JMH's own {@code # VM version} line.
+ *
+ * JDK 8 column measured 2026-08-27 (Zulu 8.72.0.17, this machine, {@code -Pjmh.fork=1}): every
+ * arm lands on the same B/op as the 17/25 columns it was checked against, including {@code
+ * mergeWithNull} staying at 8 rather than following JDK 25's drop to 0 — the {@code
+ * ReduceAllocationMerges} relaxation is JDK 21+ only, so 8's floor for this shape is the older,
+ * unconditional one.
+ *
+ * What the two measured columns say so far:
+ *
+ * Fixed or growable, chosen at construction. {@link #createFixed} keeps the raw core's
* bounded posture — the table holds up to {@code maxCapacity} entries, then {@link
- * #tryGetOrCreate} caps and returns {@code null} (the caller supplies the overflow default).
- * {@link #createGrowable} trades that for {@code HashMap}-like ergonomics — its {@code
+ * #tryGetOrCreateOrNull} caps and returns {@code null} (the caller supplies the overflow
+ * default). {@link #createGrowable} trades that for {@code HashMap}-like ergonomics — its {@code
* initialCapacity} is a sizing hint, not a cap, the table doubles when it fills past its load
- * factor, and {@code tryGetOrCreate} never returns {@code null}. The distinct factory names make
- * the choice explicit at the call site (there's no ambiguous {@code (Class, int)} constructor);
- * {@code Capacity} always counts entries, matching the chained {@code
+ * factor, and {@code tryGetOrCreateOrNull} never returns {@code null}. The distinct factory names
+ * make the choice explicit at the call site (there's no ambiguous {@code (Class, int)}
+ * constructor); {@code Capacity} always counts entries, matching the chained {@code
* Hashtable.D1.createCapped}. Across the family a table factory's number is always entries — only
* the low-level array allocators take a bucket count.
*
@@ -197,7 +197,7 @@ private D1(TEntry[] table, float loadFactor, boolean growable, int limit) {
/**
* A bounded {@link D1} holding up to {@code maxCapacity} entries at the {@link
- * #DEFAULT_LOAD_FACTOR}, then capping ({@link #tryGetOrCreate} returns {@code null}).
+ * #DEFAULT_LOAD_FACTOR}, then capping ({@link #tryGetOrCreateOrNull} returns {@code null}).
*/
@Nonnull
public static The {@code try} prefix marks "this may refuse" — a growable table simply never exercises
- * it. The name has to serve both postures, since the posture is chosen per instance at the
- * factory while the method name is per class, and the two mistakes are not symmetric:
- * under-promising refusal costs an NPE at the cap, over-promising it costs a redundant null
- * check. So it errs toward {@code try}.
+ * it.
+ *
+ * Delegates to {@link #tryGetOrCreateOrNull} as the sole {@link Maybe#of} call site -- see
+ * {@link Maybe}'s class javadoc for why that shape is required to stay allocation-free.
+ */
+ @Nonnull
+ public Maybe Capacity is fixed at construction. The table does not resize, so the caller is responsible
* for choosing a capacity appropriate to the working set. Once {@link #size()} reaches that
- * capacity, {@link #insert} returns {@code false} and {@link #tryGetOrCreate} returns {@code
- * null} rather than adding more entries -- a lookup hit is still always returned even at
+ * capacity, {@link #insert} returns {@code false} and {@link #tryGetOrCreateOrNull} returns
+ * {@code null} rather than adding more entries -- a lookup hit is still always returned even at
* capacity, the cap only blocks new entries. Want your own eviction policy instead of a hard cap?
* Drop down to the static building blocks and drive the bucket array yourself -- {@link
* Hashtable#createCapped(int)} hands you a spine and a {@link SizeManager} already matched to
@@ -172,8 +172,8 @@ private D1(int maxCapacity) {
/**
* A capped single-key table: it holds at most {@code maxCapacity} live entries, after
- * which {@link #insert} returns {@code false} and {@link #tryGetOrCreate} returns {@code null}.
- * A lookup hit is still always returned at capacity -- the cap only blocks new entries.
+ * which {@link #insert} returns {@code false} and {@link #tryGetOrCreateOrNull} returns {@code
+ * null}. A lookup hit is still always returned at capacity -- the cap only blocks new entries.
*
* "Capped" names the promise, not the mechanism: the bucket array is sized once from {@code
* maxCapacity} via {@link Hashtable#capacityFor(int)} and never resized, but that is an
@@ -292,17 +292,16 @@ public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) {
}
/**
- * Returns the entry for {@code key}, building one via {@code creator} if absent -- or {@code
- * null} if the key is absent and the table is at capacity. This method can refuse:
- * despite the name it is not total, and a caller that dereferences the result without a null
- * check will NPE the first time the cap is reached. A lookup hit is always returned even at
- * capacity, so only the create half can fail. Check {@link #isFull()} beforehand if you want to
- * distinguish "refused" from "created" without inspecting the result.
+ * Returns the entry for {@code key}, building one via {@code creator} if absent -- wrapped in a
+ * {@link Maybe} that is absent if the key is absent and the table is at capacity. A
+ * lookup hit is always returned even at capacity, so only the create half can fail. Check
+ * {@link #isFull()} beforehand if you want to distinguish "refused" from "created" without
+ * inspecting the result.
*
* Refusal is a designed steady state for a capped table, not an exceptional condition -- see
* {@link #createCapped}. Decide deliberately what a refused create should do (drop the sample,
- * fall back, make room); silently ignoring the {@code null} turns the cap into data loss you
- * cannot see.
+ * fall back, make room); silently ignoring an absent {@link Maybe} turns the cap into data loss
+ * you cannot see.
*
* Computes the hash once and reuses it for both the lookup and (on miss) the insert --
* avoids the double-hash that "{@code get}; if null then {@code insert}" would incur.
@@ -311,9 +310,26 @@ public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) {
* Entry#hash(Object) D1.Entry.hash(key)} -- typically by passing {@code key} to a constructor
* that calls {@code super(key)}. A mismatched hash will leave the new entry inserted at a
* bucket that future {@link #get} calls won't probe.
+ *
+ * Exactly one {@link Maybe#of} call site, fed by delegating to {@link #tryGetOrCreateOrNull}
+ * -- see {@link Maybe}'s class javadoc for why that shape is required to stay allocation-free.
+ * Use {@link #tryGetOrCreateOrNull} directly only when a manual null check is genuinely more
+ * convenient than {@link Maybe#update}/{@link Maybe#getOrNull}.
+ */
+ @Nonnull
+ public Maybe Prefer this over the two-call form for the common read-modify-write shape -- a counter
* bump, a max, a timestamp refresh:
@@ -348,19 +365,19 @@ public TEntry tryGetOrCreate(
*
* The two-call form leaves a {@code null} on the caller's happy path, and the {@code null}
* only ever appears once the table is at capacity -- so {@code
- * tryGetOrCreate(...).inc()} reads fine, tests fine, and throws in production under cardinality
- * pressure. Fusing the update keeps that reference inside the table: at capacity the update is
- * skipped and {@code false} is returned, which a counter caller can safely ignore or check
- * deliberately.
+ * tryGetOrCreateOrNull(...).inc()} reads fine, tests fine, and throws in production under
+ * cardinality pressure. Fusing the update keeps that reference inside the table: at capacity
+ * the update is skipped and {@code false} is returned, which a counter caller can safely ignore
+ * or check deliberately.
*
* No extra work versus doing it by hand -- the hash is still computed once, by the delegated
- * {@link #tryGetOrCreate}.
+ * {@link #tryGetOrCreateOrNull}.
*/
public boolean tryGetOrUpdate(
@Nullable K key,
@Nonnull Function super K, ? extends TEntry> creator,
@Nonnull Consumer super TEntry> updater) {
- TEntry entry = tryGetOrCreate(key, creator);
+ TEntry entry = tryGetOrCreateOrNull(key, creator);
if (entry == null) {
return false;
}
@@ -379,7 +396,7 @@ public {@code entryClass} is a type token only -- it pins the concrete entry type so the compiler
* infers {@code K1}, {@code K2}, and {@code TEntry} at the call site (e.g. {@code
@@ -615,17 +632,31 @@ public boolean tryInsertOrReplace(@Nonnull TEntry newEntry) {
/**
* Two-key analogue of {@link D1#tryGetOrCreate}: returns the entry for {@code (key1, key2)},
- * building one via {@code creator} if absent -- or {@code null} if the pair is absent and the
- * table is at capacity. Like the single-key form it is not total despite the name, and
- * refusal is a designed steady state rather than an exceptional one; see {@link
- * D1#tryGetOrCreate} for the full contract and what to do about a refused create.
+ * building one via {@code creator} if absent -- wrapped in a {@link Maybe} that is absent if
+ * the pair is absent and the table is at capacity. Refusal is a designed steady state
+ * rather than an exceptional one; see {@link D1#tryGetOrCreate} for the full contract and what
+ * to do about a refused create.
*
* Computes the combined hash once and reuses it for both lookup and (on miss) insert. The
* {@code creator} is expected to build an entry whose {@code keyHash} equals {@link
* Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}.
+ *
+ * Delegates to {@link #tryGetOrCreateOrNull} as the sole {@link Maybe#of} call site.
+ */
+ @Nonnull
+ public Maybe Retained only for source compatibility with existing callers. New code should call the
- * {@code Hashtable.*} statics directly.
- *
- * @deprecated use the static building blocks on {@link Hashtable} directly.
- */
- @Deprecated
- public static final class Support {
- private Support() {}
-
- /**
- * @deprecated use {@link Hashtable#create(int)} (or {@link Hashtable#create(Class, int)} for a
- * typed spine).
- */
- @Deprecated
- @Nonnull
- public static Hashtable.Entry[] create(int requestedSize) {
- return Hashtable.create(requestedSize);
- }
-
- /**
- * Scales the requested working-set size before sizing the bucket array. Pair with {@link
- * #MAX_RATIO} to leave headroom over the working set for a desired load factor; the canonical
- * call is {@code create(n, MAX_RATIO)}.
- *
- * The scaled size is truncated to {@code int} before going through {@link
- * Hashtable#sizeFor(int)}. Truncation rather than {@code ceil} is intentional: {@code sizeFor}
- * rounds up to the next power of two anyway, so the fractional part would only matter when
- * float fuzz pushes the result across a power-of-two boundary -- {@code ceil} would then double
- * the array size for no reason (e.g. {@code 12 * 4/3 = 16.0...0005f -> ceil 17 -> sizeFor 32}).
- *
- * @deprecated use {@link Hashtable#capacityFor(int)} (or {@link Hashtable#capacityFor(int,
- * float)} for a load factor other than {@link Hashtable#DEFAULT_LOAD_FACTOR}), then {@link
- * Hashtable#create(Class, int)} with the result.
- */
- @Deprecated
- @Nonnull
- public static Hashtable.Entry[] create(int requestedSize, float scale) {
- // Deliberately multiplies by `scale` rather than routing through
- // Hashtable#capacityFor(int, float), which divides by a load factor: `n * MAX_RATIO` and
- // `n / DEFAULT_LOAD_FACTOR` are not bit-identical in float, and this deprecated path keeps
- // its exact legacy sizing. Only the allocation itself is inverted onto the blessed API.
- return Hashtable.create((int) (requestedSize * scale));
- }
-
- /**
- * Inverse of a 75% load factor. Callers that size their bucket array from a target working-set
- * size {@code n} should pass {@code create(n, MAX_RATIO)} to leave ~25% headroom in the array.
- *
- * @deprecated equivalent to {@code 1f / Hashtable#DEFAULT_LOAD_FACTOR}; prefer {@link
- * Hashtable#capacityFor(int)}, which applies that load factor directly.
- */
- @Deprecated public static final float MAX_RATIO = 1.0f / Hashtable.DEFAULT_LOAD_FACTOR;
-
- /**
- * @deprecated use {@link Hashtable#sizeFor(int)}.
- */
- @Deprecated
- static int sizeFor(int requestedSize) {
- return Hashtable.sizeFor(requestedSize);
- }
-
- /**
- * @deprecated use {@link Hashtable#clear(Hashtable.Entry[])}.
- */
- @Deprecated
- public static void clear(@Nonnull Hashtable.Entry[] buckets) {
- Hashtable.clear(buckets);
- }
-
- /**
- * @deprecated use {@link Hashtable#bucketIterator(Hashtable.Entry[], long)}.
- */
- @Deprecated
- @Nonnull
- public static Deliberately shaped against the {@code Optional}-style merge-with-singleton pitfall: {@link
+ * #of} is the only allocation site, always allocates (never returns a shared instance), and holds a
+ * plain nullable field. See {@code EscapeShapeBenchmark}'s {@code phiWithStatic} arm for why an
+ * {@code EMPTY} singleton would cost 8 B/op on every JDK measured, including 25.
+ *
+ * This shape -- one allocation site, a plain nullable field, no singleton merge -- scalar-
+ * replaces on ordinary escape analysis, on JDK 8/11/17/25, no JDK-21+ {@code
+ * ReduceAllocationMerges} needed (see {@code EscapeShapeBenchmark}). The discipline required of a
+ * caller is that the wrapping method itself construct a {@code Maybe} at exactly one call site (fed
+ * by a plain nullable local merged through ordinary branches, or by delegating to an
+ * already-nullable-returning method) rather than once per {@code return} statement -- multiple
+ * construction sites inline into a multi-producer phi that fails scalar replacement on JDK
+ * 8/11/17/21 (measured 16 B/op, {@code MaybeUsagePatternsBenchmark#badMultiConstructionSite}) once
+ * the refusal branch is reachable. On JDK 25, {@code ReduceAllocationMerges} collapses this
+ * specific shape -- two branches allocating the same final type with identical field layout -- back
+ * down to 0 B/op; do not rely on that JDK-25-only behavior, since it is exactly the kind of
+ * EA-dependent elision that can regress silently the moment the two branches stop being trivially
+ * mergeable (e.g. one branch gains extra state). See {@code EscapeShapeBenchmark}'s {@code
+ * phiOfTwoAllocations} arm, which uses two distinct interface implementations rather than one
+ * concrete type and therefore fails to scalar-replace on every JDK including 25 -- a different,
+ * stronger failure mode than the one demonstrated here.
+ */
+public final class Maybe Unlike the single-arg {@link #of}, {@code fn} here is typically a capturing lambda
+ * -- it closes over whatever local arguments the caller's method has in scope, so a fresh lambda
+ * instance is created on every invocation (capturing lambdas are never cached the way a
+ * non-capturing lambda's singleton instance commonly is) -- which makes it a second heap-object
+ * candidate distinct from the {@code Maybe} itself. That freshly-allocated capturing lambda still
+ * scalar-replaces as reliably as a plain delegating method call does, for the shape actually
+ * measured (JDK 8/11/17/25): a monomorphic receiver and a {@code fn} that is applied exactly once
+ * and does not itself escape (e.g. by being stored or passed further). If {@code fn} itself
+ * captures something that must be freshly allocated per call (e.g. a non-singleton creator), that
+ * allocation is real regardless of what happens to the lambda wrapping it.
+ */
+ @Nonnull
+ public static Deliberately the only primitive-context overload of {@code update}. An {@code
+ * int}/{@code boolean} sibling was tried and reverted: Java's overload resolution can pick
+ * cleanly between a primitive overload and the generic {@link #update(Object, BiConsumer)} form
+ * for a reference-typed argument (boxing is only considered once no non-boxing candidate
+ * applies), but that guarantee does not extend to a second primitive overload -- {@code update(1,
+ * lambda)} is ambiguous between {@code int} and {@code long} even with no {@code double} overload
+ * in the picture, because {@link ObjIntConsumer} and {@link ObjLongConsumer} are unrelated
+ * interfaces and JLS 15.12.2.5's most-specific-method rule requires every parameter position to
+ * agree, not just the numeric one. Confirmed by direct compilation, not just JLS reading: an
+ * inline lambda call breaks as soon as a second primitive overload exists. A plain {@code int}
+ * argument still widens to {@code long} for free at this single overload -- callers are not
+ * required to have a {@code long} in hand. {@code double} context is rare enough not to bother
+ * keeping pretty -- see {@link #updateDouble} for that case, given its own name to sidestep the
+ * ambiguity rather than trying to squeeze it into an overload.
+ */
+ public void update(long context, ObjLongConsumer super T> mutator) {
+ if (value != null) {
+ mutator.accept(value, context);
+ }
+ }
+
+ /**
+ * {@code double}-context sibling of {@link #update(long, ObjLongConsumer)}, given a distinct name
+ * rather than a second primitive overload -- see that method's javadoc for why overloading {@code
+ * update} a second time breaks inline-lambda call sites.
+ */
+ public void updateDouble(double context, ObjDoubleConsumer super T> mutator) {
+ if (value != null) {
+ mutator.accept(value, context);
+ }
+ }
+
+ public void ifPresentOrElse(Consumer super T> action, Runnable emptyAction) {
+ if (value != null) {
+ action.accept(value);
+ } else {
+ emptyAction.run();
+ }
+ }
+}
diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java
index e51462451aa..56594c292fc 100644
--- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java
+++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java
@@ -188,7 +188,7 @@ void getOrCreateOnMissBuildsEntryViaCreator() {
FlatHashtable.D1
+ * ./gradlew :internal-api:jmh -Pjmh.includes=MaybeUsagePatternsBenchmark -Pjmh.profilers=gc -PtestJvm=17
+ *
+ *
+ * This is the intended backing example for a perf-review check like "EA-dependent elision on a hot
+ * path where a structural alternative exists at parity → prefer the deterministic form": both pairs
+ * below have a same-cost deterministic form available, so reviewing a real diff against these arms
+ * is a matter of asking "which arm does this call site look like," not re-deriving the
+ * escape-analysis argument each time.
+ *
+ * ./gradlew :internal-api:jmh -Pjmh.includes=EscapeShape -Pjmh.profilers=gc -PtestJvm=17
+ *
+ *
+ * and read the same rows across {@code -PtestJvm} 8, 11, 17, 21 and 25. The point is the matrix of
+ * shape against JDK, so that "will this allocate" stops being a question two people answer from
+ * memory. Nothing here is specific to any one caller: the arms model a generic two-outcome wrapper
+ * (something that is either present with a value or absent) and carry over unchanged to {@code
+ * Maybe}, because the shapes under test are about the compiler's allocation behavior, not about
+ * what the wrapped value represents.
+ *
+ *
+ * shape JDK 8 JDK 11 JDK 17 JDK 21 JDK 25 what it isolates
+ * singleSite 0 ? 0 ? 0 the floor
+ * flagOnOneAllocation 0 ? 0 ? 0 outcome in a field
+ * closedInFinally 0 ? 0 ? 0 try/finally
+ * closedInFinallyWithThrow 0 ? 0 ? ? ... with the handler taken
+ * flagOnOneAllocationClosedInFinally 0 ? 0 ? 0 flag field, whole
+ * passedToInlinedStrategy 0 ? 0 ? 0 @Strategy boundary
+ * backingMonomorphic 0 ? 0 ? 0 one backing
+ * backingBimorphic 0 ? 0 ? 0 two backings
+ * mergeWithNull 8 ? 8 ? 0 merge with null
+ * mergeWithStatic 8 ? 8 ? 8 merge with a singleton
+ * mergeWithStaticClosedInFinally 8 ? 8 ? 8 ... the same, whole
+ * mergeOfTwoAllocations 16 ? 16 ? 16 merge of two allocations
+ * passedToUninlinedStrategy 24 ? 24 ? 24 the same boundary, uninlined
+ * backingMegamorphic 24 ? 24 ? 24 three backings
+ *
+ *
+ *
+ *
+ */
+@Fork(
+ value = 2,
+ jvmArgsAppend = {
+ "-XX:CompileCommand=dontinline,datadog.trace.util.escape.EscapeShapeBenchmark$UninlinedStrategy::apply"
+ })
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Thread)
+public class EscapeShapeBenchmark {
+
+ /**
+ * Minimal two-method interface -- a value to read and a close to call -- standing in for any
+ * short-lived object more complex than a single field.
+ */
+ interface Outcome {
+ int value();
+
+ void close();
+ }
+
+ static final class SingleAllocation implements Outcome {
+ private final int seed;
+
+ SingleAllocation(int seed) {
+ this.seed = seed;
+ }
+
+ @Override
+ public int value() {
+ return seed + 1;
+ }
+
+ @Override
+ public void close() {}
+ }
+
+ /** A second allocation site, for the merge that C2 has some chance with. */
+ static final class AlternateAllocation implements Outcome {
+ private final int seed;
+
+ AlternateAllocation(int seed) {
+ this.seed = seed;
+ }
+
+ @Override
+ public int value() {
+ return seed + 2;
+ }
+
+ @Override
+ public void close() {}
+ }
+
+ /** The absent outcome, reachable from a static, so the merge it takes part in is not local. */
+ static final Outcome STATIC_SINGLETON =
+ new Outcome() {
+ @Override
+ public int value() {
+ return 0;
+ }
+
+ @Override
+ public void close() {}
+ };
+
+ /** One allocation site carrying the outcome in a field: the shape that survives. */
+ static final class FlaggedAllocation {
+ private final boolean present;
+ private final int seed;
+
+ FlaggedAllocation(boolean present, int seed) {
+ this.present = present;
+ this.seed = seed;
+ }
+
+ int value() {
+ return present ? seed + 1 : 0;
+ }
+
+ void close() {}
+ }
+
+ /**
+ * A non-capturing strategy held in a static final field of concrete type, as {@code @Strategy}
+ * requires.
+ */
+ interface OutcomeStrategy {
+ int apply(FlaggedAllocation cell);
+ }
+
+ static final OutcomeStrategy INLINED = FlaggedAllocation::value;
+
+ /**
+ * Kept out of line by the {@code CompileCommand} in {@link Fork}, not by {@link CompilerControl}:
+ * JMH's processor only collects that annotation from {@code @Benchmark} methods, so putting it
+ * here emits no hint at all and the arm silently becomes a duplicate of the inlined one. Check
+ * the timing against {@code passedToInlinedStrategy} before believing this row — a call that
+ * really did not inline cannot cost the same as no call.
+ */
+ static final class UninlinedStrategy implements OutcomeStrategy {
+ @Override
+ public int apply(FlaggedAllocation cell) {
+ return cell.value();
+ }
+ }
+
+ static final OutcomeStrategy UNINLINED = new UninlinedStrategy();
+
+ /**
+ * The template-method shape: a final method on a base type calling out to an abstract one, with
+ * the object under test riding along as the argument. How many concrete subclasses are loaded is
+ * the whole experiment — C2 inlines a monomorphic call outright and a bimorphic one behind a type
+ * guard, but gives up at three, and a call it does not inline turns its argument into an escape.
+ */
+ abstract static class Backing {
+ final int admit(FlaggedAllocation cell) {
+ return store(cell);
+ }
+
+ abstract int store(FlaggedAllocation cell);
+ }
+
+ static final class ArrayBacking extends Backing {
+ @Override
+ int store(FlaggedAllocation cell) {
+ return cell.value();
+ }
+ }
+
+ static final class LinkedBacking extends Backing {
+ @Override
+ int store(FlaggedAllocation cell) {
+ return cell.value() + 1;
+ }
+ }
+
+ static final class ThirdBacking extends Backing {
+ @Override
+ int store(FlaggedAllocation cell) {
+ return cell.value() + 2;
+ }
+ }
+
+ // All three the same length, so the index arithmetic and the bounds check are identical and the
+ // only difference between the arms is how many types reach the call site.
+ //
+ // Unexplained: the monomorphic arm times slower than the bimorphic one (2.14 against 1.26 ns on
+ // 17), and equalising the lengths did not change it, so it is not the index arithmetic. Both
+ // eliminate their allocation, which is what this matrix is for, so the timing oddity does not
+ // touch any conclusion drawn here — but do not quote these two timings against each other until
+ // someone has read the assembly.
+ private final Backing[] one = {new ArrayBacking(), new ArrayBacking(), new ArrayBacking()};
+ private final Backing[] two = {new ArrayBacking(), new LinkedBacking(), new ArrayBacking()};
+ private final Backing[] three = {new ArrayBacking(), new LinkedBacking(), new ThirdBacking()};
+
+ // The three arms below are deliberately copy-pasted rather than sharing a helper. A shared helper
+ // would carry one profile for all three call sites, so the megamorphic arm would poison the other
+ // two and the matrix would report the same answer three times.
+
+ @Benchmark
+ public void backingMonomorphic(Blackhole bh) {
+ Backing backing = one[(counter++ & 0x7fffffff) % one.length];
+ FlaggedAllocation cell = new FlaggedAllocation(true, counter);
+ bh.consume(backing.admit(cell));
+ }
+
+ @Benchmark
+ public void backingBimorphic(Blackhole bh) {
+ Backing backing = two[(counter++ & 0x7fffffff) % two.length];
+ FlaggedAllocation cell = new FlaggedAllocation(true, counter);
+ bh.consume(backing.admit(cell));
+ }
+
+ @Benchmark
+ public void backingMegamorphic(Blackhole bh) {
+ Backing backing = three[(counter++ & 0x7fffffff) % three.length];
+ FlaggedAllocation cell = new FlaggedAllocation(true, counter);
+ bh.consume(backing.admit(cell));
+ }
+
+ /**
+ * Alternates so both sides of every branch are taken and the profile is honest. A branch C2 never
+ * sees taken becomes an uncommon trap, which would quietly turn the merge arms into single-site
+ * arms and make the whole matrix a lie.
+ */
+ private int counter;
+
+ private boolean alternate() {
+ return (counter++ & 1) == 0;
+ }
+
+ @Benchmark
+ public void singleSite(Blackhole bh) {
+ SingleAllocation cell = new SingleAllocation(counter++);
+ bh.consume(cell.value());
+ }
+
+ @Benchmark
+ public void mergeOfTwoAllocations(Blackhole bh) {
+ Outcome cell = alternate() ? new SingleAllocation(counter) : new AlternateAllocation(counter);
+ bh.consume(cell.value());
+ }
+
+ @Benchmark
+ public void mergeWithStatic(Blackhole bh) {
+ Outcome cell = alternate() ? new SingleAllocation(counter) : STATIC_SINGLETON;
+ bh.consume(cell.value());
+ }
+
+ @Benchmark
+ public void mergeWithNull(Blackhole bh) {
+ SingleAllocation cell = alternate() ? new SingleAllocation(counter) : null;
+ bh.consume(cell == null ? 0 : cell.value());
+ }
+
+ @Benchmark
+ public void flagOnOneAllocation(Blackhole bh) {
+ FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter);
+ bh.consume(cell.value());
+ }
+
+ @Benchmark
+ public void closedInFinally(Blackhole bh) {
+ SingleAllocation cell = new SingleAllocation(counter++);
+ try {
+ bh.consume(cell.value());
+ } finally {
+ cell.close();
+ }
+ }
+
+ /** Preallocated and stackless, so the arm measures control flow rather than fillInStackTrace. */
+ static final class Failure extends RuntimeException {
+ static final Failure INSTANCE = new Failure();
+
+ private Failure() {
+ super("failure", null, false, false);
+ }
+ }
+
+ /**
+ * The same try/finally, with the handler actually taken often enough to be compiled rather than
+ * left as an uncommon trap. This is the case {@link #closedInFinally} does not cover: there, C2
+ * has never seen the exception path, so there is no code for the object to be live into.
+ */
+ @Benchmark
+ public void closedInFinallyWithThrow(Blackhole bh) {
+ SingleAllocation cell = new SingleAllocation(counter++);
+ try {
+ if ((counter & 15) == 0) {
+ throw Failure.INSTANCE;
+ }
+ bh.consume(cell.value());
+ } catch (Failure failure) {
+ bh.consume(cell.value() + 1);
+ } finally {
+ cell.close();
+ }
+ }
+
+ /** The Optional-style shape, whole: a singleton for one outcome, under try/finally. */
+ @Benchmark
+ public void mergeWithStaticClosedInFinally(Blackhole bh) {
+ Outcome cell = alternate() ? new SingleAllocation(counter) : STATIC_SINGLETON;
+ try {
+ bh.consume(cell.value());
+ } finally {
+ cell.close();
+ }
+ }
+
+ /** The single-site shape, whole: one allocation carrying a flag, under try/finally. */
+ @Benchmark
+ public void flagOnOneAllocationClosedInFinally(Blackhole bh) {
+ FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter);
+ try {
+ bh.consume(cell.value());
+ } finally {
+ cell.close();
+ }
+ }
+
+ /**
+ * A non-escaping object handed across a call boundary the strategy discipline keeps inlinable.
+ */
+ @Benchmark
+ public void passedToInlinedStrategy(Blackhole bh) {
+ FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter);
+ bh.consume(INLINED.apply(cell));
+ }
+
+ /**
+ * The same, with only the inlining taken away. Whatever this costs is what the discipline buys.
+ */
+ @Benchmark
+ public void passedToUninlinedStrategy(Blackhole bh) {
+ FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter);
+ bh.consume(UNINLINED.apply(cell));
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java
index f8f6731d9a7..6b78c1c4539 100644
--- a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java
+++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java
@@ -123,12 +123,12 @@ protected Entry(long hash) {
*
*