Garbage Collection
Understand how the JVM finds and frees unused objects, which collector to pick for a service, and the habits that keep pauses short.
java -XX:+UseG1GC -Xmx1g -Xlog:gc -jar app.jar
java -XX:+UseZGC -Xmx8g -jar app.jarThe garbage collector (GC) frees heap memory that the program can no longer reach. It starts from the roots: local variables in every thread's stack, static fields and a few JVM internals. Anything it can reach by following references from a root is live; everything else is garbage, however many objects point at each other.
Generations
Most objects die young. A request's DTOs, strings and lists are garbage a few milliseconds after the response is sent. Collectors exploit this by putting new objects in a young generation that is collected often and cheaply, and promoting the survivors to an old generation that is collected rarely. Short-lived allocation is cheap in Java; objects that live for a medium time, such as sessions or cache entries that expire after a few minutes, survive long enough to be copied and promoted, and that is what costs GC work.
The collectors in Java 25
| Collector | Flag | Good for |
|---|---|---|
| G1 | -XX:+UseG1GC | The default. Balanced throughput and pause times for most services. |
| ZGC | -XX:+UseZGC | Large heaps and latency-sensitive APIs. Pauses stay around a millisecond or less. Generational only since Java 24. |
| Parallel | -XX:+UseParallelGC | Batch jobs where total throughput matters more than pauses. |
| Serial | -XX:+UseSerialGC | Small heaps and single-CPU containers. |
Watch out for the automatic choice: when the JVM sees fewer than two CPUs or less than about 1792 MB of memory, it picks Serial instead of G1. A container limited to one CPU and 1 GB therefore gets Serial unless you set a collector explicitly.
Seeing what it does
Turn on GC logging in every environment; it is cheap and answers most questions after an incident:
java -Xlog:gc:file=gc.log -Xmx512m -jar app.jarLook at pause times, how often collections run and whether the heap after each full collection keeps rising. A rising floor means something is holding on to objects: a leak, not a tuning problem.
Habits that matter
- Do not call
System.gc()in service code. It is only a request, and when honoured it usually triggers a full collection. - Do not use the GC to release files, sockets or connections. Close them with try-with-resources. Finalizers are deprecated for removal;
Cleanerexists only as a safety net. - Tune the heap size first. Most GC trouble is a heap that is too small for the live data, or a leak. Change collector flags only with GC logs in hand.
- Weak references (
WeakReference,WeakHashMap) let an entry go when nothing else uses its key, but they are no substitute for a cache with a size limit.
Example
When to use it
- Switch a latency-sensitive pricing API with a 16 GB heap to ZGC after GC logs show G1 pauses breaking the response-time target.
- Set -XX:+UseG1GC explicitly in a one-CPU container so the service does not silently fall back to the Serial collector.
- Keep GC logs from production pods so a slowly rising heap after each full collection can be traced to a leaking cache.
More examples
A weak reference lets go
Once no strong reference remains, the collector may clear weak references to the object at its next collection. Real code must expect get() to return null at any time and never depends on when that happens.
import java.lang.ref.WeakReference;
void main() {
byte[] report = new byte[1_000_000];
WeakReference<byte[]> weak = new WeakReference<>(report);
IO.println("before: " + (weak.get() != null));
report = null; // drop the only strong reference
System.gc(); // only for this demo: a request, not a command
IO.println("after: " + (weak.get() != null));
}
// Output:
// before: true
// after: falseAn oversized allocation fails on its own
The heap had no room for the array, so the allocation failed and nothing else was affected. Catching OutOfMemoryError is only reasonable in a case like this; when the heap fills up gradually, exit and restart instead.
void main() {
try {
// 12 GB in one array, as a naive 'read the whole upload' might ask for
long[] huge = new long[1_500_000_000];
IO.println(huge.length);
} catch (OutOfMemoryError e) {
IO.println("OutOfMemoryError: " + e.getMessage());
}
int[] small = new int[1_000];
IO.println("still running, allocated " + small.length + " ints");
}
// Output:
// OutOfMemoryError: Java heap space
// still running, allocated 1000 intsRelease resources explicitly, with Cleaner as a backstop
try-with-resources releases the connection at a known moment. The Cleaner would run the same action only if someone forgot to close it and the object was later collected, which may be never.
import java.lang.ref.Cleaner;
static final Cleaner CLEANER = Cleaner.create();
static final class Connection implements AutoCloseable {
private final Cleaner.Cleanable cleanable;
Connection(String name) {
// The action must not capture 'this', or the object never becomes unreachable.
cleanable = CLEANER.register(this, () -> IO.println("released " + name));
}
@Override
public void close() {
cleanable.clean(); // runs the action at most once
}
}
void main() {
try (Connection c = new Connection("connection-1")) {
IO.println("querying");
}
IO.println("done");
}
// Output:
// querying
// released connection-1
// done
Discussion