The Observer Effect in JVM Profiling
Attach a profiler to see what the JIT is really doing, and you’ve just changed what the JIT does. On the JVM, that’s not a metaphor. It’s a specific, mechanical consequence of how inlining thresholds work.
Physicists get to say “the act of measuring disturbs the system” and leave it at that, a little philosophical, a little abstract. On the JVM, the same idea has an exact address: a bytecode size in bytes, an invocation counter, a threshold flag with a documented default value. Attach a profiler, add a logging call, or wrap a method in instrumentation, and you may have just pushed that method past a specific number the JIT compiler checks before deciding whether to inline it, compile it, or optimize the allocations inside it at all. The measurement isn’t just imprecise. It can quietly change the exact thing you were trying to measure.
Why the JVM Is Especially Exposed to This
An ahead-of-time compiled program mostly doesn’t have this problem. Its machine code is fixed before it runs, so attaching a profiler adds overhead without changing what got compiled in the first place. The JVM works completely differently, and that difference is exactly why the observer effect bites harder here than almost anywhere else.
HotSpot decides what to optimize by watching your program run. Tiered compilation starts every method in the interpreter, promotes frequently called methods to a fast, lightly optimized C1 compile after roughly a thousand invocations, and eventually recompiles the hottest of those with the aggressive C2 optimizer once invocation counts climb high enough, historically 10,000 calls under the older single-tier default. Every one of those decisions depends on counting how often code actually runs and how long it takes to warm up. Add overhead anywhere in that pipeline, logging, instrumentation, a profiler’s own sampling mechanism, and you’ve changed the very timing and call-count data the JIT uses to decide what’s worth optimizing.
The Inlining Threshold You Just Broke by Adding a Log Line
This is where the effect stops being abstract. HotSpot’s inlining decisions are governed by concrete bytecode size limits: methods under 35 bytes get inlined more or less unconditionally via -XX:MaxInlineSize, hot methods get a more generous 325-byte allowance via -XX:FreqInlineSize (the exact figure is platform-dependent), and any method whose bytecode exceeds 8,000 bytes is excluded from compilation entirely by the DontCompileHugeMethods flag, which is enabled by default.
A hot method sitting comfortably under 325 bytes today can cross that line the moment you add a single logging statement, an instrumentation probe, or a profiler’s injected call. It doesn’t take much: a call to a logging framework, even one that’s conditionally disabled, still contributes bytecode to the method that has to be evaluated, and if it pushes the method’s size over the applicable threshold, C2 simply stops inlining it into its caller. The method still runs. It’s often still compiled. It just stops being folded into the surrounding code the way it was before you started watching it, which changes everything downstream of that decision.
Where This Collides With Escape Analysis
This is the mechanism worth understanding if you’ve read about escape analysis and scalar replacement, because it’s exactly where the two topics meet. Escape analysis can only prove an object never leaves its allocating scope, and therefore replace a heap allocation with fields kept in registers or on the stack, when the compiler can see the object’s entire lifetime in one optimization unit. Inlining is what makes that possible across method boundaries: once a callee is folded directly into its caller, the JIT can trace whether an object it constructs actually escapes, gets stored somewhere reachable, or simply gets used and discarded.
Break that inlining, and escape analysis loses visibility it depended on. A small object that used to be scalar-replaced into stack slots, because C2 could see straight through the inlined call and prove nothing ever referenced it externally, can suddenly require a real heap allocation once the method housing it stops being inlined. That’s not a hypothetical risk; it’s precisely the property well-designed profilers advertise as something they deliberately avoid. Async-profiler’s own documentation states plainly that it does not affect escape analysis or prevent optimizations like allocation elimination, specifically because it avoids bytecode instrumentation altogether. That’s a meaningful claim only because the alternative, instrumentation that does add bytecode to hot methods, genuinely can disturb exactly this optimization, changing your allocation rate and GC pressure while you’re in the middle of trying to measure them.
Safepoint Bias: The Other Half of the Story
Even a profiler that never touches your bytecode still has to get its samples from somewhere, and that introduces a second, independent source of distortion. Traditional sampling profilers can only safely inspect a thread’s stack at a safepoint, a point where the JVM guarantees every object reference is in a known, walkable location. But safepoints aren’t evenly distributed through your code; the compiler places them based on its own reasoning about where allocation or blocking might occur, which means a method with no allocation and no calls might have no safepoint at all, while an adjacent method that merely makes a call gets one by default, since the compiler conservatively assumes any call might eventually allocate or recurse.
A well-documented, real example makes this concrete: in a study evaluating Java profiler accuracy, an artificial hot method that ran in a tight, allocation-free loop received no safepoint at all, while a neighboring, nearly-idle cold method that merely contained a method call did. A safepoint-biased profiler sampling that program attributed 99.8% of execution time to the cold method, the one actually responsible for almost none of it, simply because that was the only place it was legally allowed to look.
Yield Points Placed Per Method Under Different Profilers, Same Program

