Topic 14.2
JVM Memory Areas
In one line
A running JVM uses several separate memory areas: one shared heap for objects, one stack per thread for method frames, Metaspace for class metadata, a code cache for JIT-compiled code, and other native memory such as direct buffers and thread stacks. Each has its own limit, its own flag, and its own kind of OutOfMemoryError or StackOverflowError.
Think of it like this
An office building. Everyone shares the big warehouse in the basement where the goods are stored (the heap). Each worker has a personal desk with a pile of sticky notes, one note per task in progress, the newest on top (that worker's stack). The company handbook describing every job lives in the library (Metaspace). The speed-dial cards that experienced workers wrote for their most frequent tasks are kept in a card box (the code cache). And some things are rented in a storage unit across the street, outside the building's own floor plan (native and direct memory). Running out of space in any one of these is a different problem with a different fix.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Heap
- The shared memory area where every object and array lives. The garbage collector cleans it.
- Stack frame
- The block of memory for one method call in progress: its local variables, its working values, and where to return to.
- Metaspace
- Native memory where HotSpot keeps class metadata (field layouts, method bytecode, constant pools). Replaced PermGen in Java 8.
- Native memory
- Memory the JVM process gets straight from the operating system, outside the Java heap. The garbage collector doesn't manage it.
- Code cache
- The memory area where the JIT compiler stores the machine code it generates.
- Direct buffer
- A
ByteBufferwhose bytes live in native memory instead of abyte[]on the heap, so the operating system can read and write them without copying. - Object header
- The hidden bytes at the start of every object: lock and hash information plus a pointer to the object's class.
- Compressed oops
- Storing object references as 32-bit numbers instead of 64-bit ones, which saves memory when the heap is smaller than about 32 GB. "Oop" means ordinary object pointer.
- TLAB
- Thread-local allocation buffer: a chunk of the heap reserved for one thread, so it can create objects without coordinating with other threads.
Step by step
01The map of a running JVM
The JVM Specification (chapter 2.5) defines the run-time data areas: the pc register, JVM stacks and native method stacks (one per thread), and the heap, method area and run-time constant pool (shared). HotSpot adds the code cache and plenty of other native memory. The diagram shows which parts are shared and which belong to a single thread.
02Inside a stack frame
Compile static int add(int a, int b) { int sum = a + b; return sum; } and look at it with javap -c. The frame has local variable slots (0 = a, 1 = b, 2 = sum) and an operand stack: iload_0 pushes a, iload_1 pushes b, iadd pops both and pushes the sum, istore_2 stores it. A long or double takes two slots; an instance method's slot 0 is this.
The compiler computes each method's maximum stack depth and number of locals, so a frame's size is known before the method runs. That's why stack allocation is so fast: just move the stack pointer.
03Allocation: TLABs and bump pointers
Every thread gets a TLAB, a private slice of the young generation. new Point(1, 2) checks that the TLAB has 24 bytes left, advances a pointer by 24, writes the header and zeroes the fields. No lock is taken. When the TLAB is full the thread asks for a new one, and when the young generation is full a garbage collection starts (Topic 14.3).
Very large arrays skip the TLAB. In G1, an object of at least half a region is a humongous object allocated straight into contiguous old-generation regions, which is why huge short-lived arrays are expensive under G1.
04Object layout and what an object really costs
A Point { int x; int y; } is 12 bytes of header plus 8 bytes of fields = 20, padded to 24 bytes. An int[10] is 16 bytes of header and length plus 40 bytes = 56. An ArrayList<Integer> of 1,000 numbers holds 1,000 references (4 bytes each) plus 1,000 Integer objects (16 bytes each, except the cached ones) plus the list itself, roughly 20 KB for 4 KB of data.
The OpenJDK tool JOL (Java Object Layout) prints exact layouts. The sample output below is from a typical 64-bit JVM with compressed oops and class pointers.
05Which OutOfMemoryError is it?
The message after java.lang.OutOfMemoryError: tells you which area ran out, and each needs a different fix.
Java heap space: the heap is full of live objects (a leak, or the heap is too small). GC overhead limit exceeded (Parallel collector): the JVM spends nearly all its time collecting and frees almost nothing. Metaspace or Compressed class space: too many classes, usually a class-loader leak. Direct buffer memory: direct ByteBuffers exceed MaxDirectMemorySize. unable to create native thread: the OS refused another thread (process limits or memory). Requested array size exceeds VM limit: an array longer than the JVM allows, whatever the heap size.
06Sizing a JVM in a container
In a container with a 2 GB memory limit, the JVM's default max heap is 512 MB (25%). Many teams raise it with -XX:MaxRAMPercentage=75, leaving the rest for Metaspace, thread stacks, the code cache, direct buffers and GC structures. Setting -Xmx equal to the container limit is a classic mistake: the heap fits, the process doesn't, and the kernel's OOM killer ends it with exit code 137 and no Java error at all.
07Seeing native memory with NMT
Start the JVM with -XX:NativeMemoryTracking=summary (a small overhead), then ask it for a report. The sample output is abbreviated; numbers vary.
Try it yourself
- 1
Shrink the stack
On your own JDK, change the first example to print
depthitself, then run it withjava -Xss256k Main.javaand withjava -Xss4m Main.java. The depth changes roughly in proportion. Add a few extra local variables todiveand run again: bigger frames, fewer of them fit. - 2
Fill the heap on purpose
Write a loop that adds
new byte[1_000_000]to anArrayListforever and run it withjava -Xmx64m -XX:+HeapDumpOnOutOfMemoryError Main.java. Predict the message (Java heap space), then open the.hproffile it writes in a heap analyser such as Eclipse MAT or VisualVM and find theArrayListholding everything (Topic 14.5). - 3
Move the Integer cache
Run the third example with
java -XX:AutoBoxCacheMax=1000 Main.java. Predict line 2 first. (It becomestrue: HotSpot lets you raise the upper bound of theIntegercache, which is one more reason never to rely on==for boxed values.)
Code & diagrams
The exact depth depends on -Xss and frame sizes, so the program prints only whether it passed 1000. A StackOverflowError only affects the thread that hit it.
Expected output
each thread counted its own local to 5
shared box on the heap = 10
StackOverflowError caught, more than 1000 frames deep: true
stack unwound; main carries onHotSpot rejects an array this long before even looking at the heap size, so the message is the same on every machine. Catching OutOfMemoryError is for the demo only.
Expected output
OutOfMemoryError: Requested array size exceeds VM limit
heap buffer: direct=false, has byte[]=true
direct buffer: direct=true, has byte[]=false
total capacity: 2048 bytesInteger.valueOf returns shared objects for -128 to 127 (the cache is required by the JLS for that range), so == happens to work there and fails above it. Always compare boxed values with equals.
Expected output
127 == 127 (boxed): true
128 == 128 (boxed): false
128 equals 128: true
literal == folded literal: true
literal == runtime concat: false
literal == s3.intern(): true# heap
-Xms512m -Xmx2g # initial and maximum heap
-XX:MaxRAMPercentage=75 # max heap as % of container/host memory (JDK 10+)
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps
-XX:+ExitOnOutOfMemoryError # die fast so the orchestrator restarts you
# other areas
-Xss512k # per-thread stack size
-XX:MaxMetaspaceSize=256m # cap class metadata (unlimited by default)
-XX:ReservedCodeCacheSize=256m # JIT code cache
-XX:MaxDirectMemorySize=512m # direct ByteBuffers (default: same as max heap)
# look inside
-XX:NativeMemoryTracking=summary # then: jcmd <pid> VM.native_memory summaryBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Metaspace capped too low
Run a program that loads a few thousand classes, such as the javac compiler itself, with a tiny Metaspace cap.
Break #2
Heap as big as the container
Deploy with -Xmx2g in a container whose memory limit is 2 GiB.
Myth vs fact
Myth
Objects created inside a method live on the stack.
Fact
In Java every object is on the heap; only its reference is in the frame. The JIT may avoid the allocation completely with escape analysis (Topic 14.4), but that's an optimisation, not a rule you can rely on.
Myth
-Xmx is how much memory the JVM uses.
Fact
-Xmx limits only the heap. Metaspace, thread stacks, the code cache, direct buffers and the JVM's own structures come on top.
Myth
Static variables live in Metaspace (or PermGen).
Fact
In modern HotSpot, static field values live in the heap with the class's Class object. Metaspace holds the class's description, not its values.
Myth
A bigger heap is always faster.
Fact
A bigger heap means fewer collections, but each full scan has more to cover, and heaps over about 32 GB lose compressed oops so every reference grows to 8 bytes.
When it breaks
A service using Netty slowly grows in resident memory while the heap looks flat, until the container is killed.
What you see
Restarts every few days; heap dumps show nothing wrong because the growth is in direct buffers or other native memory.
Fix & prevent
Turn on NMT and compare summaries over time (jcmd <pid> VM.native_memory baseline, later summary.diff). Cap -XX:MaxDirectMemorySize, enable the library's leak detector, and make sure pooled buffers are released.
A batch job creating one platform thread per file hits OutOfMemoryError: unable to create native thread with plenty of heap free.
What you see
The job crashes on large inputs; raising -Xmx makes it worse because it leaves less memory for thread stacks.
Fix & prevent
Use a bounded pool (Topic 13.6) or virtual threads (Topic 13.10) instead of a thread per task; check ulimit -u and container PID limits.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Compressed oops store a 32-bit offset that the JVM shifts left by 3 (objects are 8-byte aligned), addressing 32 GB. With
-XX:ObjectAlignmentInBytes=16the limit doubles at the cost of more padding. Check withjava -Xmx40g -XX:+PrintFlagsFinal -version | grep UseCompressedOops. - ▸
Compact object headers (JEP 450, experimental in JDK 24, product option in JDK 25 via
-XX:+UseCompactObjectHeaders) shrink the header to 8 bytes by packing the class pointer into the mark word, cutting heap use for small-object-heavy apps. - ▸
The JIT may scalar-replace an object that never escapes a method: its fields become locals in registers and no heap allocation happens at all (Topic 14.4). This is why short-lived iterator and wrapper objects are often free in hot code.
- ▸
Thread stacks are reserved virtual memory, committed page by page as they're used, with guard pages at the end. That's how the JVM turns running off the end of the stack into a
StackOverflowErrorinstead of a crash. Very deep native or JNI frames can still crash the process.
Remember this
- 1
The heap holds every object and array, shared by all threads, and is managed by the garbage collector (Topic 14.3). Its size is set with
-Xms(initial) and-Xmx(maximum); without them the JVM picks defaults from the machine or container: a maximum of 1/4 of available memory (-XX:MaxRAMPercentage=25) and an initial size of 1/64. Since JDK 10 (and 8u191) the JVM reads container limits from cgroups, so in Docker or Kubernetes "available memory" means the container's limit. Static field values, the string pool (Topic 6.1) and theIntegercache are all ordinary heap objects too. - 2
Each thread has its own JVM stack of frames, one frame per method call in progress (Topic 3.11). A frame holds the method's local variables (primitives and references, never objects themselves), an operand stack that bytecode instructions push and pop, and a link back to the caller. Frames are created on call and thrown away on return, so stack memory needs no garbage collection. The size is fixed per thread with
-Xss(typically 1 MB by default on 64-bit Linux); recursion that is too deep throwsStackOverflowError. Each thread also has a program counter register and, for native code, a native stack. - 3
The JVM Specification calls the place for class data the method area; HotSpot implements it as Metaspace since Java 8, when it replaced the fixed-size PermGen (
-XX:MaxPermSizeis gone). Metaspace is native memory outside the heap and holds class structures, method bytecode, constant pools and profiling data. It grows on demand and is unlimited by default, so set-XX:MaxMetaspaceSizeto catch class-loader leaks (Topic 14.1). With compressed class pointers, class structures sit in a separate compressed class space (1 GB reserved by default,-XX:CompressedClassSpaceSize). - 4
The code cache holds machine code produced by the JIT (Topic 14.4); since JDK 9 it is split into segments for JVM-internal code, profiled code and fully optimised code. When it fills up the JIT stops compiling and logs
CodeCache is full. Compiler has been disabled., and performance quietly drops. The default reserved size with tiered compilation is 240 MB (-XX:ReservedCodeCacheSize). - 5
Outside all of those, the process also uses native memory for thread stacks (thread count ×
-Xss), direct buffers (ByteBuffer.allocateDirect, used by NIO and libraries like Netty; capped by-XX:MaxDirectMemorySize, which defaults to the max heap size), memory-mapped files, the garbage collector's own data structures, symbol tables and the JIT compilers' working memory. That's why a JVM with-Xmx2gcan easily use 3 GB of RAM, and why a container memory limit must leave room above-Xmx. Native Memory Tracking (-XX:NativeMemoryTracking=summaryplusjcmd <pid> VM.native_memory summary) shows the breakdown. - 6
Inside the heap, objects have a header before their fields. On 64-bit HotSpot it's a mark word (8 bytes: hash code, lock state, GC age) plus a class pointer (4 bytes with compressed class pointers), and arrays add a 4-byte length; every object is padded to a multiple of 8 bytes. So
new Object()takes 16 bytes and anInteger16 bytes to hold 4 bytes of data. Compressed oops store references as 32-bit values for heaps under about 32 GB, which is why a 31 GB heap can hold more objects than a 33 GB one. New objects are allocated by bumping a pointer inside the thread's private TLAB (thread-local allocation buffer), which makesnewabout as cheap as a few machine instructions.
Explain it without notes
Name the JVM's memory areas and what lives in each.
Your container keeps getting OOMKilled but the Java logs show no OutOfMemoryError. Why, and what do you do?
What changed when Metaspace replaced PermGen in Java 8?
Why does a HashMap<Integer, Integer> with a million entries use so much more memory than two int[] arrays of a million?
Practice
Write a program that recurses until StackOverflowError, catches it, and prints whether a method with more local variables reached a smaller depth than one with fewer (print only the boolean).
Show that Integer.valueOf(100) returns the same object every time but new Integer(100)-style allocation (use Integer.valueOf on 1000) does not.
Write a program that creates a direct buffer, writes the ints 1 to 4 into it and reads them back, printing the sum.
Trade-offs
- ↔
A larger heap reduces GC frequency but takes memory from everything else on the machine, and above about 32 GB loses compressed oops.
- ↔
Direct buffers avoid a copy for I/O but are slower to allocate, are freed only when their small heap object is collected, and are invisible to heap-size tuning.
- ↔
Smaller thread stacks (
-Xss) let you run more platform threads but make deep recursion fail sooner; virtual threads (Topic 13.10) sidestep the trade-off by storing their stacks on the heap.
Done when you can
Done when you can draw the JVM memory map and say which areas are per-thread and which are shared.
Done when you can read an OutOfMemoryError message and name the area and the likely fix.
Done when you can estimate an object's size from its header and fields.
Done when you can size a JVM for a container, leaving room for non-heap memory.
Done when you can use Native Memory Tracking to see where non-heap memory goes.