Memory Leaks in Java: Weak, Soft and Phantom References
Java collects every object unreachable from GC roots, so cycles never leak. What a Java memory leak really is, and strong, soft, weak and phantom references.
- Course: Java study plan
- Module: Memory and the JVM runtime
- Kind: Lesson
- Reading time: 14 min
- Runtime: OpenJDK 18
What is a memory leak in Java?
A memory leak in Java is an object that is still reachable from a GC root but will never be used again, so the garbage collector must keep it. The usual causes are static collections that only grow, listeners never removed, ThreadLocal values in thread pools and unclosed resources. The fix is to remove the reference when done, or hold it through a reference type the collector may clear.
Lesson
An object is born at new, lives while something can reach it, and is reclaimed some time after nothing can. Java has no delete; you cannot free an object, you can only stop referring to it. That single rule — reachability, not reference counting — decides what a "memory leak" means in Java, why cycles are harmless, and why WeakHashMap and SoftReference exist. This lesson follows an object from allocation to collection and introduces the four strengths of reference.
Reachability and GC roots
The collector starts from the roots: every local variable and operand on every thread's stack, every static field of every loaded class, JNI handles, and a few runtime internals. Anything a root points to is live; anything a live object points to is live; and so on transitively. Everything else is garbage, no matter how many other garbage objects point to it.
Node a = new Node(); Node b = new Node();
a.next = b; b.next = a; // a cycle
a = null; b = null; // nothing on the stack reaches the pair: both are garbage
Reference counting would keep that cycle alive forever; tracing from roots does not — the count of incoming references is irrelevant, only the path from a root matters. This is the first thing to say when asked "does Java leak with circular references?": no.
What a leak is, then
A Java leak is an object that is reachable but never used again. The collector cannot tell the difference between "still needed" and "forgotten in a collection", so the classic leaks are all forms of forgetting:
- A
static ListorMapthat only ever grows (a cache with no eviction, a registry nothing unregisters from). - Listeners and callbacks registered with a long-lived object and never removed — the subject keeps the observer alive, and the observer keeps everything it references alive.
ThreadLocalvalues in a thread pool: the thread outlives the request, so does the value, unlessremove()is called.- Inner-class instances holding an implicit
thisto a large outer object (a lambda or anonymous class stored somewhere long-lived). - Substrings before Java 7u6 shared the parent's
char[]; a habit of keeping a two-character substring of a 10 MB string kept the 10 MB. Fixed now — but the shape of the bug, a small handle retaining a large structure, recurs everywhere (anIteratorkept alive, aMap.Entry, a view fromsubList). - Unclosed resources: a
FileInputStreamthat is not closed holds a native file descriptor the collector may take minutes to release.
The fix is always the same: remove the reference when you are done, or use a reference type the collector may clear.
The four reference strengths
| Reference | Collected when… | Typical use |
|---|---|---|
Strong (Object o = …) | never, while reachable | everything by default |
SoftReference<T> | memory is short — cleared before an OutOfMemoryError | memory-sensitive caches |
WeakReference<T> | the next GC finds no strong refs | canonicalising maps, listener lists, WeakHashMap keys |
PhantomReference<T> | after finalization, before memory reuse | resource cleanup scheduling (with a ReferenceQueue) |
WeakReference<byte[]> ref = new WeakReference<>(new byte[1_000_000]);
byte[] data = ref.get(); // the array, or null if it has been collected
get() on a weak or soft reference returns null once the collector has cleared it; code using them must always handle that. A ReferenceQueue passed to the constructor is where the reference object itself is enqueued after clearing, so a cache can learn that an entry died without polling.
WeakHashMap holds its keys weakly: when a key is no longer strongly referenced elsewhere, the entry disappears at the next collection. It is right for metadata attached to objects you do not own (a map from Thread to some per-thread info) and wrong for anything whose keys are String literals or Integers — those are interned or cached and never become unreachable, so the map never shrinks.
Soft references are a tempting cache but a poor one in practice: the JVM clears them all at once under pressure, giving a cache that is either full or empty. A bounded LRU (LinkedHashMap with removeEldestEntry, lesson 6) or a real cache library is nearly always better.
Finalization is gone; use Cleaner or try-with-resources
Object.finalize() was deprecated in Java 9 and marked for removal in 18: it ran at an unpredictable time on an unspecified thread, could resurrect objects, slowed collection, and an exception in it was silently swallowed. Resource cleanup is the job of AutoCloseable + try-with-resources, deterministic and immediate. For the rare case where a class must clean up native memory even if a careless caller forgets close(), java.lang.ref.Cleaner registers a cleanup action that runs after the object becomes phantom-reachable:
class Handle implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private final Cleaner.Cleanable cleanable;
Handle(long nativePtr) { this.cleanable = CLEANER.register(this, () -> free(nativePtr)); } // the action must not capture `this`
@Override public void close() { cleanable.clean(); }
}
The safety net runs eventually; close() runs now. Always provide and prefer close().
The lifecycle, end to end
- Allocation —
newbumps a pointer in the thread's local allocation buffer (TLAB): a few instructions, no lock. This is why "allocation is expensive" is folklore from another language; in Java it is collection that costs, in proportion to how many objects survive. - Use — the object is strongly reachable from a root.
- Unreachable — the last strong reference is overwritten or its frame is popped. Nothing happens yet.
- Discovered — a collection runs, traces from roots, does not find the object. Weak references to it are cleared and enqueued.
- Reclaimed — its memory is reused. If it had a
Cleaner, the action runs on the cleaner thread.
Between 3 and 5 the memory is still occupied; System.gc() suggests a collection and is ignored by many collectors (-XX:+DisableExplicitGC). Code that depends on an object being collected at a particular moment is wrong by design.
Interview angle
- "How does Java decide what to collect?" Reachability from GC roots — stack locals, statics, JNI handles — traced transitively; not reference counts.
- "Can circular references leak?" No; an unreachable cycle is garbage.
- "What is a memory leak in Java?" A reachable object no one will use again — static collections, unremoved listeners,
ThreadLocals in pools. - "Weak versus soft reference?" Weak: cleared at the next GC once unreferenced. Soft: kept until memory is short.
- "Why not
finalize()?" Unpredictable, slow, unsafe, deprecated; use try-with-resources, andCleaneras a last-resort safety net.
Key takeaways
- Live = reachable from a root; everything else is garbage, cycles included.
- Leaks are forgotten references: static collections, listeners,
ThreadLocals, small handles to big structures. - Strong / soft / weak / phantom — decreasing strength, increasing willingness to be cleared;
get()may returnnull. WeakHashMapweak-keys; useless with interned or cached keys.- No
finalize;AutoCloseablenow,Cleaneras the safety net; never rely on when a collection happens.
Common questions
Can circular references cause a memory leak in Java?
No. The garbage collector traces reachability from GC roots — stack locals, static fields, JNI handles — instead of counting references, so a cycle of objects that no root can reach is garbage and is collected like any other.
What is the difference between weak and soft references in Java?
A WeakReference is cleared at the next garbage collection once no strong reference to its object remains. A SoftReference is kept until memory runs short and is cleared before an OutOfMemoryError. With both, get() returns null once the reference has been cleared.
What are GC roots in Java?
GC roots are where the collector's trace starts: the local variables and operands on every thread's stack, the static fields of loaded classes, JNI handles and a few runtime internals. Anything reachable from a root is live; everything else is garbage.
When should I use WeakHashMap?
Use WeakHashMap for metadata attached to objects you do not own: it holds its keys weakly, so an entry disappears once its key is no longer strongly referenced elsewhere. It never shrinks with String literal or small Integer keys, because those are pooled or cached and stay reachable.
Why is finalize() deprecated in Java?
finalize() ran at an unpredictable time on an unspecified thread, could resurrect objects, slowed collection and silently swallowed exceptions. It was deprecated in Java 9 and marked for removal in Java 18; clean up with AutoCloseable and try-with-resources, and use Cleaner only as a safety net.
Exercises
Mark from the roots
Implement the mark phase. Read an integer n and n object names; an integer r and r root names; an integer e and e edges from to (object from holds a reference to to). Print live=<reachable objects in natural order, space-separated> and garbage=<the rest, natural order> (print - for an empty list). Cycles among unreachable objects are still garbage.
Example: 5 then a b c d e, 1 then a, 4 then a b, b c, d e, e d →
live=a b c
garbage=d eA cache with weak entries
Simulate a map whose entries can be held strongly or weakly. Read an integer n and n commands: put <k> <v> strong|weak stores the entry with that strength (replacing any previous); gc removes every weak entry and prints gc cleared <count>; get <k> prints <k>=<v> or <k>=null. After all commands print size=<entries>.
Example: 6 then put a 1 strong, put b 2 weak, get b, gc, get b, get a →
b=2
gc cleared 1
b=null
a=1
size=1In this module: Memory and the JVM runtime
- Stack and heap — where every value lives
- Object lifecycle, reachability and reference types (this lesson)
- Garbage collection — generations, pauses and the collectors
- The string pool, interning and the Integer cache
- Class loading, initialisation order and the JIT
- Diagnosing memory problems — leaks, dumps and bounded caches
- Checkpoint — Memory and the JVM runtime
← Stack and heap — where every value lives · Garbage collection — generations, pauses and the collectors →