skip to content

Walk through, step by step, what the JVM actually does when it performs on-stack replacement on a frame that is currently executing an interpreted loop. Include how the running frame's state survives the transfer.

level: seniorimportance: should knowfreq 26%

answer

  1. Frames are structurally different -> rebuild, not jump
  2. Compile task keyed on (method, bci)
  3. OSR entry contract = live locals + expression stack (+ monitors)
  4. OSR buffer packed, compiled frame unpacked, interpreted frame dropped
  5. Caller's frame untouched; normal calls use a separate nmethod

basics

~20 s

Back-edge counter overflows at a loop bytecode index; a compile task keyed to (method, bci) produces an OSR nmethod whose entry expects the interpreter's live locals and stack. At the next back-edge the runtime copies that state into an OSR buffer, builds a compiled frame from it, and jumps to the OSR entry; the interpreted frame is discarded.

solid answer

~60 s

1. **Trigger** - the interpreter's back-edge counter for a specific backward branch overflows and calls into the runtime. 2. **Request** - the compilation policy enqueues a compile task keyed on `(method, bytecode index of the loop head)`. The current invocation keeps interpreting meanwhile; nothing blocks. 3. **Compile** - a compiler thread compiles the method *from that bci onward*, generating an nmethod with a non-standard **OSR entry point**. Its calling contract is not the usual argument convention: it expects the full set of live locals (and expression-stack values) valid at that bci. 4. **Install** - the nmethod is registered against `(method, bci)`. 5. **Transfer** - at a later arrival at that back-edge, the interpreter finds a ready OSR nmethod. The runtime builds an **OSR buffer** holding the frame's live locals and monitor state, allocates a compiled frame, moves each value to the register or stack slot the compiled code expects (the compiler emitted a state map for exactly this), pops the interpreted frame and jumps into the OSR entry. 6. **Continue** - the loop resumes at its current iteration, now in optimized code. On method exit the caller returns normally; later *calls* use the standard nmethod, if one exists.

code

text · 3 lines
text
130    7 %     3       Batch::crunch @ 12 (58 bytes)
    141    9 %     4       Batch::crunch @ 12 (58 bytes)
# '%' marks an OSR compilation; '@ 12' is the bytecode index of the loop entry

go deeper

for a junior

Not expected in depth; be able to say the running frame's variables are transferred into a compiled frame and execution continues at the same iteration.

for a middle

Give the ordered steps: counter overflow, keyed compile request, special entry point, state transfer, continue in compiled code.

for a senior

Explain why a transfer is necessary at all (different frame layouts and value locations), name the OSR buffer and the (method, bci) key, and cover monitors and the untouched caller frame.

for a principal

Position OSR and deoptimization as the same invariant - compiled and interpreted frames remain inter-convertible via compiler-emitted state maps - which is what makes speculative optimization safe at all.

## Why a transfer is needed at all An interpreted frame and a compiled frame are structurally different objects. The interpreter keeps a **locals array** and an **operand stack** at known offsets, plus a bytecode pointer; every value lives in memory in a uniform layout. Compiled code keeps values wherever the register allocator decided - some in registers, some in spill slots, some not materialized at all because the compiler proved them redundant. There is no way to 'just continue' from one representation in the other; a frame must be **rebuilt**. OSR is the machinery that does that rebuild in the interpreter-to-compiled direction. ## Step by step **1. Detection.** Every backward branch in the interpreter increments the method's back-edge counter and periodically checks it (the check frequency is governed by tiered notification flags). On overflow the interpreter calls a runtime entry, passing the bytecode index of the branch. **2. Policy decision and enqueue.** The compilation policy decides whether this loop warrants an OSR compile at some optimization level. If yes, a task keyed on `(method, bci)` goes on the compile queue. Crucially the requesting thread does **not** wait: it returns to interpreting the loop. It will re-check on subsequent back-edges whether an OSR nmethod has appeared. **3. Compilation with a mid-method entry.** The compiler builds its intermediate representation for the whole method, but marks the loop head at that bci as the entry. Everything before the loop is not reachable from the OSR entry and is dropped or kept only for the values it defines. The compiler must record, for the entry, a mapping: interpreter local slot *n* and expression-stack slot *m* map to specific machine locations in the compiled frame. It also emits the ordinary things - safepoint polls, exception handlers, deoptimization metadata for the speculative assumptions it made. **4. Installation.** The finished nmethod is attached to the method as an OSR nmethod for that bci. If a different loop in the same method later becomes hot, it gets its own separate OSR nmethod at its own bci. **5. The switch.** On a subsequent arrival at the back-edge, the interpreter's check finds an installed, valid OSR nmethod. The runtime then: - collects the live values - all locals valid at that bci, any operand-stack entries, and information about held monitors (locks the frame owns, which must be carried across so unlocking still works) - into a contiguous **OSR buffer**; - computes the size of the compiled frame and reserves it, typically by extending the current stack region; - unpacks the buffer into the exact locations the compiled entry expects, per the map the compiler emitted; - discards the interpreted frame and transfers control to the OSR entry address. From the caller's point of view nothing happened: the return address and the caller's frame are untouched, so when the method eventually returns it returns normally. **6. Afterwards.** The now-compiled frame runs the remainder of the loop and the rest of the method. Later *invocations* of the same method do not use the OSR nmethod - its entry contract only makes sense mid-loop - so they either interpret or, if the method also got hot by invocation count, use a standard nmethod. ## Interactions worth knowing - **Locks.** If the interpreted frame holds monitors (a `synchronized` block enclosing the loop), monitor ownership is part of the transferred state; getting this wrong would leak or double-release locks. - **Safepoints.** Back-edges already carry safepoint polls, so the OSR check rides on an existing periodic check rather than adding a new one on the fast path. - **Deoptimization.** The reverse mapping also exists: compiled frames carry metadata describing how to rebuild interpreter frames if an assumption fails. OSR and deoptimization are two directions of the same 'frames are convertible if you keep a state map' idea. - **Multiple entries.** Nested or sequential hot loops in one big method can produce several OSR nmethods, each compiled separately - one reason very large loop-heavy methods can be expensive to warm up. ## What a strong answer emphasises The non-obvious part is that this is **not** a jump into existing compiled code, it is a *purpose-built* compilation with an unusual entry contract, plus a state-mapping step that moves values from the interpreter's uniform layout into the optimizer's chosen layout. Candidates who can say that, and can name the OSR buffer and the `(method, bci)` key, clearly understand the mechanism rather than the slogan.

  • What happens to monitors the interpreted frame already holds when the OSR transfer occurs?
    They must be carried across as part of the transferred state, so the compiled frame is recorded as the owner of exactly the same monitors. Otherwise a synchronized block enclosing the loop would either fail to release its lock at exit or attempt to release a lock the frame is not recorded as holding. Monitor state is part of what the OSR buffer describes.
  • Can the OSR nmethod later be discarded, and what happens to the frame if it is?
    Yes. Like any compiled code it can be invalidated when a speculative assumption is broken, and the running frame is then deoptimized: the compiled frame is converted back into one or more interpreter frames using the deoptimization metadata, and execution continues interpreted from the corresponding bytecode. That reverse mapping is the same idea as OSR, run in the other direction.

saying these in an interview costs you the question

  • Saying the interpreter simply jumps into the existing compiled method's entry point
  • Claiming the loop restarts or that some iterations are re-executed
  • Forgetting that the compiled frame must be initialized from interpreter state at all
  • Describing OSR as replacing the caller's frame or unwinding to the caller
  • Assuming one nmethod serves both normal calls and OSR entry

context