A method has been running fast for an hour and then gets slower, with no code change and no extra traffic. What might the runtime be doing?
A JIT compiles against a profile gathered earlier, so its fast code rests on assumptions that can be invalidated: a new receiver type at a call site, a branch taken for the first time, a class loaded that gives an interface a second implementation. The method deoptimises and recompiles from a worse profile.
What the interviewer is scoring
- Does the candidate reach for speculation and its invalidation rather than assuming garbage collection
- Whether they can name a specific event that invalidates already-compiled code
- That they know deoptimisation returns execution to the interpreter and recompilation uses the updated profile
- Whether claims are scoped to a named runtime instead of described as how JITs behave in general
- Can they say which single observation would confirm the diagnosis
Answer
Fast code is conditional code
An optimising just-in-time compiler does not translate your method once and finish. It observes the method running, and then compiles a version that is fast because it assumes the future will resemble what it observed. In HotSpot the method starts in the interpreter, is compiled by C1 once its invocation and loop back-edge counters cross a threshold, and may then be recompiled by C2 using the profile C1's instrumented code collected. That profile is the important artefact: it records which types actually arrived at each call site, which branches were never taken, and which loops mattered.
C2 then bakes those observations in as assumptions. A call site that only ever saw one receiver type is compiled as a direct call, and the body can be inlined into the caller, which is the enabler for nearly every other optimisation. A branch that was never taken can be compiled as a trap rather than as code. An interface method with exactly one loaded implementation can be called as if it were final, on the strength of class-hierarchy analysis. None of that is safe in general, only while the assumption holds, so each one is recorded with a way to detect that it has stopped.
What breaks the assumption an hour in
The obvious candidate is a new type. Consider a dispatch point that has processed one message class for an hour:
// For an hour, handler is always the same concrete class, so this call site is
// monomorphic and C2 can inline the body straight into this loop.
for (Message m : batch) {
handlerFor(m).apply(m); // one receiver type observed, so far
}
The first message of a second type arrives — a new tenant configured differently, a feature flag enabled, a rarely used code path reached at month end. The compiled code's guard fails. HotSpot marks the compiled method not entrant, reconstructs an interpreter frame for the in-flight invocation from the compiled frame's metadata, and continues in the interpreter. That is deoptimisation, and by itself it is cheap and correct. The performance change comes from what happens next: the method is recompiled, and the new profile no longer says one type at that call site. With two types HotSpot can still inline both behind a type check. With several, the site becomes megamorphic, dispatch goes through the vtable or itable, and the inlining that everything else depended on is gone. The method is now permanently slower than it was at minute fifty-nine, and nothing about your code changed.
Class loading produces the same effect without any new data. If a method was called as if final because its interface had one implementation loaded, loading a second invalidates that assumption for every compiled method that relied on it. In an application that lazily loads a plugin an hour into its life, an unrelated hot path can slow down at that instant.
The third trigger is a branch. Compiled code can treat a never-taken path as an uncommon trap rather than emit it at all, which is why the first error or the first oversized input can be far more expensive than the millionth: reaching that code at all required leaving compiled code.
Why the profile does not unlearn
The asymmetry is what makes this worth understanding rather than merely knowing. Speculation is recovered from safely but the knowledge is not discarded, so degradation is one-way within a process. Once a call site has recorded three receiver types, later recompilations start from the polluted profile; the site does not return to monomorphic because the second type stopped appearing. The route back to the original speed is a restart.
This is also why a shared generic helper can be slow for reasons outside itself. A utility invoked from forty places with forty different types has a hopeless profile at its internal call sites, where the same source inlined into one specialised caller would be fast. It is also why a microbenchmark exercising a single implementation flatters code that in production sees several.
HotSpot also has a bounded code cache. If it fills, which is plausible in a long-lived process with a great deal of compiled code, HotSpot reports the code cache full and stops compiling, so newly hot methods stay interpreted and the application slows in a way no profiler pointed at your code will explain. The size is set by -XX:ReservedCodeCacheSize, and since JDK 9 the cache is segmented, so it can be pressure in one segment rather than in total.
V8 is worth naming separately rather than generalising: it speculates on object shapes and feedback, and a function whose shapes keep changing can be optimised and deoptimised repeatedly rather than settling. The principle transfers; the specific triggers do not, so say which runtime you mean.
Confirming it rather than guessing
The plausible alternatives are numerous, so a strong answer names the observation that distinguishes them. Garbage collection produces this shape too, as the old generation fills and collections lengthen, and so does a warm cache turning cold. What separates the compiler explanation is direct evidence of recompilation: HotSpot's -XX:+PrintCompilation marks methods made not entrant when they are deoptimised, and a JDK Flight Recorder recording carries compilation and deoptimisation events with the reason attached. If the slowdown coincides with a burst of deoptimisation on that method you have the cause; if it coincides with pause time, you were looking at the wrong subsystem.
The JIT's fast code is a bet on the profile it collected, and losing that bet is silent, cheap and irreversible within the process. Look for deoptimisation events at the moment of the change, and remember that the profile only ever gets more general — a call site that has seen three types will not go back to one.
Likely follow-ups
- How would you keep a shared utility method's call sites monomorphic, and what does that cost you in design terms?
- What must a warm-up phase exercise for the steady-state profile to resemble production?
- What happens to compiled code when an instrumenting agent is attached to a running process?
- Why can a microbenchmark that exercises one implementation mislead you about code that sees three?
Related questions
- Before you tune anything, how do you decide which queries are worth your time?mediumAlso on profiling4 min
- A nightly job took 40 minutes last month and takes two hours forty on twice the data. What do you conclude about its complexity, and what would you measure next?mediumAlso on profiling4 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardAlso on profiling6 min
- Walk me through what happens between the source you write and the instructions the CPU runs, and where does a JIT fit into that?mediumAlso on jit5 min
- A long-running Python service keeps climbing in memory and never comes back down. How do you work out where the memory went?hardAlso on profiling5 min
- When you call an overridden method through a base-class reference, how does the runtime know which implementation to run?mediumAlso on jit5 min
- CPU saturates on every node minutes after a release that contained forty commits. How do you find the cause?hardAlso on profiling5 min
- A list of a million small objects uses far more memory than the data inside them. Where is it going?mediumSame kind of round: concept5 min