With a fixed thread stack size, why does the same recursive Java method reach a different call depth from run to run, and typically a much greater depth once the JIT has compiled it?
answer
- depth ≈ stack bytes / frame bytes — both vary
- interpreted frame built from max_locals + max_stack
- compiled frame: registers, calling convention, smaller
- inlining deletes frames; no tail-call elimination
- guard pages + stack banging reserve part of the budget
basics
~20 sDepth is stack bytes divided by frame size, and frame size is not constant. Interpreted frames are laid out generically from max_locals/max_stack; compiled frames are smaller and inlining can erase frames entirely. Add variable startup depth, differing entry stack usage and OS-level stack placement, and the achievable depth varies each run.
solid answer
~60 sThe reachable depth is roughly *available stack bytes / bytes per frame*, and neither term is a constant. **Frame size varies by execution mode.** An interpreted frame is built generically from the method's `max_locals` and `max_stack` plus interpreter bookkeeping. Once C1/C2 compiles the method, values live in registers, the abstract operand stack largely disappears, and the frame follows the platform calling convention — usually noticeably smaller. More dramatically, **inlining removes frames outright**: an inlined callee contributes no frame at all, so a chain of small methods can collapse. **Depth varies run to run** because the method may be interpreted for the first thousands of invocations and compiled after, because on-stack replacement can switch mode mid-loop, because the frames already on the stack below the recursion differ, and because the OS and libraries do not place the stack at the same offset every time. So any measured maximum depth is an artifact of one run, not a property of the code — never build a design on a specific number.
code
text · 6 lines// javap -c -v of two recursive methods
int deep(int n) stack=2, locals=2 // slim frame
int deepWithScratch(int n) stack=6, locals=14 // several times the local slots
// -> fewer levels fit in the same -Xssgo deeper
Say that each call costs a frame, the stack is a fixed size, and different methods have differently sized frames, so no fixed depth number exists.
Explain that max_locals/max_stack shape the interpreted frame while compiled frames are leaner, and that -Xss is per thread and native memory.
Cover inlining removing frames, OSR and deoptimization changing stack usage mid-run, guard pages and stack banging, and why depth-sensitive failures fail to reproduce.
Treat input-driven recursion depth as a reliability and capacity issue: bound it or make it iterative, and weigh per-thread stack reservations against thread count and native memory budget instead of raising -Xss reflexively.
## Depth is a quotient, and both terms move A thread's stack is a fixed byte budget, set when the thread is created (`-Xss` / `-XX:ThreadStackSize`, or the `Thread` constructor's stack-size hint). The number of frames that fit is approximately: ``` depth ≈ (stack bytes − what is already used) / (average bytes per frame) ``` Candidates often assume the denominator is a constant per method. It is not, and the numerator is not fixed either. ## Why frame size differs: interpreter versus compiled code When a method runs in the **interpreter**, its frame is built to a generic template: a local variable array of `max_locals` slots, an operand stack area of `max_stack` slots (both numbers taken straight from the `Code` attribute), plus interpreter bookkeeping — a pointer to the method, the constant-pool cache reference, the saved caller state, monitor slots for a `synchronized` method. Two methods with wildly different `max_locals` therefore produce wildly different frames; depth depends on *which* methods are on the chain. Once a method becomes hot and C1 or C2 compiles it, the picture changes: - The abstract operand stack ceases to exist as storage; intermediate values live in machine registers. - Locals that fit in registers never touch memory; only spilled values need stack slots. - The frame follows the platform's native calling convention and carries only what the compiler decided it needed. The usual result is a substantially smaller frame — so the very same recursion goes deeper after warmup than it did during the first thousands of invocations. ## Inlining removes frames entirely The larger effect is **inlining**. When the compiler inlines a callee into a caller, the callee gets no frame at all; its locals are merged into the caller's frame (often into registers). A chain of five small delegating methods that cost five interpreted frames per level can, once compiled, cost one. For self-recursion the compiler will inline a bounded number of levels, further reducing frames-per-level. This is also why the achievable depth of a *mutually* recursive pair can change enormously once one of them becomes inlinable, and why a seemingly unrelated change — making a method larger, so it exceeds the inlining size threshold — can reduce the depth a program reaches. Note the JVM performs no general tail-call elimination: a self-call in tail position still consumes a frame in the specification model, and any saving comes from inlining or compiled-frame size, not from turning recursion into iteration. ## Why the numerator varies too - **Startup context.** The recursion does not begin at an empty stack. It starts below whatever frames already exist — a servlet container's dispatch chain, a test harness, a stream pipeline — and that prefix differs between contexts and versions. - **Mode changes mid-flight.** A method may start interpreted and be replaced by compiled code partway through a run; on-stack replacement can even switch a long-running loop's execution mode while its frame is live. Deoptimization goes the other way, rebuilding interpreter frames from a compiled frame — which *increases* stack usage at that moment. - **Placement and alignment.** The OS reserves the stack region and may randomize its address; alignment requirements, guard-page sizes and thread-library overhead shave a variable amount off the usable budget. - **Guard region.** HotSpot reserves guard pages at the stack's end and, on Java method entry, *bangs* the stack — touching an address one frame-size ahead — so that exhaustion is detected before the frame is built and can be reported and unwound cleanly. The reserved region is unavailable to frames, and its size is part of the budget arithmetic. ## What this means in practice 1. **Never encode a depth constant.** "We tested it to 12,000 levels" is a measurement of one build, one JDK, one warm-up state and one call context. Recursion over user-controlled input needs an explicit bound or an iterative formulation with an explicit worklist. 2. **Bigger `-Xss` costs native memory per thread.** The reservation is per thread and outside the heap, so raising it in a service with thousands of platform threads is a real memory decision, not a free knob. 3. **Expect non-reproducibility.** A depth-sensitive failure that appears in production and not in a short local run is often just the difference between interpreted and compiled frames, or a different framework prefix on the stack. 4. **Frame size is a per-method property you can inspect.** `max_locals` and `max_stack` from `javap -c -v` tell you the interpreted frame's shape; a method with a large number of locals is genuinely more expensive per level. ## Summary The stack budget is fixed; the cost per level is not. Interpreted frames are generic and larger, compiled frames are leaner, inlining can delete levels altogether, and the starting depth and usable region vary with context and platform. Any specific maximum depth is an observation about one execution, never a contract.
- If a recursion is in tail position, does the JVM avoid pushing a frame for it?No. HotSpot performs no general tail-call elimination, so a self-call in tail position still consumes a frame in the specification model. Any depth improvement you observe comes from the JIT inlining a bounded number of recursion levels or from compiled frames being smaller — not from turning the recursion into a loop. Code that must handle unbounded depth needs an explicit iterative formulation.
- Is raising -Xss a safe way to support deeper recursion in a server?It works, but the cost is per thread and in native memory outside the heap, so a service with thousands of platform threads multiplies the increase by the thread count and can exhaust native memory or hit OS limits. It is also a ceiling, not a fix: input-driven depth will find whatever new limit you set. Bounding the recursion or rewriting it with an explicit stack is the durable answer.
saying these in an interview costs you the question
- Treating maximum recursion depth as a fixed number per method or per JVM.
- Believing the JVM performs tail-call elimination for tail-recursive methods.
- Assuming interpreted and JIT-compiled frames are the same size.
- Thinking -Xss comes out of the heap or is a per-process rather than per-thread reservation.
- Ignoring that inlining can remove frames entirely, so the compiled frame count differs from the source call count.