Software Engineer · Java · Spring Boot · Microservices
JVM Memory Areas Deep Dive: Heap, Metaspace, Stack & Off-Heap Memory
Every OutOfMemoryError in production points to a specific JVM memory area — but engineers who haven't studied these areas spend hours guessing the cause. Understanding the heap's generational layout, how Metaspace replaced PermGen, what goes on a JVM stack frame, and how off-heap direct buffers sit completely outside garbage collection gives you a systematic map for diagnosing and fixing memory issues confidently, the first time.
Last updated: April 1, 2026
Table of Contents
- Why JVM Memory Areas Matter for Production Java Engineers
- The Heap: Eden, Survivor Spaces, and Old Generation
- Metaspace: Class Metadata Storage After Java 8
- JVM Stack: Stack Frames, Local Variables, and Operand Stack
- Program Counter Register and Native Method Stack
- Off-Heap Memory: DirectByteBuffer and Unsafe
- OOM Error Types and Their Memory Area
- Key Takeaways
- Conclusion
1. Why JVM Memory Areas Matter for Production Java Engineers
The JVM does not have a single monolithic block of memory. It divides the runtime memory it manages into several distinct areas, each with a different lifecycle, ownership model, and garbage-collection strategy. When something goes wrong — an application slows to a crawl under GC pressure, or crashes with java.lang.OutOfMemoryError — the error message itself encodes which memory area is exhausted:
- Java heap space — the object heap is full; long-lived objects are accumulating.
- GC overhead limit exceeded — GC is spending >98% of CPU time recovering <2% heap.
- Metaspace — class metadata is unbounded; classloader leak suspected.
- Direct buffer memory — NIO off-heap pool exhausted.
- unable to create new native thread — OS-level thread stack memory exhausted.
A senior Java engineer reading one of these messages immediately knows which JVM flag to inspect, which profiler view to open, and which diagnostic command to run. That fluency is only possible when you have a precise mental model of every memory area. The sections that follow build that model from first principles, with practical JVM flags and code examples throughout.
The JVM specification defines five runtime data areas: the heap, the method area (implemented as Metaspace since Java 8), the JVM stack (one per thread), the PC register (one per thread), and the native method stack (one per thread). Beyond those, the JVM also manages off-heap direct memory accessible via NIO APIs — technically outside the JVM specification but critically important for any engineer building high-throughput I/O systems.
2. The Heap: Eden, Survivor Spaces, and Old Generation
The heap is the largest and most important JVM memory area. It is created at JVM startup, shared by all threads, and is where all object instances and arrays are allocated. The heap is the domain of the garbage collector, which reclaims memory occupied by objects that are no longer reachable.
HotSpot's default collectors (G1GC from Java 9+, with Parallel GC as fallback) organize the heap into generations, based on the generational hypothesis: most objects die young. The young generation handles new allocations and is collected frequently (minor GC). The old generation holds long-lived objects and is collected infrequently (major/full GC).
Young Generation: Eden and Survivor Spaces
The young generation is split into three sub-regions:
- Eden space — where all new object allocations happen. Each thread has its own Thread-Local Allocation Buffer (TLAB) carved from Eden, which allows lock-free bump-pointer allocation within the TLAB boundary.
- Survivor Space S0 (From) — objects that survived one minor GC live here.
- Survivor Space S1 (To) — the destination for objects being copied during the next minor GC. S0 and S1 swap roles each collection cycle.
During a minor GC, live objects in Eden and the active Survivor space are copied to the empty Survivor space (compacting them in the process). Each surviving copy has its age counter incremented. When an object's age exceeds -XX:MaxTenuringThreshold (default 15 for G1), it is promoted to the old generation. This is called tenuring.
// Object allocation and generational promotion demonstration
public class GenerationalDemo {
static List<byte[]> longLived = new ArrayList<>();
public static void main(String[] args) throws Exception {
// Short-lived objects — die in Eden, never promoted
for (int i = 0; i < 100_000; i++) {
byte[] shortLived = new byte[1024]; // 1 KB, allocated in Eden via TLAB
// shortLived goes out of scope immediately → reclaimed at next minor GC
}
// Long-lived objects — survive multiple minor GCs → promoted to Old Gen
for (int i = 0; i < 50; i++) {
longLived.add(new byte[512 * 1024]); // 512 KB, held by GC root
Thread.sleep(50);
}
System.out.println("Long-lived objects tenured to Old Generation.");
}
}
// Relevant JVM flags to run with:
// java -Xms256m -Xmx512m -Xmn128m -XX:SurvivorRatio=8
// -XX:MaxTenuringThreshold=5 -XX:+PrintGCDetails GenerationalDemo
//
// -Xms256m : initial heap size
// -Xmx512m : maximum heap size
// -Xmn128m : young generation size (Eden + both Survivors)
// -XX:SurvivorRatio=8 : Eden : Survivor = 8:1 (each Survivor is 1/10 of young gen)
// -XX:NewRatio=2 : Old Gen : Young Gen = 2:1 (alternative to -Xmn)
// -XX:MaxTenuringThreshold=5 : promote after surviving 5 minor GCs
-XX:G1HeapRegionSize). Regions are dynamically assigned roles: Eden, Survivor, Old, or Humongous (for objects >50% of a region). G1 collects the regions with the most garbage first — hence “Garbage First.” This allows G1 to hit pause-time goals (-XX:MaxGCPauseMillis=200) while handling heaps from 4 GB to hundreds of GB. In containerized workloads with cgroup memory limits, always set both -Xms and -Xmx to the same value to prevent over-commitment and avoid dynamic heap resize pauses.
3. Metaspace: Class Metadata Storage After Java 8
Before Java 8, the JVM stored class metadata (class structures, method bytecode, constant pool, annotations, field and method descriptors) in a fixed-size heap region called PermGen (Permanent Generation), sized with -XX:MaxPermSize. Engineers routinely hit java.lang.OutOfMemoryError: PermGen space in application servers deploying multiple WARs.
Java 8 replaced PermGen with Metaspace. The key differences:
- Metaspace is allocated from native (OS) memory, not from the Java heap. It grows and shrinks dynamically.
- Without
-XX:MaxMetaspaceSize, Metaspace can grow until the OS refuses further allocation — potentially consuming all available native memory. - Metaspace is reclaimed when a ClassLoader is garbage collected, which happens only when no live references remain to any class it loaded.
What lives in Metaspace: class structures (Klass objects in HotSpot), method metadata, constant pools, annotations, bytecode (in some JVM implementations), and vtables. String literals and interned strings moved to the heap in Java 7+, not Metaspace.
// Dynamic class generation causing Metaspace growth
// (Simulates classloader leaks common in OSGi, Spring proxies, Groovy/JRuby scripts)
import net.bytebuddy.ByteBuddy;
public class MetaspaceLeakDemo {
public static void main(String[] args) throws Exception {
long count = 0;
while (true) {
// Each iteration creates a new ClassLoader and a dynamically generated class
// The ClassLoader is never explicitly released
ClassLoader leakyLoader = new ByteBuddy()
.subclass(Object.class)
.name("com.example.Dynamic$" + count++)
.make()
.load(MetaspaceLeakDemo.class.getClassLoader(),
ClassLoadingStrategy.Default.WRAPPER) // new ClassLoader each time
.getLoaded()
.getClassLoader();
if (count % 1000 == 0) {
System.out.println("Classes loaded: " + count);
// Monitor with: jcmd <pid> VM.metaspace
}
}
// Eventually throws: java.lang.OutOfMemoryError: Metaspace
}
}
// Protective JVM flags:
// -XX:MetaspaceSize=128m : initial commit size (triggers GC when first reached)
// -XX:MaxMetaspaceSize=256m : hard cap — prevents OS memory exhaustion
// -XX:+UseGCOverheadLimit : also triggers if GC can't recover Metaspace fast enough
// Diagnose live Metaspace usage:
// jcmd <pid> VM.metaspace
// jstat -gcmetacapacity <pid> 1000
-XX:MaxMetaspaceSize in application servers and monitor with jcmd <pid> VM.metaspace after each redeploy to detect growth trends before they become incidents.
4. JVM Stack: Stack Frames, Local Variables, and Operand Stack
The JVM creates a private JVM stack for each thread at the moment the thread is created. The stack is not shared — it belongs exclusively to its thread. It stores stack frames, one frame per method invocation. When a method is called, a new frame is pushed; when it returns (normally or via exception), the frame is popped.
Each stack frame contains three logical components:
- Local Variable Array — holds the method's parameters and local variables. Index 0 in non-static methods is
this.longanddoubleconsume two slots. - Operand Stack — a LIFO stack used as a scratchpad during bytecode execution. Arithmetic, method invocations, and type conversions push/pop values here.
- Frame Data — a reference to the runtime constant pool of the class (for symbolic resolution) and the method's exception table for structured exception handling.
The JVM stack size per thread is controlled by -Xss. The default varies: 512 KB on some 32-bit VMs, 512 KB–1 MB on 64-bit HotSpot, and 256 KB on some container-optimized builds. When recursive calls exceed the stack's capacity, the JVM throws java.lang.StackOverflowError.
// StackOverflowError demonstration with unbounded recursion
public class StackDemo {
static int depth = 0;
// Each call pushes a new stack frame containing: this, depth (local), operand stack
public static void recurse() {
depth++;
recurse(); // no base case → StackOverflowError
}
public static void main(String[] args) {
try {
recurse();
} catch (StackOverflowError e) {
System.out.println("StackOverflowError at depth: " + depth);
// Typical output with -Xss512k: ~5,000–8,000 frames
// With -Xss8m: ~70,000–100,000 frames
}
}
}
// Controlling stack size:
// java -Xss256k StackDemo → fewer frames, lower memory per thread
// java -Xss8m StackDemo → more frames, higher memory per thread
//
// IMPORTANT: For 1,000 threads with -Xss1m, the JVM reserves ~1 GB just for stacks.
// In high-concurrency servers, keep -Xss as small as your deepest call stack requires.
// Virtual Threads (Project Loom, Java 21+) — a game changer:
// Platform thread stack: 1 MB reserved upfront (committed on demand)
// Virtual thread stack: starts at ~1 KB, grows as needed (heap-backed)
// You can run 1,000,000+ virtual threads without exhausting native memory.
Thread virtualThread = Thread.ofVirtual()
.name("vt-worker-1")
.start(() -> System.out.println("Virtual thread stack starts at ~1 KB"));
virtualThread.join();
Virtual threads (introduced as a preview in Java 19, GA in Java 21 via Project Loom) revolutionize JVM stack economics. A virtual thread's stack is stored on the heap as a linked list of stack chunk objects, not in native memory. It starts at approximately 1 KB and grows lazily only as deep as the actual call stack requires. When a virtual thread blocks (e.g., waiting on I/O), its stack is unmounted from the carrier platform thread and serialized back to the heap, freeing the carrier thread to run other virtual threads. This eliminates the per-thread memory reservation that makes platform threads expensive at scale.
5. Program Counter Register and Native Method Stack
The Program Counter (PC) Register is the smallest and simplest JVM memory area. Each JVM thread has its own PC register. At any given moment, a thread is executing the instructions of exactly one method — its current method. The PC register holds the address (bytecode offset) of the JVM instruction currently being executed by that thread.
When the thread is executing a Java method, the PC register contains the offset into the bytecode of the current instruction. When the thread is executing a native method (a method declared with the native keyword and implemented via JNI), the PC register's value is undefined — native code executes at the OS/CPU level, not inside the JVM bytecode interpreter, so there is no JVM bytecode address to track.
The PC register is internal to the JVM and is not directly accessible from Java code. It is updated by the execution engine on every instruction dispatch. Its role is analogous to the instruction pointer (RIP/EIP) in x86 processors — but at the JVM bytecode level rather than native machine code level.
The Native Method Stack is the companion to the JVM stack for native method execution. When a Java thread calls a method declared native, the JVM hands off execution to native code through the Java Native Interface (JNI). The native method runs on a C/C++ call stack — the native method stack — which uses the platform's native stack rather than the JVM's managed stack frames. If a native method causes a C-level stack overflow, the behaviour is platform-specific; the JVM typically maps this to a StackOverflowError thrown into the calling Java thread.
JVM implementations that do not support native methods (e.g., a hypothetical embedded JVM) need not implement the native method stack. HotSpot uses the same native stack for both native method calls and its own internal C++ VM code — in practice, the platform thread's OS stack serves as the native method stack.
6. Off-Heap Memory: DirectByteBuffer and Unsafe
Off-heap memory (also called direct memory) is native OS memory that the JVM allocates outside the managed heap. It is not subject to garbage collection pauses because no GC algorithm needs to scan or compact it. Off-heap data persists until explicitly freed, making it ideal for large, long-lived data structures where GC pause predictability matters more than automatic memory management.
The primary Java API for off-heap allocation is java.nio.ByteBuffer.allocateDirect(). This allocates a DirectByteBuffer backed by native memory, wired to a Cleaner (a phantom-reference-based mechanism) that frees the native memory when the ByteBuffer object becomes unreachable and the Cleaner fires.
import java.lang.ref.Cleaner;
import java.nio.ByteBuffer;
public class DirectBufferDemo {
// Allocate 256 MB of off-heap direct memory (not counted by -Xmx)
private static final int DIRECT_SIZE = 256 * 1024 * 1024;
public static void main(String[] args) {
// allocateDirect() allocates native memory, not heap memory
ByteBuffer directBuf = ByteBuffer.allocateDirect(DIRECT_SIZE);
System.out.println("isDirect: " + directBuf.isDirect()); // true
System.out.println("capacity: " + directBuf.capacity()); // 268435456
// Write and read — zero-copy DMA transfer possible from OS kernel
directBuf.putInt(0, 42);
int value = directBuf.getInt(0);
System.out.println("Read back: " + value); // 42
// Explicit release (Java 9+) — do NOT wait for GC/Cleaner in tight loops
if (directBuf instanceof sun.nio.ch.DirectBuffer db) {
db.cleaner().clean(); // immediately returns native memory to OS
}
// Heap buffer for comparison — lives on GC-managed heap
ByteBuffer heapBuf = ByteBuffer.allocate(DIRECT_SIZE);
System.out.println("Heap isDirect: " + heapBuf.isDirect()); // false
}
}
// Off-heap sizing flag:
// -XX:MaxDirectMemorySize=512m : caps total allocateDirect() across the JVM
// Default: equal to -Xmx if not set
//
// Monitor off-heap usage:
// jcmd <pid> VM.native_memory summary
// -XX:NativeMemoryTracking=summary (add to JVM flags, then use jcmd)
//
// OOM from off-heap exhaustion:
// java.lang.OutOfMemoryError: Direct buffer memory
Beyond NIO's DirectByteBuffer, sun.misc.Unsafe (and its successor java.lang.foreign.MemorySegment from Project Panama, GA in Java 22) can allocate arbitrary native memory blocks. Serialization frameworks like Kryo and Chronicle Map, messaging systems like Aeron, and in-process caches like Ehcache off-heap store use these APIs to maintain gigabytes of data outside the heap — preventing GC from ever having to scan them.
7. OOM Error Types and Their Memory Area
Every OutOfMemoryError variant maps to a specific JVM memory area. The table below provides a systematic reference for rapid triage in production incidents:
When any OOM occurs in production, the first step is always to capture a heap dump. Enable automatic capture with:
# JVM flags for automatic heap dump on OOM (add to your startup script)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
# Trigger a live heap dump manually without crashing the JVM:
jcmd <pid> GC.heap_dump /var/log/app/live-heapdump.hprof
# Or with jmap (legacy, may require attaching agent):
jmap -dump:format=b,file=/var/log/app/heap.hprof <pid>
# For Metaspace OOM — diagnose classloader growth:
jcmd <pid> VM.metaspace
jstat -gcmetacapacity <pid> 1000 10
# For off-heap / native memory OOM — enable NMT and check summary:
# (Must be enabled at JVM startup, not dynamically)
# Add to JVM flags: -XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary
# For thread count OOM — check active threads:
jcmd <pid> Thread.print | grep "^\"" | wc -l
# Or via jstack:
jstack <pid> | grep "^\"" | wc -l
8. Key Takeaways
- The JVM has five memory areas: heap, method area (Metaspace), JVM stack (per-thread), PC register (per-thread), and native method stack (per-thread). Off-heap direct memory sits outside the JVM spec but is managed by the JVM process.
- The heap's generational layout (Eden → Survivor → Old Gen) is driven by the generational hypothesis. TLAB allocation makes object creation nearly free in the Eden space.
- G1GC replaces fixed-size generations with dynamically assigned regions and targets configurable pause-time goals with
-XX:MaxGCPauseMillis. - Metaspace grows into native memory without a hard cap unless you set
-XX:MaxMetaspaceSize. ClassLoader leaks in application servers are the primary cause of runaway Metaspace growth. - Every thread has its own JVM stack of frames. The
-Xssflag controls stack size. Virtual threads (Java 21+) store their stacks on the heap (~1 KB initial), enabling millions of concurrent threads without proportional native memory cost. - The PC register tracks the current bytecode instruction per thread. It is undefined during native method execution.
- Off-heap direct buffers (
ByteBuffer.allocateDirect()) bypass GC entirely. Cap them with-XX:MaxDirectMemorySizeand release explicitly in tight allocation loops. - Every
OutOfMemoryErrormessage pinpoints a specific memory area. Use-XX:+HeapDumpOnOutOfMemoryErroruniversally, and supplement withjcmdfor Metaspace and native memory diagnostics.
9. Conclusion
JVM memory areas are not academic trivia — they are the operational map that lets you diagnose production incidents, tune JVM flags with confidence, and design systems that scale without GC pathology. When you know that OutOfMemoryError: Metaspace points to a ClassLoader leak rather than a heap sizing problem, you skip hours of wrong debugging. When you know that virtual thread stacks are heap-backed, you design concurrency models that scale to millions of concurrent tasks on commodity hardware.
The memory model is also the foundation for deeper JVM topics: garbage collector internals depend on heap layout, JIT compilation and OSR depend on the stack frame structure, and the Panama Foreign Function API extends off-heap access into a safe, first-class language feature. Master the memory areas, and these advanced topics become natural progressions rather than intimidating new domains.
Bookmark the OOM table in Section 7, add -XX:+HeapDumpOnOutOfMemoryError and -XX:MaxMetaspaceSize to every service you deploy, and explore the related posts below to go deeper into GC tuning and heap analysis.
Related Posts
Java GC Tuning: G1GC, ZGC, and Shenandoah in Production
Master JVM garbage collection tuning: G1GC region sizing, ZGC pause targets, Shenandoah concurrent GC, and GC log analysis...
Java Heap Dump Analysis: Finding Memory Leaks with MAT and VisualVM
Step-by-step guide to Java heap dump analysis: capturing dumps, finding memory leaks with Eclipse MAT, dominator trees...
JVM Architecture Deep Dive: ClassLoader Subsystem, Runtime Data Areas & Execution Engine
Master JVM architecture: ClassLoader subsystem, runtime data areas, and execution engine internals for every Java engineer...