Where Java keeps your variables and objects, how the garbage collector decides what to free, why a garbage-collected program can still leak or run out of memory, and how the JIT makes code faster the longer it runs.
When you run a Java program, the JVM does a lot of work you never see. It keeps a stack of frames for every thread, puts every object on a shared heap, frees objects nobody can reach, and compiles your hottest methods to machine code while the program runs. Knowing how that works explains stack overflows, leaks, OutOfMemoryError and benchmarks that lie.
This post covers the stack and the heap, reachability, generational garbage collection with G1 and ZGC, leaks, the JIT, and what happens when a class is loaded. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java. GC logs and timings change on every run, so they’re shown as trimmed terminal sessions, and your numbers will differ.
Every thread has a stack of frames
Each time a method is called, the JVM pushes a frame onto the current thread’s stack. The frame holds that call’s parameters and local variables. For a primitive, that’s the value itself. For an object, it’s a reference, and the object lives on the heap. When the method returns, its frame is popped.
int sumTo(int n) {
if (n == 0) {
IO.println("frame n=0: bottom, returns 0");
return 0;
}
int rest = sumTo(n - 1);
int total = n + rest;
IO.println("frame n=" + n + ": rest=" + rest + ", returns " + total);
return total;
}
List<String> makeNames() {
var names = new ArrayList<String>();
names.add("Ana");
names.add("Ben");
return names;
}
void main() {
IO.println("sum: " + sumTo(3));
var kept = makeNames();
IO.println("the list outlived its frame: " + kept);
}
It prints:
frame n=0: bottom, returns 0
frame n=1: rest=0, returns 1
frame n=2: rest=1, returns 3
frame n=3: rest=3, returns 6
sum: 6
the list outlived its frame: [Ana, Ben]
Four calls to sumTo were on the stack at once, and each had its own n, rest and total. They finished in reverse order, from the bottom frame up.
makeNames shows the other half. Its local variable names was gone as soon as it returned, but the ArrayList wasn’t in the frame. It was on the heap, and main got a copy of the reference. The part on values and references covers how references are copied.
A stack that runs out: StackOverflowError
A thread’s stack has a fixed maximum size, and recursion with no base case fills it. The JVM then throws StackOverflowError. It’s an Error, not an Exception, but you can still catch it:
int depth = 0;
void dive() {
depth++;
dive();
}
void main() {
try {
dive();
} catch (StackOverflowError e) {
IO.println("caught: " + e.getClass().getName());
IO.println("message: " + e.getMessage());
IO.println("deeper than 1,000 calls: " + (depth > 1_000));
}
IO.println("main carries on");
}
It prints:
caught: java.lang.StackOverflowError
message: null
deeper than 1,000 calls: true
main carries on
The error has no message. By the time the catch block runs, the frames have been popped, so there’s room to print. In real code, fix the recursion instead.
The program doesn’t print the exact depth, because it changes from run to run. Here’s what happened when we changed the catch block to print depth and ran it a few times:
$ java Main.java
depth 17830
$ java Main.java
depth 17699
$ java -Xint Main.java
depth 9826
$ java -Xint Main.java
depth 9826
This surprised us. -Xint runs the interpreter only, and it stopped at the same depth every time. With the JIT on, the depth moved around and was almost twice as deep. Once the JIT compiles dive, its frames take less room than interpreted frames, and how many calls happen before that switch depends on timing. The JIT is covered below.
To give threads a bigger stack, use -Xss. java -Xss4m Main.java sets 4 MB, and on one run it let the same program recurse 77,347 calls deep. The default on this 64-bit Linux JDK is 1 MB (ThreadStackSize = 1024, in KB).
An object is garbage when nothing can reach it
The garbage collector frees an object when no chain of references leads to it from a GC root. The roots are the references the JVM knows are in use: local variables in every live frame of every thread, static fields of loaded classes, and a few internal ones. Everything the collector can reach by following references from the roots stays. Everything else is garbage.
You can watch this with a WeakReference. A weak reference lets you look at an object without keeping it alive. Once the only references left are weak, the collector may clear them. In this program, the two nodes point at each other:
import java.lang.ref.WeakReference;
class Node {
final String name;
Node partner;
Node(String name) {
this.name = name;
}
}
String check(WeakReference<Node> ref) {
Node node = ref.get();
return node == null ? "collected" : node.name + " is still here";
}
void main() {
var a = new Node("a");
var b = new Node("b");
a.partner = b;
b.partner = a;
var weakA = new WeakReference<>(a);
System.gc();
IO.println("while main holds a: " + check(weakA));
a = null;
b = null;
System.gc();
IO.println("after a = b = null: " + check(weakA));
}
It prints:
while main holds a: a is still here
after a = b = null: collected
The two nodes still referred to each other when they were collected. That doesn’t keep them alive, because the collector doesn’t count references. It traces from the roots, and once main‘s locals were null, no path led to either node.
System.gc() is only a request. The JVM is allowed to ignore it. We ran this program 25 times with each of the Serial, Parallel, G1, ZGC and Shenandoah collectors, and the weak reference was cleared every time. That’s what makes it safe to show here. It’s not a promise, and a flag proves it:
$ java -Xlog:gc Main.java
[0.005s][info][gc] Using G1
[1.726s][info][gc] GC(0) Pause Full (System.gc()) 43M->3M(34M) 21.996ms
while main holds a: a is still here
[1.761s][info][gc] GC(1) Pause Full (System.gc()) 4M->3M(14M) 28.521ms
after a = b = null: collected
$ java -XX:+DisableExplicitGC Main.java
while main holds a: a is still here
after a = b = null: a is still here
On G1, System.gc() runs a full collection. With -XX:+DisableExplicitGC it does nothing, and the node survives. Real code should never depend on System.gc().
Explain it like I’m ten
The heap is a big shared playroom full of toys. Every child in the house can put toys in it.
The stack is each child’s own tray on their desk. The tray holds what they’re working on right now, and some strings. Each string runs from the tray to a toy in the playroom. Toys can have strings to other toys, too.
The garbage collector is a helper who tidies the playroom. The helper doesn’t ask whether a toy looks important. They start at the trays and follow every string. Any toy they can reach by following strings stays. Any toy nobody is holding a string to goes back in the box, even two toys tied to each other.
The precise version
A thread’s stack holds frames. A frame holds the method’s local variables and intermediate values, including references. Objects are allocated on the heap, which all threads share. The collector finds live objects by tracing: it starts at the GC roots and marks everything reachable through references. Memory held by unmarked objects is reclaimed. How and when that happens depends on the collector.
Where the analogy breaks: the helper in the story never interrupts anyone. Real collectors stop your program for short pauses, at least for some of their work. They also move toys. G1 and ZGC copy live objects to new places and update every reference to them, which you never see from Java code. And toys don’t disappear the moment a string is cut. They wait until the next collection.
Most objects die young
Most objects in a typical program are used briefly and then dropped: a string built for one log line, an iterator, a temporary record. This is called the generational hypothesis, and HotSpot’s collectors are built around it. The heap is split into a young generation, where new objects go, and an old generation for objects that have lasted.
The young generation has an eden space, where objects are allocated, and survivor spaces. A young collection copies the live objects out of eden into a survivor space, then treats all of eden as free. It never visits dead objects, so when most objects are dead, it’s cheap. Each collection an object survives adds one to its age. Once it’s old enough, it’s promoted to the old generation, which is collected less often.
A simplified young collection. Most new objects in eden die. The collection copies the live ones into a survivor space, promotes an object that has survived enough collections to old, and frees all of eden at once.
Here are those steps in words, in case the animation doesn’t play for you:
- New objects A to F are allocated in eden. The survivor space already holds S, which lived through earlier collections.
- The program moves on, and A, C, D and E become unreachable. Most objects die young.
- A young collection pauses the program and copies the live objects, B and F, into a survivor space. Their age is now 1.
- S has survived enough collections, so it’s promoted: copied into the old generation.
- The dead objects are never copied or visited. All of eden is freed in one go.
- The pause ends. Eden is empty again, and new objects such as G start filling it.
The drawing is simplified. G1 doesn’t use three fixed boxes. It splits the heap into equal regions, 2 MB each on this machine, and marks each region as eden, survivor or old. The maximum age before promotion is MaxTenuringThreshold, which defaults to 15.
G1: the default collector, and its logs
G1 is HotSpot’s default garbage collector, but the JVM picks based on the machine. On this one, with 12 CPUs and 16 GB of RAM, it chose G1:
$ java -XX:+PrintFlagsFinal -version | grep -E ' (UseG1GC|UseSerialGC|MaxHeapSize) '
size_t MaxHeapSize = 4095737856 {product} {ergonomic}
bool UseG1GC = true {product} {ergonomic}
bool UseSerialGC = false {product} {default}
$ java -XX:ActiveProcessorCount=1 -Xlog:gc -version
[0.003s][info][gc] Using Serial
$ systemd-run --user --scope -q -p MemoryMax=1G java -Xlog:gc -version
[0.003s][info][gc] Using Serial
{ergonomic} means the JVM chose the value itself. With one CPU, or inside a 1 GB memory limit, it picked the Serial collector instead. The default maximum heap was a quarter of the memory: about 3.8 GB here, and 256 MB inside the limit. In a container, check what the JVM picked there.
This program creates 20 million orders and keeps one in a thousand:
record Order(int id, byte[] payload) {}
void main() {
var recent = new ArrayList<Order>();
long checksum = 0;
for (int i = 0; i < 20_000_000; i++) {
var order = new Order(i, new byte[256]);
checksum += order.id() % 7;
if (i % 1_000 == 0) {
recent.add(order);
}
}
IO.println("orders created: 20000000");
IO.println("orders kept: " + recent.size());
IO.println("checksum: " + checksum);
}
It prints:
orders created: 20000000
orders kept: 20000
checksum: 59999997
-Xlog:gc,gc+heap makes G1 report each collection. We capped the heap at 64 MB so it collects often. Here are two collections from the middle of one run:
$ java -Xmx64m -Xlog:gc,gc+heap Main.java
[0.008s][info][gc] Using G1
...
[2.398s][info][gc,heap] GC(63) Eden regions: 37->0(37)
[2.398s][info][gc,heap] GC(63) Survivor regions: 1->1(5)
[2.398s][info][gc,heap] GC(63) Old regions: 12->13
[2.398s][info][gc ] GC(63) Pause Young (Normal) (G1 Evacuation Pause) 48M->11M(64M) 0.944ms
...
[3.506s][info][gc,heap] GC(154) Eden regions: 37->0(37)
[3.506s][info][gc,heap] GC(154) Survivor regions: 1->1(5)
[3.506s][info][gc,heap] GC(154) Old regions: 17->17
[3.506s][info][gc ] GC(154) Pause Young (Normal) (G1 Evacuation Pause) 52M->15M(64M) 1.813ms
Each block is one young collection. Eden went from 37 regions to 0, because everything live was copied out. One survivor region was enough, since most orders were already dead. Most collections left the old regions unchanged, and about one in twenty added one, as kept orders were promoted. Each pause took about a millisecond.
G1 aims to keep pauses under a target, MaxGCPauseMillis, which defaults to 200 ms. It’s a goal, not a guarantee.
ZGC: when pause times matter most
ZGC does almost all of its work while your program keeps running, so its pauses stay very short, even with large heaps. You turn it on with -XX:+UseZGC:
$ java -XX:+UseZGC -Xlog:gc -version
[0.079s][info][gc] Using The Z Garbage Collector
$ java -XX:+UseZGC -XX:+ZGenerational -version 2>&1 | grep warning
OpenJDK 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0
The second command surprised us. Older guides tell you to add -XX:+ZGenerational. Since JDK 24, ZGC is always generational, and the flag is ignored with a warning. Here’s the order program on ZGC, with its pause lines picked out of -Xlog:gc*:
$ java -XX:+UseZGC -Xlog:gc* Main.java
[1.678s][info][gc ] GC(0) Major Collection (Warmup)
[1.679s][info][gc,phases ] GC(0) Y: Pause Mark Start (Major) 0.032ms
[1.702s][info][gc,phases ] GC(0) Y: Pause Mark End 0.039ms
[1.704s][info][gc,phases ] GC(0) Y: Pause Relocate Start 0.047ms
[1.712s][info][gc,phases ] GC(0) O: Pause Mark End 0.033ms
[1.726s][info][gc,phases ] GC(0) O: Pause Relocate Start 0.032ms
[1.726s][info][gc ] GC(0) Major Collection (Warmup) 356M(9%)->42M(1%) 0.048s
Y: is the young generation and O: is the old one. On this run, each pause was well under a millisecond, while the whole collection took 48 ms alongside the program.
Pick ZGC when pauses are the problem, such as a service with a large heap and strict response times. It does its work on background threads, so it needs spare CPU. Without a pause problem, stay on G1. For batch jobs where only throughput matters, measure -XX:+UseParallelGC too.
Memory leaks in a garbage-collected language
A garbage collector frees only what’s unreachable, so a Java leak is an object you’ve stopped using that is still reachable. The collector can’t know you’re done with it. The usual culprits are a static collection that only grows, listeners that are added and never removed, and caches with no limit.
import java.lang.ref.WeakReference;
import java.util.function.Consumer;
class EventBus {
static final List<Consumer<String>> listeners = new ArrayList<>();
static void subscribe(Consumer<String> listener) {
listeners.add(listener);
}
static void unsubscribe(Consumer<String> listener) {
listeners.remove(listener);
}
}
class Screen {
final byte[] image = new byte[10_000];
final Consumer<String> listener = this::onEvent;
void open() {
EventBus.subscribe(listener);
}
void close(boolean unsubscribe) {
if (unsubscribe) {
EventBus.unsubscribe(listener);
}
}
void onEvent(String event) {
}
}
int stillAlive(List<WeakReference<Screen>> refs) {
System.gc();
int alive = 0;
for (var ref : refs) {
if (ref.get() != null) {
alive++;
}
}
return alive;
}
void run(boolean unsubscribe) {
var refs = new ArrayList<WeakReference<Screen>>();
for (int i = 0; i < 1_000; i++) {
var screen = new Screen();
screen.open();
screen.close(unsubscribe);
refs.add(new WeakReference<>(screen));
}
IO.println("unsubscribe on close: " + unsubscribe);
IO.println(" listeners held: " + EventBus.listeners.size());
IO.println(" screens the GC couldn't free: " + stillAlive(refs));
}
void main() {
run(false);
EventBus.listeners.clear();
run(true);
}
It prints:
unsubscribe on close: false
listeners held: 1000
screens the GC couldn't free: 1000
unsubscribe on close: true
listeners held: 0
screens the GC couldn't free: 0
Every screen was closed and dropped, yet in the first run none of them could be freed. The method reference this::onEvent holds a reference to its Screen. The static list holds the listener, and a static field is a GC root. So every screen, with its image, stayed reachable. Removing the listener on close fixed it.
Caches leak the same way. A map that remembers every result for every key grows for as long as the program runs. A LinkedHashMap can evict its oldest entry once it’s full:
Map<String, String> unbounded = new HashMap<>();
Map<String, String> bounded = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
return size() > 100;
}
};
String render(String userId) {
return "<profile of " + userId + ">";
}
void main() {
for (int i = 0; i < 50_000; i++) {
String userId = "user-" + i;
unbounded.computeIfAbsent(userId, this::render);
bounded.computeIfAbsent(userId, this::render);
}
IO.println("unbounded cache entries: " + unbounded.size());
IO.println("bounded cache entries: " + bounded.size());
IO.println("newest still cached: " + bounded.containsKey("user-49999"));
IO.println("oldest still cached: " + bounded.containsKey("user-0"));
}
It prints:
unbounded cache entries: 50000
bounded cache entries: 100
newest still cached: true
oldest still cached: false
The true in the constructor orders entries by last access. removeEldestEntry runs after each insert, and returning true drops the least recently used entry. The unbounded map kept all 50,000 users, and the bounded one kept 100.
OutOfMemoryError: the heap is full of live objects
When the heap can’t fit a new object even after a collection, the JVM throws OutOfMemoryError. A leak gets you there slowly. This program gets there fast, by keeping every block it allocates:
List<byte[]> kept = new ArrayList<>();
void main() {
IO.println("keeping 1 MB blocks until the heap runs out");
while (true) {
kept.add(new byte[1_000_000]);
}
}
It isn’t run with the others, because it has to fail. Run it with a 16 MB heap, and it prints a stack trace too, trimmed here:
$ java -Xmx16m Main.java
keeping 1 MB blocks until the heap runs out
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
Java heap space means ordinary objects filled the heap. -Xmx sets the maximum heap size. -XX:+HeapDumpOnOutOfMemoryError writes a heap dump when this happens, which a tool such as VisualVM or Eclipse MAT can open to show what was holding the memory.
We also tried an 8 MB heap:
$ java -Xmx8m Main.java
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.EnumMap.values(EnumMap.java:423)
at jdk.compiler/com.sun.tools.javac.util.Log.flush(Log.java:486)
Our first line never printed. java Main.java compiles your source inside the same JVM before it runs it, and the compiler ran out of heap first. The frames name javac, not Main.
The JIT: code gets faster as it runs
The JVM starts by interpreting bytecode, one instruction at a time. It counts how often each method runs. A method that gets hot is compiled to machine code by C1, a fast compiler that also records profiling data. If it stays hot, C2 compiles it again, using that profile to optimise harder. This is tiered compilation, and it’s on by default.
int digitSum(int n) {
int sum = 0;
while (n > 0) {
sum += n % 10;
n /= 10;
}
return sum;
}
void main() {
long total = 0;
for (int i = 0; i < 5_000_000; i++) {
total += digitSum(i);
}
IO.println("sum of all digits below 5,000,000: " + total);
}
It prints:
sum of all digits below 5,000,000: 145000000
-XX:+PrintCompilation prints a line for each compilation. Here are the lines for digitSum from one run. The timings and IDs change every run:
$ java -XX:+PrintCompilation Main.java | grep digitSum
1115 1544 3 Main::digitSum (23 bytes)
1116 1545 4 Main::digitSum (23 bytes)
1118 1544 3 Main::digitSum (23 bytes) made not entrant: not used
1124 1548 % 4 Main::digitSum @ 2 (23 bytes)
The columns are milliseconds since start, a compilation ID, flags, the tier, and the method. Tier 3 is C1 with profiling, and tier 4 is C2. “Made not entrant” means the C1 version was retired once C2’s was ready. % marks an on-stack replacement: a compiled version of the while loop that a call already inside the loop can jump into.
The whole run printed over 1,700 lines, and about 1,500 compilations came before digitSum‘s. Most were javac itself, compiling your file.
Warm-up, and why micro-benchmarks lie
Because code starts slow and speeds up, timing it once tells you very little. This version times eight equal rounds. It can’t be run with the others, because it prints timings:
int digitSum(int n) {
int sum = 0;
while (n > 0) {
sum += n % 10;
n /= 10;
}
return sum;
}
void main() {
for (int round = 1; round <= 8; round++) {
long start = System.nanoTime();
long total = 0;
for (int i = 0; i < 50_000; i++) {
total += digitSum(i);
}
long micros = (System.nanoTime() - start) / 1_000;
IO.println("round " + round + ": " + micros + " microseconds, total " + total);
}
}
$ java Main.java
round 1: 4776 microseconds, total 1000000
round 2: 3878 microseconds, total 1000000
round 3: 3254 microseconds, total 1000000
round 4: 3717 microseconds, total 1000000
round 5: 3669 microseconds, total 1000000
round 6: 3696 microseconds, total 1000000
round 7: 1539 microseconds, total 1000000
round 8: 659 microseconds, total 1000000
$ java -Xint Main.java
round 1: 11436 microseconds, total 1000000
...
round 8: 11645 microseconds, total 1000000
The same work got about seven times faster by round 8. With -Xint it never sped up. On other runs the jump came a round earlier or later. Time the first rounds and you measure the interpreter and the compiler, not your code. GC pauses and CPU frequency changes add more noise.
For real measurements, use JMH, the Java Microbenchmark Harness from the OpenJDK project. It handles warm-up, forks fresh JVMs and reports error margins. It’s a separate library, so this series doesn’t use it.
Escape analysis
The C2 compiler runs escape analysis. It checks whether an object created in compiled code can be seen outside it. If it can’t, C2 may skip the allocation and keep the object’s fields in local variables or CPU registers. That’s scalar replacement. You can’t observe it from Java code: the output is the same, and no API says an allocation was removed. You can only see it from outside, such as in GC logs. A loop that created a small record Point(long x, long y) 200 million times, never letting it leave the loop, caused one GC pause on this machine. With -XX:-DoEscapeAnalysis, it caused 17. It’s an optimisation the JIT may apply, not a rule you can rely on.
What happens when a class is loaded
Before a class’s code can run, the JVM loads it (reads the bytecode), links it (verifies the bytecode and sets up static fields with default values) and initialises it (runs static field initialisers and static blocks, top to bottom). Loading can happen early, but initialisation waits for the first real use, such as reading a static field. That’s why the static block in the part on classes ran after main had started.
class Config {
static final int MAX_USERS = 100;
static final String VERSION = "2.1";
static final List<String> REGIONS = List.of("eu", "us");
static {
IO.println(" Config is being initialised");
}
}
void main() {
IO.println("1. declare an array of Config");
Config[] slots = new Config[3];
IO.println("2. use the class literal: " + Config.class.getSimpleName());
IO.println("3. read a constant int: " + Config.MAX_USERS);
IO.println("4. read a constant String: " + Config.VERSION);
IO.println("5. read a List field: " + Config.REGIONS);
IO.println("6. read it again: " + Config.REGIONS.size() + " regions, "
+ slots.length + " slots");
}
It prints:
1. declare an array of Config
2. use the class literal: Config
3. read a constant int: 100
4. read a constant String: 2.1
Config is being initialised
5. read a List field: [eu, us]
6. read it again: 2 regions, 3 slots
Creating an array of Config and using Config.class didn’t initialise the class. Reading MAX_USERS and VERSION didn’t either. They’re compile-time constants, so javac copied 100 and "2.1" straight into main. REGIONS is a real field read at run time, so step 5 triggered initialisation, once. Step 6 didn’t repeat it.
-Xlog:class+load,class+init shows the steps. Here are the lines about our two classes, with the path shortened:
$ java -Xlog:class+load,class+init Main.java
[1.341s][info][class,load] Main source: file:/home/you/Main.java
1. declare an array of Config
[1.344s][info][class,load] Main$Config source: file:/home/you/Main.java
2. use the class literal: Config
3. read a constant int: 100
4. read a constant String: 2.1
[1.345s][info][class,init] Start class verification for: Main$Config
[1.345s][info][class,init] End class verification for: Main$Config
Config is being initialised
5. read a List field: [eu, us]
Main$Config was loaded as soon as main needed the array type, but it was verified and initialised only at step 5. The Main$ is there because a compact source file wraps everything in a class called Main.
The full log listed about 2,600 loaded classes, about 1,100 of them from jdk.compiler. Compiled with javac and run as java Main, the program loaded about 600, and Main loaded after 0.06 seconds instead of 1.3.
What to remember
- Each thread has a stack of frames holding locals and references. Objects live on the shared heap. Deep recursion throws
StackOverflowError, and-Xsssets the stack size. - An object is garbage when no chain of references leads to it from a GC root. Objects that only refer to each other are still garbage.
- Most objects die young. A young collection copies the few live ones out of eden and frees the rest at once. Long-lived objects are promoted to old.
- G1 is the usual default, but the JVM picks based on CPUs and memory. Choose ZGC when pause times matter most, and check with
-Xlog:gc. - A Java leak is an object you’re done with that’s still reachable: a growing static list, a listener never removed, a cache with no limit.
- The JIT compiles hot code in tiers, so code speeds up as it runs. Don’t trust a hand-timed loop. Use JMH.
- Classes are loaded, linked and initialised lazily, and
java Main.javaruns the compiler in the same JVM first.
The garbage collector frees what nothing can reach, so a leak is always something you’re still holding.