Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

JVM Memory Areas: Heap, Stack, Metaspace, Direct

Every region the JVM allocates, which are per-thread and which are shared, why the heap is generational, where Metaspace lives since Java 8, and why total process memory always exceeds Xmx.

Intermediate6 min readUpdated
On this page

Knowing which region a piece of data lives in is what turns an OutOfMemoryError message into a diagnosis. It is also the foundation for every question about garbage collection, container sizing and memory leaks.

Key Takeaways

  • Per thread: the stack, the program counter, and native method stacks. Shared: the heap, Metaspace and the code cache.
  • The heap is split into young (eden + two survivors) and old, because most objects die young.
  • Metaspace replaced PermGen in Java 8 and lives in native memory, growing on demand.
  • Direct buffers are off-heap, allocated by ByteBuffer.allocateDirect and NIO, and freed only when their wrapper is collected.
  • Process memory ≈ heap + Metaspace + code cache + (threads × stack) + GC overhead + direct + native.

The map

Only the green box is bounded by Xmx. Everything in the blue box is additional, which is why containers need headroom.

Per-thread regions

The stack. One per thread, holding a frame per active method call. A frame contains the local variable array, the operand stack for bytecode evaluation, and a reference to the constant pool. Frames are pushed and popped with the calls, so nothing here needs garbage collection.

what lives where
public void process() {
    int count = 0;                       // the int itself: on the stack
    Order order = new Order();           // the reference: stack. The object: heap.
    String name = order.customerName();  // reference on the stack, String on the heap
}                                        // frame popped; the Order becomes unreachable

Default stack size is about 1MB on 64-bit HotSpot, set with -Xss. Exceeding it — usually through unbounded recursion — throws StackOverflowError. Reducing -Xss to 256k lets you run several times more threads, at the cost of shallower recursion; on a service with thousands of threads that trade is sometimes worth making, though virtual threads are now the better answer.

Program counter. A tiny per-thread register holding the address of the current instruction. It is listed for completeness; nothing ever goes wrong with it.

The heap

Shared by every thread, and the only region the garbage collector manages. It is generational because of an empirical observation, the weak generational hypothesis: the overwhelming majority of objects become garbage almost immediately, and objects that survive a while tend to keep surviving.

the layout
|<----------- young generation ----------->|<-------- old generation -------->|
|   eden (~80%)   | S0 (~10%) | S1 (~10%)  |                                  |

Allocation happens in eden, by simply bumping a pointer — a handful of instructions, cheaper than malloc. When eden fills, a minor GC copies the few live objects into a survivor space and clears eden wholesale. Objects that survive enough minor collections (-XX:MaxTenuringThreshold, 15 by default) are promoted to the old generation, which is collected less often and more expensively.

Two allocation details worth knowing. Each thread gets a TLAB (thread-local allocation buffer), a private slice of eden, so allocation needs no synchronisation at all. And an object too large for a TLAB or for eden is allocated directly in the old generation, which is how a few very large arrays can trigger old-generation pressure without any promotion.

G1, ZGC and Shenandoah divide the heap into regions rather than contiguous generations, but the young/old distinction still applies logically — see Garbage collectors compared.

Metaspace

Class metadata — the runtime representation of classes, method bytecode, field and method descriptors, the constant pool — lives in Metaspace since Java 8. Before that it was PermGen, a fixed-size region inside the heap, and java.lang.OutOfMemoryError: PermGen space was one of the most common failures in application servers that redeployed WARs.

the flags that still matter
-XX:MaxMetaspaceSize=256m     # bound it — the default is unlimited
-XX:MetaspaceSize=128m        # initial high-water mark before the first metaspace GC

Metaspace grows on demand from native memory, which removed the tuning problem and replaced it with a subtler one: a classloader leak no longer fails fast with a clear message. Instead the process grows until the kernel or the container kills it, which produces no Java stack trace at all. Setting MaxMetaspaceSize is what converts that into a diagnosable OutOfMemoryError: Metaspace.

