ArchitectMind
  1. Home
  2. Tutorials
  3. Book 2 · JVM, Concurrency & Performance
  4. Volume II
  5. JVM Stack
Book 2 · Volume II — JVM Memory, Object Model & Garbage Collection · Topic 1.2

JVM Stack

Thread stack, frames, local variables, operand stack and StackOverflowError

8 min readStage 1 · Foundations7 interview questions
Prerequisites
B2V1 4.5 Operand Stack · B2V1 5.4 Stack Frames
Covers
Thread stack · Frames · Local variables · Operand stack · StackOverflowError
Related
1.3 Program Counter · 3.3 GC Roots · 7.2 OOM Types
On this page
  1. 1. Concept / Definition
  2. 2. Why It Exists
  3. 3. Core Concepts
  4. 4. How It Works Internally
  5. 5. APIs / Features / Syntax
  6. 6. Practical Examples
  7. 7. Common Problems & Mistakes
  8. 8. Performance & Production Considerations
  9. 9. Architecture & Design Considerations
  10. 10. Interview Questions & Answers

1Concept / Definition

Each Java thread has a private JVM stack, created together with the thread (JVMS §2.5.2). It stores one frame per method invocation that has not yet completed. A frame holds the method's local variables, its operand stack and data needed to return and to handle exceptions. The stack's maximum size is fixed at thread creation; a call that needs more space throws StackOverflowError.

One stack per thread, one frame per active call
Figure 1 — One stack per thread, one frame per active call

2Why It Exists

Method invocation needs per-call storage that is created and discarded extremely quickly, in strict last-in-first-out order, and that is private to the executing thread. A stack provides exactly that: pushing and popping a frame is a pointer adjustment, no garbage collection is involved, and thread confinement makes local variables inherently thread-safe.

3Core Concepts

Thread stack

Placement

Platform threads map one-to-one to OS threads. HotSpot places the JVM stack on the OS thread's native stack, so Java frames and native frames (JNI, VM runtime code) are interleaved on the same memory.

Size

SettingEffect
-Xss / -XX:ThreadStackSizedefault stack size for new threads (1 MB on 64-bit Linux and macOS)
Thread.ofPlatform().stackSize(n)per-thread request (a hint the JVM may round)
VM and compiler threadsseparate settings (VMThreadStackSize, CompilerThreadStackSize)

The stack size is reserved address space; pages are committed as the stack actually grows. Deep stacks therefore cost memory only when used — but reservations still count toward virtual-memory limits and thousands of threads multiply the committed portion.

Virtual threads

Virtual threads (Java 21) are not tied to an OS thread's stack. While running, their frames live on a carrier thread's stack; when they block, the frames are copied into heap-allocated stack chunks and restored later. This is why millions of virtual threads are affordable: an idle virtual thread's stack is ordinary, compactly sized heap data.

Stack sizing facts
Figure 2 — Stack sizing facts

Frames

Creation and destruction

A frame is pushed when a method is invoked and popped when it completes — normally (return) or abruptly (an uncaught exception propagates to the caller). Only the top frame, the current frame, is active; the frames below are suspended callers.

Interpreted vs compiled frames

  • Interpreted frames have the layout defined by the method's max_locals and max_stack.
  • Compiled frames (C1/C2) have a layout chosen by the compiler; values live in registers or spill slots. Inlined callees do not get separate frames — the JVM reconstructs virtual frames from debug information when needed (stack traces, deoptimisation).
Contents of an interpreted frame
Figure 3 — Contents of an interpreted frame

Local variables

The local variable array holds this (slot 0 for instance methods), parameters and locals. Primitives are stored by value; objects are stored as references — the objects themselves are on the heap. Consequences:

  • Locals are thread-confined and need no synchronisation; the objects they reference may still be shared.
  • Active local references are GC roots (3.3). The JIT computes precise liveness, so an object no longer used later in a compiled method can be collected before the method returns.
  • Setting locals to null to "help the GC" is unnecessary in compiled code.

Operand stack

A per-frame LIFO work area used by bytecode instructions for intermediate values and for passing arguments to calls (B2V1 4.5). Its maximum depth (max_stack) is computed by javac and checked by the verifier. In compiled code operand values live in registers; for memory purposes the operand stack contributes to frame size and, in interpreted code, to the set of GC roots.

StackOverflowError

Detection

HotSpot protects the end of each thread stack with guard zones (yellow/red zones, plus a reserved zone used to let critical JDK sections finish — JEP 270). When a frame would extend into the guard zone, the access faults, the JVM converts it into a StackOverflowError thrown in that thread, and temporarily enables the yellow zone so that exception handling itself has stack space.

