Runtime Data Areas: Heap, Stack, and Method Area Internals
Understanding JVM memory architecture: Heap, JVM Stack, Method Area, PC Registers, and Native Method Stacks - what they store and how they interact.
Understanding JVM memory architecture: Heap, JVM Stack, Method Area, PC Registers, and Native Method Stacks - what they store and how they interact.
Runtime Data Areas: Heap, Stack, and Method Area Internals
The JVM divides its memory into several runtime data areas. Each area has a specific purpose and lifecycle. Getting these areas wrong leads to memory leaks, StackOverflowError, or mysterious OutOfMemoryError messages that reference regions you did not know existed.
This post covers every memory region the JVM uses: which ones are shared across threads, which ones are private to each thread, which ones are garbage collected, and which ones grow independently of the heap.
Introduction
The JVM divides its available memory into distinct runtime data areas, each with a specific purpose, lifecycle, and behavior. The heap stores objects and arrays and is shared across all threads. The metaspace (Java 8+) stores class metadata in native memory outside the heap. Each thread gets its own private JVM stack for method frames, along with a program counter register. Native method stacks handle JNI code. Getting these areas wrong as a developer leads to the three most common JVM memory errors: OutOfMemoryError referencing the heap or metaspace, StackOverflowError from excessive recursion, and subtle performance degradation from improper heap sizing that triggers frequent garbage collection pauses.
Runtime data areas matter to practitioners for two reasons. The first is diagnosing production failures. When an OutOfMemoryError appears in logs, the message identifies which region is exhausted — “Heap Space” versus “Metaspace” versus “Direct Buffer Memory” — and the fix is completely different in each case. Heap exhaustion typically means a memory leak or inappropriate object retention; metaspace exhaustion means a classloader leak or excessive dynamic class generation; direct buffer exhaustion means too many NIO buffers created without release. Knowing which region is affected narrows the investigation immediately. The second reason is performance tuning, where understanding the generational hypothesis (most objects die young) informs why young generation sizing affects overallGC behavior, and why survivor space capacity prevents premature promotion of short-lived objects.
This post covers every runtime data area in detail: the heap and its young/old generation structure, the JVM stack and what each stack frame contains, metaspace versus the old PermGen and why the migration mattered, the PC register and native method stacks, and the production failure scenarios tied to each region. You will learn how to interpret OutOfMemoryError messages, what causes metaspace growth to appear unbounded, why direct ByteBuffers threaten native memory even when the heap has space, and how the generational hypothesis justifies the JVM’s default heap division.
When NOT to Use
Most developers never need to think about runtime data areas in detail. If you are writing business logic and hit an OutOfMemoryError, the error message tells you which region is exhausted. Start there, not with stack frame internals. The JVM defaults work fine for typical applications. Framework authors, library maintainers, and engineers debugging production memory leaks are the actual audience for this depth of detail.
Do not use runtime data area knowledge to optimize before you have measurements. Setting -Xms equal to -Xmx because you read that resizing adds GC overhead is premature if your heap is large enough that resizing almost never triggers. Tuning survivor ratios without GC logs showing actual promotion problems rarely helps. If you have StackOverflowError, the fix is usually finding and fixing infinite recursion, not adjusting -Xss.
Metaspace problems almost always trace to classloader leaks or excessive reflection, not undersized metaspace. Direct buffer memory issues come from NIO usage patterns that need auditing regardless of what you know about memory regions. Only go deep on this stuff when profiling data or production incidents actually demand it. Related topics like Execution Engine and JVM Startup and Shutdown come into play when memory issues overlap with JIT or lifecycle events.
Memory Region Overview
graph TD
subgraph "Heap (Shared across threads)"
Young["Young Generation<br/>Eden + Survivor Spaces"]
Old["Old Generation<br/>Tenured"]
end
subgraph "Method Area (Shared, per Class Loader)"
ClassMeta["Class Metadata"]
CodeCache["Code Cache<br/>(JIT compiled code)"]
InternStrings["Interned Strings"]
end
subgraph "Per-Thread Areas"
Stack["JVM Stack<br/>(Frames)"]
PCR["PC Register"]
NMS["Native Method Stack"]
end
Heap --> GC["Garbage Collector"]
ClassMeta --> Metaspace["Metaspace<br/>(Java 8+: Replaces PermGen)"]
The Heap
The heap is the runtime memory area where objects and arrays are stored. It is shared across all threads, which means race conditions apply when multiple threads allocate objects concurrently. The garbage collector manages this region, reclaiming memory from objects that are no longer reachable.
The heap is divided into generations. Young generation holds short-lived objects. Old generation holds long-lived objects that survived multiple young collections.
Heap Structure
The young generation is further divided into three regions: Eden and two Survivor spaces (S0 and S1). Eden is where every new object gets allocated, unless the allocation is too large to fit in the Survivor spaces directly. When Eden fills up, the garbage collector runs a minor GC and evicts all the dead objects from Eden in one pass. The survivors — objects still reachable but no longer needed by the application — get copied to whichever Survivor space is currently active. The other Survivor space sits empty. This copy-and-empty cycle is why minor GC is so fast: it is essentially a memcpy of live objects, not a scan of the entire Eden.
After a minor GC, surviving objects land in the active Survivor space and their age counter increments. Age tracks how many minor GCs an object has survived. Each time the object gets copied again (when Eden fills and the Survivor space it was in is needed as the destination), it moves to the other Survivor space and its age increases. When age hits the tenuring threshold, the object gets promoted to old generation during the next minor GC. The threshold is tunable with -XX:MaxTenuringThreshold (default varies by JVM, commonly 6-15). Setting it too low floods old generation with objects that should have died young. Setting it too high risks Survivor space overflow if allocation rate is high.
The Survivor spaces exist to solve a specific problem: premature promotion. Without them, any object that survives even one minor GC would go straight to old generation, regardless of whether it dies in the next collection 10 milliseconds later. That would destroy young generation collection efficiency because old generation is orders of magnitude larger and collecting it is orders of magnitude slower. Survivor spaces act as a staging area — a short-term holding zone where objects that are still alive but likely to die soon can wait out a few GC cycles before heading to old generation. If the Survivor spaces are too small, the JVM promotes objects to old generation not because they are old but because there is nowhere else to put them. This is the “premature promotion” failure mode, and GC logs will show it as abnormally high promotion rates during young collections.
Old Generation holds objects that have lived long enough. It is larger than young generation because most objects die young (the generational hypothesis). The old generation is collected less frequently but takes longer when it is collected.
Metaspace (Java 8+) replaced PermGen. It stores class metadata, method information, constant pools, and JIT compiled code. Unlike the heap, Metaspace uses native memory outside the garbage-collected regions. It can grow until the system runs out of native memory.
Heap Sizing
# Set initial and maximum heap size
-Xms2g -Xmx2g
# Set young generation size (absolute)
-Xmn512m
# Set young generation size (ratio, e.g., 1/3 of heap)
-XX:NewRatio=2
# Set Eden to Survivor ratio
-XX:SurvivorRatio=8
# Metaspace size limit (Java 8+)
-XX:MaxMetaspaceSize=256m
# Compressed class pointers (Java 8+)
-XX:+UseCompressedClassPointers
Setting -Xms equal to -Xmx eliminates heap resizing during runtime. Resizing adds GC overhead because the JVM must recalculate sizes and move objects.
JVM Stack
Each thread gets its own JVM stack. This is not the same as the heap. The stack stores method frames, not objects. Each frame contains local variables, operand stack, and reference to the constant pool of the class being executed.
The stack operates like a standard stack: push a frame when entering a method, pop it when exiting. StackOverflowError occurs when there is no room for a new frame, usually due to excessive recursion.
Stack Frame Contents
Local Variables Array holds method parameters and local variables. Primitive types take one slot. Object references take one slot (the slot holds the reference, not the object itself). Long and double types take two consecutive slots.
Operand Stack is a LIFO stack used during bytecode execution. Most bytecode instructions push values onto the operand stack or pop them off. The JVM uses this stack instead of registers for intermediate values and calculations.
Reference to Constant Pool points to the class constant pool, allowing the method to resolve symbolic references to other classes and methods.
public class StackFrameDemo {
// Example: local variable at index 0 is 'this' reference
// local variable at index 1 is int 'x'
public int instanceMethod(int x, int y) {
int result = x + y; // x at slot 1, y at slot 2, result at slot 3
return result;
}
}
Stack Depth and Thread Count
The -Xss flag sets the stack size for each thread the JVM creates. On HotSpot, the default is typically 1MB, which sounds generous until you remember that the JVM itself, the standard library, and most reflection-heavy frameworks push hundreds of stack frames before your application code ever runs. Deep recursion in your code competes with this baseline. When a thread starts, the JVM reserves the full -Xss size in virtual memory — not as committed physical pages, but as virtual address space. As frames are pushed and accessed, the OS commits physical pages on demand via page faults. This means a thread that never uses deep recursion still reserves the full stack size in address space, which matters in containerized environments where virtual memory limits are often set per process.
The practical implication: thread count and stack size multiply together to determine your total stack memory budget. A server with 200 threads at 1MB each has 200MB of stack reservation, most of it unused. That is fine if you have the memory to spare. But if you are running in a Kubernetes pod with a 512MB memory limit, 200 threads at 1MB each is 200MB just for stacks before the heap, metaspace, or code cache even factor in. This is why ThreadPoolExecutor with unbounded queue and too many threads is dangerous in containerized environments — not just from CPU contention but from memory pressure. The fix is not always to increase memory. Sometimes the fix is to use smaller stacks, a bounded thread pool, or virtual threads (Project Loom) which share memory at the OS level and consume only a few KB each.
You can reduce stack size with -Xss256k or increase it to -Xss2m, but reducing below what the JVM and your libraries need causes StackOverflowError during startup, not during your application logic. If you see StackOverflowError in production, check whether a library you added uses recursion internally before blaming your own code. jstack <pid> dumps thread stacks and can show you where frames are being consumed.
Method Area and Metaspace
The method area stores class-level data. In Java 8 and earlier, this was called PermGen. Starting with Java 9, PermGen was replaced by Metaspace, which uses native memory instead of heap memory.
What the Method Area Stores
Class metadata is not just a name and a list of methods. When the JVM loads a class, it creates a klass structure in native memory that describes everything about that class: the access flags, the superclass reference, the list of fields with their descriptors, and vtable (virtual method table) offsets for dispatch. This is what the JVM operates on internally — Java code sees this through reflection, but the JVM itself reads the raw metadata structures directly. The Class object you get from MyClass.class is a heap object that wraps a pointer to this native metadata. The metadata itself lives in Metaspace.
The constant pool is the most directly code-relevant part of the method area. Every bytecode instruction that references another class, method, field, or literal value does so through a constant pool index. When the JVM resolves a symbolic reference — say, invokevirtual #42 — it looks up index 42 in the constant pool, which points to the actual method or field. This resolution happens lazily or eagerly depending on JVM implementation, but the constant pool is the lookup table that makes dynamic linking work. String literals and numeric constants declared in the class also live here, as do values computed at load time like InvokeDynamic bootstrap methods. If you call String.intern() on a dynamically constructed string, that interned reference gets added to the runtime constant pool, which is why excessive interning can inflate Metaspace.
Field information is not just the field name. For each field, the JVM stores the field’s type in JVM descriptor format (e.g., I for int, Ljava/lang/String; for an object reference), the access flags, and the offset from the base of the object where this field lives. This offset is what the JVM’s oop (ordinary object pointer) machinery uses to find the field value at runtime. When you use Unsafe to manually read a field offset, you are reading directly from the object at that computed offset. Method information includes the bytecode itself — the raw bytecode[] array that the JIT compiler reads to generate native code. The exception table tells the JVM which PC offset to jump to when an exception is thrown from a given PC range, which is why exceptions have the performance cost they do: they involve walking this table and doing a non-linear jump.
Code Cache stores JIT compiled code. The JVM profiles executing bytecode and compiles hot methods to native code. This compiled code is stored in the code cache, which lives in Metaspace.
Metaspace vs. PermGen
| Aspect | PermGen (Java 8 and earlier) | Metaspace (Java 9+) |
|---|---|---|
| Memory Location | Heap | Native memory |
| Size Limit | Fixed at JVM startup | Grows dynamically |
| Default Size | Small (~80MB) | Unlimited (system memory) |
| OOM Name | java.lang.OutOfMemoryError: PermGen space | java.lang.OutOfMemoryError: Metaspace |
| Garbage Collection | Subject to heap GC | Automatically reclaimed when classloaders die |
PC Register
Each thread has a Program Counter register that holds the address of the currently executing JVM instruction. For native methods (methods written in C or C++ via JNI), the PC register is undefined.
The PC register enables thread scheduling. When a thread yields or is preempted, the JVM saves the PC register value so execution can resume at the correct instruction.
Native Method Stack
Native method stacks are analogous to JVM stacks but for native code. They store native method calls rather than Java bytecode. JNI code uses this stack to call native functions and receive callbacks from native code.
Some JVM implementations (like the HotSpot JVM) use the same stack for both Java bytecode and native code. Others use distinct stacks.
Production Failure Scenarios
| Failure | Root Cause | Symptoms | Fix |
|---|---|---|---|
| OutOfMemoryError: Heap Space | Memory leak or excessive object allocation | Heap fills up, GC thrashing | Heap dump, -Xmx increase |
| OutOfMemoryError: Metaspace | Classloader leak or dynamic class generation | Metaspace grows unbounded | Limit class generation, fix classloader leaks |
| OutOfMemoryError: Direct Buffer Memory | Excessive NIO buffer allocation | Native memory exhaustion | -XX:MaxDirectMemorySize, audit ByteBuffer usage |
| StackOverflowError | Deep recursion or infinite loop | Thread dies with stack trace | Fix recursion, increase -Xss |
| GC Overhead Limit Exceeded | Excessive GC with little reclaimable memory | Application slows dramatically | Heap analysis, fix memory leaks |
| Unable to Create New Native Thread | Too many threads or native memory exhausted | OutOfMemoryError: unable to create new native thread | Reduce thread count, increase native memory |
Trade-off Analysis
| Trade-off | Considerations |
|---|---|
| Heap Size vs. GC Frequency | Larger heap means fewer GC cycles but longer pauses when GC runs. Smaller heap triggers more frequent but shorter pauses. |
| Young vs. Old Generation Ratio | More young generation benefits short-lived objects. More old generation helps long-lived objects. The right ratio depends on object lifetime distribution. |
| Stack Size vs. Thread Count | Larger stacks allow deeper recursion but reduce the number of threads that can run simultaneously. |
| Metaspace vs. Heap Monitoring | Metaspace growth is less visible than heap growth. Native memory exhaustion affects the whole system. |
| Compressed Pointers vs. Address Space | Compressed class pointers save ~50% Metaspace but limit addressing. Disabling compression allows huge metaspace but wastes memory. |
Failure Scenarios Deep Dive
Young Generation Premature Promotion
The problem manifests in GC logs as promotion rate spikes during young collections. When you run with -Xlog:gc*:file=gc.log, look for lines like age 1: or age 2: counts in the minor GC output — these show object ages being promoted. If most of your live set is being promoted at age 1 or 2, the Survivor spaces are not providing their filtering function; objects are being pushed to old generation because there is no room in Survivor to hold them between minor GCs.
The most common culprit is an undersized young generation relative to the allocation rate. High-throughput services with large request buffers, batch jobs processing streams of records, and any workload that creates many short-lived intermediate objects will fill Eden fast. If Eden fills in seconds but the next minor GC does not run for 30 seconds (because old generation collection triggers differently), Survivor spaces get overwhelmed on the first minor GC and everything that survived gets promoted. The fix is either -Xmn to set an explicit young generation size or -XX:SurvivorRatio to increase Survivor space capacity relative to Eden. For example, -XX:SurvivorRatio=4 gives each Survivor space 1/4 of Eden’s size instead of the default 1/8.
A subtler version of this problem is when the allocation rate is not excessive but the Survivor spaces are too small relative to the live set that survives minor GC. Even if minor GCs run frequently, if the surviving objects from the last minor GC plus the new Eden allocations exceed Survivor capacity, promotion happens regardless. This is a survivor space sizing problem, not a young generation sizing problem. The distinction matters: -Xmn changes the entire young/old ratio; -XX:SurvivorRatio redistributes capacity within the young generation. You can have a correctly sized young generation that still suffers premature promotion because the Survivor spaces are too small.
Native Memory Leak from Direct ByteBuffers
ByteBuffer.allocateDirect() does not allocate native memory in the constructor. The constructor sets up the Java object and the internal structure, but the actual native memory allocation — the call to malloc or equivalent — happens on the first I/O operation that uses the buffer. This matters because the leak is not always from creating too many direct buffers. It is from creating them and then letting them become unreachable before the I/O operation that triggers the native allocation completes. The cleaner (a phantom reference-based cleanup mechanism) will eventually run and free the native memory when the DirectByteBuffer is GC’d, but only if the buffer becomes unreachable before the I/O completes. If your I/O operation holds a reference to the buffer while network I/O is in flight, the buffer cannot be collected until the I/O finishes. In a high-throughput system where NIO channels write to many short-lived direct buffers, you can create native memory pressure without creating an abnormal number of buffer objects, simply because each buffer’s native memory persists until its I/O completes.
To diagnose this with NMT, run jcmd <pid> VM.native_memory summary. The output shows a breakdown by category: Java heap, Metaspace, Code Cache, Symbol tables, Thread stacks, Direct buffers, and internal JVM allocations. Direct buffer memory appears under “Direct” and grows with ByteBuffer.allocateDirect() calls. The NMT output shows both reserved and committed native memory — a direct buffer may reserve more native memory than it has used yet, so look at committed, not just reserved. If “Direct” is growing steadily over time without corresponding growth in the number of live direct buffers, you have a leak. Taking NMT snapshots at regular intervals and diffing them (jcmd <pid> VM.native_memory summary.diff) isolates which category is growing.
The fix is either to limit total direct buffer memory with -XX:MaxDirectMemorySize=256m (the JVM will throw OutOfMemoryError: Direct buffer memory instead of crashing the process), audit your buffer lifecycle to ensure they are released after use, or switch to heap-based ByteBuffer.allocate() if your I/O patterns do not require native I/O performance.
Metaspace Fragmentation
Metaspace allocates memory in chunks from the OS. Each classloader gets its own chunk or set of chunks when it loads classes. When a classloader is garbage collected (because all references to it are gone), its chunks go back to the free list. The free list is managed by a simple allocator that searches for a chunk large enough to satisfy an allocation request. Over time, with many classloader load/unload cycles, the free list looks like a pincushion: many small free chunks scattered between used ones. The total free space may look fine — say, 50MB — but if the largest free chunk is 1MB and you need 2MB for a new class’s metadata, the allocation fails with OutOfMemoryError: Metaspace.
This is different from heap fragmentation because the heap has compaction (various GC algorithms do this), but Metaspace does not compact. Class metadata has fixed addresses in native memory — moving it would require updating every class that references the moved class, which is effectively the entire JVM. So Metaspace fragmentation is permanent within a running JVM process. The only way to recover is for classloader churn to stop and for existing free chunks to be coalesced, or for the JVM to request more native memory from the OS (which it will do until the system is out of native memory).
You can monitor this with NMT as well: jcmd <pid> VM.native_memory summary shows Metaspace usage. If committed Metaspace keeps growing but the number of loaded classes is stable, you are likely seeing fragmentation rather than genuine classloader leaks. Classloader leaks also cause growth, but in leaks the class count grows too; in fragmentation it does not. The practical fix for fragmentation is to reduce classloader churn (e.g., in OSGi environments or plugin frameworks where bundles are updated frequently), or accept periodic JVM restarts. There is no tuning flag to defragment Metaspace in place.
Implementation Patterns
// Diagnosing heap usage
public class HeapDiagnostics {
public static void main(String[] args) {
Runtime runtime = Runtime.getRuntime();
long maxMemory = runtime.maxMemory();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
System.out.printf("Max Memory: %d MB%n", maxMemory / 1024 / 1024);
System.out.printf("Total Memory: %d MB%n", totalMemory / 1024 / 1024);
System.out.printf("Used Memory: %d MB%n", usedMemory / 1024 / 1024);
System.out.printf("Free Memory: %d MB%n", freeMemory / 1024 / 1024);
}
}
// Stack frame inspection via bytecode
import java.lang.reflect.*;
public class StackFrameInspection {
public void inspectMethod(Class<?> clazz, String methodName) throws Exception {
Method[] methods = clazz.getDeclaredMethods();
for (Method m : methods) {
if (m.getName().equals(methodName)) {
System.out.println("Method: " + m.getName());
System.out.println(" Slot count: " + m.getParameterCount() + " params");
// Slot 0 is 'this' for instance methods
}
}
}
}
// Monitoring metaspace via MXBean
import java.lang.management.*;
import javax.management.*;
public class MetaspaceMonitor {
public static void monitor() {
try {
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
ObjectName name = new ObjectName("java.lang:type=MemoryPool,name=Metaspace");
MemoryUsage usage = (MemoryUsage) mbs.getAttribute(name, "Usage");
System.out.printf("Metaspace Used: %d MB%n",
usage.getUsed() / 1024 / 1024);
System.out.printf("Metaspace Committed: %d MB%n",
usage.getCommitted() / 1024 / 1024);
System.out.printf("Metaspace Max: %d MB%n",
usage.getMax() / 1024 / 1024);
} catch (Exception e) {
e.printStackTrace();
}
}
}
Observability Checklist
- Track heap usage over time: used vs. committed vs. max
- Monitor young vs. old generation allocation rates
- Watch Metaspace committed vs. used for classloader leaks
- Monitor code cache size for JIT compilation issues
- Track direct buffer memory usage if using NIO
- Enable GC logs:
-Xlog:gc*:file=/path/to/gc.log - Use
jmap -heapto capture heap histogram - Monitor thread stack memory consumption
- Watch for native memory exhaustion symptoms
Security Notes
The JVM enforces memory safety through the type system and access controls. Code running in one thread cannot directly read or write another thread’s stack frames. Access to heap objects is mediated through references, which the JVM validates.
However, native code (JNI) operates outside the JVM’s safety guarantees. JNI code can read and write arbitrary memory addresses. Never expose JNI interfaces to untrusted code.
Reflection can bypass normal access controls. The module system (Java 9+) restricts reflective access to internal APIs. Code that worked in Java 8 may fail in Java 9+ unless modules are explicitly opened.
Common Pitfalls / Anti-Patterns
Assuming heap is the only memory region that matters. Many developers focus exclusively on heap and forget Metaspace, code cache, direct buffers, and thread stacks. All of these can cause OutOfMemoryError.
Setting heap too small for the workload. Applications under memory pressure spend more time in GC, causing latency spikes and throughput degradation. Profile your application to determine realistic memory requirements.
Ignoring the young generation size. If young generation is too small, short-lived objects get promoted to old generation prematurely, accelerating old generation fill-up.
Assuming String.intern() is free. String.intern() stores strings in the constant pool (part of Metaspace/PermGen). Excessive interning fills Metaspace. Modern JVMs automatically intern string literals, so explicit intern() is rarely needed.
Not sizing stacks correctly for recursive code. StackOverflowError occurs when the stack cannot accommodate another frame. If your application uses deep recursion (even indirectly through library calls), you may need to increase stack size.
Quick Recap Checklist
- Heap stores objects, managed by GC
- Method Area/Metaspace stores class metadata, uses native memory
- Each thread has its own JVM Stack and PC Register
- Native Method Stack is for JNI code
- Heap is divided into young and old generations
- Young generation has Eden and two Survivor spaces
- Metaspace replaced PermGen in Java 8+
- Stack stores method frames, not objects
- StackOverflowError means recursion depth exceeded
- Metaspace exhaustion causes different OOM than heap exhaustion
- Direct buffers use native memory outside the heap
Interview Questions
The heap is divided into young generation (containing Eden and two Survivor spaces) and old generation. New objects start in Eden. After minor GC, surviving objects move to a Survivor space. Objects that survive enough GC cycles get promoted to old generation. This division exists because of the generational hypothesis: most objects die young. By focusing GC effort on the young generation, the JVM minimizes pause times and maximizes throughput. Old generation collection is less frequent because most objects do not live long enough to need it.
The heap stores objects and arrays. It is shared across all threads and garbage collected. The JVM stack stores method frames for each thread. Each frame contains local variables, operand stack, and a reference to the constant pool. Stacks are not garbage collected; they are allocated and deallocated as methods are called and return. Heap objects are accessed via references stored in stack variables. A thread cannot access another thread's stack directly.
In Java 8, PermGen was replaced by Metaspace. The key differences: PermGen lived in the heap and had a fixed maximum size configured at JVM startup. Metaspace uses native memory outside the heap, allowing it to grow dynamically until the system runs out of memory. This eliminated the common OutOfMemoryError: PermGen space errors that plagued applications using reflection, proxies, or dynamic class generation. However, it also meant Metaspace exhaustion could affect the entire system, not just the JVM process. GC now automatically reclaims Metaspace when classloaders die.
StackOverflowError occurs when the JVM stack cannot allocate a new frame, typically from infinite recursion or excessively deep recursion. The fix is to correct the recursive code: add a base case, convert recursion to iteration, or increase stack size with the -Xss flag. Before increasing stack size, consider whether the recursion is legitimate. If method A calls method B which calls method C which calls A again, you have a cycle. Stack size per thread multiplied by thread count determines total stack memory consumption.
A stack frame contains three components. The local variables array holds method parameters and local variables, indexed by slot number. Primitive values occupy one slot; object references occupy one slot; long and double occupy two consecutive slots. The operand stack is a LIFO stack used as a workspace during bytecode execution. Most bytecode instructions push values to or pop values from the operand stack. The reference to constant pool allows the method to resolve symbolic references to other classes and methods at runtime. When a method is called, a new frame is pushed. When it returns, the frame is popped.
The young generation is collected by Minor GC (also called young GC), which is stop-the-world but typically very fast (milliseconds). Objects start in Eden, and after surviving a minor GC, they move to a Survivor space. Objects that survive enough minor GCs (after reaching the tenuring threshold) are promoted to the old generation. Major GC or Full GC collects the old generation, which is larger and contains long-lived objects. Major GC is slower and more disruptive because the old generation can be hundreds of megabytes to several gigabytes. In G1, mixed GC collects both young and old regions. The generational hypothesis (most objects die young) justifies focusing GC effort on the young generation.
Compressed class pointers allow the JVM to use 32-bit offsets for class metadata references instead of 64-bit pointers, reducing Metaspace usage by approximately 50%. This optimization is enabled by default on JVMs with heap sizes under around 32GB. Each class's metadata (instance size, method table, vtable) is stored in Metaspace, and compressed pointers reference this metadata. When the heap exceeds ~32GB, the JVM disables compressed class pointers because 32-bit offsets can no longer address all possible memory locations. With compressed class pointers disabled, Metaspace usage increases and may require a larger MaxMetaspaceSize setting.
Metaspace grows unbounded when classloaders are retained in memory after their classes should have been unloaded. This typically happens through classloader leaks: static collections holding classloader references, ThreadLocal values pointing to classloaders, orJNI global references. Diagnostic steps: enable -XX:+TraceClassLoading to see class loading/unloading, use JMX to monitor Metaspace usage over time, and take heap dumps when Metaspace usage is elevated. Eclipse MAT or VisualVM can identify classloader instances with large retained sets. The fix is to ensure classloaders are properly released: remove references from ThreadLocals, clear static collections, and auditJNI global references.
Each thread has its own Program Counter register holding the address of the currently executing JVM instruction. When a thread is preempted (by the OS scheduler giving the CPU to another thread) or yields, the JVM saves the PC register value for that thread. When the thread later resumes execution, the JVM restores the saved PC value, allowing the thread to continue from exactly where it left off. For native methods, the PC register is undefined because native code executes outside the JVM's instruction set. The PC register effectively provides each thread with the ability to pause and resume execution without losing its place in the bytecode stream.
The constant pool is a runtime table that holds numeric and string constants and symbolic references to classes, methods, and fields. It is loaded from the class file constant pool and expanded at runtime to include dynamically generated constants (like String.intern() results). Bytecode instructions reference the constant pool by index to load literal values, access fields, invoke methods, and create objects. Without the constant pool, bytecode would need to embed raw values inline, making class files larger and updates harder. The constant pool also enables dynamic linking by deferring the resolution of symbolic references until runtime.
Eden is where new objects are initially allocated. When Eden fills, a minor GC runs and surviving objects (those still referenced) are copied to a Survivor space. Objects that survive multiple minor GCs get promoted to old generation. The two Survivor spaces (S0 and S1) alternate as the destination for surviving objects—one is always empty. This arrangement allows the GC to reclaim Eden and one Survivor space in each minor GC. Without Survivor spaces, every surviving object would be promoted directly to old generation, overwhelming it with short-lived objects. The Survivor spaces act as a filtering mechanism before tenuring.
The tenuring threshold determines how many minor GCs an object must survive before being promoted to old generation. When an object is copied from Eden to a Survivor space, it has an age counter. After each minor GC that the object survives, the age increments. When the age reaches the tenuring threshold (default varies by JVM, often 6-15), the object is eligible for promotion to old generation. Setting the threshold low promotes objects faster, reducing Survivor space pressure but filling old generation sooner. Setting it high keeps objects in young generation longer, which is beneficial if most die young but can cause Survivor overflow if allocation rate is high.
TLABs are pre-allocated heap regions for each thread to use for object allocation, reducing contention on the heap's allocation bitmap. Without TLABs, every allocation would require atomic operations on shared heap structures, creating contention in multi-threaded applications. With TLABs, each thread allocates into its private region, writing only the pointer at the end (which requires minimal synchronization). When a thread's TLAB fills, it requests a new one from the Eden space. TLAB size is determined by thread count and heap size, with defaults around 1MB for larger heaps. This allocation path compression makes object allocation nearly as fast as stack allocation.
Direct ByteBuffers (ByteBuffer.allocateDirect()) allocate native memory outside the JVM heap. They grow when applications create many direct buffers without releasing them—the cleaner does not run immediately when the buffer becomes unreachable, and the native memory may not be freed until a forced full GC or explicit invocation. Unlike heap memory, direct buffer memory is not automatically managed by the GC, meaning it can cause native OOM even when heap has space. Monitoring with Native Memory Tracking (NMT) via jcmd VM.native_memory summary shows direct buffer usage. The -XX:MaxDirectMemorySize flag limits total direct buffer allocation.
The method area (or Metaspace in Java 8+) is the memory region that stores class metadata. This includes the class name, superclass name, modifiers, and package; the list of fields, methods, and constructors with their attributes; field descriptors and method descriptors; constant pools; and JIT compiled code. Each loaded class has its metadata stored here. The metadata is organized in the native memory of Metaspace as classld (class loader data) structures linked in a hierarchy mirroring the classloader hierarchy. When a classloader is garbage collected, its classes' metadata is reclaimed.
The JVM allocates stack memory for each thread at thread creation time, not on-demand. The -Xss flag (or -XX:ThreadStackSize) sets the stack size in bytes, with typical defaults of 1MB. If the OS cannot allocate the requested stack size (insufficient virtual memory, especially in containerized environments with memory limits), thread creation fails with an OutOfMemoryError. The actual committed stack memory grows over time as frames are pushed (guarded by page faults), but the virtual memory reservation happens upfront. Larger stacks allow deeper recursion but reduce the number of threads that can run simultaneously under memory constraints.
Old generation and tenured generation are the same thing—different terminology for the region holding long-lived objects that have survived multiple young generation collections. In G1 GC, the equivalent is the "old regions." Objects promoted from Survivor spaces during minor GC are copied to the old generation. The old generation is larger than the young generation (typically 2-3x ratio) because most objects die young, so only long-lived objects need the larger space. Major GC or Full GC reclaims memory from the old generation, which takes longer than minor GC because of the larger size and potential fragmentation.
Native method stack overflow occurs when native code (via JNI) pushes too many frames on the native stack, analogous to StackOverflowError for JVM stacks. In HotSpot, the native method stack and JVM stack share the same OS thread stack, so native and Java call depth are both limited by the -Xss setting. When a JNI call chain is too deep, the same StackOverflowError can be thrown even though the Java code using the stack is valid. Some JVMs (like those for embedded systems) use separate native stacks. The fix is to either limit JNI call depth or increase -Xss, though the latter is rarely the right solution for deep JNI chains.
The JVM aligns objects to 8-byte boundaries by default (configurable with -XX:ObjectAlignmentInBytes). An object with 7 bytes of fields actually consumes 8 bytes, wasting 1 byte. A 15-byte object wastes 1 byte; a 16-byte object wastes 0. This padding means smaller objects may consume more memory than their field totals suggest. Alignment enables faster CPU access (aligned loads/stores are cheaper) but creates internal fragmentation. ObjectAlignmentInBytes of 16 reduces fragmentation for larger objects but wastes more for small ones. Understanding alignment helps when profiling memory with tools like JOL (Java Object Layout).
The code cache stores JIT-compiled native code—hot methods translated from bytecode to machine instructions. It is part of Metaspace and uses native memory. The code cache has a fixed size limit (controlled by -XX:ReservedCodeCacheSize); when full, JIT compilation stops for methods not yet compiled, causing the JVM to fall back to interpretation for those methods. This manifests as performance degradation after warmup instead of peak performance. Monitoring code cache usage with jstat -printcompilation or JMX helps identify when the cache is filling up. The default size scales with heap and GC algorithm, but applications with many hot methods may need larger code caches.
Further Reading
- JVM Architecture Overview - How runtime data areas fit into the JVM
- Class Loader Subsystem - How classes are loaded into Metaspace
- Execution Engine - How bytecode uses the data areas and triggers GC
- JVM Startup and Shutdown - How data areas are initialized during bootstrap
- Advanced Java & JVM Internals Roadmap - Structured learning path
Conclusion
The JVM divides memory into shared areas (Heap, Metaspace) and per-thread areas (JVM Stack, PC Register, Native Method Stack). The Heap stores objects; Metaspace stores class metadata in native memory; each thread has its own stack for method frames. Understanding these regions helps diagnose OutOfMemoryError in heap vs. Metaspace, StackOverflowError from recursion, and performance issues from improper sizing of young/old generations.
Category
Related Posts
Heap Walking and Allocation Tracking: TLABs and Heap Analysis
Understand how the JVM allocates memory with TLABs, how to track allocations with low overhead, and how heap walking tools analyze object graphs.
JVM Architecture Overview: Understanding the Java Virtual Machine
A deep dive into the JVM architecture covering Class Loader, Runtime Data Areas, and Execution Engine components that power Java applications.
Async-Profiler: Low-Overhead CPU and Memory Profiling
Learn async-profiler for low-overhead CPU and memory profiling in production. Generate flame graphs, analyze allocations, and diagnose JVM bottlenecks.