The code cache

Machine code produced by the JIT compiler. Default maximum is 240MB, tunable with -XX:ReservedCodeCacheSize. If it fills, the JIT switches off entirely and prints "CodeCache is full. Compiler has been disabled" — after which the application falls back to interpretation and becomes several times slower while looking otherwise healthy.

It is rare, and it does happen in very large applications and in those that generate many classes at runtime through proxies or scripting. Worth knowing as a diagnosis for "the service got slow and nothing in the profile explains it".

Direct and mapped memory

off-heap allocation
ByteBuffer heapBuffer   = ByteBuffer.allocate(1024);        // on the Java heap
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);  // native memory, off-heap
 
MappedByteBuffer mapped = channel.map(READ_ONLY, 0, size);  // memory-mapped file

Direct buffers exist so that I/O can hand a memory address straight to the operating system without copying out of the Java heap first, which is why every NIO channel, Netty and most high-performance serialisation libraries use them.

The catch is deallocation. A direct buffer's native memory is freed when its Java wrapper object is garbage-collected — so a buffer that stays reachable, or one whose collection is delayed because the heap is nowhere near full, keeps holding native memory. -XX:MaxDirectMemorySize bounds the total and defaults to the value of -Xmx, which surprises people: a 4GB heap implicitly permits another 4GB of direct buffers.

OutOfMemoryError: Direct buffer memory means that limit was reached, and the usual cause is buffers being allocated per request rather than pooled.

Accounting for the whole process

a 4GB container, roughly
-Xmx2g                      2048 MB   Java heap
Metaspace                    150 MB   after class loading settles
Code cache                   100 MB
200 threads × 1MB stack      200 MB   reserved, less committed
GC structures                100 MB   ~5% of heap for G1
Direct buffers               256 MB   Netty, JDBC drivers
JVM native / malloc          150 MB
                            -------
                            ~3000 MB   resident, against a 2GB heap

This is the arithmetic behind every "why did Kubernetes kill my pod?" question. The container limit must cover the whole table, not just -Xmx — see JVM flags and container limits.

measuring it rather than guessing
java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory summary

Native Memory Tracking breaks the process down by category — Java heap, class, thread, code, GC, compiler, internal, symbol — and is the only reliable way to find out where non-heap memory has gone. It costs roughly 5–10% overhead, so enable it when investigating rather than permanently.

What gets asked

The reliable opener is "explain the JVM memory model" or "heap versus stack". Answer with the per-thread and shared split, then the generational heap, then name the native regions. The question that separates candidates is the follow-up: "your container has a 2GB limit and -Xmx is 2GB — what happens?" The answer is that the process gets OOMKilled by the kernel with no Java error, because everything outside the heap is unaccounted for.

Frequently Asked Questions

Why does my Java process use more memory than Xmx?
Because Xmx bounds only the Java heap. On top of it the process holds Metaspace, code cache, thread stacks at about 1MB each, GC data structures, direct byte buffers, compiler scratch space and the JVM own C++ structures. A rule of thumb is that resident memory runs 25 to 50 percent above the heap, which is exactly why a container limit set equal to Xmx gets OOMKilled.
What is the difference between the stack and the heap?
Each thread has a private stack holding frames for its method calls — local variables, references and the return address — reclaimed automatically when a method returns. The heap is shared by all threads, holds every object, and is managed by the garbage collector. A local variable of an object type lives on the stack; the object it points to lives on the heap.
Did Metaspace remove the possibility of running out of class metadata space?
No, it changed where you run out. PermGen was a fixed region inside the heap and defaulted to about 64 to 256MB. Metaspace is in native memory and grows on demand, so a classloader leak now consumes machine memory until the OS or the container kills the process. Setting MaxMetaspaceSize converts that into a diagnosable OutOfMemoryError instead.

Related tutorials