Stack exhaustion reaches the guard zone
Figure 4 — Stack exhaustion reaches the guard zone

Typical causes

  • Unbounded recursion (missing base case, cyclic data).
  • Legitimately deep recursion on large inputs: recursive-descent parsers, tree/graph traversals, deeply nested JSON/XML.
  • Accidental mutual recursion: cyclic toString/equals/hashCode on bidirectional entity relations, framework proxy or interceptor loops.
  • Very large frames (many locals) combined with moderate depth.

Handling

StackOverflowError is an Error: catching it is legal, but the thread may have been interrupted in the middle of updating shared state. Treat it as a bug to fix — bound depth, convert recursion to iteration, or run known-deep work on a dedicated thread with a larger stack.

4How It Works Internally

  • Frames are laid out contiguously toward lower addresses on x86-64 and AArch64; the stack pointer and frame pointer registers identify the current frame.
  • Stack walking (exceptions, StackWalker, profilers, GC root scanning) uses frame metadata; for compiled code, stack maps / oop maps record which slots hold references at each safepoint.
  • The GC scans every thread stack at safepoints to find roots; deep stacks increase root-scanning time (ZGC scans stacks concurrently using stack watermarks).
  • Thread creation reserves the stack via mmap/pthread; failure to reserve or commit it produces OutOfMemoryError: unable to create native thread (7.2).

5APIs / Features / Syntax

JAVAConfiguring and inspecting stacks
# Default stack size and per-thread overrides
$ java -Xss512k -jar app.jar  # smaller stacks for many shallow threads
// 64 MB for a deep parser
Thread t = Thread.ofPlatform().name("parser").stackSize(64L << 20).start(task);

# Inspect stacks of a live JVM
$ jcmd <pid> Thread.print | head -60
# includes virtual threads (JDK 21+)
$ jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json

// StackWalker: cheap, lazy stack inspection
StackWalker.getInstance().walk(frames -> frames.limit(5)
        .map(f -> f.getClassName() + "." + f.getMethodName() + ":" + f.getLineNumber())
        .toList());

6Practical Examples

JAVARecursion depth and accidental recursion
// Recursive depth computation → StackOverflowError on degenerate (linked-list-shaped) trees
int depth(Node n) { return n == null ? 0 : 1 + Math.max(depth(n.left), depth(n.right)); }

// Iterative version with a heap-allocated stack (bounded only by heap)
int depthIterative(Node root) {
    record Item(Node node, int d) {}
    Deque<Item> stack = new ArrayDeque<>();
    int max = 0;
    if (root != null) stack.push(new Item(root, 1));
    while (!stack.isEmpty()) {
        Item it = stack.pop();
        max = Math.max(max, it.d());
        if (it.node().left != null)  stack.push(new Item(it.node().left, it.d() + 1));
        if (it.node().right != null) stack.push(new Item(it.node().right, it.d() + 1));
    }
    return max;
}

// Cyclic toString with bidirectional relations (e.g. Order <-> Customer)
// Customer prints orders → infinite
@Override public String toString() { return "Order[" + id + ", customer=" + customer + "]"; }
// Fix: print identifiers only: "customerId=" + customer.getId()

7Common Problems & Mistakes

MistakeConsequenceBetter approach
Raising -Xss globally for one deep algorithmmemory × thread countdedicated thread or iteration
Catching StackOverflowError and continuinginconsistent statefail the operation; fix depth
Cyclic toString/equals/hashCodeSOE in logging or collectionsexclude back-references
Thousands of platform threadsstack memory and scheduling overheadvirtual threads / bounded pools
Deeply nested user input parsed recursivelySOE → potential DoSenforce nesting limits

8Performance & Production Considerations

  • Stack memory = committed stack pages × threads; check the 'Thread' category in NMT.
  • Very deep call chains (framework layers, proxies) increase frame count, inlining pressure and root-scanning time.
  • Thread dumps are the primary tool for diagnosing hangs; long stacks make them harder to read but also reveal layering costs.
  • Parsers of untrusted input should enforce depth limits (Jackson StreamReadConstraints.maxNestingDepth, XML parser limits).

9Architecture & Design Considerations

Stack characteristics influence concurrency architecture: thread-per-request with platform threads caps concurrency by stack memory and OS limits, while virtual threads remove that ceiling by moving idle stacks to the heap. For input-driven recursion, depth limits are part of the security design (denial-of-service protection), not just correctness.

