Garbage Collection
Here is the classic textbook demo: override finalize(), set the reference to null, call System.gc() and wait for «Cup disappears forever» to show up in the console. Run that code on a current JDK and the console may stay completely empty. The JVM is not obliged to honour a collection request, it is not obliged to run finalizers before main() returns, and on JDK 18+ a single launch flag — java --finalization=disabled Cup — guarantees that nothing is printed at all. That is exactly why finalize() is not a tool for releasing resources.
1. Garbage collection in Java
The garbage collector (GC) in Java is a JVM subsystem that automatically finds objects that are no longer reachable from the running program and frees the memory they occupy. In languages such as C or C++ the developer releases that memory manually. In Java the JVM does it for you: you allocate an object with new, and once nothing refers to it any more, the garbage collector will eventually reclaim it.
What this buys you in practice:
- no manual
free()ordelete, and therefore no double frees and no dangling pointers; - allocation is cheap — in the Eden space it is essentially a pointer bump;
- the price is GC pauses and the loss of control over when memory is actually released.
The HotSpot heap is split into generations: the young generation (Eden plus two Survivor spaces) and the old generation (Tenured). Most objects die young, so a minor GC over the young generation is fast; objects that survive several collections are promoted to the old generation, which is collected less often and at a higher cost (major or full GC).
Important
The garbage collector manages the heap only. Since Java 8 class metadata lives in Metaspace (which replaced PermGen) outside the heap, and memory allocated through ByteBuffer.allocateDirect() or JNI is off-heap as well — the GC does not release it directly.
2. When an object becomes garbage: reachability and GC roots
An object is garbage when it cannot be reached through any chain of references starting from a GC root. The roots include:
- local variables and method parameters on the stacks of all live threads;
- static fields of loaded classes;
- live
Threadobjects themselves; - references held by native code (JNI) and objects used as synchronization monitors.
Consider an example. In the main() method of the Cup class a Cup object is created and referenced by the variable cup. After the line cup = null executes, the object still sits in memory but nothing points to it any more, so it becomes a candidate for collection. The Spoon object referenced by the cup.spoon field becomes unreachable along with it.
public class Spoon {
} public class Cup {
Spoon spoon;
Cup(Spoon spoon) {
this.spoon = spoon;
}
public static void main(String[] args) {
Cup cup = new Cup(new Spoon());
cup = null; // the Cup and the nested Spoon are now unreachable
}
} One important consequence of the reachability model: cyclic references do not prevent garbage collection. If two objects reference each other but neither can be reached from a GC root, they form an «island of isolation» and are collected together. This is the key difference from reference counting, where such a cycle would stay in memory forever.
public class Island {
Island friend;
public static void main(String[] args) {
Island a = new Island();
Island b = new Island();
a.friend = b; // a -> b
b.friend = a; // b -> a
a = null;
b = null; // both objects are unreachable and will be collected
}
} 3. System.gc(): can you force garbage collection?
There are two equivalent ways to ask the JVM to run a collection:
System.gc();
Runtime.getRuntime().gc(); // the same thing - System.gc() simply delegates here The operative word is ask. The documentation for System.gc() states plainly that the call is only a hint to the virtual machine. The JVM may run a full collection, run it later, or ignore the call entirely. On top of that, an application can be started with -XX:+DisableExplicitGC, which turns every explicit System.gc() into a no-op.
Do not call System.gc() in application code: it usually triggers an expensive stop-the-world full collection and makes behaviour worse, not better. Legitimate uses are limited to teaching examples, microbenchmarks and memory measurements taken right before a heap dump.
4. The finalize() method
protected void finalize() is a method of the Object class that the JVM finalizer thread may invoke on an object before its memory is reclaimed. The original idea was this: if an object owns an external resource (a file, a socket, a native buffer), you override finalize() and close the resource there, so it does not leak even when someone forgets to call close().
Let us add finalize() to the Spoon and Cup classes and try to provoke the call with System.gc():
public class Spoon {
@Override
protected void finalize() {
System.out.println("Spoon disappears forever");
}
} public class Cup {
private Spoon spoon;
public Cup(Spoon spoon) {
this.spoon = spoon;
}
@Override
protected void finalize() {
System.out.println("Cup disappears forever");
}
public static void main(String[] args) {
Cup cup = new Cup(new Spoon());
cup = null;
System.gc();
}
} A possible output:
Cup disappears forever
Spoon disappears forever The word «possible» matters here. The two lines may swap places, only one of them may appear, or none at all: finalization order is unspecified, and the JVM happily exits without draining the finalizer queue. Teaching examples sometimes add a short sleep after System.gc() to make the output more stable, but that is a workaround, not a guarantee.
Interview-ready wording
finalize() is not called when an object goes out of scope, is not called on JVM shutdown, and is not guaranteed to be called at all. The only promise the specification makes is that if the method does run, it runs at most once in the object's lifetime.
5. Why finalize() was deprecated
Finalization accumulated so many problems that the platform decided to remove it:
- Unpredictable delay. Minutes can pass between the moment an object becomes garbage and the
finalize()call. File descriptors and connections run out long before that. - Risk of running out of memory. An object with a finalizer survives at least one extra collection and goes into the finalization queue. If finalizers are slow, the queue grows and the application dies with
OutOfMemoryError. - Swallowed exceptions. An exception thrown from
finalize()is simply ignored: the object is left in a broken state and nothing appears in the logs. - Object resurrection. Inside
finalize()you can storethisinto a static field and bring the object back to life. Itsfinalize()will never be called again. - Concurrency and security. The finalizer runs on a separate thread, which invites race conditions; the classic finalizer attack gives access to a half-constructed object whose constructor threw an exception.
- Overhead. Allocating and collecting objects that have a finalizer is noticeably more expensive than ordinary ones.
Status timeline of the method:
| Java version | What happened to finalization |
|---|---|
| Java 9 | Object.finalize() marked @Deprecated; the java.lang.ref.Cleaner class added as its replacement |
| Java 11 | System.runFinalizersOnExit() and Runtime.runFinalizersOnExit() removed |
| Java 18 | JEP 421: finalization deprecated for removal; the --finalization=disabled launch flag appears and switches off every finalize() call |
| Future releases | Finalization will be disabled by default and then removed from the platform together with the finalize() method |
The practical conclusion: never override finalize() in new code. You still need to know about it in order to read legacy code and to answer interview questions.
6. Modern alternatives: try-with-resources and Cleaner
The main way to release resources in Java is deterministic: the class implements AutoCloseable, and its user opens it inside try-with-resources. Then close() is invoked as soon as the block is left, no matter what the garbage collector is doing.
java.lang.ref.Cleaner (Java 9+) is only a safety net: it performs the cleanup action if the object became garbage and close() was never called. Unlike finalize(), the cleanup action has no access to the object itself, runs on a dedicated thread and does not hold up collection.
import java.lang.ref.Cleaner;
public class Cup implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
// The state must not reference Cup, otherwise the object never becomes garbage
private static class State implements Runnable {
@Override
public void run() {
System.out.println("Cup washed: resources released");
}
}
private final Cleaner.Cleanable cleanable;
public Cup() {
this.cleanable = CLEANER.register(this, new State());
}
@Override
public void close() {
cleanable.clean(); // the cleanup runs exactly once
}
public static void main(String[] args) {
try (Cup cup = new Cup()) {
System.out.println("Pouring tea");
}
}
} Pouring tea
Cup washed: resources released Easy to get wrong
The state class passed to Cleaner must be a static nested class (or a top-level one) and must not keep a reference to its owner. A non-static inner class — or a lambda that captures this — creates a strong reference to the object, so it never becomes unreachable and the cleanup never runs.
How the available mechanisms compare:
| Mechanism | When it runs | Deterministic? | Status |
|---|---|---|---|
try-with-resources + AutoCloseable | On leaving the try block | Yes, the moment is known exactly | Recommended approach (Java 7+) |
Explicit close() in finally | Wherever the call is written | Yes, but easy to forget | Legacy code before Java 7 |
java.lang.ref.Cleaner | After the object becomes unreachable | No, safety net only | Java 9+, replacement for finalize() |
PhantomReference + ReferenceQueue | After collection, polled manually from the queue | No, needs your own thread | Low-level version of Cleaner |
finalize() | Unknown, possibly never | No guarantees at all | Deprecated for removal (Java 18) |
7. Reference types: strong, soft, weak, phantom
The java.lang.ref package lets you control how strongly a reference keeps an object alive. Interviewers like to ask about this right after finalize().
| Reference type | Class | When the object may be collected | Typical use |
|---|---|---|---|
| Strong (ordinary) | — | Never, while the reference is reachable from a GC root | All everyday code |
| Soft | SoftReference | When memory runs low, before OutOfMemoryError is thrown | Caches you can afford to lose |
| Weak | WeakReference | At the very next collection, if no strong references remain | WeakHashMap, metadata, listeners |
| Phantom | PhantomReference | Already collected; get() always returns null | Releasing native resources, the foundation of Cleaner |
8. Garbage collectors in the JVM
In HotSpot the garbage collector is chosen with a launch flag. Since Java 9 the default is G1.
| Collector | Flag | Characteristics | When to choose it |
|---|---|---|---|
| Serial | -XX:+UseSerialGC | Single-threaded, minimal overhead | Small heap, single-core container |
| Parallel | -XX:+UseParallelGC | Maximum throughput, longer pauses | Batch processing where pauses do not matter |
| G1 | -XX:+UseG1GC | Default since Java 9, region-based heap, pause-time target | Most server applications |
| ZGC | -XX:+UseZGC | Sub-millisecond pauses on multi-terabyte heaps | Latency-sensitive services |
| Shenandoah | -XX:+UseShenandoahGC | Compacts the heap concurrently with the application | Low latency, OpenJDK builds |
| Epsilon | -XX:+UseEpsilonGC | Collects nothing; the heap simply fills up | Benchmarks and short-lived jobs |
9. What developers get wrong about garbage collection
- «There is a GC, so there are no memory leaks». A leak in Java is an object that is no longer needed but still reachable: an entry in a static collection, an unsubscribed listener, a key in a
HashMapwith brokenequals()/hashCode(), a pooled thread holding on to aThreadLocal. - «
cup = nulldeletes the object». Assigningnullonly drops one reference. Memory is freed by the garbage collector, and only if no other references remain. - «
System.gc()starts a collection». It is a request, not a command, and it can be switched off with a JVM flag. - «
finalize()is a destructor». Java has no destructors: a C++ destructor runs deterministically,finalize()does not. The closest equivalent in spirit isclose()in try-with-resources. - «Cyclic references are never collected». They are: the JVM relies on reachability, not on reference counting.
- «
finalize()will at least run when the program exits». It will not:runFinalizersOnExit(), the method that promised this, was removed in Java 11 as unsafe.
Frequently asked questions
Which garbage collector does Java use by default?
Since Java 9 the default is G1 (Garbage-First); in Java 8 it was Parallel GC. On a small machine the JVM ergonomics may pick Serial GC instead. You can check the current choice with java -XX:+PrintFlagsFinal -version or by starting the application with -Xlog:gc.
Can a Java application still leak memory if there is a garbage collector?
Yes. The collector only removes unreachable objects, so a leak in Java is an object that is no longer needed but stays reachable. Typical sources: growing static collections, caches without eviction, listeners that are never unsubscribed, ThreadLocal values in a thread pool, unclosed resources. Such leaks are diagnosed from a heap dump in VisualVM or Eclipse MAT, or with Java Flight Recorder.
How is PhantomReference different from finalize()?
A phantom reference gives no access to the object: its get() method always returns null, so resurrection is impossible. The notification arrives in a ReferenceQueue after the object has been declared unreachable, and you process it on your own thread whenever it suits you. Cleaner is built on phantom references and is what you should use instead of handling the queue by hand.
How do I turn finalization off completely in JDK 18 and later?
Start the application with the --finalization=disabled flag. The JVM will then call no finalize() method at all, even an overridden one. It is a convenient way to check in advance whether you are ready for the future removal of finalization: if nothing breaks with the flag on, neither your code nor your libraries depend on finalize().
Can I count collected objects by using finalize()?
No, such a counter would be wrong. The method is not called for every object, may never be called, runs at most once and at an unpredictable moment. To count objects and analyse memory use JVM tooling instead: jcmd GC.class_histogram, a heap dump, or Java Flight Recorder events.
Comments