Notice what that chart actually shows: it’s not one profiler versus no profiler, it’s two different profilers, observing the same code, causing HotSpot to place a measurably different number of yield points. The tool you chose to observe with became part of what got observed.
Instrumentation Profilers Make It Considerably Worse
Sampling profilers at least try to minimize their footprint. Instrumentation-based profilers, which inject bytecode directly into methods to record exact call counts and timings, pay a much steeper price for that precision. A 2024 study from Stefan Marr’s research group at Kent, examining state-of-the-art instrumentation-based Java profilers, found that even the lowest-overhead tool in their comparison increased total application runtime by 82 times.
Typical Profiling Overhead by Approach

An 82x slowdown doesn’t just make your program slower. At that scale, warmup timing, GC cadence, and thread interleaving all shift so dramatically that the JIT is effectively compiling and optimizing a different program than the one that runs in production, which is precisely why the same research concluded that instrumentation-based profilers “interact badly with inlining and other standard JIT optimisations, leading to profiles that are not representative of production performance.”
Side by Side
| Approach | Adds bytecode to hot methods? | Safepoint bias? | Disturbs escape analysis? | Typical overhead |
|---|---|---|---|---|
| Ad hoc logging in hot paths | Yes | Indirect (changes inlining, which changes yield point placement) | Yes, if it pushes a method past an inlining threshold | Varies, but rarely negligible in hot loops |
| Instrumentation-based profiler (JVMTI bytecode injection) | Yes, directly | Yes | Yes | Up to 82x in the best case measured (2024 study) |
| Naive sampling profiler | No | Yes, significant | No | 10–50% |
| Modern low-overhead sampler (e.g. async-profiler) | No | Reduced, not eliminated | No, by design | Typically under 1% |
Quick Checklist: Measuring Without Lying to Yourself
- Prefer a sampling profiler that uses AsyncGetCallTrace or an equivalent, non-safepoint-bound mechanism over anything based on JVMTI bytecode instrumentation, especially for production or near-production measurement.
- Run with
-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepointswhen sampling, so the profiler can resolve stack frames more accurately between safepoints rather than snapping to the nearest one. - Always let a benchmark warm up before measuring; a comparison that mixes interpreted, C1, and C2 execution phases isn’t measuring steady-state behavior at all.
- Check
-XX:+PrintInliningoutput before and after adding logging or instrumentation to a hot path, since “too big” is a real, printed reason a method stops being inlined. - Treat any profiling result that shows a hot method suddenly allocating where it didn’t before as a signal to check whether your own instrumentation broke inlining, not just a signal about your business logic.
What We Have Learned
The observer effect on the JVM isn’t a vague warning to “be careful,” it’s a specific chain of cause and effect you can trace through real, documented threshold values. Adding a log line or an instrumentation probe to a hot method can push its bytecode size past the 35-byte or 325-byte inlining limits HotSpot actually checks, which stops that method from being folded into its caller, which removes the visibility escape analysis needs to scalar-replace objects that would otherwise never touch the heap, which changes the allocation behavior and GC pressure you may have been trying to measure in the first place.
Even profilers that never touch your bytecode still contend with safepoint bias, since HotSpot only guarantees a safe stack inspection point where the compiler decided to place one, not where your hottest code happens to run, a gap real research has measured attributing 99.8% of runtime to the wrong method. Instrumentation-based profilers pay for their precision at a steep, measured cost, up to 82 times slower in the best case studied. None of this means profiling is pointless. It means the tool matters as much as the technique: a sampling profiler built specifically to avoid bytecode injection and minimize safepoint dependency is measuring something much closer to your real production behavior than a naive one ever will.