10Interview Questions & Answers

Q1KnowledgeConcept

What is stored on the JVM stack?

The JVM stack holds one frame for every method invocation on that thread that has not yet completed. Each frame contains the local variable array, with this in slot 0 for instance methods followed by parameters and locals; the operand stack that bytecode uses for intermediate values; and frame data for returning to the caller and handling exceptions. Primitives are stored by value, while objects are stored only as references; the objects themselves always live on the heap. Frames are pushed on invocation and popped on return or when an exception propagates, so the stack needs no garbage collection. In compiled code, inlined methods share one physical frame and values often live in registers.

Q2UnderstandingInternals

What causes StackOverflowError, and how is it detected?

It is thrown when a thread needs a new frame and its fixed-size stack has no room left. The usual cause is unbounded recursion, such as a missing base case or a cyclic toString, equals or hashCode across bidirectional entities, but legitimately deep recursion on large or deeply nested input causes it too. HotSpot detects it with guard zones at the end of each stack: protected pages that fault when touched. The JVM converts the fault into a StackOverflowError in that thread and temporarily releases the yellow zone so that exception handling has some stack to work with. A reserved zone also lets critical JDK sections, such as lock code, complete safely.

Q3UnderstandingTricky

Are objects allocated on the stack in Java?

Not semantically. The Java language and JVM specifications place every object on the heap, and frames hold only primitives and references to those objects. However, C2's escape analysis can prove that an object never escapes the compiled method and its inlined callees. It then applies scalar replacement: the object's fields become locals held in registers or stack slots and the allocation disappears entirely, which has the effect of stack allocation and also removes GC pressure. This only happens in hot, compiled code and is defeated when a callee is too large to inline or the object escapes on any path. So the correct answer is no, but the JIT often eliminates short-lived allocations.

Q4ApplicationCode

How do you change the stack size?

Globally with -Xss or the equivalent -XX:ThreadStackSize, which sets the default for new platform threads; the default is 1 MB on 64-bit Linux and macOS. Per thread with Thread.ofPlatform().stackSize(bytes) or the stackSize constructor argument, which the JVM treats as a hint and may round. Prefer the per-thread option for an isolated deep workload such as a parser, because a global increase multiplies by every thread in the process, including pool and framework threads. Stack size is reserved address space and committed only as it is used, but committed pages across thousands of threads still add up. JVM-internal threads have their own settings, such as VMThreadStackSize and CompilerThreadStackSize.

Q5ApplicationConcept

Why are local variables thread-safe?

Because each method invocation gets its own frame on the stack of the thread executing it, and no other thread can access that frame. Two threads calling the same method concurrently have two independent copies of every local variable and parameter, so primitives in locals never need synchronisation. The guarantee covers only the variable itself. If a local holds a reference to an object that is also reachable through a field, a collection or a captured lambda, other threads can see and modify that object, so it needs the usual synchronisation or immutability. This is why methods that work only with locals scale safely, and why letting a local object escape removes that safety.

Q6Senior EngineerInternals

How do virtual threads store their stacks?

A virtual thread does not own an OS stack. While mounted, its frames run on the carrier platform thread's native stack exactly like normal frames. When it blocks or parks, the JVM freezes it: the frames are copied into heap-allocated StackChunk objects attached to its continuation, and the carrier is freed for other work. When it resumes, possibly on another carrier, the frames are thawed back lazily, often only the top few at first, with more copied as methods return. An idle virtual thread therefore costs heap memory proportional to its actual stack depth, typically hundreds of bytes to a few kilobytes, rather than a megabyte reservation, which is why millions are affordable.

Q7Technical ArchitectDesign

How would you protect a service from StackOverflowError caused by user input?

Treat input depth as a security limit, not just a correctness issue, because a deeply nested JSON or XML document can crash request threads cheaply and become a denial-of-service attack. First, enforce nesting limits in parsers, for example Jackson's StreamReadConstraints maxNestingDepth and XML parser limits, and check depth in your own recursive validators. Second, use iterative algorithms with an explicit Deque for structures of unbounded depth such as trees and graphs. Where deep recursion is legitimate, run it on a dedicated thread with a larger stack instead of raising -Xss globally. Finally, include worst-case nested inputs in tests and fuzzing, and reject the request cleanly rather than catching StackOverflowError.

Newsletter

Get new tutorials every week

New ArchitectMind tutorials and interview questions, once a week. No spam, unsubscribe anytime.

Almost done! Check your inbox and click the confirmation link to complete your subscription.