diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/CardinalityLimitReporter.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/CardinalityLimitReporter.java index fc64b9015d7..3b13a8800bd 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/CardinalityLimitReporter.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/CardinalityLimitReporter.java @@ -58,7 +58,7 @@ final class CardinalityLimitReporter { /** Records {@code count} values blocked for {@code tag} in the current reporting cycle. */ void record(String tag, long count) { if (count > 0) { - TagBlockEntry entry = blockedByTag.tryGetOrCreate(tag, TagBlockEntry::new); + TagBlockEntry entry = blockedByTag.tryGetOrCreateOrNull(tag, TagBlockEntry::new); if (entry != null) { entry.count += count; } diff --git a/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java new file mode 100644 index 00000000000..07a05f69627 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java @@ -0,0 +1,191 @@ +package datadog.trace.util; + +import java.util.function.BiConsumer; +import java.util.function.ObjLongConsumer; +import javax.annotation.Nullable; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +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.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * A do/don't guide for using {@link Maybe}, not a research instrument like {@code + * datadog.trace.util.escape.EscapeShapeBenchmark} (which this class's arms are built on top of). + * Read {@code gc.alloc.rate.norm} — the "good" arm in each pair is expected to read 0 B/op on every + * JDK the way {@code EscapeShapeBenchmark}'s {@code singleSite}/{@code passedToInlinedStrategy} + * arms do; the paired "bad" arm exists to make the regression visible rather than theoretical. Run + * as + * + *
+ * ./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. + * + *
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 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=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) {
*
*