Java Memory Model — Explained with Examples
The Java Memory Model defines how threads interact through memory and how the JVM organizes runtime data into heap (shared) and stack (per-thread) areas, with garbage collection automatically reclaiming unreferenced objects.
Why the Memory Model Matters
Understanding Java’s memory model is essential for writing performant and stable applications. A poorly configured JVM can crash with OutOfMemoryError even with plenty of RAM. DodaTech’s services process millions of requests daily, and tuning GC settings reduced our p99 latency by 40%. Without this knowledge, developers write code that leaks memory, triggers frequent GC pauses, and wastes hardware resources.
Heap and Stack — Where Data Lives
Think of the heap as a public warehouse where everyone stores shared boxes. The stack is each worker’s personal desk — their own work area that’s cleaned up when they leave.
graph TD
subgraph JVM[<b>JVM Memory</b>]
subgraph Heap[<b>Heap</b>]
subgraph Young[<b>Young Generation</b>]
Eden[Eden Space<br/>New objects]
S0[Survivor S0<br/>Objects surviving 1st GC]
S1[Survivor S1<br/>Objects surviving 2nd GC]
end
subgraph Old[<b>Old Generation</b>]
Tenured[Tenured Space<br/>Long-lived objects]
end
subgraph Meta[<b>Metaspace</b>]
ClassMeta[Class metadata, methods,<br/>constants, static variables]
end
end
subgraph Stack[<b>Thread Stacks</b>]
T1[Thread 1 Stack<br/>Local variables, frames]
T2[Thread 2 Stack]
T3[...Thread N Stack]
end
PC[Program Counter<br/>per thread]
Native[Native Method Stacks]
end
Young --> |Minor GC<br/>promoted| Old
Old --> |Major GC / Full GC<br/>G1, ZGC, Shenandoah| Meta
style Heap fill:#3b82f6,color:#fff
style Stack fill:#22c55e,color:#fff
Stack — Per-Thread Memory
Each thread gets its own stack containing:
- Local variables (primitives and object references)
- Method call frames (return address, parameters)
- Stacks have fixed size (default 1MB, configurable with
-Xss)
public class StackDemo {
public static void main(String[] args) {
recurse(1);
}
static void recurse(int depth) {
int local = depth; // Stored on stack
String msg = "Depth: " + depth; // Reference on stack, string in heap
System.out.println(msg);
try {
recurse(depth + 1);
} catch (StackOverflowError e) {
System.out.println("Stack overflow at depth: " + depth);
}
}
}Expected output:
Depth: 1
Depth: 2
...
Depth: 20183
Stack overflow at depth: 20184Heap — Shared Memory
The heap is where all objects live. It’s shared across all threads and managed by the garbage collector.
import java.util.*;
public class HeapAllocationDemo {
public static void main(String[] args) {
Runtime rt = Runtime.getRuntime();
System.out.println("Initial heap: " + (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024 + " MB");
List<byte[]> chunks = new ArrayList<>();
for (int i = 0; i < 10; i++) {
chunks.add(new byte[10 * 1024 * 1024]); // 10 MB
long used = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
System.out.println("After allocation " + (i + 1) + ": " + used + " MB used");
}
chunks.clear();
System.gc(); // Request GC (not guaranteed)
System.out.println("After GC: " + (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024 + " MB used");
}
}Expected output (varies by JVM):
Initial heap: 5 MB
After allocation 1: 18 MB
After allocation 2: 28 MB
After allocation 3: 38 MB
...
After GC: 3 MBGenerational Garbage Collection
Most objects die young. The JVM exploits this by dividing the heap into generations.
Young Generation (Minor GC)
New objects go to Eden. When Eden fills up, a Minor GC runs. Live objects move to Survivor spaces (S0, S1). Objects that survive multiple Minor GCs get promoted to Old Generation.
import java.util.*;
public class GCPhenomenonDemo {
// -Xms64m -Xmx64m -XX:+PrintGCDetails
public static void main(String[] args) throws Exception {
System.out.println("Allocating objects...");
for (int i = 0; i < 100; i++) {
byte[] data = new byte[512 * 1024]; // 512 KB
Thread.sleep(10);
}
System.out.println("Done. Check GC log.");
}
}Expected output (with GC logging enabled):
Allocating objects...
[GC (Allocation Failure) -- 16384K->1024K(59392K), 0.002s]
[GC (Allocation Failure) -- 17408K->896K(59392K), 0.001s]
Done. Check GC log.G1 Garbage Collector (Default Since Java 9)
G1 divides the heap into regions (1-32 MB) and prioritizes regions with the most garbage. It provides predictable pause times.
// Run with: -XX:+UseG1GC -Xms512m -Xmx512m -XX:MaxGCPauseMillis=200
import java.util.*;
public class G1GCDemo {
public static void main(String[] args) throws Exception {
List<int[]> list = new ArrayList<>();
Random rand = new Random();
System.out.println("G1 GC with 200ms pause target");
System.out.println("Allocating random arrays...");
for (int i = 0; i < 5000; i++) {
int size = rand.nextInt(1000) + 100;
list.add(new int[size]);
if (i % 100 == 0) {
Thread.sleep(1); // Let GC catch up
}
if (i % 50 == 0 && i > 0) {
// Clear some references to create garbage
for (int j = 0; j < 25; j++) {
list.set(rand.nextInt(list.size()), null);
}
}
}
System.out.println("Allocations complete. " + list.size() + " entries.");
}
}ZGC and Shenandoah — Low-Latency GCs
| GC | Max Pause | Heap Size | Best For |
|---|---|---|---|
| G1 | ~10-50ms | Up to 200GB | General purpose |
| ZGC | <1ms | Up to 16TB | Large heaps, low latency |
| Shenandoah | <1ms | Up to 16TB | Low latency, concurrent compaction |
# Run with ZGC
java -XX:+UseZGC -Xms4g -Xmx4g -jar application.jar
# Run with Shenandoah
java -XX:+UseShenandoahGC -Xms4g -Xmx4g -jar application.jarMemory Leaks — The Silent Killer
Java’s GC handles memory, but it can’t collect objects that are still referenced but no longer needed. These are memory leaks.
import java.util.*;
public class MemoryLeakDemo {
private static final Map<String, int[]> cache = new HashMap<>();
public static void main(String[] args) throws Exception {
System.out.println("Simulating a memory leak...");
for (int i = 0; i < 1_000_000; i++) {
// Objects accumulate in cache but are never removed
cache.put("key-" + i, new int[100]);
if (i % 100_000 == 0) {
long used = (Runtime.getRuntime().totalMemory()
- Runtime.getRuntime().freeMemory()) / 1024 / 1024;
System.out.println("Allocated " + i + ": " + used + " MB used");
Thread.sleep(50);
}
}
}
}Expected output:
Simulating a memory leak...
Allocated 0: 16 MB used
Allocated 100000: 95 MB used
Allocated 200000: 175 MB used
...
Exception in thread "main" java.lang.OutOfMemoryError: Java heap spaceCommon Leak Patterns
- Static collections growing unbounded (as above)
- Unclosed resources (streams, connections, sockets)
- Inner class references holding outer class instances
- ThreadLocal variables not removed after use
- Listeners/callbacks registered but never unregistered
Analyzing Heap Dumps with JVisualVM
When your application runs out of memory, you need a heap dump — a snapshot of all live objects.
# Generate heap dump on OOM
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dump.hprof -jar app.jar
# Generate heap dump manually
jmap -dump:live,format=b,file=/tmp/dump.hprof <pid>
# Analyze with JVisualVM
jvisualvm --openfd /tmp/dump.hprofimport java.util.*;
import javax.management.*;
public class HeapDumpDemo {
public static void main(String[] args) throws Exception {
System.out.println("PID: " + ManagementFactory.getRuntimeMXBean().getName());
List<String[]> memoryHog = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) {
memoryHog.add(new String[500]);
if (i % 10_000 == 0) {
System.out.println("Allocated " + i + " arrays");
Thread.sleep(10);
}
}
}
}Expected output:
PID: 12345@hostname
Allocated 0 arrays
Allocated 10000 arrays
...
// Take heap dump at this point: jmap -dump:live,format=b,file=/tmp/dump.hprof 12345Common Mistakes
Ignoring GC tuning: Default JVM settings work for development, not production. Always profile and tune for your workload.
Creating too many threads: Each thread has a stack (~1MB). Thousands of threads = gigabytes of stack memory. Use thread pools.
Holding references in static collections: Objects in
static Mapinstances live forever. Implement eviction withWeakHashMapor caching libraries.Not sizing Metaspace: If your application dynamically generates classes (proxies, bytecode), Metaspace can grow unbounded. Set
-XX:MaxMetaspaceSize.Calling
System.gc()explicitly: It triggers a full GC and can pause the application for seconds. Let the JVM decide when to collect.
Practice Questions
- What is the difference between stack and heap memory?
- What happens during a Minor GC?
- How does ZGC achieve sub-millisecond pause times?
- What causes
OutOfMemoryError: Java heap space? - How do you find the source of a memory leak?
Answers:
- Stack is per-thread (local variables, method frames), automatically reclaimed when method exits. Heap is shared (all objects), managed by GC.
- Objects in Eden that are still reachable are moved to Survivor spaces. Objects that survive multiple Minor GCs are promoted to Old Generation. Eden is cleared.
- ZGC uses colored pointers, load barriers, and concurrent compaction — it doesn’t stop application threads for most operations.
- The heap is full and GC cannot reclaim enough space. Either increase heap size or fix a memory leak.
- Use heap dump analysis tools (JVisualVM, Eclipse MAT, YourKit). Look for objects with unexpectedly high counts or retention chains.
Mini Project: Profiling a Web Application
- Write a simple HTTP server that processes requests and caches results
- Intentionally introduce a memory leak (unbounded cache, unclosed streams, or ThreadLocal misuse)
- Run with GC logging:
-Xlog:gc* -Xms256m -Xmx256m - Use
jvisualvmorjconsoleto monitor heap usage - Generate a heap dump on OOM
- Analyze the dump to find the leak source
This mirrors how DodaTech’s SRE team profiles services to ensure stable operation at scale.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro