From 7baa45769fbc48be5a9d4185ca961b04dfa72a95 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 14:04:23 -0400 Subject: [PATCH 01/15] Measure which code shapes survive escape analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourteen minimal arms, each isolating one thing believed to decide whether C2 can delete a short-lived object, read through gc.alloc.rate.norm. The suite was written against APMLP-1642's granted/refused reservation and carries over unchanged, because a Try is the same two-outcome wrapper and what is measured is the shape, not the caller. It answers part of APMLP-1799's section A ahead of time: the Optional-style merge with a singleton costs 8 B/op on 25 as well as 17, since ReduceAllocationMerges never covers a static input; a merge of two allocations, which is what Success/Failure compiles to, stays 16 on 25; and three receiver types at one call site cost 24. One allocation site carrying a flag is 0 on both, with or without try/finally. Crossing a call boundary is free when the callee inlines and 24 B/op when it does not. The JDK 8, 11 and 21 columns are unfilled, there is no EA-off control arm and no type-profile pollution, so this is not yet the card's deliverable 2 — it is a running start on it. Cold-path escape and callee size, the two arms that could still kill the wrapper, are not probed at all. Co-Authored-By: Claude Opus 5 --- .../util/escape/EscapeShapeBenchmark.java | 374 ++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java new file mode 100644 index 00000000000..af8e6eabec6 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java @@ -0,0 +1,374 @@ +package datadog.trace.util.escape; + +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.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; + +/** + * Minimal code shapes, each isolating one thing that is believed to decide whether C2 can delete a + * short-lived object. Read {@code gc.alloc.rate.norm} — bytes per operation — not the timings: a + * shape that scalar-replaces reports 0, and one that does not reports the object's real size. Run + * it as + * + *
./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 were written for the granted/refused + * reservation of APMLP-1642's WorkQueue, and carry over unchanged to APMLP-1799's Try<T>, + * because both are the same two-outcome wrapper and the shapes are what is measured. + * + *

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

+ * shape                                 JDK 8  JDK 11  JDK 17  JDK 21  JDK 25   what it isolates
+ * singleSite                                ?       ?       0       ?       0   the floor
+ * flagOnOneAllocation                       ?       ?       0       ?       0   outcome in a field
+ * closedInFinally                           ?       ?       0       ?       0   try/finally
+ * closedInFinallyWithThrow                  ?       ?       0       ?       ?   ... with the handler taken
+ * flagOnOneAllocationClosedInFinally        ?       ?       0       ?       0   flag field, whole
+ * passedToInlinedStrategy                   ?       ?       0       ?       0   @Strategy boundary
+ * backingMonomorphic                        ?       ?       0       ?       0   one backing
+ * backingBimorphic                          ?       ?       0       ?       0   two backings
+ * phiWithNull                               ?       ?       8       ?       0   merge with null
+ * phiWithStatic                             ?       ?       8       ?       8   merge with a singleton
+ * phiWithStaticClosedInFinally              ?       ?       8       ?       8   ... the same, whole
+ * phiOfTwoAllocations                       ?       ?      16       ?      16   merge of two allocations
+ * passedToUninlinedStrategy                 ?       ?      24       ?      24   the same boundary, uninlined
+ * backingMegamorphic                        ?       ?      24       ?      24   three backings
+ * 
+ * + *

What the two measured columns say so far: + * + *

    + *
  • try/finally is free, including with the handler taken often enough to be compiled rather + * than left as an uncommon trap. It was the suspected culprit and it is not one. Note the + * catch is in the same method, so C2 can reduce the throw to control flow; this does not + * exercise an unwind through frames. + *
  • A merge with a static allocates on every JDK measured, JDK 25 included. The JDK 21 + * allocation-merge work shows up only in the {@code phiWithNull} row, which goes 8 to 0; a + * merge of two live allocations still allocates at 25, because the merged reference is called + * through rather than only read from. + *
  • Moving the outcome into a field of a single allocation costs nothing, with or without the + * {@code finally}. That is the whole fix. + *
  • Inlining is the gate, and the strategy discipline is what holds it open: the same object + * through the same call boundary is 0 when the callee inlines and 24 when it does not. + *
  • Two backings behind a template method are free; three are not. The inheritance layout is + * not costing anything today, and would cost 24 bytes an operation the day a third arrives. + *
