JIT Inlining and Escape Analysis
Purpose
Decide allocation and inlining questions by measurement instead of by belief. Two
symmetric errors live here — "allocation is expensive, avoid objects" and "the JIT handles
it, allocate freely" — and both are unverified. The defensible position is to measure, and
a command starts the measurement but does not establish attribution or adequacy. This skill is the practitioner's layer: what to do about
a hot call that was not inlined or an allocation that survived. The mechanism is
escape-analysis-internals and c2-sea-of-nodes; reading the logs end to end is
compilation-and-inlining-logs.
Inspect the target compiler/JDK build, toolchain, flags and directives first; tier 4 denotes
C2 here only under the stated HotSpot configuration, not when a different compiler is selected.
JDK 25 observations do not authorize upgrading the project or changing production VM policy.
Workflow
- Measure the allocation, do not infer it from source. Under JMH,
-prof gc estimates
normalized bytes per operation for the benchmark fork. A controlled harness can use a
supported/enabled com.sun.management.ThreadMXBean, but must subtract harness work,
isolate the measured thread and confirm compilation state; it is not automatically the
same experiment. In production,
jdk.ObjectAllocationSample in JFR names the most-allocated types; allocation-profiling
owns the attribution.
- Reconcile bytes with object layout and compilation. A repeatable delta close to an
aligned object size is a useful hypothesis, not identity proof: boxing, lambda objects,
arrays, harness/class-init work and different compiled paths contribute too. Correlate
allocation profiles/types with compilation IDs within each run; IDs are not comparable
identities across JVM forks.
- Find the boundary before theorising: non-inlined/unknown calls; returns or stores to
heap/global/thread-visible state; identity-sensitive uses; merges, arrays and indices C2
cannot scalarize; or profile-dependent paths excluded from the current graph. Use the
inlining log and
escape-analysis-internals; do not infer an escape category from one
source construct.
- For a non-inlined hot call, pick the fix from the verdict, not from the flag list:
hot method too big wants the callee's rare part extracted, virtual call wants fewer
types at that site, inlining too deep wants a flatter chain, already compiled into a big method means the callee grew. Refactor first; CompileCommand to confirm in the lab;
a global limit last, measured process-wide. See
references/inlining-verdicts-and-fixes.md.
- Isolate factors in a disposable benchmark fork. Compare EA/allocation/lock-elision
switches only as diagnostic experiments; they are global and change many compilations.
Then establish whether retained CPU, allocation rate, GC or tail latency matters to the
service before accepting a less maintainable source shape.
Rules
- Inlining commonly exposes object uses to C2's connection graph and downstream
optimisations. A non-inlined ordinary Java call is usually an escape boundary, but
intrinsics and compiler-known methods are exceptions. A refused call can cost more than
dispatch—constant propagation, scalar replacement and dead-code elimination may also stop.
- Current HotSpot C2 scalar replacement is not stack allocation. The object is
decomposed into scalar values and ceases to exist in optimized code. That is not the same as
moving it to another memory region. Deoptimization may rematerialize virtual objects, so
preserve debug/deopt semantics when interpreting assembly and profiles.
- C2's connection-graph escape state is generally flow-insensitive for code retained in the
compiled graph. An unobserved path may initially be removed behind an uncommon trap; if it
later executes, deoptimization/recompilation can produce a different graph. One execution
does not guarantee a permanent state. Exercise realistic rare paths and correlate each
allocation result with compilation/deoptimization history.
- In current C2,
ArgEscape is not enough for scalar replacement; it may still enable some
lock elimination. Treat measured nanoseconds and byte counts as benchmark-specific.
- Splitting rare/cold work can improve inlining, but extra boundaries can also block it.
Refactor around the measured hot graph.
HugeMethodLimit and DontCompileHugeMethods are
implementation policy: scope the 8,000-byte observation to JDK/build and check known
version/policy exceptions in compilation-and-inlining-logs.
- Pooling small objects often adds escape, retention, synchronization/cache traffic and stale
state risk. Consider it only for resources with measured construction/lifecycle cost and a
bounded ownership protocol; compare against ordinary allocation plus GC under load.
- Polymorphic sites can cost through dispatch and lost optimisation. C2 records a bounded
receiver profile and may guard-inline dominant types; exact width/percent thresholds and
behavior are version-specific. Profile data belongs to a bytecode call site and can be
affected by all executions reaching that site, especially shared helpers.
@ForceInline and @DontInline are unsupported internal annotations. The tested JDK 25
build honored them only for privileged boot/platform classes; class-path use with exports
changed nothing. Do not depend on that implementation detail. Application experiments use
-XX:CompileCommand=inline|dontinline, compiler directives and JMH @CompilerControl,
and all three are lab tools, not the fix.
- A lambda/capture,
Optional or stream is not intrinsically free or allocating. Its
allocation depends on linkage, caching, inlining, escape and the exact pipeline. Use the
reference's reproduction cases to test the actual pipeline, never as API cost guarantees.
- C2 array scalar replacement has stricter implementation limits than object scalar
replacement, commonly requiring constant small length and analyzable constant offsets.
EliminateAllocationArraySizeLimit=64 is a tested JDK 25 policy value, not a Java rule.
- Some same-shape allocation merges became scalar-replaceable with
ReduceAllocationMerges work delivered from JDK 22. Eligibility depends on classes,
control flow and uses; do not carry either “merges always escape” or “merges are free”
across JDKs without evidence.
- Reflection/method-handle transparency depends on constant targets, modern reflection
implementation, linkage and inlining.
Method.invoke is not universally opaque after
JEP 416, and a MethodHandle is not automatically transparent. Measure the concrete chain.
- Partial escape analysis in Graal exists precisely for the flow limitation — it decides
per path rather than per method. Graal left the JDK with JEP 410 (JDK 17); using it is
graalvm-jit.
Decision framework
| Observation |
Prefer |
Avoid until proven |
| allocation survives but is not hot/retained |
keep readable code |
pooling or API distortion |
| non-inlined hot boundary blocks several optimisations |
extract cold work or specialize a local hot path |
global inlining limits |
| rare escape invalidates common-path scalar replacement |
construct on the rare path or pass scalars, if semantics stay clear |
benchmark that never exercises the path |
| polymorphic shared site loses inlining |
isolate stable call sites or redesign only with profile evidence |
type checks added solely to game C2 |
| JDK upgrade changes allocation/code shape |
compare compile logs, bytes/op, CPU and tails |
pinning an obsolete compiler heuristic forever |
The production acceptance test is not “0 B/op”. Require the same behavior, maintainable code,
improved relevant SLO/resource metric under realistic concurrency, no code-cache/compile-time
regression, and stable results across supported JDK/CPU variants.
Troubleshooting
Allocation or latency regression
↓ correlate deploy/JDK, allocation type+stack, compile id and deoptimizations
Expected call did not inline
↓ read the C2 verdict at the exact call site; inspect profile/size/node budget
Call inlined but allocation remains
↓ inspect stores/returns/identity/array/merge and escape-analysis limits
Allocation disappears in JMH only
↓ exercise production receiver mix, rare paths, exceptions and framework boundaries
Bytes improve but service does not
↓ measure CPU, GC, tails, code cache and bottleneck migration; revert complexity if no value
References
- Verifying escape analysis — the flags to
confirm, the JMH,
ThreadMXBean and JFR measurements, the hypothesis cases for
common patterns, and the factor-isolation runs. Read before changing any
allocation-related code.
- From an inlining verdict to a code change —
the limits with their JDK 25 defaults and what each measures, the verdict-to-fix table,
polymorphism outcomes, why internal inlining annotations are not an application contract,
huge-method exclusion, the cost of raising a limit, and production behavior. Read when
a hot call was refused and the next step is unclear.
1---2name: jit-inlining-and-escape-analysis3description: Inlining and escape analysis in C2: inlining as the multiplier, scalar replacement versus "stack allocation", flow-insensitivity, turning a PrintInlining verdict into a code change, and measuring with gc.alloc.rate.norm. Use when allocation rate is high on a hot path, when a hot call is refused inlining and the fix is unclear, when an object pool for small objects, @ForceInline on application code or a higher FreqInlineSize is proposed, when an interface gains a third implementation on a critical path, when "the JIT will handle it" or "allocation is expensive" is asserted without a measurement, when a rare branch makes an object escape, when Optional, a stream or a lambda capture is blamed or excused for allocation, or when a hot method never appears in PrintCompilation. Does not cover warm-up and the tiered pipeline (jit-compilation), benchmark construction (jmh-microbenchmarks) or GC cost (gc-fundamentals). The algorithm itself is escape-analysis-internals; byte attribution is allocation-profiling.4---56# JIT Inlining and Escape Analysis78## Purpose910Decide allocation and inlining questions by measurement instead of by belief. Two11symmetric errors live here — "allocation is expensive, avoid objects" and "the JIT handles12it, allocate freely" — and both are unverified. The defensible position is to measure, and13a command starts the measurement but does not establish attribution or adequacy. This skill is the practitioner's layer: what to do about14a hot call that was not inlined or an allocation that survived. The mechanism is15`escape-analysis-internals` and `c2-sea-of-nodes`; reading the logs end to end is16`compilation-and-inlining-logs`.1718Inspect the target compiler/JDK build, toolchain, flags and directives first; tier 4 denotes19C2 here only under the stated HotSpot configuration, not when a different compiler is selected.20JDK 25 observations do not authorize upgrading the project or changing production VM policy.2122## Workflow23241. **Measure the allocation, do not infer it from source.** Under JMH, `-prof gc` estimates25 normalized bytes per operation for the benchmark fork. A controlled harness can use a26 supported/enabled `com.sun.management.ThreadMXBean`, but must subtract harness work,27 isolate the measured thread and confirm compilation state; it is not automatically the28 same experiment. In production,29 `jdk.ObjectAllocationSample` in JFR names the most-allocated types; `allocation-profiling`30 owns the attribution.312. **Reconcile bytes with object layout and compilation.** A repeatable delta close to an32 aligned object size is a useful hypothesis, not identity proof: boxing, lambda objects,33 arrays, harness/class-init work and different compiled paths contribute too. Correlate34 allocation profiles/types with compilation IDs within each run; IDs are not comparable35 identities across JVM forks.363. **Find the boundary before theorising:** non-inlined/unknown calls; returns or stores to37 heap/global/thread-visible state; identity-sensitive uses; merges, arrays and indices C238 cannot scalarize; or profile-dependent paths excluded from the current graph. Use the39 inlining log and `escape-analysis-internals`; do not infer an escape category from one40 source construct.414. **For a non-inlined hot call, pick the fix from the verdict**, not from the flag list:42 `hot method too big` wants the callee's rare part extracted, `virtual call` wants fewer43 types at that site, `inlining too deep` wants a flatter chain, `already compiled into a44big method` means the callee grew. Refactor first; `CompileCommand` to confirm in the lab;45 a global limit last, measured process-wide. See46 `references/inlining-verdicts-and-fixes.md`.475. **Isolate factors in a disposable benchmark fork.** Compare EA/allocation/lock-elision48 switches only as diagnostic experiments; they are global and change many compilations.49 Then establish whether retained CPU, allocation rate, GC or tail latency matters to the50 service before accepting a less maintainable source shape.5152## Rules5354- Inlining commonly exposes object uses to C2's connection graph and downstream55 optimisations. A non-inlined ordinary Java call is usually an escape boundary, but56 intrinsics and compiler-known methods are exceptions. A refused call can cost more than57 dispatch—constant propagation, scalar replacement and dead-code elimination may also stop.58- **Current HotSpot C2 scalar replacement is not stack allocation.** The object is59 decomposed into scalar values and ceases to exist in optimized code. That is not the same as60 moving it to another memory region. Deoptimization may rematerialize virtual objects, so61 preserve debug/deopt semantics when interpreting assembly and profiles.62- C2's connection-graph escape state is generally flow-insensitive for code retained in the63 compiled graph. An unobserved path may initially be removed behind an uncommon trap; if it64 later executes, deoptimization/recompilation can produce a different graph. One execution65 does not guarantee a permanent state. Exercise realistic rare paths and correlate each66 allocation result with compilation/deoptimization history.67- In current C2, `ArgEscape` is not enough for scalar replacement; it may still enable some68 lock elimination. Treat measured nanoseconds and byte counts as benchmark-specific.69- Splitting rare/cold work can improve inlining, but extra boundaries can also block it.70 Refactor around the measured hot graph. `HugeMethodLimit` and `DontCompileHugeMethods` are71 implementation policy: scope the 8,000-byte observation to JDK/build and check known72 version/policy exceptions in `compilation-and-inlining-logs`.73- Pooling small objects often adds escape, retention, synchronization/cache traffic and stale74 state risk. Consider it only for resources with measured construction/lifecycle cost and a75 bounded ownership protocol; compare against ordinary allocation plus GC under load.76- Polymorphic sites can cost through dispatch and lost optimisation. C2 records a bounded77 receiver profile and may guard-inline dominant types; exact width/percent thresholds and78 behavior are version-specific. Profile data belongs to a bytecode call site and can be79 affected by all executions reaching that site, especially shared helpers.80- `@ForceInline` and `@DontInline` are unsupported internal annotations. The tested JDK 2581 build honored them only for privileged boot/platform classes; class-path use with exports82 changed nothing. Do not depend on that implementation detail. Application experiments use83 `-XX:CompileCommand=inline|dontinline`, compiler directives and JMH `@CompilerControl`,84 and all three are lab tools, not the fix.85- A lambda/capture, `Optional` or stream is not intrinsically free or allocating. Its86 allocation depends on linkage, caching, inlining, escape and the exact pipeline. Use the87 reference's reproduction cases to test the actual pipeline, never as API cost guarantees.88- C2 array scalar replacement has stricter implementation limits than object scalar89 replacement, commonly requiring constant small length and analyzable constant offsets.90 `EliminateAllocationArraySizeLimit=64` is a tested JDK 25 policy value, not a Java rule.91- Some same-shape allocation merges became scalar-replaceable with92 `ReduceAllocationMerges` work delivered from JDK 22. Eligibility depends on classes,93 control flow and uses; do not carry either “merges always escape” or “merges are free”94 across JDKs without evidence.95- Reflection/method-handle transparency depends on constant targets, modern reflection96 implementation, linkage and inlining. `Method.invoke` is not universally opaque after97 JEP 416, and a `MethodHandle` is not automatically transparent. Measure the concrete chain.98- Partial escape analysis in Graal exists precisely for the flow limitation — it decides99 per path rather than per method. Graal left the JDK with JEP 410 (JDK 17); using it is100 `graalvm-jit`.101102## Decision framework103104| Observation | Prefer | Avoid until proven |105| ------------------------------------------------------ | ------------------------------------------------------------------- | ---------------------------------------------- |106| allocation survives but is not hot/retained | keep readable code | pooling or API distortion |107| non-inlined hot boundary blocks several optimisations | extract cold work or specialize a local hot path | global inlining limits |108| rare escape invalidates common-path scalar replacement | construct on the rare path or pass scalars, if semantics stay clear | benchmark that never exercises the path |109| polymorphic shared site loses inlining | isolate stable call sites or redesign only with profile evidence | type checks added solely to game C2 |110| JDK upgrade changes allocation/code shape | compare compile logs, bytes/op, CPU and tails | pinning an obsolete compiler heuristic forever |111112The production acceptance test is not “0 B/op”. Require the same behavior, maintainable code,113improved relevant SLO/resource metric under realistic concurrency, no code-cache/compile-time114regression, and stable results across supported JDK/CPU variants.115116## Troubleshooting117118```text119Allocation or latency regression120 ↓ correlate deploy/JDK, allocation type+stack, compile id and deoptimizations121Expected call did not inline122 ↓ read the C2 verdict at the exact call site; inspect profile/size/node budget123Call inlined but allocation remains124 ↓ inspect stores/returns/identity/array/merge and escape-analysis limits125Allocation disappears in JMH only126 ↓ exercise production receiver mix, rare paths, exceptions and framework boundaries127Bytes improve but service does not128 ↓ measure CPU, GC, tails, code cache and bottleneck migration; revert complexity if no value129```130131## References132133- [Verifying escape analysis](references/verifying-escape-analysis.md) — the flags to134 confirm, the JMH, `ThreadMXBean` and JFR measurements, the hypothesis cases for135 common patterns, and the factor-isolation runs. Read before changing any136 allocation-related code.137- [From an inlining verdict to a code change](references/inlining-verdicts-and-fixes.md) —138 the limits with their JDK 25 defaults and what each measures, the verdict-to-fix table,139 polymorphism outcomes, why internal inlining annotations are not an application contract,140 huge-method exclusion, the cost of raising a limit, and production behavior. Read when141 a hot call was refused and the next step is unclear.