Why this matters
- "Reachable, not referenced" is the distinction that makes reference-counting cycles a non-problem in Java and explains why
null-ing a field sometimes does nothing. - The choice of collector is one of the few JVM settings that genuinely changes application behaviour, and the defaults have changed across versions.
- The four reference strengths are the mechanism behind every cache that evicts itself under memory pressure.
Reachability
The collector begins at the GC roots and follows every reference it can.
What counts as a root
- Local variables and parameters in every frame of every live thread's stack.
- Static fields of loaded classes.
- Active threads themselves.
- JNI references held by native code.
- Objects used as monitors in a
synchronizedblock.
Anything not reached from a root is garbage, regardless of how many references point at it.
class Node { Node partner; }
Node a = new Node();
Node b = new Node();
a.partner = b;
b.partner = a; // a cycle: each references the other
a = null;
b = null; // no root reaches either one
// Both are collectable. A reference-counting collector would leak this cycle;
// a tracing collector simply never reaches it.
The phases
- Mark — walk from the roots and flag every reachable object.
- Sweep — reclaim the space held by unmarked objects.
- Compact — slide survivors together so free space is contiguous, which keeps allocation a pointer bump and prevents fragmentation.
Young collections use copying instead: survivors are moved to the empty survivor space in one pass, which marks, frees and compacts simultaneously. This is why collecting the young generation is so much cheaper than collecting the old one.
Stop-the-world
Some phases require application threads to be paused, because moving an object while a thread is reading it would be unsafe. Threads stop at safepoints — predetermined spots in compiled code where their state is known.
Modern collectors minimise but cannot entirely eliminate these pauses. Concurrent marking happens while the application runs; the short pauses that remain are for starting and finishing the cycle.
The collectors
| Aspect | Collector | Characteristics |
|---|---|---|
| Serial | One thread, stop-the-world | Small heaps, single CPU, containers with one core |
| Parallel | Multiple threads, stop-the-world | Best raw throughput; pauses can be long |
| G1 | Region-based, concurrent marking | The default since Java 9; balances pause and throughput |
| ZGC | Concurrent, sub-millisecond pauses | Very large heaps where latency dominates |
| Shenandoah | Concurrent compaction | Similar goals to ZGC, different implementation |
Serial
CollectorOne thread, stop-the-worldCharacteristicsSmall heaps, single CPU, containers with one coreParallel
CollectorMultiple threads, stop-the-worldCharacteristicsBest raw throughput; pauses can be longG1
CollectorRegion-based, concurrent markingCharacteristicsThe default since Java 9; balances pause and throughputZGC
CollectorConcurrent, sub-millisecond pausesCharacteristicsVery large heaps where latency dominatesShenandoah
CollectorConcurrent compactionCharacteristicsSimilar goals to ZGC, different implementation
G1 is the default and the right starting point. Change only with latency measurements that justify it.
-XX:+UseSerialGC
-XX:+UseParallelGC
-XX:+UseG1GC # default since Java 9
-XX:MaxGCPauseMillis=200 # G1's pause target, a goal rather than a guarantee
-XX:+UseZGC -XX:+ZGenerational # generational ZGC, production-ready in Java 21
How G1 differs
G1 divides the heap into equal-sized regions — typically 1 to 32 MB — and assigns each the role of Eden, survivor or old dynamically. It then collects the regions containing the most garbage first, which is where the name "garbage first" comes from.
- Generations are logical sets of regions, not fixed contiguous areas, so their sizes adapt automatically.
- Marking runs concurrently with the application; only short pauses remain.
- Collection is incremental — a subset of regions per cycle, chosen to fit the pause target.
- Objects larger than half a region go into humongous regions, which are handled specially and are worth avoiding where you can.
The four reference strengths
Reachability is not binary. Three special reference types let you tell the collector how much you care.
| Aspect | Reference type | When the object is collected |
|---|---|---|
| Strong — Object o = new Object() | Never while the reference exists | The ordinary case |
| SoftReference | Only when memory is short | Memory-sensitive caches |
| WeakReference | At the next collection | Canonical maps, metadata keyed by object |
| PhantomReference | Already finalised; get() always returns null | Cleanup after collection, via a queue |
Strong — Object o = new Object()
Reference typeNever while the reference existsWhen the object is collectedThe ordinary caseSoftReference
Reference typeOnly when memory is shortWhen the object is collectedMemory-sensitive cachesWeakReference
Reference typeAt the next collectionWhen the object is collectedCanonical maps, metadata keyed by objectPhantomReference
Reference typeAlready finalised; get() always returns nullWhen the object is collectedCleanup after collection, via a queue
Soft means 'keep if you can'. Weak means 'drop as soon as nobody else wants it'.
// Soft: a cache that shrinks under pressure instead of causing OutOfMemoryError
private final Map<String, SoftReference<Image>> cache = new HashMap<>();
Image load(String key) {
SoftReference<Image> ref = cache.get(key);
Image image = ref == null ? null : ref.get();
if (image == null) {
image = decode(key);
cache.put(key, new SoftReference<>(image));
}
return image;
}
// Weak: metadata that disappears when the subject does
Map<Session, Metrics> metrics = new WeakHashMap<>();
metrics.put(session, new Metrics());
// When nothing else references `session`, the entry vanishes on its own.
What you can and cannot control
System.gc(); // a request, which the JVM is free to ignore entirely
What actually helps
- Let objects go out of scope. The collector is better at this than you are.
- Null out long-lived fields that hold large objects you have finished with. In a long-lived cache or a custom data structure this is worth doing; for a local variable it is pointless.
- Prefer short-lived objects. The young generation is specifically optimised for them, so churn is cheap.
- Bound every cache. An unbounded cache is a leak with good intentions.
- Close resources with try-with-resources. Native memory and file handles are not managed by the collector at all.
Finalisation is gone
finalize() was deprecated in Java 9 and removed in Java 18. It was unreliable by design: there was no
guarantee it would ever run, it could resurrect the object, and it delayed collection by an extra cycle.
// The modern replacement for native resource cleanup
public class NativeBuffer implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private final Cleaner.Cleanable cleanable;
public NativeBuffer(long address) {
// The action must not reference the NativeBuffer itself, or it can never be collected
this.cleanable = CLEANER.register(this, () -> free(address));
}
@Override
public void close() {
cleanable.clean(); // deterministic cleanup, the normal path
}
private static void free(long address) { }
}
Cleaner is a safety net for the case where someone forgets close(). Deterministic cleanup through
try-with-resources remains the primary mechanism.
Common misreadings
- "An object with references pointing at it cannot be collected." Only reachability from a root matters. Isolated cycles are collected.
- "
System.gc()collects garbage." It requests a collection, which may be ignored, and usually forces the most expensive kind. - "Setting a local to
nullhelps." The frame is discarded on return anyway. This only matters for long-lived fields. - "Garbage collection prevents memory leaks." It prevents unreachable memory from accumulating. Objects you still reference but no longer need are a leak the collector cannot help with.
- "
finalize()is a safety net." It was never guaranteed to run, and no longer exists. - "A bigger heap means less GC work." It means less frequent but longer collections, and a larger live set to mark.
- "ZGC is always better." It trades throughput for latency and is worth it only when pause times are the actual constraint.
Quick recall
Everything you need if you only revisit this box.
- Collection is based on reachability from GC roots — stack frames, static fields, live threads, JNI, monitors. Reference cycles are not a problem.
- Phases are mark, sweep, compact; young collections copy, which does all three at once.
- Pauses happen at safepoints. Modern collectors mark concurrently and keep pauses short.
- G1 is the default since Java 9: fixed-size regions, dynamic generation sizing, concurrent marking, incremental collection against a pause target.
- Soft references survive until memory is short; weak ones die at the next collection; phantom ones support post-collection cleanup.
- In a
WeakHashMaponly keys are weak — a value referencing its key defeats it. - Never call
System.gc(). Bound your caches and close your resources instead. finalize()is removed; use try-with-resources, withCleaneras a backstop for native memory.
Test yourself
Answer these before moving on — recall is what makes it stick.