+ */ +@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 { + + /** Stands in for a reservation: two fields and a method worth calling. */ + interface Cell { + int value(); + + void close(); + } + + static final class Granted implements Cell { + private final int seed; + + Granted(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 Alternate implements Cell { + private final int seed; + + Alternate(int seed) { + this.seed = seed; + } + + @Override + public int value() { + return seed + 2; + } + + @Override + public void close() {} + } + + /** The shared refusal: reachable from a static, so the merge it takes part in is not local. */ + static final Cell REFUSED = + new Cell() { + @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 Flagged { + private final boolean granted; + private final int seed; + + Flagged(boolean granted, int seed) { + this.granted = granted; + this.seed = seed; + } + + int value() { + return granted ? seed + 1 : 0; + } + + void close() {} + } + + /** + * A non-capturing strategy held in a static final field of concrete type, as {@code @Strategy} + * requires. + */ + interface CellStrategy { + int apply(Flagged cell); + } + + static final CellStrategy INLINED = Flagged::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 CellStrategy { + @Override + public int apply(Flagged cell) { + return cell.value(); + } + } + + static final CellStrategy 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(Flagged cell) { + return store(cell); + } + + abstract int store(Flagged cell); + } + + static final class ArrayBacking extends Backing { + @Override + int store(Flagged cell) { + return cell.value(); + } + } + + static final class LinkedBacking extends Backing { + @Override + int store(Flagged cell) { + return cell.value() + 1; + } + } + + static final class ThirdBacking extends Backing { + @Override + int store(Flagged 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]; + Flagged cell = new Flagged(true, counter); + bh.consume(backing.admit(cell)); + } + + @Benchmark + public void backingBimorphic(Blackhole bh) { + Backing backing = two[(counter++ & 0x7fffffff) % two.length]; + Flagged cell = new Flagged(true, counter); + bh.consume(backing.admit(cell)); + } + + @Benchmark + public void backingMegamorphic(Blackhole bh) { + Backing backing = three[(counter++ & 0x7fffffff) % three.length]; + Flagged cell = new Flagged(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) { + Granted cell = new Granted(counter++); + bh.consume(cell.value()); + } + + @Benchmark + public void phiOfTwoAllocations(Blackhole bh) { + Cell cell = alternate() ? new Granted(counter) : new Alternate(counter); + bh.consume(cell.value()); + } + + @Benchmark + public void phiWithStatic(Blackhole bh) { + Cell cell = alternate() ? new Granted(counter) : REFUSED; + bh.consume(cell.value()); + } + + @Benchmark + public void phiWithNull(Blackhole bh) { + Granted cell = alternate() ? new Granted(counter) : null; + bh.consume(cell == null ? 0 : cell.value()); + } + + @Benchmark + public void flagOnOneAllocation(Blackhole bh) { + Flagged cell = new Flagged(alternate(), counter); + bh.consume(cell.value()); + } + + @Benchmark + public void closedInFinally(Blackhole bh) { + Granted cell = new Granted(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) { + Granted cell = new Granted(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 phiWithStaticClosedInFinally(Blackhole bh) { + Cell cell = alternate() ? new Granted(counter) : REFUSED; + 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) { + Flagged cell = new Flagged(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) { + Flagged cell = new Flagged(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) { + Flagged cell = new Flagged(alternate(), counter); + bh.consume(UNINLINED.apply(cell)); + } +} From 57cce4f48c3b7aa07e9e67def90a0ce8a61e4ab8 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 14:17:03 -0400 Subject: [PATCH 02/15] Fill in the JDK 8 column for EscapeShapeBenchmark Ran the suite on this machine's Zulu 8.72.0.17 with -Pjmh.fork=1, -prof gc. Every arm matches its 17/25 reading, including phiWithNull staying at 8 B/op rather than dropping to 0 -- the ReduceAllocationMerges relaxation only applies from JDK 21, so JDK 8 sees the older, unconditional merge-with-null cost. Co-Authored-By: Claude Sonnet 5 --- .../util/escape/EscapeShapeBenchmark.java | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java index af8e6eabec6..8dfee2b91ab 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java @@ -37,22 +37,28 @@ * *
  * shape                                 JDK 8  JDK 11  JDK 17  JDK 21  JDK 25   what it isolates
- * singleSite                                ?       ?       0       ?       0   the floor
- * flagOnOneAllocation                       ?       ?       0       ?       0   outcome in a field
- * closedInFinally                           ?       ?       0       ?       0   try/finally
- * closedInFinallyWithThrow                  ?       ?       0       ?       ?   ... with the handler taken
- * flagOnOneAllocationClosedInFinally        ?       ?       0       ?       0   flag field, whole
- * passedToInlinedStrategy                   ?       ?       0       ?       0   @Strategy boundary
- * backingMonomorphic                        ?       ?       0       ?       0   one backing
- * backingBimorphic                          ?       ?       0       ?       0   two backings
- * phiWithNull                               ?       ?       8       ?       0   merge with null
- * phiWithStatic                             ?       ?       8       ?       8   merge with a singleton
- * phiWithStaticClosedInFinally              ?       ?       8       ?       8   ... the same, whole
- * phiOfTwoAllocations                       ?       ?      16       ?      16   merge of two allocations
- * passedToUninlinedStrategy                 ?       ?      24       ?      24   the same boundary, uninlined
- * backingMegamorphic                        ?       ?      24       ?      24   three backings
+ * 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
+ * phiWithNull                               8       ?       8       ?       0   merge with null
+ * phiWithStatic                             8       ?       8       ?       8   merge with a singleton
+ * phiWithStaticClosedInFinally              8       ?       8       ?       8   ... the same, whole
+ * phiOfTwoAllocations                      16       ?      16       ?      16   merge of two allocations
+ * passedToUninlinedStrategy                24       ?      24       ?      24   the same boundary, uninlined
+ * backingMegamorphic                       24       ?      24       ?      24   three backings
  * 
* + *

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

    From e1a5eb2f8d25beea3859698fe6f6d04266b67260 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 16:18:30 -0400 Subject: [PATCH 03/15] Introduce Try as a standalone allocation-free wrapper primitive APMLP-1799 spike: a generic fallible-operation return shape (single construction site, plain nullable field, no Optional-style singleton merge) confirmed allocation-free on JDK 8/11/17/25 by EscapeShapeBenchmark. Not wired into any real caller yet -- Hashtable usage lands in a follow-up PR once it can build on this. --- .../src/main/java/datadog/trace/util/Try.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 internal-api/src/main/java/datadog/trace/util/Try.java diff --git a/internal-api/src/main/java/datadog/trace/util/Try.java b/internal-api/src/main/java/datadog/trace/util/Try.java new file mode 100644 index 00000000000..1e4f465e8da --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/Try.java @@ -0,0 +1,71 @@ +package datadog.trace.util; + +import java.util.function.Consumer; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * APMLP-1799 spike -- candidate return shape for fallible operations (e.g. a table's + * capacity-refusing {@code tryGetOrCreate}), evaluating whether it can be allocation-free under + * escape analysis. Not wired into any real caller yet. + * + *

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

    Confirmed 2026-08-27 ({@code EscapeShapeBenchmark}, JDK 8/11/17/25): the shape here -- + * one allocation site, a plain nullable field, no singleton merge -- scalar-replaces on ordinary + * escape analysis, no JDK-21+ {@code ReduceAllocationMerges} needed. The discipline required of a + * caller is that the wrapping method itself construct a {@code Try} 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 every JDK + * 8-25 once the refusal branch is reachable. See {@code EscapeShapeBenchmark}'s {@code + * phiOfTwoAllocations} arm for that failure mode in isolation. + */ +public final class Try { + @Nullable private final T value; + + private Try(@Nullable T value) { + this.value = value; + } + + @Nonnull + public static Try of(@Nullable T value) { + return new Try<>(value); + } + + public boolean isPresent() { + return value != null; + } + + /** + * Raw accessor -- named to make the null case unmissable at the call site, rather than {@code + * orElse}/{@code get}, neither of which says so on its own. + */ + @Nullable + public T getOrNull() { + return value; + } + + /** + * Primary intended usage: a guard in front of mutation, e.g. {@code + * table.tryGetOrCreateAsTry(key, FooEntry::new).update(FooEntry::inc)}. No-op if the operation + * was refused (table full) rather than throwing or requiring the caller to branch on {@link + * #isPresent()} first. + */ + public void update(Consumer mutator) { + if (value != null) { + mutator.accept(value); + } + } + + public void ifPresentOrElse(Consumer action, Runnable emptyAction) { + if (value != null) { + action.accept(value); + } else { + emptyAction.run(); + } + } +} From e64ee039225806e4f04615fa2ef22ff82044fb1a Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 16:21:36 -0400 Subject: [PATCH 04/15] Keep Try.of(R, Function) receiver+function overload Confirmed 2026-08-27 against a real capacity-refusing lookup method (JDK 8/11/17/25, common and rare refusal ratios): the capturing lambda scalar-replaces as reliably as a plain delegating call. Full benchmark lands with the Hashtable-integration follow-up. --- .../src/main/java/datadog/trace/util/Try.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal-api/src/main/java/datadog/trace/util/Try.java b/internal-api/src/main/java/datadog/trace/util/Try.java index 1e4f465e8da..2cf5021dd88 100644 --- a/internal-api/src/main/java/datadog/trace/util/Try.java +++ b/internal-api/src/main/java/datadog/trace/util/Try.java @@ -1,6 +1,7 @@ package datadog.trace.util; import java.util.function.Consumer; +import java.util.function.Function; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -36,6 +37,28 @@ public static Try of(@Nullable T value) { return new Try<>(value); } + /** + * Convenience form for the common shape {@code Try.of(receiver.someNullableMethod(args))}: {@code + * Try.of(receiver, r -> r.someNullableMethod(args))}. Useful when {@code receiver} would + * otherwise have to be re-evaluated or named twice at the call site. + * + *

    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 -- which makes it a + * second heap-object candidate distinct from the {@code Try} itself. Confirmed 2026-08-27 + * against a real capacity-refusing lookup method (JDK 8/11/17/25, both a common and a rare + * refusal ratio): the capturing lambda scalar-replaces as reliably as a plain delegating method + * call does -- see the Hashtable-integration follow-up for that benchmark and the full numbers. + * That confirmation is specific to the shape actually measured: a monomorphic receiver and a + * {@code fn} built once per call site (not a fresh lambda per invocation) and applied exactly + * once. 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 Try of(R receiver, @Nonnull Function fn) { + return new Try<>(fn.apply(receiver)); + } + public boolean isPresent() { return value != null; } From b0741a7b8928ac1e458d2c39fd2a4554039cf2d4 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 16:27:18 -0400 Subject: [PATCH 05/15] Add long/int-context update() overloads to Try Both flavors are named in the ticket's mutator-flavor set (Consumer / BiConsumer / ObjLongConsumer / ObjIntConsumer) that motivated the N*M overload explosion on Hashtable. Consolidating them onto Try means that shape is paid for once here instead of once per table type. double/boolean context forms are deliberately not added: neither is a named flavor, and boolean has no JDK ObjBooleanConsumer to reuse -- both would be speculative additions without a real caller. --- .../src/main/java/datadog/trace/util/Try.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal-api/src/main/java/datadog/trace/util/Try.java b/internal-api/src/main/java/datadog/trace/util/Try.java index 2cf5021dd88..f5d4b8da12f 100644 --- a/internal-api/src/main/java/datadog/trace/util/Try.java +++ b/internal-api/src/main/java/datadog/trace/util/Try.java @@ -2,6 +2,8 @@ import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.ObjIntConsumer; +import java.util.function.ObjLongConsumer; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -84,6 +86,27 @@ public void update(Consumer mutator) { } } + /** + * Primitive-{@code long}-context form of {@link #update(Consumer)}, for the common case where the + * mutation needs one caller-supplied {@code long} (e.g. a duration or count) and boxing it into a + * captured {@code Long}/generic-context object would be the actual per-call allocation. This + * exists so a table wrapping a fallible lookup in {@code Try} pays for this shape once, here, + * instead of once per mutator-flavor per table type -- see {@code Hashtable#tryGetOrUpdate}'s + * {@code ObjLongConsumer} overload for the caller-side problem this replaces. + */ + public void update(long context, ObjLongConsumer mutator) { + if (value != null) { + mutator.accept(value, context); + } + } + + /** {@code int}-context sibling of {@link #update(long, ObjLongConsumer)}. */ + public void update(int context, ObjIntConsumer mutator) { + if (value != null) { + mutator.accept(value, context); + } + } + public void ifPresentOrElse(Consumer action, Runnable emptyAction) { if (value != null) { action.accept(value); From 991959c84930e0b0703a03ef42f8850894af20b5 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 16:30:00 -0400 Subject: [PATCH 06/15] Add double/boolean-context update() overloads to Try Rounds out the primitive-context set to int/long/double/boolean, anticipating future callers rather than waiting on one, matching how Stream/Optional carry int/long/double specializations. boolean has no JDK ObjBooleanConsumer to reuse (neither does Stream/Optional -- the JDK never shipped a boolean specialization there either), so it's a small hand-rolled functional interface instead. --- .../trace/util/ObjBooleanConsumer.java | 11 +++++++++ .../src/main/java/datadog/trace/util/Try.java | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 internal-api/src/main/java/datadog/trace/util/ObjBooleanConsumer.java diff --git a/internal-api/src/main/java/datadog/trace/util/ObjBooleanConsumer.java b/internal-api/src/main/java/datadog/trace/util/ObjBooleanConsumer.java new file mode 100644 index 00000000000..137d587442e --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/ObjBooleanConsumer.java @@ -0,0 +1,11 @@ +package datadog.trace.util; + +/** + * {@code boolean}-context sibling of {@link java.util.function.ObjLongConsumer}/{@link + * java.util.function.ObjIntConsumer}/{@link java.util.function.ObjDoubleConsumer} -- {@code + * java.util.function} never shipped one. + */ +@FunctionalInterface +public interface ObjBooleanConsumer { + void accept(T t, boolean value); +} diff --git a/internal-api/src/main/java/datadog/trace/util/Try.java b/internal-api/src/main/java/datadog/trace/util/Try.java index f5d4b8da12f..9f395b5c4f2 100644 --- a/internal-api/src/main/java/datadog/trace/util/Try.java +++ b/internal-api/src/main/java/datadog/trace/util/Try.java @@ -2,6 +2,7 @@ import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.ObjDoubleConsumer; import java.util.function.ObjIntConsumer; import java.util.function.ObjLongConsumer; import javax.annotation.Nonnull; @@ -107,6 +108,29 @@ public void update(int context, ObjIntConsumer mutator) { } } + /** + * {@code double}-context sibling of {@link #update(long, ObjLongConsumer)}. Not driven by a known + * caller today; kept in step with the {@code int}/{@code double}/{@code long} specializations + * {@code Stream}/{@code Optional} carry, on the same anticipated-future-use basis. + */ + public void update(double context, ObjDoubleConsumer mutator) { + if (value != null) { + mutator.accept(value, context); + } + } + + /** + * {@code boolean}-context sibling of {@link #update(long, ObjLongConsumer)}. Unlike the other + * primitive forms, this one breaks from the {@code Stream}/{@code Optional} precedent -- the JDK + * never shipped a boolean specialization for either -- so {@link ObjBooleanConsumer} is a + * hand-rolled interface rather than a reused JDK one. + */ + public void update(boolean context, ObjBooleanConsumer mutator) { + if (value != null) { + mutator.accept(value, context); + } + } + public void ifPresentOrElse(Consumer action, Runnable emptyAction) { if (value != null) { action.accept(value); From 173d5f5c449924ea0ff6b63db7e90c94c55798d5 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 16:31:31 -0400 Subject: [PATCH 07/15] Add generic-context update(C, BiConsumer) overload to Try Completes the mutator-flavor set named in the ticket (Consumer / BiConsumer / ObjLongConsumer / ObjIntConsumer) plus the anticipated double/boolean siblings. Argument order matches Hashtable#forEach's existing (context, entry) convention rather than the primitive forms' (entry, context), since that's the precedent this is meant to line up with. --- .../src/main/java/datadog/trace/util/Try.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal-api/src/main/java/datadog/trace/util/Try.java b/internal-api/src/main/java/datadog/trace/util/Try.java index 9f395b5c4f2..d1278df7e7d 100644 --- a/internal-api/src/main/java/datadog/trace/util/Try.java +++ b/internal-api/src/main/java/datadog/trace/util/Try.java @@ -1,5 +1,6 @@ package datadog.trace.util; +import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.ObjDoubleConsumer; @@ -87,6 +88,18 @@ public void update(Consumer mutator) { } } + /** + * Generic-context form of {@link #update(Consumer)}, for callers that already have a reusable, + * non-capturing {@code BiConsumer} (typically a {@code static final}) plus whatever context it + * needs -- mirrors {@code Hashtable#forEach}'s {@code (context, entry)} argument order for the + * same reason: the entry doesn't need naming at the call site, only the context does. + */ + public void update(C context, BiConsumer mutator) { + if (value != null) { + mutator.accept(context, value); + } + } + /** * Primitive-{@code long}-context form of {@link #update(Consumer)}, for the common case where the * mutation needs one caller-supplied {@code long} (e.g. a duration or count) and boxing it into a From 5a82ed60349b151eccdba14ef950e821d66ffe1f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 22:30:07 -0400 Subject: [PATCH 08/15] Add TryUsagePatternsBenchmark: a do/don't guide for Try Standalone JMH demonstration (not a research instrument like EscapeShapeBenchmark) meant to back a perf-review check like APMLP-1799's J12 note. Two pairs: - goodSingleConstructionSite vs badMultiConstructionSite: the single-construction-point discipline from Try's class javadoc, made visible as 0 vs 16 B/op rather than left as a claim. - goodPrimitiveContextUpdate vs badBoxedContext{Inlined,Uninlined}: update(long, ObjLongConsumer) reads 0 B/op deterministically; update(Long, BiConsumer) also reads 0 B/op but only because this call site stays inlined and C2 scalar-replaces the box -- forcing the same call out of line (CompileCommand=dontinline, the same technique EscapeShapeBenchmark uses for UninlinedStrategy) shows the real 24 B/op the primitive overload avoids unconditionally. --- .../trace/util/TryUsagePatternsBenchmark.java | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 internal-api/src/jmh/java/datadog/trace/util/TryUsagePatternsBenchmark.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/TryUsagePatternsBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/TryUsagePatternsBenchmark.java new file mode 100644 index 00000000000..356d5016a8d --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/TryUsagePatternsBenchmark.java @@ -0,0 +1,187 @@ +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 Try}, 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=TryUsagePatterns -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" + * (APMLP-1799's J12 note): 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 J12 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 J12's phrase: {@link Try#update(long, ObjLongConsumer)} has no box to eliminate in the + * first place, so it reads 0 B/op *regardless* of whether this call site keeps inlining; the + * generic-context form's 0 B/op is contingent on inlining holding, which {@code + * badBoxedContextUpdateUninlined} demonstrates by taking that away via the same {@code + * -XX:CompileCommand=dontinline} technique {@code EscapeShapeBenchmark} uses for its {@code + * UninlinedStrategy} arm. + */ +@Fork( + value = 2, + jvmArgsAppend = { + "-XX:CompileCommand=dontinline,datadog.trace.util.TryUsagePatternsBenchmark$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 TryUsagePatternsBenchmark { + + static final class Widget { + long count; + } + + /** A non-capturing updater, as {@link Try#update(long, ObjLongConsumer)} expects. */ + static final ObjLongConsumer ADD_PRIMITIVE = (w, delta) -> w.count += delta; + + /** + * The same update expressed through the generic-context overload instead. {@code Long} is not + * assignable from {@code long} without boxing, so calling {@link Try#update(Object, BiConsumer)} + * with a {@code long} argument boxes it every time — the exact per-call allocation {@link + * Try#update(long, ObjLongConsumer)} exists to avoid. Kept as a {@code BiConsumer} + * rather than inlined at the call site so the two arms below differ only in which overload is + * selected, not in lambda shape. + */ + static final BiConsumer ADD_BOXED = (delta, w) -> w.count += delta; + + /** + * Same logic as {@link #ADD_BOXED}, but as a named class rather than a lambda so {@code + * -XX:CompileCommand=dontinline} (see this class's {@link Fork} annotation) has a concrete method + * to target -- kept out of line the same way {@code EscapeShapeBenchmark}'s {@code + * UninlinedStrategy} is, by the {@code CompileCommand} rather than {@code CompilerControl}, since + * JMH's processor only reads that annotation from {@code @Benchmark} methods. + */ + static final class UninlinedBoxedAdder implements BiConsumer { + @Override + public void accept(Long delta, Widget w) { + w.count += delta; + } + } + + static final BiConsumer ADD_BOXED_UNINLINED = new UninlinedBoxedAdder(); + + /** + * Deliberately outside {@code Long}'s [-128, 127] cache range -- a cached delta like {@code 1L} + * would make {@link #badBoxedContextUpdateUninlined} read 0 B/op too, for a reason with nothing + * to do with which overload got picked. + */ + static final long DELTA = 1_000L; + + private final Widget[] table = new Widget[8]; + private int counter; + + public TryUsagePatternsBenchmark() { + for (int i = 0; i < table.length; i++) { + // Half the slots stay null so every arm below actually exercises the refused/empty path, + // not just the present one -- see EscapeShapeBenchmark's `alternate()` javadoc for why an + // always-taken branch would quietly turn these into single-site arms and lie. + if ((i & 1) == 0) { + table[i] = new Widget(); + } + } + } + + private int nextKey() { + return (counter++) & (table.length - 1); + } + + @Nullable + private Widget lookup(int key) { + return table[key]; + } + + /** + * GOOD: exactly one {@code Try.of(...)} call site, fed by delegating to the existing nullable + * method. See {@link Try}'s class javadoc for why this is the recommended shape. + */ + private Try tryLookupDelegating(int key) { + return Try.of(lookup(key)); + } + + /** + * BAD: a {@code Try.of(...)} call site per branch. Both branches return the same wrapper type, so + * this looks equivalent to {@link #tryLookupDelegating} at every call site that uses it — the + * difference only shows up here, in the allocation profile of the method that builds the {@code + * Try}, which is exactly why it is easy to introduce by accident. + */ + private Try tryLookupMultiSite(int key) { + Widget w = lookup(key); + if (w != null) { + return Try.of(w); + } else { + return Try.of(null); + } + } + + @Benchmark + public void goodSingleConstructionSite(Blackhole bh) { + Try t = tryLookupDelegating(nextKey()); + bh.consume(t.isPresent()); + } + + @Benchmark + public void badMultiConstructionSite(Blackhole bh) { + Try t = tryLookupMultiSite(nextKey()); + bh.consume(t.isPresent()); + } + + @Benchmark + public void goodPrimitiveContextUpdate(Blackhole bh) { + Try t = tryLookupDelegating(nextKey()); + t.update(DELTA, ADD_PRIMITIVE); + bh.consume(t.isPresent()); + } + + /** + * Reads 0 B/op here despite boxing {@link #DELTA} on every call -- this call site stays inlined, + * so C2 scalar-replaces the {@code Long} the same as any other non-escaping object. See {@link + * #badBoxedContextUpdateUninlined} for what that 0 is actually contingent on. + */ + @Benchmark + public void badBoxedContextUpdateInlined(Blackhole bh) { + Try t = tryLookupDelegating(nextKey()); + t.update(DELTA, ADD_BOXED); + bh.consume(t.isPresent()); + } + + /** + * The same boxing, with only the inlining taken away (via {@link UninlinedBoxedAdder} and this + * class's {@code CompileCommand}). Whatever this costs above {@link #goodPrimitiveContextUpdate} + * is the box {@link #badBoxedContextUpdateInlined} was quietly relying on EA to remove. + */ + @Benchmark + public void badBoxedContextUpdateUninlined(Blackhole bh) { + Try t = tryLookupDelegating(nextKey()); + t.update(DELTA, ADD_BOXED_UNINLINED); + bh.consume(t.isPresent()); + } +} From 7ee147297b5d35cf6b9c528005668ed6449c32ec Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 22:36:03 -0400 Subject: [PATCH 09/15] Rename Try to Maybe Try collides with Scala/Vavr's Try, which carries a captured exception (Success/Failure) -- a model this class never had and, by design, never will: it's a plain present/absent wrapper with no error payload, i.e. Haskell's Maybe (Just/Nothing) rather than Either or Result. The name should say so rather than borrow one that implies exception handling. --- ...agePatternsBenchmark.java => MaybeUsagePatternsBenchmark.java} | 0 .../src/main/java/datadog/trace/util/{Try.java => Maybe.java} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename internal-api/src/jmh/java/datadog/trace/util/{TryUsagePatternsBenchmark.java => MaybeUsagePatternsBenchmark.java} (100%) rename internal-api/src/main/java/datadog/trace/util/{Try.java => Maybe.java} (100%) diff --git a/internal-api/src/jmh/java/datadog/trace/util/TryUsagePatternsBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java similarity index 100% rename from internal-api/src/jmh/java/datadog/trace/util/TryUsagePatternsBenchmark.java rename to internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java diff --git a/internal-api/src/main/java/datadog/trace/util/Try.java b/internal-api/src/main/java/datadog/trace/util/Maybe.java similarity index 100% rename from internal-api/src/main/java/datadog/trace/util/Try.java rename to internal-api/src/main/java/datadog/trace/util/Maybe.java From ec1419151eda4f8e5496e9178f8bcf5364fa2aae Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 22:36:22 -0400 Subject: [PATCH 10/15] Finish Try -> Maybe rename (identifiers and javadoc text) The previous commit only captured the file renames -- the actual Try -> Maybe identifier/javadoc replacements were left uncommitted by a failed `git add -A` (stale pathspec aborted the whole add silently). --- .../util/MaybeUsagePatternsBenchmark.java | 52 +++++++++---------- .../util/escape/EscapeShapeBenchmark.java | 2 +- .../main/java/datadog/trace/util/Maybe.java | 38 +++++++------- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java index 356d5016a8d..fc8d946ba32 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java @@ -16,7 +16,7 @@ import org.openjdk.jmh.infra.Blackhole; /** - * A do/don't guide for using {@link Try}, not a research instrument like {@code + * 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} @@ -36,7 +36,7 @@ * 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 J12's phrase: {@link Try#update(long, ObjLongConsumer)} has no box to eliminate in the + * half of J12's phrase: {@link Maybe#update(long, ObjLongConsumer)} has no box to eliminate in the * first place, so it reads 0 B/op *regardless* of whether this call site keeps inlining; the * generic-context form's 0 B/op is contingent on inlining holding, which {@code * badBoxedContextUpdateUninlined} demonstrates by taking that away via the same {@code @@ -46,7 +46,7 @@ @Fork( value = 2, jvmArgsAppend = { - "-XX:CompileCommand=dontinline,datadog.trace.util.TryUsagePatternsBenchmark$UninlinedBoxedAdder::accept" + "-XX:CompileCommand=dontinline,datadog.trace.util.MaybeUsagePatternsBenchmark$UninlinedBoxedAdder::accept" }) @Warmup(iterations = 3, time = 1) @Measurement(iterations = 5, time = 1) @@ -54,22 +54,22 @@ @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(java.util.concurrent.TimeUnit.NANOSECONDS) @State(Scope.Thread) -public class TryUsagePatternsBenchmark { +public class MaybeUsagePatternsBenchmark { static final class Widget { long count; } - /** A non-capturing updater, as {@link Try#update(long, ObjLongConsumer)} expects. */ + /** A non-capturing updater, as {@link Maybe#update(long, ObjLongConsumer)} expects. */ static final ObjLongConsumer ADD_PRIMITIVE = (w, delta) -> w.count += delta; /** * The same update expressed through the generic-context overload instead. {@code Long} is not - * assignable from {@code long} without boxing, so calling {@link Try#update(Object, BiConsumer)} - * with a {@code long} argument boxes it every time — the exact per-call allocation {@link - * Try#update(long, ObjLongConsumer)} exists to avoid. Kept as a {@code BiConsumer} - * rather than inlined at the call site so the two arms below differ only in which overload is - * selected, not in lambda shape. + * assignable from {@code long} without boxing, so calling {@link Maybe#update(Object, + * BiConsumer)} with a {@code long} argument boxes it every time — the exact per-call allocation + * {@link Maybe#update(long, ObjLongConsumer)} exists to avoid. Kept as a {@code BiConsumer} rather than inlined at the call site so the two arms below differ only in which + * overload is selected, not in lambda shape. */ static final BiConsumer ADD_BOXED = (delta, w) -> w.count += delta; @@ -99,7 +99,7 @@ public void accept(Long delta, Widget w) { private final Widget[] table = new Widget[8]; private int counter; - public TryUsagePatternsBenchmark() { + public MaybeUsagePatternsBenchmark() { for (int i = 0; i < table.length; i++) { // Half the slots stay null so every arm below actually exercises the refused/empty path, // not just the present one -- see EscapeShapeBenchmark's `alternate()` javadoc for why an @@ -120,43 +120,43 @@ private Widget lookup(int key) { } /** - * GOOD: exactly one {@code Try.of(...)} call site, fed by delegating to the existing nullable - * method. See {@link Try}'s class javadoc for why this is the recommended shape. + * GOOD: exactly one {@code Maybe.of(...)} call site, fed by delegating to the existing nullable + * method. See {@link Maybe}'s class javadoc for why this is the recommended shape. */ - private Try tryLookupDelegating(int key) { - return Try.of(lookup(key)); + private Maybe tryLookupDelegating(int key) { + return Maybe.of(lookup(key)); } /** - * BAD: a {@code Try.of(...)} call site per branch. Both branches return the same wrapper type, so - * this looks equivalent to {@link #tryLookupDelegating} at every call site that uses it — the + * BAD: a {@code Maybe.of(...)} call site per branch. Both branches return the same wrapper type, + * so this looks equivalent to {@link #tryLookupDelegating} at every call site that uses it — the * difference only shows up here, in the allocation profile of the method that builds the {@code - * Try}, which is exactly why it is easy to introduce by accident. + * Maybe}, which is exactly why it is easy to introduce by accident. */ - private Try tryLookupMultiSite(int key) { + private Maybe tryLookupMultiSite(int key) { Widget w = lookup(key); if (w != null) { - return Try.of(w); + return Maybe.of(w); } else { - return Try.of(null); + return Maybe.of(null); } } @Benchmark public void goodSingleConstructionSite(Blackhole bh) { - Try t = tryLookupDelegating(nextKey()); + Maybe t = tryLookupDelegating(nextKey()); bh.consume(t.isPresent()); } @Benchmark public void badMultiConstructionSite(Blackhole bh) { - Try t = tryLookupMultiSite(nextKey()); + Maybe t = tryLookupMultiSite(nextKey()); bh.consume(t.isPresent()); } @Benchmark public void goodPrimitiveContextUpdate(Blackhole bh) { - Try t = tryLookupDelegating(nextKey()); + Maybe t = tryLookupDelegating(nextKey()); t.update(DELTA, ADD_PRIMITIVE); bh.consume(t.isPresent()); } @@ -168,7 +168,7 @@ public void goodPrimitiveContextUpdate(Blackhole bh) { */ @Benchmark public void badBoxedContextUpdateInlined(Blackhole bh) { - Try t = tryLookupDelegating(nextKey()); + Maybe t = tryLookupDelegating(nextKey()); t.update(DELTA, ADD_BOXED); bh.consume(t.isPresent()); } @@ -180,7 +180,7 @@ public void badBoxedContextUpdateInlined(Blackhole bh) { */ @Benchmark public void badBoxedContextUpdateUninlined(Blackhole bh) { - Try t = tryLookupDelegating(nextKey()); + Maybe t = tryLookupDelegating(nextKey()); t.update(DELTA, ADD_BOXED_UNINLINED); bh.consume(t.isPresent()); } diff --git a/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java index 8dfee2b91ab..5e8ff22a20a 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java @@ -25,7 +25,7 @@ * 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 were written for the granted/refused - * reservation of APMLP-1642's WorkQueue, and carry over unchanged to APMLP-1799's Try<T>, + * reservation of APMLP-1642's WorkQueue, and carry over unchanged to APMLP-1799's Maybe<T>, * because both are the same two-outcome wrapper and the shapes are what is measured. * *

    Every arm consumes the object's fields rather than the object. Handing the reference diff --git a/internal-api/src/main/java/datadog/trace/util/Maybe.java b/internal-api/src/main/java/datadog/trace/util/Maybe.java index d1278df7e7d..56befbfaf48 100644 --- a/internal-api/src/main/java/datadog/trace/util/Maybe.java +++ b/internal-api/src/main/java/datadog/trace/util/Maybe.java @@ -22,45 +22,45 @@ *

    Confirmed 2026-08-27 ({@code EscapeShapeBenchmark}, JDK 8/11/17/25): the shape here -- * one allocation site, a plain nullable field, no singleton merge -- scalar-replaces on ordinary * escape analysis, no JDK-21+ {@code ReduceAllocationMerges} needed. The discipline required of a - * caller is that the wrapping method itself construct a {@code Try} at exactly one call site (fed + * 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 every JDK * 8-25 once the refusal branch is reachable. See {@code EscapeShapeBenchmark}'s {@code * phiOfTwoAllocations} arm for that failure mode in isolation. */ -public final class Try { +public final class Maybe { @Nullable private final T value; - private Try(@Nullable T value) { + private Maybe(@Nullable T value) { this.value = value; } @Nonnull - public static Try of(@Nullable T value) { - return new Try<>(value); + public static Maybe of(@Nullable T value) { + return new Maybe<>(value); } /** - * Convenience form for the common shape {@code Try.of(receiver.someNullableMethod(args))}: {@code - * Try.of(receiver, r -> r.someNullableMethod(args))}. Useful when {@code receiver} would + * Convenience form for the common shape {@code Maybe.of(receiver.someNullableMethod(args))}: + * {@code Maybe.of(receiver, r -> r.someNullableMethod(args))}. Useful when {@code receiver} would * otherwise have to be re-evaluated or named twice at the call site. * *

    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 -- which makes it a - * second heap-object candidate distinct from the {@code Try} itself. Confirmed 2026-08-27 - * against a real capacity-refusing lookup method (JDK 8/11/17/25, both a common and a rare - * refusal ratio): the capturing lambda scalar-replaces as reliably as a plain delegating method - * call does -- see the Hashtable-integration follow-up for that benchmark and the full numbers. - * That confirmation is specific to the shape actually measured: a monomorphic receiver and a - * {@code fn} built once per call site (not a fresh lambda per invocation) and applied exactly - * once. 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. + * second heap-object candidate distinct from the {@code Maybe} itself. Confirmed + * 2026-08-27 against a real capacity-refusing lookup method (JDK 8/11/17/25, both a common + * and a rare refusal ratio): the capturing lambda scalar-replaces as reliably as a plain + * delegating method call does -- see the Hashtable-integration follow-up for that benchmark and + * the full numbers. That confirmation is specific to the shape actually measured: a monomorphic + * receiver and a {@code fn} built once per call site (not a fresh lambda per invocation) and + * applied exactly once. 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 Try of(R receiver, @Nonnull Function fn) { - return new Try<>(fn.apply(receiver)); + public static Maybe of(R receiver, @Nonnull Function fn) { + return new Maybe<>(fn.apply(receiver)); } public boolean isPresent() { @@ -104,7 +104,7 @@ public void update(C context, BiConsumer mutator) { * Primitive-{@code long}-context form of {@link #update(Consumer)}, for the common case where the * mutation needs one caller-supplied {@code long} (e.g. a duration or count) and boxing it into a * captured {@code Long}/generic-context object would be the actual per-call allocation. This - * exists so a table wrapping a fallible lookup in {@code Try} pays for this shape once, here, + * exists so a table wrapping a fallible lookup in {@code Maybe} pays for this shape once, here, * instead of once per mutator-flavor per table type -- see {@code Hashtable#tryGetOrUpdate}'s * {@code ObjLongConsumer} overload for the caller-side problem this replaces. */ From f40ced4c9a3edc862a3fcf999622af7057228189 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 27 Aug 2026 23:31:41 -0400 Subject: [PATCH 11/15] Fix Maybe javadoc/benchmark inaccuracies found by Codex review The multi-construction-site claim ("fails scalar replacement on every JDK 8-25") was wrong: measured badMultiConstructionSite directly on JDK 8/11/17/21/25 -- 16 B/op on 8-21, but ~0 B/op on 25, where ReduceAllocationMerges collapses two branches allocating the same final type. Documented the JDK-25 exception and why it's not something to rely on. Also corrected the capturing-lambda javadoc (a capturing lambda is freshly instantiated per call, not "built once per call site") and the stale TryUsagePatterns JMH filter in the class doc. --- .../util/MaybeUsagePatternsBenchmark.java | 17 +++---- .../main/java/datadog/trace/util/Maybe.java | 44 ++++++++++++------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java index fc8d946ba32..be9a9c74d5a 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java @@ -23,7 +23,8 @@ * arms do; the paired "bad" arm exists to make the regression visible rather than theoretical. Run * as * - *

    ./gradlew :internal-api:jmh -Pjmh.includes=TryUsagePatterns -Pjmh.profilers=gc -PtestJvm=17
    + * 
    + * ./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 @@ -67,11 +68,11 @@ static final class Widget { * The same update expressed through the generic-context overload instead. {@code Long} is not * assignable from {@code long} without boxing, so calling {@link Maybe#update(Object, * BiConsumer)} with a {@code long} argument boxes it every time — the exact per-call allocation - * {@link Maybe#update(long, ObjLongConsumer)} exists to avoid. Kept as a {@code BiConsumer} rather than inlined at the call site so the two arms below differ only in which - * overload is selected, not in lambda shape. + * {@link Maybe#update(long, ObjLongConsumer)} exists to avoid. Kept as a {@code + * BiConsumer} rather than inlined at the call site so the two arms below differ + * only in which overload is selected, not in lambda shape. */ - static final BiConsumer ADD_BOXED = (delta, w) -> w.count += delta; + static final BiConsumer ADD_BOXED = (w, delta) -> w.count += delta; /** * Same logic as {@link #ADD_BOXED}, but as a named class rather than a lambda so {@code @@ -80,14 +81,14 @@ static final class Widget { * UninlinedStrategy} is, by the {@code CompileCommand} rather than {@code CompilerControl}, since * JMH's processor only reads that annotation from {@code @Benchmark} methods. */ - static final class UninlinedBoxedAdder implements BiConsumer { + static final class UninlinedBoxedAdder implements BiConsumer { @Override - public void accept(Long delta, Widget w) { + public void accept(Widget w, Long delta) { w.count += delta; } } - static final BiConsumer ADD_BOXED_UNINLINED = new UninlinedBoxedAdder(); + static final BiConsumer ADD_BOXED_UNINLINED = new UninlinedBoxedAdder(); /** * Deliberately outside {@code Long}'s [-128, 127] cache range -- a cached delta like {@code 1L} diff --git a/internal-api/src/main/java/datadog/trace/util/Maybe.java b/internal-api/src/main/java/datadog/trace/util/Maybe.java index 56befbfaf48..dfb509b6162 100644 --- a/internal-api/src/main/java/datadog/trace/util/Maybe.java +++ b/internal-api/src/main/java/datadog/trace/util/Maybe.java @@ -25,9 +25,16 @@ * 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 every JDK - * 8-25 once the refusal branch is reachable. See {@code EscapeShapeBenchmark}'s {@code - * phiOfTwoAllocations} arm for that failure mode in isolation. + * 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 { @Nullable private final T value; @@ -47,16 +54,18 @@ public static Maybe of(@Nullable T value) { * otherwise have to be re-evaluated or named twice at the call site. * *

    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 -- which makes it a - * second heap-object candidate distinct from the {@code Maybe} itself. Confirmed - * 2026-08-27 against a real capacity-refusing lookup method (JDK 8/11/17/25, both a common - * and a rare refusal ratio): the capturing lambda scalar-replaces as reliably as a plain - * delegating method call does -- see the Hashtable-integration follow-up for that benchmark and - * the full numbers. That confirmation is specific to the shape actually measured: a monomorphic - * receiver and a {@code fn} built once per call site (not a fresh lambda per invocation) and - * applied exactly once. 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. + * -- 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. Confirmed 2026-08-27 against a real + * capacity-refusing lookup method (JDK 8/11/17/25, both a common and a rare refusal ratio): that + * freshly-allocated capturing lambda still scalar-replaces as reliably as a plain delegating + * method call does -- see the Hashtable-integration follow-up for that benchmark and the full + * numbers. That confirmation is specific to the shape actually measured: 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 Maybe of(R receiver, @Nonnull Function fn) { @@ -91,12 +100,13 @@ public void update(Consumer mutator) { /** * Generic-context form of {@link #update(Consumer)}, for callers that already have a reusable, * non-capturing {@code BiConsumer} (typically a {@code static final}) plus whatever context it - * needs -- mirrors {@code Hashtable#forEach}'s {@code (context, entry)} argument order for the - * same reason: the entry doesn't need naming at the call site, only the context does. + * needs -- {@code (value, context)} to stay consistent with the primitive-context overloads + * below, at the cost of departing from {@code Hashtable#forEach}'s {@code (context, entry)} + * convention. */ - public void update(C context, BiConsumer mutator) { + public void update(C context, BiConsumer mutator) { if (value != null) { - mutator.accept(context, value); + mutator.accept(value, context); } } From befdc8e804d946374ad320e13947930359282c4c Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 09:43:28 -0400 Subject: [PATCH 12/15] Address second-round review comments on Maybe - Drop the int/double/boolean update overloads: confirmed by direct compilation that a second primitive overload makes inline-lambda calls ambiguous (JLS 15.12.2.5's most-specific-method rule requires every parameter position to agree, and ObjIntConsumer/ObjLongConsumer/ ObjDoubleConsumer are unrelated interfaces). Keep only update(long, ObjLongConsumer) -- flagged independently by Codex and the Datadog Autotest bot. Removes the now-dead ObjBooleanConsumer. - Qualify MaybeUsagePatternsBenchmark's zero-allocation claim: the boxed-context pair's 0 B/op is contingent on the update call itself staying inlined, not immune to every inlining failure. - Add MaybeTest covering every branch of Maybe (both of() overloads, isPresent/getOrNull, all three update overloads, ifPresentOrElse) so internal-api's per-class JaCoCo coverage gate passes. Co-Authored-By: Claude Sonnet 5 --- .../util/MaybeUsagePatternsBenchmark.java | 13 ++- .../main/java/datadog/trace/util/Maybe.java | 63 +++++------- .../trace/util/ObjBooleanConsumer.java | 11 --- .../java/datadog/trace/util/MaybeTest.java | 99 +++++++++++++++++++ 4 files changed, 130 insertions(+), 56 deletions(-) delete mode 100644 internal-api/src/main/java/datadog/trace/util/ObjBooleanConsumer.java create mode 100644 internal-api/src/test/java/datadog/trace/util/MaybeTest.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java index be9a9c74d5a..2b30d7c46f9 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java @@ -37,12 +37,15 @@ * 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 J12's phrase: {@link Maybe#update(long, ObjLongConsumer)} has no box to eliminate in the - * first place, so it reads 0 B/op *regardless* of whether this call site keeps inlining; the - * generic-context form's 0 B/op is contingent on inlining holding, which {@code - * badBoxedContextUpdateUninlined} demonstrates by taking that away via the same {@code + * half of J12's 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. + * 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, diff --git a/internal-api/src/main/java/datadog/trace/util/Maybe.java b/internal-api/src/main/java/datadog/trace/util/Maybe.java index dfb509b6162..9e6cdd8033f 100644 --- a/internal-api/src/main/java/datadog/trace/util/Maybe.java +++ b/internal-api/src/main/java/datadog/trace/util/Maybe.java @@ -3,16 +3,16 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.ObjDoubleConsumer; import java.util.function.ObjIntConsumer; import java.util.function.ObjLongConsumer; import javax.annotation.Nonnull; import javax.annotation.Nullable; /** - * APMLP-1799 spike -- candidate return shape for fallible operations (e.g. a table's - * capacity-refusing {@code tryGetOrCreate}), evaluating whether it can be allocation-free under - * escape analysis. Not wired into any real caller yet. + * Candidate return shape for fallible operations (e.g. a table's capacity-refusing {@code + * tryGetOrCreate}), evaluating whether it can be allocation-free under escape analysis. + * + *

    Not wired into any real caller yet. * *

    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 @@ -111,12 +111,25 @@ public void update(C context, BiConsumer mutator) { } /** - * Primitive-{@code long}-context form of {@link #update(Consumer)}, for the common case where the - * mutation needs one caller-supplied {@code long} (e.g. a duration or count) and boxing it into a - * captured {@code Long}/generic-context object would be the actual per-call allocation. This - * exists so a table wrapping a fallible lookup in {@code Maybe} pays for this shape once, here, - * instead of once per mutator-flavor per table type -- see {@code Hashtable#tryGetOrUpdate}'s - * {@code ObjLongConsumer} overload for the caller-side problem this replaces. + * Primitive-context form of {@link #update(Consumer)}, for the common case where the mutation + * needs one caller-supplied number (e.g. a duration or count) and boxing it into a captured + * {@code Long}/generic-context object would be the actual per-call allocation. This exists so a + * table wrapping a fallible lookup in {@code Maybe} pays for this shape once, here, instead of + * once per mutator-flavor per table type -- see {@code Hashtable#tryGetOrUpdate}'s {@code + * ObjLongConsumer} overload for the caller-side problem this replaces. + * + *

    Deliberately the only primitive-context overload. An {@code int}/{@code + * double}/{@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. */ public void update(long context, ObjLongConsumer mutator) { if (value != null) { @@ -124,36 +137,6 @@ public void update(long context, ObjLongConsumer mutator) { } } - /** {@code int}-context sibling of {@link #update(long, ObjLongConsumer)}. */ - public void update(int context, ObjIntConsumer mutator) { - if (value != null) { - mutator.accept(value, context); - } - } - - /** - * {@code double}-context sibling of {@link #update(long, ObjLongConsumer)}. Not driven by a known - * caller today; kept in step with the {@code int}/{@code double}/{@code long} specializations - * {@code Stream}/{@code Optional} carry, on the same anticipated-future-use basis. - */ - public void update(double context, ObjDoubleConsumer mutator) { - if (value != null) { - mutator.accept(value, context); - } - } - - /** - * {@code boolean}-context sibling of {@link #update(long, ObjLongConsumer)}. Unlike the other - * primitive forms, this one breaks from the {@code Stream}/{@code Optional} precedent -- the JDK - * never shipped a boolean specialization for either -- so {@link ObjBooleanConsumer} is a - * hand-rolled interface rather than a reused JDK one. - */ - public void update(boolean context, ObjBooleanConsumer mutator) { - if (value != null) { - mutator.accept(value, context); - } - } - public void ifPresentOrElse(Consumer action, Runnable emptyAction) { if (value != null) { action.accept(value); diff --git a/internal-api/src/main/java/datadog/trace/util/ObjBooleanConsumer.java b/internal-api/src/main/java/datadog/trace/util/ObjBooleanConsumer.java deleted file mode 100644 index 137d587442e..00000000000 --- a/internal-api/src/main/java/datadog/trace/util/ObjBooleanConsumer.java +++ /dev/null @@ -1,11 +0,0 @@ -package datadog.trace.util; - -/** - * {@code boolean}-context sibling of {@link java.util.function.ObjLongConsumer}/{@link - * java.util.function.ObjIntConsumer}/{@link java.util.function.ObjDoubleConsumer} -- {@code - * java.util.function} never shipped one. - */ -@FunctionalInterface -public interface ObjBooleanConsumer { - void accept(T t, boolean value); -} diff --git a/internal-api/src/test/java/datadog/trace/util/MaybeTest.java b/internal-api/src/test/java/datadog/trace/util/MaybeTest.java new file mode 100644 index 00000000000..1799c4a2157 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/MaybeTest.java @@ -0,0 +1,99 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +public class MaybeTest { + + static final class Widget { + long count; + } + + @Test + public void ofPresent() { + Maybe maybe = Maybe.of("value"); + assertTrue(maybe.isPresent()); + assertEquals("value", maybe.getOrNull()); + } + + @Test + public void ofAbsent() { + Maybe maybe = Maybe.of(null); + assertFalse(maybe.isPresent()); + assertNull(maybe.getOrNull()); + } + + @Test + public void ofReceiverFunctionPresent() { + Maybe maybe = Maybe.of("value", String::length); + assertTrue(maybe.isPresent()); + assertEquals(5, maybe.getOrNull()); + } + + @Test + public void ofReceiverFunctionAbsent() { + Maybe maybe = Maybe.of("value", r -> null); + assertFalse(maybe.isPresent()); + assertNull(maybe.getOrNull()); + } + + @Test + public void updateConsumerRunsWhenPresent() { + Widget w = new Widget(); + Maybe.of(w).update(widget -> widget.count = 42); + assertEquals(42, w.count); + } + + @Test + public void updateConsumerNoOpWhenAbsent() { + Maybe maybe = Maybe.of(null); + maybe.update(widget -> widget.count = 42); + assertFalse(maybe.isPresent()); + } + + @Test + public void updateBiConsumerRunsWhenPresent() { + Widget w = new Widget(); + Maybe.of(w).update("context", (widget, ctx) -> widget.count = ctx.length()); + assertEquals(7, w.count); + } + + @Test + public void updateBiConsumerNoOpWhenAbsent() { + Maybe maybe = Maybe.of(null); + maybe.update("context", (widget, ctx) -> widget.count = ctx.length()); + assertFalse(maybe.isPresent()); + } + + @Test + public void updateLongRunsWhenPresent() { + Widget w = new Widget(); + Maybe.of(w).update(5L, (widget, delta) -> widget.count += delta); + assertEquals(5, w.count); + } + + @Test + public void updateLongNoOpWhenAbsent() { + Maybe maybe = Maybe.of(null); + maybe.update(5L, (widget, delta) -> widget.count += delta); + assertFalse(maybe.isPresent()); + } + + @Test + public void ifPresentOrElseRunsActionWhenPresent() { + StringBuilder sb = new StringBuilder(); + Maybe.of("value").ifPresentOrElse(sb::append, () -> sb.append("empty")); + assertEquals("value", sb.toString()); + } + + @Test + public void ifPresentOrElseRunsEmptyActionWhenAbsent() { + StringBuilder sb = new StringBuilder(); + Maybe.of(null).ifPresentOrElse(sb::append, () -> sb.append("empty")); + assertEquals("empty", sb.toString()); + } +} From c58d2c95c2c8009e501319c35266ee0dde9c0bde Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 10:24:34 -0400 Subject: [PATCH 13/15] Drop "not wired into any real caller yet" line from Maybe javadoc Per review nit: that context belongs in the PR description, not a comment that will rot as soon as a caller adopts it. Co-Authored-By: Claude Sonnet 5 --- internal-api/src/main/java/datadog/trace/util/Maybe.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/Maybe.java b/internal-api/src/main/java/datadog/trace/util/Maybe.java index 9e6cdd8033f..7792065fe6d 100644 --- a/internal-api/src/main/java/datadog/trace/util/Maybe.java +++ b/internal-api/src/main/java/datadog/trace/util/Maybe.java @@ -12,8 +12,6 @@ * Candidate return shape for fallible operations (e.g. a table's capacity-refusing {@code * tryGetOrCreate}), evaluating whether it can be allocation-free under escape analysis. * - *

    Not wired into any real caller yet. - * *

    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 From c3ecd6fef80fd0a8b4a69e063e7336c64be0daa9 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 10:36:37 -0400 Subject: [PATCH 14/15] Address bric3's nits on Maybe/EscapeShapeBenchmark - Rename EscapeShapeBenchmark's domain-flavored types (Cell/Granted/ Alternate/REFUSED/Flagged) to neutral names (Outcome/SingleAllocation/ AlternateAllocation/STATIC_SINGLETON/FlaggedAllocation) so the benchmark reads as a compiler-behavior experiment, not a tracer reservation model. - Drop the APMLP-1642/APMLP-1799 JIRA references from the class javadoc in favor of a generic two-outcome-wrapper description. - Add a short glossary paragraph (escape analysis, scalar replacement, ReduceAllocationMerges) and note the results are HotSpot/C2-specific, not validated against OpenJ9/GraalVM. - Rename MaybeUsagePatternsBenchmark's ADD_BOXED to ADD_BOXED_INLINED for symmetry with ADD_BOXED_UNINLINED. - Trim the dated "Confirmed 2026-08-27" research-note phrasing out of Maybe's javadoc, keeping the substance without the research-log tone. Co-Authored-By: Claude Sonnet 5 --- .../util/MaybeUsagePatternsBenchmark.java | 6 +- .../util/escape/EscapeShapeBenchmark.java | 100 ++++++++++-------- .../main/java/datadog/trace/util/Maybe.java | 21 ++-- 3 files changed, 70 insertions(+), 57 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java index 2b30d7c46f9..78ee062e1ec 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java @@ -75,10 +75,10 @@ static final class Widget { * BiConsumer} rather than inlined at the call site so the two arms below differ * only in which overload is selected, not in lambda shape. */ - static final BiConsumer ADD_BOXED = (w, delta) -> w.count += delta; + static final BiConsumer ADD_BOXED_INLINED = (w, delta) -> w.count += delta; /** - * Same logic as {@link #ADD_BOXED}, but as a named class rather than a lambda so {@code + * Same logic as {@link #ADD_BOXED_INLINED}, but as a named class rather than a lambda so {@code * -XX:CompileCommand=dontinline} (see this class's {@link Fork} annotation) has a concrete method * to target -- kept out of line the same way {@code EscapeShapeBenchmark}'s {@code * UninlinedStrategy} is, by the {@code CompileCommand} rather than {@code CompilerControl}, since @@ -173,7 +173,7 @@ public void goodPrimitiveContextUpdate(Blackhole bh) { @Benchmark public void badBoxedContextUpdateInlined(Blackhole bh) { Maybe t = tryLookupDelegating(nextKey()); - t.update(DELTA, ADD_BOXED); + t.update(DELTA, ADD_BOXED_INLINED); bh.consume(t.isPresent()); } diff --git a/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java index 5e8ff22a20a..7cd6fb09f99 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java @@ -24,9 +24,22 @@ * * 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 were written for the granted/refused - * reservation of APMLP-1642's WorkQueue, and carry over unchanged to APMLP-1799's Maybe<T>, - * because both are the same two-outcome wrapper and the shapes are what is measured. + * 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. + * + *

    Three terms recur below. Escape analysis (EA) is the compiler's proof that an allocated + * object's lifetime is provably confined to the method (or thread) that created it -- it never + * escapes into a field, a return value visible outside, or a call the compiler cannot see into. + * Scalar replacement is what C2 does once EA holds: it replaces the object with its + * individual fields, held in registers or on the stack, so no heap allocation happens at all -- the + * arms that read 0 B/op below scalar-replaced. {@code ReduceAllocationMerges} (JDK-8287061) + * is a JDK 21+ extension of that proof to certain merges of two live allocations reaching the same + * use, 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. @@ -91,17 +104,20 @@ @State(Scope.Thread) public class EscapeShapeBenchmark { - /** Stands in for a reservation: two fields and a method worth calling. */ - interface Cell { + /** + * 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 Granted implements Cell { + static final class SingleAllocation implements Outcome { private final int seed; - Granted(int seed) { + SingleAllocation(int seed) { this.seed = seed; } @@ -115,10 +131,10 @@ public void close() {} } /** A second allocation site, for the merge that C2 has some chance with. */ - static final class Alternate implements Cell { + static final class AlternateAllocation implements Outcome { private final int seed; - Alternate(int seed) { + AlternateAllocation(int seed) { this.seed = seed; } @@ -131,9 +147,9 @@ public int value() { public void close() {} } - /** The shared refusal: reachable from a static, so the merge it takes part in is not local. */ - static final Cell REFUSED = - new Cell() { + /** 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; @@ -144,17 +160,17 @@ public void close() {} }; /** One allocation site carrying the outcome in a field: the shape that survives. */ - static final class Flagged { - private final boolean granted; + static final class FlaggedAllocation { + private final boolean present; private final int seed; - Flagged(boolean granted, int seed) { - this.granted = granted; + FlaggedAllocation(boolean present, int seed) { + this.present = present; this.seed = seed; } int value() { - return granted ? seed + 1 : 0; + return present ? seed + 1 : 0; } void close() {} @@ -164,11 +180,11 @@ void close() {} * A non-capturing strategy held in a static final field of concrete type, as {@code @Strategy} * requires. */ - interface CellStrategy { - int apply(Flagged cell); + interface OutcomeStrategy { + int apply(FlaggedAllocation cell); } - static final CellStrategy INLINED = Flagged::value; + static final OutcomeStrategy INLINED = FlaggedAllocation::value; /** * Kept out of line by the {@code CompileCommand} in {@link Fork}, not by {@link CompilerControl}: @@ -177,14 +193,14 @@ interface CellStrategy { * 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 CellStrategy { + static final class UninlinedStrategy implements OutcomeStrategy { @Override - public int apply(Flagged cell) { + public int apply(FlaggedAllocation cell) { return cell.value(); } } - static final CellStrategy UNINLINED = new UninlinedStrategy(); + static final OutcomeStrategy UNINLINED = new UninlinedStrategy(); /** * The template-method shape: a final method on a base type calling out to an abstract one, with @@ -193,30 +209,30 @@ public int apply(Flagged cell) { * 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(Flagged cell) { + final int admit(FlaggedAllocation cell) { return store(cell); } - abstract int store(Flagged cell); + abstract int store(FlaggedAllocation cell); } static final class ArrayBacking extends Backing { @Override - int store(Flagged cell) { + int store(FlaggedAllocation cell) { return cell.value(); } } static final class LinkedBacking extends Backing { @Override - int store(Flagged cell) { + int store(FlaggedAllocation cell) { return cell.value() + 1; } } static final class ThirdBacking extends Backing { @Override - int store(Flagged cell) { + int store(FlaggedAllocation cell) { return cell.value() + 2; } } @@ -240,21 +256,21 @@ int store(Flagged cell) { @Benchmark public void backingMonomorphic(Blackhole bh) { Backing backing = one[(counter++ & 0x7fffffff) % one.length]; - Flagged cell = new Flagged(true, counter); + FlaggedAllocation cell = new FlaggedAllocation(true, counter); bh.consume(backing.admit(cell)); } @Benchmark public void backingBimorphic(Blackhole bh) { Backing backing = two[(counter++ & 0x7fffffff) % two.length]; - Flagged cell = new Flagged(true, counter); + FlaggedAllocation cell = new FlaggedAllocation(true, counter); bh.consume(backing.admit(cell)); } @Benchmark public void backingMegamorphic(Blackhole bh) { Backing backing = three[(counter++ & 0x7fffffff) % three.length]; - Flagged cell = new Flagged(true, counter); + FlaggedAllocation cell = new FlaggedAllocation(true, counter); bh.consume(backing.admit(cell)); } @@ -271,37 +287,37 @@ private boolean alternate() { @Benchmark public void singleSite(Blackhole bh) { - Granted cell = new Granted(counter++); + SingleAllocation cell = new SingleAllocation(counter++); bh.consume(cell.value()); } @Benchmark public void phiOfTwoAllocations(Blackhole bh) { - Cell cell = alternate() ? new Granted(counter) : new Alternate(counter); + Outcome cell = alternate() ? new SingleAllocation(counter) : new AlternateAllocation(counter); bh.consume(cell.value()); } @Benchmark public void phiWithStatic(Blackhole bh) { - Cell cell = alternate() ? new Granted(counter) : REFUSED; + Outcome cell = alternate() ? new SingleAllocation(counter) : STATIC_SINGLETON; bh.consume(cell.value()); } @Benchmark public void phiWithNull(Blackhole bh) { - Granted cell = alternate() ? new Granted(counter) : null; + SingleAllocation cell = alternate() ? new SingleAllocation(counter) : null; bh.consume(cell == null ? 0 : cell.value()); } @Benchmark public void flagOnOneAllocation(Blackhole bh) { - Flagged cell = new Flagged(alternate(), counter); + FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter); bh.consume(cell.value()); } @Benchmark public void closedInFinally(Blackhole bh) { - Granted cell = new Granted(counter++); + SingleAllocation cell = new SingleAllocation(counter++); try { bh.consume(cell.value()); } finally { @@ -325,7 +341,7 @@ private Failure() { */ @Benchmark public void closedInFinallyWithThrow(Blackhole bh) { - Granted cell = new Granted(counter++); + SingleAllocation cell = new SingleAllocation(counter++); try { if ((counter & 15) == 0) { throw Failure.INSTANCE; @@ -341,7 +357,7 @@ public void closedInFinallyWithThrow(Blackhole bh) { /** The Optional-style shape, whole: a singleton for one outcome, under try/finally. */ @Benchmark public void phiWithStaticClosedInFinally(Blackhole bh) { - Cell cell = alternate() ? new Granted(counter) : REFUSED; + Outcome cell = alternate() ? new SingleAllocation(counter) : STATIC_SINGLETON; try { bh.consume(cell.value()); } finally { @@ -352,7 +368,7 @@ public void phiWithStaticClosedInFinally(Blackhole bh) { /** The single-site shape, whole: one allocation carrying a flag, under try/finally. */ @Benchmark public void flagOnOneAllocationClosedInFinally(Blackhole bh) { - Flagged cell = new Flagged(alternate(), counter); + FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter); try { bh.consume(cell.value()); } finally { @@ -365,7 +381,7 @@ public void flagOnOneAllocationClosedInFinally(Blackhole bh) { */ @Benchmark public void passedToInlinedStrategy(Blackhole bh) { - Flagged cell = new Flagged(alternate(), counter); + FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter); bh.consume(INLINED.apply(cell)); } @@ -374,7 +390,7 @@ public void passedToInlinedStrategy(Blackhole bh) { */ @Benchmark public void passedToUninlinedStrategy(Blackhole bh) { - Flagged cell = new Flagged(alternate(), counter); + FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter); bh.consume(UNINLINED.apply(cell)); } } diff --git a/internal-api/src/main/java/datadog/trace/util/Maybe.java b/internal-api/src/main/java/datadog/trace/util/Maybe.java index 7792065fe6d..5c0a1fb0499 100644 --- a/internal-api/src/main/java/datadog/trace/util/Maybe.java +++ b/internal-api/src/main/java/datadog/trace/util/Maybe.java @@ -17,9 +17,9 @@ * 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. * - *

    Confirmed 2026-08-27 ({@code EscapeShapeBenchmark}, JDK 8/11/17/25): the shape here -- - * one allocation site, a plain nullable field, no singleton merge -- scalar-replaces on ordinary - * escape analysis, no JDK-21+ {@code ReduceAllocationMerges} needed. The discipline required of a + *

    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 @@ -55,15 +55,12 @@ public static Maybe of(@Nullable T value) { * -- 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. Confirmed 2026-08-27 against a real - * capacity-refusing lookup method (JDK 8/11/17/25, both a common and a rare refusal ratio): that - * freshly-allocated capturing lambda still scalar-replaces as reliably as a plain delegating - * method call does -- see the Hashtable-integration follow-up for that benchmark and the full - * numbers. That confirmation is specific to the shape actually measured: 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. + * 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 Maybe of(R receiver, @Nonnull Function fn) { From 9296289331891da4ff161dc674ca30a1e6b2aff0 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 28 Aug 2026 13:03:36 -0400 Subject: [PATCH 15/15] Simplify EscapeShapeBenchmark's compiler jargon per bric3's review Lead the glossary paragraph with plain-language framing before naming EA/scalar-replacement/ReduceAllocationMerges, drop "phi" in favor of "merge" throughout (prose, table, and benchmark method names), and explain the merge case with a concrete if/else example instead of SSA terminology. --- .../util/escape/EscapeShapeBenchmark.java | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java index 7cd6fb09f99..8f9ab1c63fe 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/escape/EscapeShapeBenchmark.java @@ -29,14 +29,22 @@ * Maybe}, because the shapes under test are about the compiler's allocation behavior, not about * what the wrapped value represents. * - *

    Three terms recur below. Escape analysis (EA) is the compiler's proof that an allocated - * object's lifetime is provably confined to the method (or thread) that created it -- it never - * escapes into a field, a return value visible outside, or a call the compiler cannot see into. - * Scalar replacement is what C2 does once EA holds: it replaces the object with its - * individual fields, held in registers or on the stack, so no heap allocation happens at all -- the - * arms that read 0 B/op below scalar-replaced. {@code ReduceAllocationMerges} (JDK-8287061) - * is a JDK 21+ extension of that proof to certain merges of two live allocations reaching the same - * use, which is why a few rows below only drop to 0 starting at JDK 21/25 rather than on every JDK. + *

    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. @@ -58,17 +66,17 @@ * passedToInlinedStrategy 0 ? 0 ? 0 @Strategy boundary * backingMonomorphic 0 ? 0 ? 0 one backing * backingBimorphic 0 ? 0 ? 0 two backings - * phiWithNull 8 ? 8 ? 0 merge with null - * phiWithStatic 8 ? 8 ? 8 merge with a singleton - * phiWithStaticClosedInFinally 8 ? 8 ? 8 ... the same, whole - * phiOfTwoAllocations 16 ? 16 ? 16 merge of two allocations + * 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 *

    * *

    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 - * phiWithNull} staying at 8 rather than following JDK 25's drop to 0 — the {@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. * @@ -80,7 +88,7 @@ * catch is in the same method, so C2 can reduce the throw to control flow; this does not * exercise an unwind through frames. *

  • A merge with a static allocates on every JDK measured, JDK 25 included. The JDK 21 - * allocation-merge work shows up only in the {@code phiWithNull} row, which goes 8 to 0; a + * allocation-merge work shows up only in the {@code mergeWithNull} row, which goes 8 to 0; a * merge of two live allocations still allocates at 25, because the merged reference is called * through rather than only read from. *
  • Moving the outcome into a field of a single allocation costs nothing, with or without the @@ -292,19 +300,19 @@ public void singleSite(Blackhole bh) { } @Benchmark - public void phiOfTwoAllocations(Blackhole bh) { + public void mergeOfTwoAllocations(Blackhole bh) { Outcome cell = alternate() ? new SingleAllocation(counter) : new AlternateAllocation(counter); bh.consume(cell.value()); } @Benchmark - public void phiWithStatic(Blackhole bh) { + public void mergeWithStatic(Blackhole bh) { Outcome cell = alternate() ? new SingleAllocation(counter) : STATIC_SINGLETON; bh.consume(cell.value()); } @Benchmark - public void phiWithNull(Blackhole bh) { + public void mergeWithNull(Blackhole bh) { SingleAllocation cell = alternate() ? new SingleAllocation(counter) : null; bh.consume(cell == null ? 0 : cell.value()); } @@ -356,7 +364,7 @@ public void closedInFinallyWithThrow(Blackhole bh) { /** The Optional-style shape, whole: a singleton for one outcome, under try/finally. */ @Benchmark - public void phiWithStaticClosedInFinally(Blackhole bh) { + public void mergeWithStaticClosedInFinally(Blackhole bh) { Outcome cell = alternate() ? new SingleAllocation(counter) : STATIC_SINGLETON; try { bh.consume(cell.value());