11. Memory Model and Object Lifecycle
Java's headline promise is that you never free memory by hand, but that promise
is kept by machinery you can starve, confuse, and defeat. This chapter covers
where objects actually live, what the JVM does between new and the moment
memory is reclaimed, how garbage collection decides what is garbage, and the
reference types that let you influence that decision. Understanding it is the
difference between reading a heap dump and guessing.
<a id="111-memory-layout-and-object-allocation"></a>
11.1 Memory Layout and Object Allocation
This subchapter covers the regions the JVM divides memory into, what happens in
the moments around a new expression, and how the just-in-time compiler
sometimes makes an allocation disappear entirely.
Stack versus Heap Allocation
A running program needs memory for two jobs with very different lifetimes. One job is bookkeeping for a method call: the parameters, the local variables, and where to return to. That memory is needed for exactly as long as the call lasts, and calls nest perfectly, so the last one started is always the first one finished. The other job is holding data that outlives the method that created it, which can be shared, and whose lifetime nobody can predict.
Those two shapes call for two different structures, and the JVM provides both.
The stack handles the first job. It is a stack in the data-structure sense: frames are pushed on call and popped on return. Because the discipline is strict last-in-first-out, allocation is nothing more than moving a pointer, and deallocation is moving it back. There is no bookkeeping and no garbage collection. Crucially, each thread has its own stack, so nothing on a stack is shared and nothing on it needs locking.
The heap handles the second job. It is a large shared region with no ordering: objects are created in any order and die in any order. That flexibility is what garbage collection pays for.
+------------------------------ JVM PROCESS --------------------------------+
| |
| PER THREAD (created and destroyed with the thread) |
| +---------------------+ +---------------------+ |
| | Thread 1 | | Thread 2 | |
| | stack frames | | stack frames | <- locals, references,|
| | program counter | | program counter | return addresses |
| +---------------------+ +---------------------+ |
| |
| SHARED BY ALL THREADS |
| +---------------------------------------------------------------------+ |
| | HEAP every object and array; garbage collected | |
| +---------------------------------------------------------------------+ |
| | METASPACE class metadata; native memory, NOT part of the heap | |
| +---------------------------------------------------------------------+ |
| | CODE CACHE machine code produced by the JIT compiler | |
| +---------------------------------------------------------------------+ |
+---------------------------------------------------------------------------+
The rule for where a value lives has one subtlety that trips up almost everyone. Every object lives on the heap. What can live on the stack is a primitive value or a reference.
public class Where {
private int instanceCount; // on the HEAP, inside the Where object
private static int total; // in the class's storage, a GC root
void run() {
int count = 3; // primitive LOCAL: on the stack
Point p = new Point(1, 2); // the REFERENCE p is on the stack,
// the Point OBJECT is on the heap
int[] data = new int[100]; // same split: reference on the stack,
// the 100-element array on the heap
Integer boxed = 42; // boxed: reference on stack, object on heap
}
}
Drawn out, the split looks like this:
STACK (thread 1, private) HEAP (shared)
+----------------------------+ +---------------------+
| frame: run() | | Point object |
| count = 3 <- a value | | x = 1 |
| p = 0x7f3a ----------|------->| y = 2 |
| data = 0x7f52 ----------|---+ +---------------------+
+----------------------------+ | +---------------------+
| frame: main() | +--->| int[100] array |
| args = 0x7f10 ----------|--... +---------------------+
+----------------------------+
Notice that int count = 3 stores the number itself, while Point p = ...
stores an address. That single difference is the whole of "primitives have value
semantics, objects have reference semantics". A field, in contrast, lives
wherever its owner lives: an int field sits inside the heap object that
declares it, not on any stack.
The two regions also fail differently, and the two errors tell you different things.
| Region | Exhausted by | Error thrown | Sized by |
|---|---|---|---|
| Stack | Deep or infinite recursion | StackOverflowError | -Xss |
| Heap | Too many live objects | OutOfMemoryError: Java heap space | -Xmx |
| Metaspace | Too many loaded classes | OutOfMemoryError: Metaspace | -XX:MaxMetaspaceSize |
public class Overflow {
// Each call pushes a frame; nothing ever pops. Typical depth is
// 10,000 to 20,000 frames with a default 512 KB to 1 MB stack.
static int depth(int n) { return depth(n + 1); }
public static void main(String[] args) {
try {
depth(0);
} catch (StackOverflowError e) { // an Error, not an Exception
System.out.println("stack exhausted");
}
}
}
Note that these are Error subclasses, not exceptions. Catching
StackOverflowError to print a message in a demo is fine; catching it to
continue working is not, because you have no idea what was half-finished.
Two practical notes on sizing. Stack size is per thread, so a server with four thousand threads at 1 MB each reserves four gigabytes of stack before touching the heap. And since Java 10 the JVM reads container memory limits, so inside Docker prefer a percentage over a fixed figure:
# Fixed sizing: same minimum and maximum avoids resize pauses
java -Xms2g -Xmx2g -Xss512k -jar app.jar
# Container-friendly: take 75% of whatever the cgroup allows
java -XX:MaxRAMPercentage=75 -jar app.jar
One clarification about a phrase you will hear: "the Java memory model". That term does not mean this layout. The Java Memory Model is the specification of when one thread is guaranteed to see another thread's writes, and it belongs to the concurrency chapter. This topic is about memory regions, which is a different subject with a confusingly similar name.
Key Takeaways
- The stack holds per-call bookkeeping with strict last-in-first-out lifetimes; the heap holds data with unpredictable lifetimes.
- Every thread gets a private stack, so stack data needs no synchronization.
- All objects and arrays live on the heap; only primitives and references can live on the stack.
- A field lives inside its owning object, wherever that object lives.
- Storing a value versus storing an address is the whole of value versus reference semantics.
- Metaspace holds class metadata in native memory and is not part of the heap.
- Deep recursion gives
StackOverflowError; too much live data givesOutOfMemoryError, and both areError, notException.- Stack size is per thread, so thread count multiplies it.
- "The Java Memory Model" means thread visibility rules, not this layout.
🧪 Practice
- Write a recursive method and print a counter before each call to measure how deep your JVM goes before
StackOverflowError. Re-run with-Xss256kand-Xss4mand compare.- For a method containing a
long, aStringreference, anew int[10], and astaticcounter, draw which region each one occupies.- Write a program that fills an
ArrayListuntil it throwsOutOfMemoryErrorunder-Xmx64m, and print how many elements it managed.- Interview: An object has an
intfield. Is thatinton the stack or the heap, and does the answer change if the object is a local variable? (Hint: ask where the object is, then ask where its fields must therefore be.)
Object Creation and Reachability
new Point(1, 2) looks like one operation. It is closer to seven, and knowing
them explains initialization bugs that otherwise look like magic.
When the JVM evaluates a new expression:
- Resolve and initialize the class. If this is the first active use of
Point, it is loaded, linked, and its static initializers run. Exactly once. - Allocate memory for the header and all instance fields, including inherited ones.
- Zero the memory. Every field takes its default:
0,0.0,false,'\u0000', ornull. This happens before any of your code runs, which is why Java has no uninitialized-field reads. - Write the object header, which carries identity, locking, and GC state plus a pointer to the class metadata.
- Run the superclass constructor, recursively up to
Object. - Run instance field initializers and instance initializer blocks, in source order.
- Run the constructor body.
Steps 5, 6, and 7 in that order are the part worth memorizing: the superclass constructor finishes before your field initializers run. That single fact explains the classic trap below.
class Base {
Base() {
System.out.println("Base constructor");
describe(); // calls the OVERRIDE, on a half-built object
}
void describe() { System.out.println("Base.describe"); }
}
class Derived extends Base {
private String label = "ready"; // field initializer: runs AFTER Base()
Derived() {
super(); // implicit; runs first
System.out.println("Derived constructor, label=" + label);
}
@Override
void describe() {
// label is still null here: step 5 has not reached step 6 yet
System.out.println("Derived.describe, label=" + label);
}
}
Running new Derived() prints:
Base constructor
Derived.describe, label=null <- the trap: the field is not assigned yet
Derived constructor, label=ready
The lesson is a hard rule: never call an overridable method from a constructor. The subclass's fields do not exist yet, and no amount of care in the subclass can fix it.
Static and instance initialization interleave in a fixed order too:
class Order {
static { System.out.println("1. static block"); } // once, at class init
private int a = init("3. field initializer");
{ System.out.println("4. instance block"); } // instance initializer
Order() { System.out.println("5. constructor body"); }
private int init(String msg) { System.out.println(msg); return 0; }
public static void main(String[] args) {
System.out.println("2. before new");
new Order();
}
}
Reachability is the other half of the lifecycle. Java does not track when you are "done" with an object, because you never say so. Instead the collector asks a structural question: starting from a fixed set of roots, can this object be reached by following references? If yes it is live. If no it is garbage, and the collector may reclaim it.
The GC roots are the places a live reference can originate:
- Local variables and parameters in every frame of every live thread
- Static fields of loaded classes
- Active thread objects themselves
- JNI (native code) local and global references
- Objects held by the JVM internally, such as interned strings and class objects
- Monitors currently used for synchronization
GC ROOTS
+--------------------+
| local variables |----> A ----> B ----> C reachable: KEPT
| static fields |----> D ----> B (B has two paths in)
| live threads |
| JNI references | E <---> F unreachable CYCLE:
+--------------------+ still COLLECTED
That diagram makes the most important property visible. Java uses tracing, not reference counting, so a cycle of objects that reference each other but which no root can reach is collected without special handling. Languages that count references need weak pointers to break such cycles; Java does not.
Making an object unreachable is just breaking the last path to it:
public class Reach {
public static void main(String[] args) {
StringBuilder a = new StringBuilder("first");
StringBuilder b = new StringBuilder("second");
a = b; // the "first" object now has NO reference: garbage
b = null; // the "second" object is still reachable through a
// Cycles are not a problem for a tracing collector
Node x = new Node();
Node y = new Node();
x.next = y;
y.next = x; // they reference each other
x = null;
y = null; // the pair is now unreachable, and will be collected
}
static class Node { Node next; }
}
Note the honest wording: garbage is eligible for collection, not collected at a known moment. Nothing in Java guarantees when, or that it happens at all before the program exits.
That is why finalize() must not be used. It was deprecated in Java 9,
deprecated for removal in Java 18, and its problems were fatal: no guarantee it
ever runs, unpredictable timing, it can resurrect the object, an exception in it
is swallowed, and it delays reclamation by an extra GC cycle. Use
AutoCloseable with try-with-resources for anything that must be released
promptly, and java.lang.ref.Cleaner only as a safety net for native memory.
The full lifecycle, then:
created reachable unreachable finalized* reclaimed
(new) ---> (in use) ---> (no root path) ---> (Cleaner, ---> (memory
^ | if any) reused)
| |
+--- reachable again | * optional, and never guaranteed
(a root gets a | to run at a particular time
reference) <--+
Key Takeaways
newresolves and initializes the class, allocates, zeroes the memory, writes the header, then runs constructors.- Fields are zeroed to defaults before any user code runs, so there are no uninitialized reads.
- The superclass constructor completes before subclass field initializers, so never call an overridable method from a constructor.
- Static initializers run once at class initialization; instance initializers run per object, before the constructor body.
- An object is live if it is reachable from a GC root by following references.
- GC roots are stack frames, static fields, live threads, JNI references, and some JVM internals.
- Java traces references rather than counting them, so unreachable cycles are collected normally.
- Unreachable means eligible for collection, never collected at a known time.
finalize()is deprecated for removal; useAutoCloseable, andCleaneronly as a backstop.
🧪 Practice
- Run the
Orderclass above and predict the output before you see it. Then add a superclass with its own static block and field initializer, and predict again.- Write a class whose constructor calls an overridable method, and a subclass that overrides it and reads a subclass field. Demonstrate the
null.- Draw the object graph for a
Mapheld in a static field whose values hold references back to their keys, and mark what is reachable after the local variable pointing at the map goes out of scope.- Interview: Two objects reference each other and nothing else references either. Are they collected, and would your answer change in a reference-counted language such as CPython? (Hint: what question does a tracing collector actually ask?)
Escape Analysis and Object Scope
Heap allocation in HotSpot is remarkably cheap. Each thread gets a private slab of Eden called a thread-local allocation buffer (TLAB), and allocating in it is one pointer bump with no locking, perhaps ten instructions. But cheap is not free: the object still consumes space, still must be zeroed, and still has to be traced and eventually reclaimed.
So the JIT compiler asks a question about every allocation it compiles: can a reference to this object be observed from outside the method that created it? That analysis is called escape analysis, and it classifies each allocation into one of three outcomes.
| Result | Meaning | Optimizations unlocked |
|---|---|---|
NoEscape | The reference never leaves the method | Scalar replacement, lock elision |
ArgEscape | Passed to a method, but does not reach the heap or another thread | Lock elision |
GlobalEscape | Stored in a field, returned, or reachable by another thread | None |
An object escapes when it is returned, assigned to a field or static, added to a collection, thrown, or published to another thread. If none of that happens, the object is provably invisible to anyone else, and the compiler can rewrite the code as if it never existed.
public class Escape {
// NoEscape: the Point is created, read, and abandoned inside this method
double distance(double x, double y) {
Point p = new Point(x, y); // the JIT can delete this allocation
return Math.sqrt(p.x() * p.x() + p.y() * p.y());
}
// GlobalEscape: the reference is handed to the caller
Point origin() {
return new Point(0, 0); // must be a real heap object
}
private Point cached;
// GlobalEscape: stored in a field, so it outlives the call
void remember(double x, double y) {
this.cached = new Point(x, y);
}
record Point(double x, double y) { }
}
The main payoff is not what people usually claim. HotSpot does not generally "allocate the object on the stack". It does something better called scalar replacement: it dissolves the object and keeps its fields in registers or stack slots as if you had written the primitives by hand.
SOURCE AFTER SCALAR REPLACEMENT
------------------------------ -----------------------------------
Point p = new Point(x, y); (no object at all)
return sqrt(p.x()*p.x() double px = x; // in a register
+ p.y()*p.y()); double py = y; // in a register
return sqrt(px*px + py*py);
The second payoff is lock elision. A lock on an object that cannot escape can be removed entirely, because no other thread could ever contend for it.
// Every StringBuilder method is unsynchronized, but the classic example is
// a StringBuffer that never escapes: its locks are provably pointless.
String join(String a, String b) {
StringBuffer sb = new StringBuffer(); // NoEscape
sb.append(a); // synchronized method ...
sb.append(b); // ... but the lock can be elided
return sb.toString(); // the String escapes, the buffer does not
}
Escape analysis is a JIT optimization, which has three consequences that matter in practice.
- It only applies to hot code. The interpreter and the first compilation tier do not do it, so a method that runs ten times allocates normally.
- It is fragile. Inlining is a prerequisite, so a method too large to
inline, a megamorphic call site, or a
synchronizedblock in the wrong place can silently disable it. - You cannot rely on it. It is an optimization, not a guarantee, and it is absent in the specification.
You can observe its effect by turning it off and measuring the difference:
# Compare a benchmark with and without the optimization
java -XX:+DoEscapeAnalysis -jar bench.jar # the default
java -XX:-DoEscapeAnalysis -jar bench.jar # forces real allocations
# Show which allocations were eliminated (fastdebug JVM builds only)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintEliminateAllocations -jar bench.jar
The practical advice is deliberately boring. Do not hand-optimize for escape analysis. Write small, focused methods with short-lived local objects, which is what you should do for readability anyway, and the compiler will take the opportunities it finds. What you can usefully avoid is defeating it: do not stash objects in fields, static caches, or collections that outlive the work, because that turns a free allocation into a traced, promoted, long-lived one.
This connects back to design. An object that never escapes costs close to nothing, so "avoid creating objects" is bad advice. "Avoid publishing objects" is the useful version, and it improves thread safety and encapsulation at the same time.
Key Takeaways
- Allocation is a pointer bump in a thread-local buffer, so it is cheap but not free.
- Escape analysis asks whether a reference can be observed outside the method that created it.
- The three outcomes are
NoEscape,ArgEscape, andGlobalEscape.- Objects escape by being returned, stored in a field, added to a collection, thrown, or shared with another thread.
- The main optimization is scalar replacement, which dissolves the object into registers, not literal stack allocation.
- Lock elision removes synchronization on objects that cannot escape.
- It runs only in JIT-compiled hot code, depends on inlining, and is never guaranteed.
- Write small methods with short-lived locals rather than optimizing for it directly; the real cost is publishing objects, not creating them.
🧪 Practice
- Label each allocation in a class of your own as
NoEscape,ArgEscape, orGlobalEscape, and justify each label in one sentence.- Write a loop that creates ten million short-lived objects in a method that returns only a
double. Time it with-XX:+DoEscapeAnalysisand-XX:-DoEscapeAnalysisand report the ratio.- Take a method whose local object does not escape, then add a line that stores it in a field. Explain precisely which optimizations you just disabled.
- Interview: A colleague replaces a small local object with four loose primitive variables "for performance". When is that justified, and what would you measure first? (Hint: consider which tier of compilation the code actually reaches.)
<a id="112-garbage-collection-and-reference-types"></a>
11.2 Garbage Collection and Reference Types
This subchapter covers how the collector finds and reclaims garbage, the four reference strengths that let you tell it how badly you want an object kept, and the retention bugs that survive all of it.
Garbage Collection Fundamentals
In a language with manual memory management, every allocation creates an obligation to free it exactly once, at a moment when nothing else still needs it. Getting that wrong produces the two worst bug classes in systems programming: free too early and you have a dangling pointer that corrupts unrelated data; free twice or never and you have a crash or a leak. Worse, the obligation cannot be discharged locally, because whether an object is still needed is a property of the whole program.
Garbage collection removes the obligation by answering the question the programmer cannot: it computes reachability directly. The previous topic established the criterion. This one covers the mechanics.
Every collector is built from three primitive operations.
Mark-sweep traces from the roots, marking everything it reaches, then sweeps the heap and frees anything unmarked. Simple, and it leaves the heap fragmented.
BEFORE [A][B][ ][C][ ][D][E][ ] B and D unreachable
MARK [A*][B][ ][C*][ ][D][E*][ ] * = reached from a root
SWEEP [A][ ][ ][C][ ][ ][E][ ] free space, but in scattered holes
Mark-compact adds a third phase that slides the survivors together, which removes fragmentation and makes future allocation a pointer bump, at the cost of moving objects and updating every reference to them.
COMPACT [A][C][E][ ] one contiguous free region
Copying collection divides the space in two and copies live objects from one half to the other, then declares the source half entirely free. Its cost is proportional to the surviving data, not to the heap size, which makes it almost free when nearly everything is garbage.
That last property is decisive, because of an empirical observation that holds across nearly all programs, called the generational hypothesis:
Most objects die young. The overwhelming majority of allocations become unreachable almost immediately, and objects that have already survived a while tend to keep surviving.
Think of a restaurant kitchen. Nearly everything on the prep counter is scraps cleared within minutes, while the stockpot at the back stays for days. It would be absurd to inspect the stockpot every time you wipe the counter.
So the heap is split by age, and the two halves are collected with different algorithms at different frequencies.
HEAP
+--------------------------------------+----------------------------+
| YOUNG GENERATION | OLD GENERATION |
| +----------+ +------+ +------+ | |
| | Eden | | S0 | | S1 | | long-lived objects |
| +----------+ +------+ +------+ | |
+--------------------------------------+----------------------------+
^ ^ ^
| new objects | survivors copied | promoted after surviving
| allocated here | back and forth | enough young collections
| (TLAB bump) | (one is always empty) |
A minor GC (young collection) runs often and is fast: it copies the live objects out of Eden and the occupied survivor space into the other survivor space, bumping each object's age, and promotes anything old enough to the old generation. Eden is then entirely free. Because typically 90 to 98 percent of Eden is garbage, the copy is small.
A major or full GC touches the old generation, is much more expensive, and is what people mean when they complain about GC pauses.
| Event | Region collected | Typical duration | Frequency |
|---|---|---|---|
| Minor GC | Young generation only | Under 10 ms | Often |
| Major GC | Old generation | Tens to hundreds of ms | Rarely |
| Full GC | Entire heap, plus metaspace | 100 ms to seconds | Should be very rare |
One detail makes generational collection possible at all. To collect the young generation without tracing the old one, the JVM must know about old-to-young references. It tracks them with a write barrier, a few instructions injected after every reference field write, which records the change in a card table or remembered set. That is why a reference assignment is slightly more expensive than a primitive one.
Collection also generally requires the heap to stop changing while it is inspected, which is a stop-the-world pause. All application threads are suspended at a safepoint. Modern collectors work hard to do most of their work concurrently and keep only short pauses, but no production collector eliminates pauses entirely.
Which collector you get matters more than any other tuning decision.
| Collector | Flag | Optimized for | Use when |
|---|---|---|---|
| Serial | -XX:+UseSerialGC | Small footprint, one thread | Tiny heaps, containers with one CPU |
| Parallel | -XX:+UseParallelGC | Throughput | Batch jobs where pauses do not matter |
| G1 | -XX:+UseG1GC | Balance, pause targets | The default since Java 9; most server apps |
| ZGC | -XX:+UseZGC | Very low pauses, huge heaps | Latency-sensitive services, multi-terabyte heaps |
| Shenandoah | -XX:+UseShenandoahGC | Low pauses | Similar niche to ZGC |
| Epsilon | -XX:+UseEpsilonGC | Doing nothing at all | Benchmarking and allocation testing only |
G1, the default, is worth understanding because you are almost certainly using it. It divides the heap into a few thousand equal-sized regions, each tagged at any moment as Eden, survivor, old, or humongous, rather than using fixed contiguous generations. It then collects the regions with the most garbage first, which is where "garbage first" comes from, choosing how many to include in order to meet a pause-time target you set.
ZGC is the modern low-latency option: pauses stay in the sub-millisecond range essentially independent of heap size, and since Java 21 it is generational by default.
The flags worth knowing are few:
# Sizing and collector choice
java -Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -jar app.jar
# Observability: always turn GC logging on in production, it is nearly free
java -Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=10M -jar app.jar
# Get a heap dump automatically when the heap is exhausted
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app.hprof -jar app.jar
Two last points that come up constantly.
Do not call System.gc(). It is a suggestion, it typically triggers a
full stop-the-world collection, and if it appears to fix a problem, the real
problem is retention that is still there. Production JVMs are often launched
with -XX:+DisableExplicitGC precisely to neutralize library code that calls
it.
OutOfMemoryError is not always a leak. Read the message, because each
variant points somewhere different.
| Message | Actual cause |
|---|---|
Java heap space | Genuinely too much live data, or a retention leak |
GC overhead limit exceeded | Over 98% of time in GC reclaiming under 2%: near-full heap |
Metaspace | Too many loaded classes, often a class loader leak |
unable to create native thread | OS thread or memory limit, not the Java heap |
Requested array size exceeds VM limit | An array larger than roughly Integer.MAX_VALUE |
Cannot reserve ... direct buffer memory | ByteBuffer.allocateDirect off-heap, bounded by MaxDirectMemorySize |
Key Takeaways
- Garbage collection replaces a non-local correctness obligation with a computed reachability answer.
- The three primitives are mark-sweep, mark-compact, and copying.
- Copying costs scale with surviving data, not heap size, which makes young collection cheap.
- The generational hypothesis says most objects die young, which is why the heap is split by age.
- Minor GC collects only the young generation and is frequent and fast; full GC is rare and expensive.
- A write barrier and card table record old-to-young references so the young generation can be collected alone.
- Collection requires stop-the-world pauses at safepoints, which modern collectors shorten rather than eliminate.
- G1 is the default and collects the emptiest regions first against a pause target; ZGC targets sub-millisecond pauses.
System.gc()is only a suggestion and usually hides the real problem.- Read the
OutOfMemoryErrormessage, because metaspace, native threads, and direct buffers are not heap problems.
🧪 Practice
- Run any allocation-heavy program with
-Xlog:gcand count the minor collections against the major ones.- Run the same benchmark under
-XX:+UseSerialGC,-XX:+UseParallelGC, and-XX:+UseG1GCwith a fixed heap, and record total time and worst pause.- Trigger each of
Java heap spaceandGC overhead limit exceededdeliberately, and explain what differs between the two situations.- Interview: Your service has a 99th-percentile latency spike every few minutes that matches a full GC. Name three things you would check before changing any GC flag. (Hint: whether the heap is too small and whether something is retaining objects are different diagnoses.)
Strong, Soft, Weak, and Phantom References
Reachability as described so far is binary, and that turns out to be too blunt for two common designs.
Consider a cache. You want entries kept while memory is plentiful and dropped
when it is not. An ordinary Map cannot express that: every entry is reachable,
so nothing is ever evicted, and the cache grows until the heap is gone.
Now consider metadata keyed by object. You want to attach extra information to
objects someone else owns, for exactly as long as those objects live. An
ordinary Map keeps every key alive forever, so your bookkeeping table stops
the very objects it describes from being collected.
Both problems need the same thing: a reference that does not, by itself, keep
its target alive. The java.lang.ref package provides three of them, giving
four strengths in total.
| Type | Cleared by the collector when | Survives an OOM? | Typical use |
|---|---|---|---|
| Strong (ordinary) | Never; retention is guaranteed | Heap fills first | Everything normal |
SoftReference | The JVM needs memory | No, cleared first | Memory-sensitive caches |
WeakReference | No strong reference remains, at the next GC | No | Canonical maps, listener registries |
PhantomReference | Already unreachable; enqueued before reclamation | No | Post-mortem resource cleanup |
The ordering is a promise about collection order: soft references are cleared only when memory is tight, weak references are cleared at the first collection that notices them, and phantom references are enqueued after the object is already unreachable.
STRENGTH WILL THE OBJECT SURVIVE?
--------------------- ---------------------------------------
strong o-----> obj always, until the reference goes away
soft o- - -> obj yes, until memory pressure
weak o . . > obj no, cleared at the next GC
phantom o ~ ~ > obj no, and get() always returns null
The basic mechanics are the same for all three: wrap the target, and call
get() to attempt to retrieve it.
import java.lang.ref.*;
public class Weakly {
public static void main(String[] args) throws Exception {
Object target = new Object();
WeakReference<Object> weak = new WeakReference<>(target);
System.out.println(weak.get() != null); // true: a strong ref exists
target = null; // drop the ONLY strong reference
System.gc(); // a hint; adequate for a demonstration only
Thread.sleep(100);
System.out.println(weak.get() == null); // true: the weak ref was cleared
}
}
The critical discipline with get(): it can return null at any moment, so
assign it to a local variable once and check that, rather than calling get()
twice.
// WRONG: the object can be collected between the two calls
if (ref.get() != null) {
ref.get().doWork(); // may throw NullPointerException
}
// RIGHT: one call creates a strong reference for the duration of the block
Widget w = ref.get();
if (w != null) {
w.doWork(); // w keeps it alive here
}
WeakHashMap packages the weak-key pattern. Its entries vanish once nothing
else references the key, which is exactly the metadata case.
import java.util.*;
public class Metadata {
// Keys are held weakly: an entry disappears when its key becomes garbage
private final Map<Session, Stats> stats = new WeakHashMap<>();
void record(Session s, long millis) {
stats.computeIfAbsent(s, k -> new Stats()).add(millis);
}
}
There is a trap here that catches everyone once. If the value references the key, the entry is immortal, because the value is held strongly by the map, and through it the key stays strongly reachable.
// BROKEN: Stats holds its Session, so the weak key can never be cleared
class Stats { Session owner; }
// FIX: hold the back-reference weakly, or do not hold it at all
class Stats { WeakReference<Session> owner; }
SoftReference suits caches whose entries are expensive to rebuild but
always rebuildable.
import java.lang.ref.SoftReference;
import java.util.*;
public class ImageCache {
private final Map<String, SoftReference<byte[]>> cache = new HashMap<>();
public byte[] get(String path) {
SoftReference<byte[]> ref = cache.get(path);
byte[] data = (ref == null) ? null : ref.get(); // may have been cleared
if (data == null) { // miss, or cleared under pressure
data = load(path); // rebuild
cache.put(path, new SoftReference<>(data));
}
return data;
}
private byte[] load(String path) { return new byte[1024]; }
}
Be honest about soft references, though: the JVM's clearing policy is opaque and
tied to -XX:SoftRefLRUPolicyMSPerMB, they tend to be cleared all at once under
pressure, and they can make a full GC worse. For real caching, a library such as
Caffeine with an explicit size and time policy is almost always the better
answer. Note also that the map above accumulates dead SoftReference wrappers
forever, which the next mechanism solves.
ReferenceQueue is how you find out that clearing happened. Register a
queue when you create the reference, and the collector enqueues the reference
object after clearing its target.
import java.lang.ref.*;
import java.util.*;
public class QueueDemo {
// Subclass so the enqueued reference still tells us which key to remove
static class KeyedRef extends WeakReference<Object> {
final String key;
KeyedRef(String key, Object value, ReferenceQueue<Object> q) {
super(value, q); // register with the queue
this.key = key;
}
}
private final Map<String, KeyedRef> map = new HashMap<>();
private final ReferenceQueue<Object> queue = new ReferenceQueue<>();
void purge() {
KeyedRef ref;
while ((ref = (KeyedRef) queue.poll()) != null) { // non-blocking drain
map.remove(ref.key); // remove the dead entry
}
}
}
PhantomReference exists for cleanup that must happen after the object is
gone. Its get() always returns null by design, so you cannot resurrect the
object the way finalize() could. In modern code you almost never use it
directly, because java.lang.ref.Cleaner wraps it correctly.
import java.lang.ref.Cleaner;
public class NativeBuffer implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
// MUST be a static class holding no reference to NativeBuffer. An inner
// class would capture the outer instance, which would then never become
// unreachable, and the cleanup would never run.
private static class State implements Runnable {
private long handle;
State(long handle) { this.handle = handle; }
@Override public void run() {
if (handle != 0) {
// free(handle) via native code
handle = 0; // idempotent: safe to run twice
}
}
}
private final State state;
private final Cleaner.Cleanable cleanable;
public NativeBuffer(long handle) {
this.state = new State(handle);
this.cleanable = CLEANER.register(this, state); // the safety net
}
@Override
public void close() {
cleanable.clean(); // the PRIMARY path: prompt and deterministic
}
}
That class shows the correct pattern in full: close() is how cleanup is
supposed to happen, and the Cleaner only covers the case where a caller
forgot. The static nested state holder is not a style choice but a
requirement.
The summary rule: use ordinary strong references unless you can state exactly
which of these two problems you have. Weak for "do not let my bookkeeping
extend a lifetime", soft for "cheap to lose, expensive to rebuild", and
Cleaner for "native memory must eventually be freed".
Key Takeaways
- Ordinary references are strong and guarantee retention;
java.lang.refsupplies three weaker strengths.- Soft references are cleared only under memory pressure; weak ones at the next collection that notices them.
- Phantom references are enqueued after the object is unreachable, and their
get()always returnsnull.- Call
get()once into a local variable, then null-check that, because the target can vanish between calls.WeakHashMapholds keys weakly, which fits per-object metadata.- A
WeakHashMapvalue that references its own key makes the entry immortal.- A
ReferenceQueuenotifies you after clearing, so you can purge dead wrappers.- Prefer a real cache library over
SoftReferencewhen you need eviction policy.Cleanerreplacesfinalize(), and its state holder must be a static class that does not reference the owner.close()throughAutoCloseableis the primary cleanup path;Cleaneris only a backstop.
🧪 Practice
- Create a
WeakReferenceto an object, drop the strong reference, force a collection, and confirmget()returnsnull. Then repeat with aSoftReferenceand explain why the result differs.- Build a
WeakHashMapof one thousand keys, drop half the key references, force a collection, and print the resulting size.- Write the broken
WeakHashMapcase where the value references the key, demonstrate that the map never shrinks, then fix it.- Implement
NativeBufferabove with a counter instead of a native handle, and prove thatclose()runs the cleanup once and that a secondclose()is harmless.- Interview: You are asked to build a cache and reach for
SoftReference. What would a reviewer object to, and what would they suggest? (Hint: who decides the eviction policy, and can you predict it?)
Memory Leaks in Object Graphs
Java cannot leak in the C sense, because nothing is ever forgotten by the allocator. Yet Java programs run out of memory constantly, and the reason is subtler and harder to spot.
A Java memory leak is unintentional retention: an object you are finished with is still reachable from a GC root. The collector is working perfectly. It is correctly concluding that an object you forgot about is still needed, because you left a path to it.
The analogy is a library with a flawless catalogue. Nothing is ever lost, but if readers keep signing books out and never return them, the shelves still empty. The catalogue is not broken; the borrowing is.
Leaks cluster into a small number of recurring shapes, and recognizing them is most of the skill.
1. Unbounded static collections. A static field is a GC root, so anything
it holds lives as long as the class loader. This is the single most common
cause.
public class Registry {
// A GC root that only ever grows. Nothing here is ever collected.
private static final List<Session> ALL = new ArrayList<>();
public static void register(Session s) { ALL.add(s); }
// ... and no remove() anywhere in the codebase
}
2. Stale references in your own data structures. The classic from Effective Java: a hand-written stack that decrements its size without clearing the slot.
public class Stack {
private Object[] elements = new Object[16];
private int size;
public void push(Object e) { elements[size++] = e; }
// LEAK: the slot still holds the reference, so the object stays reachable
public Object popLeaky() {
return elements[--size];
}
// FIXED: clear the obsolete slot
public Object pop() {
Object result = elements[--size];
elements[size] = null; // let the collector have it
return result;
}
}
The amount retained is worth being precise about, because it explains why this leak hides so well. The array keeps stale references in every slot below its high-water mark, so the waste equals the largest the stack ever got, not the total number of objects pushed. A stack that stays around ten elements leaks almost nothing. A stack that once held fifty thousand and is now empty retains all fifty thousand, indefinitely.
after pushing 5000, then popping all 5000 with popLeaky()
size = 0 <- logically empty
elements = [ref][ref][ref][ref] ... [ref] [null][null] ...
\________ 5000 stale references still reachable ______/
the collector cannot touch any of them
That also makes it reproducible. With a 96 MB heap, pushing 5000 arrays of
10 KB, popping them all, and then asking for another 50 MB throws
OutOfMemoryError with popLeaky and succeeds with pop.
This is worth internalizing because it generalizes: whenever you manage the
storage, you own the job of nulling out obsolete references. Inside ArrayList
and friends this is already done for you, which is a good reason to prefer them.
3. Listeners and callbacks that are never unregistered. Registration creates a reference from the long-lived publisher to the short-lived subscriber.
public class Widget {
public Widget(EventBus bus) {
bus.subscribe(this::onEvent); // the bus now holds this Widget forever
}
private void onEvent(Event e) { }
// No unsubscribe, so every Widget ever created is retained by the bus.
}
The fix is symmetry: whatever registers must also deregister, usually in a
close() method, or the registry must hold subscribers weakly.
4. ThreadLocal in a pooled thread. Thread-pool threads are never
discarded, so their thread-local map is never discarded either.
public class Context {
private static final ThreadLocal<UserSession> CURRENT = new ThreadLocal<>();
public static void set(UserSession s) { CURRENT.set(s); }
// MANDATORY in any pooled environment, and belongs in a finally block
public static void clear() { CURRENT.remove(); }
}
Every servlet filter and request interceptor that sets a thread-local must
remove it in finally. Forgetting this leaks one session per pool thread, and
in an application server it also pins the web application's class loader.
5. Non-static inner classes and lambdas capturing this. A non-static
nested class holds an implicit reference to its enclosing instance, so a
long-lived inner object keeps a large outer object alive.
public class Report {
private final byte[] hugeData = new byte[100_000_000];
// Non-static: every Row silently retains the whole Report, including hugeData
class Row { }
// Static: retains nothing extra
static class BetterRow { }
// A method reference to an instance method also captures this
Runnable leaky() { return this::render; } // retains the Report
Runnable notLeaky() { return () -> System.out.println("ok"); } // captures nothing
private void render() { }
}
6. Caches with no eviction. A HashMap used as a cache is an unbounded
collection with a friendly name. Use a real cache with a maximum size or a time
policy.
7. Unclosed resources. An open stream, connection, or channel holds buffers and often native memory. Try-with-resources exists for this.
8. Class loader leaks. In an application server, a single strong reference
from container code into a redeployed application, very often a thread-local or
a JDBC driver registration, retains that application's entire class loader and
therefore every class it loaded. This is what usually produces
OutOfMemoryError: Metaspace after several redeployments.
Here is the general shape of all eight:
GC ROOT what you think you have
(static field, ------> COLLECTION ------> (nothing)
pooled thread, |
event bus, +--> entry --> object --> object --> object
class loader) ^
|
what you actually still have: all of it,
retained by ONE reference you forgot
Diagnosing a suspected leak follows a fixed sequence, and the order matters because each step rules something out.
# 1. Confirm it is growth, not just a small heap. Live set should be flat after
# a full GC; a leak makes the post-GC floor climb steadily over hours.
java -Xlog:gc* -jar app.jar
# 2. List processes, then watch class histograms while the heap grows
jcmd -l
jcmd <pid> GC.class_histogram | head -20
# 3. Capture a heap dump. Do this twice, minutes apart, and compare.
jcmd <pid> GC.heap_dump /tmp/app-1.hprof
# 4. Inspect it in Eclipse MAT or VisualVM
The one concept you need in order to read a heap dump is the difference between two sizes:
| Term | Meaning |
|---|---|
| Shallow size | The object's own bytes, excluding what it references |
| Retained size | Everything that would be freed if this object became unreachable |
| Dominator | The object through which all paths to a subgraph pass |
Sort by retained size, not shallow size. A HashMap with a shallow size of
48 bytes can retain two gigabytes. Then use the "path to GC roots" view on the
biggest retainer, which names the exact field that is holding on. That single
view answers "who is keeping this alive", which is the only question that
matters.
The comparison of two dumps is the most reliable signal: whatever class grew by hundreds of thousands of instances between them is your leak.
Finally, the fixes, mapped to the causes:
| Symptom found in the dump | Usual fix |
|---|---|
Huge static collection | Bound it, or remove entries on completion |
| Growing listener list | Unregister in close(), or hold weakly |
ThreadLocal entries on pool threads | remove() in a finally block |
| Map used as a cache | A real cache with size and time eviction |
| Many inner-class instances retaining outers | Make the nested class static |
| Your own array or buffer holding old slots | Null out obsolete elements |
| Class loaders retained after redeploy | Remove container-side references, deregister drivers |
Key Takeaways
- A Java leak is unintentional retention, not lost memory; the collector is behaving correctly.
staticfields are GC roots, so unbounded static collections are the most common leak.- When you manage storage yourself, you must null out obsolete references.
- Registration without deregistration leaks one object per registration.
ThreadLocalmust be removed in afinallyblock on pooled threads.- Non-static inner classes and
this-capturing lambdas retain their enclosing instance.- A
HashMapused as a cache is an unbounded collection; use eviction.- Confirm growth in the post-GC live set before capturing dumps.
- Compare two heap dumps taken minutes apart to see what is growing.
- Sort by retained size, not shallow size, then follow the path to GC roots to name the culprit.
🧪 Practice
- Write the leaky
Stackabove. Under-Xmx96m, push 5000 arrays ofnew byte[10_000], pop them all, then allocate another 50 MB. Show thatpopLeakythrowsOutOfMemoryErrorand the one-line fix does not.- Build an event bus that retains subscribers, then rewrite it with
WeakReferencesubscribers and demonstrate the difference in retained count.- Create a non-static inner class inside a class holding a large array, keep ten thousand inner instances, and measure the heap. Make the nested class
staticand measure again.- Capture two heap dumps of a deliberately leaking program a minute apart and identify the growing class by retained size in VisualVM or Eclipse MAT.
- Interview: A service restarts with
OutOfMemoryError: Java heap spaceevery few days. Walk through how you would distinguish a genuine leak from an undersized heap. (Hint: what should the live set look like immediately after a full GC in each case?)
<a id="12-concurrency-and-parallelism"></a>