JVM Heap Memory: Young Gen, Old Gen, Metaspace, and Object Headers
A deep dive into JVM heap memory organization including Young Generation, Old Generation, Metaspace, and object header internals for performance optimization.
JVM Heap Memory: Young Gen, Old Gen, Metaspace, and Object Headers
If you have ever stared at a OutOfMemoryError: Heap Space stack trace and had no idea where to start, you are not alone. The JVM heap looks simple from the outside - just memory for objects - but dig a little deeper and you will find a carefully engineered layout designed to make garbage collection faster and more predictable. This post walks through how the heap is actually organized, what Metaspace is doing in native memory, and how object headers work at the bit level.
Introduction
The JVM heap is the managed memory space where every Java object lives, yet its internal organization is something most developers never think about until things go wrong. The heap is split into generations—Young Generation for short-lived objects and Old Generation for long-lived survivors—each with different garbage collection strategies and performance characteristics. Metaspace lives separately in native memory, storing class metadata that many developers confuse with heap memory when they see an OutOfMemoryError: Metaspace. Understanding this layout is not academic: it directly explains why OutOfMemoryError: Heap Space behaves differently from OutOfMemoryError: Metaspace, why minor GC reclaims most objects while major GC takes longer, and why tuning SurvivorRatio changes application performance in ways that seem counterintuitive.
Object headers are the hidden metadata bolted onto every object—typically 12 bytes on a 64-bit JVM with Compressed OOPs. The mark word stores hash codes, age, and lock state; the klass pointer points to class metadata in Metaspace. These internals matter when you are staring at a heap dump, trying to understand why a 17-byte object actually consumes 24 bytes, or when the JVM is spending unexpected CPU time on GC betweensafepoint operations.
This post walks through heap organization in detail: how objects flow from Eden to Survivor spaces to Old Generation, what actually lives in Metaspace, and how object headers work at the bit level. You will learn to read GC logs in context of the heap layout, size heaps appropriately for your workload, and understand what the JVM is actually doing when it complains about memory.
When to Use This Knowledge
Use when:
- Diagnosing
OutOfMemoryError: Heap Spaceerrors - Tuning garbage collector settings for specific workloads
- Analyzing memory dumps (heap dumps, hprof files)
- Optimizing applications with large object graphs or high allocation rates
- Choosing between different GC algorithms based on heap behavior
Do not use when:
- Writing simple applications with predictable memory usage
- Using managed languages/platforms that abstract away memory details
- Debugging issues unrelated to memory (e.g., CPU bottlenecks)
When NOT to Use This Knowledge
If you are working on short-lived applications with predictable allocation patterns, the JVM defaults handle memory management fine. Tuning SurvivorRatio and tenuring thresholds for a batch job that runs once daily and exits is premature optimization that adds complexity without measurable benefit.
Most applications never need custom heap tuning. Modern GC collectors like G1 and ZGC self-tune effectively for most workloads. If you are not hitting memory errors in production, the default heap settings are probably fine.
In managed environments like Kubernetes, or when using a platform-as-a-service provider that abstracts memory configuration, deep heap knowledge has limited practical value. Focus on application-level concerns like algorithm efficiency and avoiding unnecessary allocations instead. For continued learning on JVM tuning, explore the Advanced Java & JVM Internals roadmap.
JVM Heap Memory Architecture
The heap is split into a few distinct regions, each handling a different phase of an object’s life.
graph TB
subgraph JVMHeap["JVM Heap Memory"]
subgraph YoungGen["Young Generation"]
subgraph Eden["Eden Space"]
E["Objects allocated here"]
end
subgraph SurvivorS["Survivor Space S0"]
S0["Survivors after minor GC"]
end
subgraph SurvivorS1["Survivor Space S1"]
S1["Survivors after minor GC"]
end
end
subgraph OldGen["Old Generation"]
OG["Long-lived objects promoted from Young Gen"]
end
end
subgraph NativeMemory["Native Memory"]
MS["Metaspace - Class Metadata"]
CCS["Compressed Class Space"]
TH["Thread Stacks"]
DM["Direct Memory Buffers"]
end
E -->|"Minor GC| Aging"| S0
E -->|"Minor GC| Aging"| S1
S0 -->|"After 15 GCs"| OG
S1 -->|"After 15 GCs"| OG
class E eden
class S0,S1 survivor
class OG old
class MS,CCS,TH,DM native
Young Generation Details
New objects land in the Young Generation. It has three parts:
- Eden Space: Where allocations start. Most objects die here before they ever get promoted anywhere.
- Survivor Spaces (S0 and S1): Two regions that hold objects that survive a minor GC. Objects cycle between S0 and S1, getting older with each pass - this is called “aging.”
Object Allocation Flow
- New object allocated in Eden space
- Minor GC runs - live objects are copied to S0 (or S1)
- Objects in S0 age and are copied to S1 during next minor GC
- This ping-pong process continues
- Objects that reach the tenuring threshold (default: 15) are promoted to Old Generation
The tenuring threshold is configurable via -XX:MaxTenuringThreshold=N where N ranges from 1 to 15 (or 65535 with UseAdaptiveSizePolicy).
Old Generation Details
Once objects have aged out in the Survivor spaces, they end up here. The Old Generation (sometimes called Tenured Generation) is where long-lived objects go to retire.
Characteristics:
- Larger than Young Generation, usually 2-3x
- GC runs less often here, but when it does, it takes longer
- Supports different GC algorithms than Young Generation
- Objects here tend to stick around
When objects get promoted:
- They outgrow the Survivor spaces
- They hit the tenuring threshold (default: 15 minor GCs)
- They are large enough to bypass Young Generation entirely (with
-XX:PretenureSizeThreshold)
Metaspace vs Heap
Metaspace is not on the heap at all. It lives in native memory, which trips up a lot of people who see OutOfMemoryError: Metaspace and assume they need to bump -Xmx.
graph LR
subgraph NativeMemory["Native Memory"]
MS["Metaspace"]
CCS["Compressed Class Space"]
TS["Thread Stacks"]
end
class MS,CCS,TS native
Metaspace
Metaspace holds:
- Class metadata (class name, modifiers, field info, method signatures)
- Constant pools
- JIT compiled code
- Internal JVM structures
Heap vs Metaspace at a glance:
| Aspect | Heap | Metaspace |
|---|---|---|
| What lives here | Object instances | Class metadata |
| Where | Java managed memory | Native memory |
| GC | Regular heap GC | Own metadata GC |
| OOM type | Heap Space | Metaspace |
| Resize | Via -Xmx | Grows by default, capped by OS |
Metaspace configuration:
-XX:MetaspaceSize- Initial size before first GC-XX:MaxMetaspaceSize- Maximum (unlimited by default)-XX:MinMetaspaceFreeRatio- Minimum free space after GC
Compressed Class Space
64-bit JVMs with Compressed OOPs need a separate space for class metadata that can be compressed. Set with -XX:CompressedClassSpaceSize (default: 1GB).
Object Header Layout
Every object has a header bolted onto the front of it. Most people never think about this until they are staring at a heap dump or using a tool like JOL.
Mark Word (64 bits / 8 bytes on 64-bit JVM)
|-----------------------|--------------|-------------|
| Hash Code | Age | Lock State |
| (25 bits) | (4 bits) | (2 bits) |
|------------------------------------------------------|
| Metadata |
| (23 bits) |
|------------------------------------------------------|
| Object Reference |
| (64 bits) |
|------------------------------------------------------|
The mark word stores:
- Identity hash code (computed on demand)
- Age (minor GCs survived)
- Lock state (unlocked, biased, thin, or fat)
- GC metadata
Klass Pointer (64 bits / 8 bytes on 64-bit JVM with Compressed OOPs)
Points to the class metadata in Metaspace. With Compressed Object Pointers (OOPs), this is 4 bytes (32 bits).
Array Length (32 bits / 4 bytes) - Arrays only
Just the length. Arrays get this extra field; regular objects do not.
Total Object Header Size
| JVM Mode | Object Header Size |
|---|---|
| 32-bit JVM | 8 bytes (mark) + 4 bytes (klass) = 12 bytes |
| 64-bit JVM | 8 bytes (mark) + 8 bytes (klass) = 16 bytes |
| 64-bit with Compressed OOPs | 8 bytes (mark) + 4 bytes (klass) = 12 bytes |
| Array (64-bit with OOPs) | 8 + 4 + 4 = 16 bytes |
Production Failure Scenarios
These are the issues I see most often in production.
1. Premature Promotion (Promotion Failure)
Symptom: Frequent Full GC events even though Old Generation is not full.
Cause: Large objects in Young Generation that survive minor GC cause immediate promotion due to -XX:PretenureSizeThreshold or Survivor Space exhaustion.
Solution:
# Increase Survivor spaces
-XX:SurvivorRatio=6
-XX:MaxTenuringThreshold=15
# Or increase total Young Generation
-Xmn512m # Set Young Generation size
2. Metaspace Exhaustion
Symptom: OutOfMemoryError: Metaspace even though your heap has plenty of room.
Cause: Class loader leaks or frameworks that generate many classes at runtime (OSGi, JSP containers, dynamic proxies, CGLIB).
Solution:
# Set Metaspace limit
-XX:MaxMetaspaceSize=256m
# Monitor class loading
jstat -gc 12078 1000
3. Heap Fragmentation
Symptom: OutOfMemoryError: Heap Space but the heap dump shows plenty of free space scattered around.
Cause: Mark-Sweep leaves gaps. When a large object needs contiguous memory, it fails even though total free memory is sufficient.
Solution:
- Use G1 or ZGC with better compaction
- Increase
-XX:MinHeapFreeRatioand-XX:MaxHeapFreeRatio
Trade-off Table
These are the knobs I reach for most often when tuning heap:
| Configuration | Default | Benefit | Cost |
|---|---|---|---|
-Xms / -Xmx | 1/64th of RAM | Predictable memory footprint | Wasted if overprovisioned |
-Xmn (Young Gen Size) | Dynamic | Control minor GC frequency | May starve Old Gen |
-XX:SurvivorRatio | 8 (Eden/Survivor) | Tune how long objects age | Too high wastes space |
-XX:MaxTenuringThreshold | 15 | Longer aging delays promotion | Can flood Old Gen if too high |
-XX:MetaspaceSize | Platform dependent | Delays first Metaspace GC | Uses more native memory |
Implementation Snippets
Checking Heap Usage
import java.lang.management.*;
public class HeapMemoryMonitor {
public static void main(String[] args) {
MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
MemoryUsage nonHeapUsage = memoryBean.getNonHeapMemoryUsage();
System.out.println("Heap Memory:");
System.out.printf(" Used: %d MB%n", heapUsage.getUsed() / 1024 / 1024);
System.out.printf(" Max: %d MB%n", heapUsage.getMax() / 1024 / 1024);
System.out.printf(" Committed: %d MB%n", heapUsage.getCommitted() / 1024 / 1024);
System.out.println("Non-Heap Memory (Metaspace):");
System.out.printf(" Used: %d MB%n", nonHeapUsage.getUsed() / 1024 / 1024);
}
}
Analyzing Object Header with JOL
import org.openjdk.jol.info.*;
import org.openjdk.jol.vm.*;
public class ObjectHeaderAnalysis {
public static void main(String[] args) {
Object obj = new Object();
ClassLayout layout = ClassLayout.parseInstance(obj);
System.out.println(layout.toPrintable());
}
}
Maven dependency:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
Observability Checklist
- Monitor heap with
jstat -gc <pid> - Enable GC logging:
-Xlog:gc* - Track native memory:
jcmd <pid> VM.native_memory summary - Dump the heap when you need to investigate:
jmap -dump:file=heap.hprof <pid> - Watch Metaspace in
jstat -gc <pid>output (columnsmcandccs) - Set up Flight Recorder for allocation profiling
- Look for frequent Full GC in your GC logs
- Check compressed class pointer mode with
-XX:+PrintCompressedOopsMode
Security Notes
- Heap Dumps can contain PII, passwords, tokens - treat them like sensitive data
- Native Memory Access (Unsafe) lets you read/write heap directly - lock down module access in production
- Memory Leaks can exhaust a containerized pod - set resource limits
- Class Structure in heap dumps can reveal implementation details to attackers
Common Pitfalls / Anti-Patterns
| Pitfall | What happens | Fix |
|---|---|---|
-Xmx set to container limit | OOMKilled because OS, Metaspace, direct buffers need memory too | Leave headroom |
| Ignoring Metaspace sizing | Metaspace OOM in production | Set -XX:MaxMetaspaceSize |
| Using default SurvivorRatio | Poor aging behavior for your workload | Tune based on allocation rate |
| Forgetting heap is fragmented | Mark-Sweep leaves gaps | Switch to G1 or ZGC |
| Young gen too small | Minor GC fires constantly | Bump -Xmn or -XX:NewRatio |
Quick Recap Checklist
- Heap = Young Generation (Eden + S0 + S1) + Old Generation
- Metaspace lives in native memory, stores class metadata
- Object header = Mark Word + Klass Pointer (+ array length for arrays)
- Compressed OOPs on 64-bit JVM = 12-byte headers instead of 16
- Young gen sizing controls minor GC frequency and promotion rate
- Metaspace grows by default; set
-XX:MaxMetaspaceSizeif you need a cap -
jstat, GC logs, and heap dumps are your main debugging tools - Tune
-Xms,-Xmx,-Xmnbased on your workload
Interview Questions
Most objects die young. By keeping the young generation small and collecting it frequently with a fast copying collector, the JVM handles the majority of garbage at low cost. Objects that survive long enough get promoted to Old Generation, which uses a different (slower but thorough) collector. This is the classic generational hypothesis in action.
Mark-Sweep marks reachable objects, then sweeps up the rest. The free memory ends up fragmented. Mark-Compact adds a compact phase that slides live objects together, eliminating the gaps. Compact is more expensive CPU-wise but prevents fragmentation issues that lead to allocation failures even when total free memory looks fine.
Objects get promoted before they should when the Survivor spaces overflow. This happens when S0/S1 are too small for your allocation rate, or when large objects are allocated directly in Old Generation via -XX:PretenureSizeThreshold. The result is more Full GCs than you would expect from your object lifetimes alone.
Metaspace lives in native memory, not on the heap. It stores class metadata - class definitions, method info, constant pools, JIT compiled code. The heap stores object instances. When Metaspace runs out, you get OutOfMemoryError: Metaspace, which you cannot fix by increasing -Xmx. Set -XX:MaxMetaspaceSize if you want an upper bound.
With compressed OOPs (the default on 64-bit JVMs with heap under 32GB), object references compress from 8 bytes to 4 bytes. The Klass pointer also shrinks from 8 to 4 bytes. This cuts the header from 16 bytes down to 12 bytes for regular objects. The CPU cost of compressing and decompressing references is negligible; the memory savings are substantial for reference-heavy applications.
Survivor spaces (S0 and S1) provide a holding area for objects that survive minor GC before they are old enough to promote to Old Generation. Objects age by copying between S0 and S1 with each minor GC. This prevents short-lived objects from flooding Old Generation and reduces the number of Full GCs. The tenuring threshold determines how many minor GCs an object must survive before promotion.
The generational hypothesis states that most objects die young. This justifies using a fast copying collector for young generation (where most garbage is) and a more thorough Mark-Compact collector for old generation (where survivors accumulate). Copying is ideal for young gen because objects die there quickly, meaning less copying work overall. Mark-Compact suits old gen because objects stick around and need efficient compaction to avoid fragmentation.
Class loader leaks are the usual culprit. Applications using OSGi, dynamic proxies, JSP containers, or frameworks that generate classes at runtime (CGLIB, bytecode generation) can accumulate class loaders that never become unreachable. Each class loader holds references to all classes it loaded. The fix is either to set -XX:MaxMetaspaceSize as a hard limit, or to find and fix the class loader leak. Use jstat -gc to monitor Metaspace growth and jmap -clstats to find class loader leaks.
-Xms sets the initial heap size at JVM start. -Xmx sets the maximum heap size the JVM can grow to. In production, setting them equal (-Xms=-Xmx) eliminates heap resizing overhead and prevents pause spikes from resize events. If they differ, the JVM will grow or shrink the heap dynamically, which triggers GC cycles and creates unpredictable pauses.
Without compression, object references on 64-bit JVMs take 8 bytes each, which increases memory usage significantly for reference-heavy applications. Compressed OOPs (enabled by default for heaps under 32GB) packs references into 4 bytes by using 35-bit addressing (covering up to 32GB) plus small adjustments. Above 32GB heap, compression must be disabled and references use full 8 bytes. The trade-off is a small CPU cost for compression/decompression, which is negligible compared to the memory savings.
Object header fragmentation refers to wasted space due to alignment padding and the fact that headers are fixed-size even when not all fields are needed. The JVM aligns objects to 8-byte boundaries, so a 17-byte object actually uses 24 bytes. Additionally, every object has an 8-16 byte header regardless of actual content. In large object graphs with many small objects, this overhead compounds significantly.
This flag causes objects larger than the threshold to be allocated directly in Old Generation instead of Eden. This is useful for large, long-lived objects like caches or connection pools that would otherwise cause frequent minor GCs and Survivor space pressure. Setting this incorrectly causes premature Old Generation fill, so it should only be used after analyzing allocation patterns with a profiler.
Shallow heap is the size of the object itself (object header plus fields), not including anything it references. Retained heap is the total memory that would be freed if the object and all objects it references (directly or indirectly) were collected. Retained heap matters because a small object holding references to a large structure keeps that entire structure alive.
Larger heaps hold more live objects, which means longer pause times during stop-the-world GC phases (Mark and Compact). However, larger heaps also mean fewer GC cycles for the same amount of work. The optimal heap size balances frequency against duration of pauses. For low-latency applications, keep heaps smaller and use collectors like G1, ZGC, or Shenandoah that do work concurrently rather than in long stop-the-world phases.
Fragmentation is the most common cause. Pure Mark-Sweep leaves free memory as scattered islands. A 500MB allocation might fail because free memory exists but not contiguously. This is why collectors with compaction (G1, ZGC, Shenandoah, Mark-Compact) prevent this scenario. Another cause is Metaspace exhaustion - it lives outside the heap, so heap metrics look fine while native memory is depleted.
Card marking divides the old generation into 512-byte cards. When a reference from old gen to young gen is modified, the card is marked dirty. During minor GC, instead of scanning the entire old generation to find cross-generational references, only dirty cards are scanned. This dramatically reduces minor GC pause time when the heap is large. The card table is a side data structure maintained by the write barrier when mutator threads modify object references.
TLAB grants each thread a private allocation buffer in Eden space. Threads allocate from their TLAB using a bump-the-pointer technique that requires no synchronization for local allocations. Only when a TLAB is exhausted does the thread need to get a new TLAB from the global allocator, which requires a small atomic operation. This design eliminates contention on the global allocation path for the common case of thread-local allocation, significantly improving throughput under multi-threaded allocation.
On a 32-bit JVM, an object header is 8 bytes (mark word only). On a 64-bit JVM without compressed OOPs, the mark word is 8 bytes, the klass pointer is 8 bytes, totaling 16 bytes. With compressed OOPs on a 64-bit JVM, the klass pointer compresses to 4 bytes, making the total header 12 bytes for regular objects and 16 bytes for arrays. The JVM uses compressed OOPs when the heap is under 32GB because 35-bit addressing can address up to 32GB using small oop displacements.
MaxTenuringThreshold determines how many minor GC cycles an object must survive in Survivor spaces before being promoted to old generation. The default is 15. With UseAdaptiveSizePolicy enabled, the JVM adaptively adjusts this threshold based on allocation and survival rates. A higher threshold means objects age longer before promotion, which is good if many long-lived objects die young. A lower threshold promotes objects earlier, which is useful if Survivor spaces are filling up. The threshold is per-object, tracked via the age field in the mark word.
The Survivor spaces (S0 and S1) provide buffering between Eden and old gen. If Survivor spaces are too small relative to Eden, objects overflow directly to old gen instead of aging properly, flooding old gen with short-lived objects. The promotion rate is determined by the ratio of Survivor space size to Eden size multiplied by the allocation rate. For example, with SurvivorRatio=8 and a young gen of 1GB, each Survivor is 100MB. Tuning this ratio controls how long objects age before promotion.
Further Reading
- Java Platform, Standard Edition Tools Reference -
jstat- Official documentation for the jstat monitoring tool - Java Object Layout (JOL) - OpenJDK tool for analyzing object and header layouts in the JVM
- Understanding GC Pauses in JVM, HotSpot’s G1 Collector - Oracle’s official G1 tuning guide
- Java Memory Management for Real-Time Applications - Whitepaper on JVM memory for latency-sensitive systems
Conclusion
The JVM heap divides into Young Generation (Eden + S0 + S1) for short-lived objects and Old Generation for long-lived survivors. Metaspace lives in native memory and stores class metadata. Object headers consist of a Mark Word, Klass Pointer, and (for arrays) a length field; compressed OOPs reduce header size from 16 to 12 bytes on 64-bit JVMs under 32GB heap. Size heap with -Xms=-Xmx for predictability and tune SurvivorRatio and tenuring threshold based on your allocation rate.
Category
Related Posts
CMS and G1 Collectors: Low-Latency Garbage Collection
How CMS and G1 garbage collectors reduce pause times through concurrent marking, region-based heap layout, and incremental compaction.
GC Fundamentals: Mark-Compact, Copying, and Mark-Sweep
Understanding the three core garbage collection algorithms - Mark-Sweep, Mark-Compact, and Copying - their mechanics, trade-offs, and when to use each.
JVM GC Tuning: Heap Sizing and Threshold Optimization
Practical strategies for sizing JVM heap, tuning generation ratios, and optimizing GC thresholds to reduce pause times and improve throughput.