Java has no free() and no delete. You stop referencing an object, and at some point its memory quietly comes back. Someone is doing that work: the garbage collector. Let's pin down the rule it uses, watch a real JVM collect live, and then be honest about where the JVM ends and the operating system begins.
The one rule: reachability
The collector does not count references or guess. It starts from a fixed set of entry points called GC roots - local variables on every live thread's stack, static fields, and a few JVM internals - and walks the object graph from there. Anything it can reach stays. Everything else is garbage, even if two unreachable objects still point at each other.
That animation is the classic mark-and-sweep idea, and it is a mental model, not a literal description of any one collector: mark everything reachable from the roots, sweep away the rest. Real collectors add generations, regions, threads and concurrency on top, but reachability is the law underneath all of them.
Watch a real JVM collect
Theory is nice; logs are better. This program allocates 20 objects of ~100 KB each per round, then drops them - 6000 objects, roughly 600 MB of garbage in total, inside a heap capped at 64 MB. It also parks one object behind a WeakReference so we can watch a single object die:
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
public class GCDemo {
static class Page { byte[] data = new byte[1024 * 100]; } // ~100 KB each
public static void main(String[] args) throws Exception {
long allocated = 0;
for (int round = 0; round < 300; round++) {
List<Page> live = new ArrayList<>();
for (int i = 0; i < 20; i++) {
live.add(new Page()); // alive only inside this round
allocated++;
}
// 'live' goes out of scope here -> all 20 pages become garbage
}
System.out.println("allocated " + allocated + " short-lived Page objects");
Page p = new Page();
WeakReference<Page> watcher = new WeakReference<>(p);
p = null; // the only strong reference is gone
System.gc(); // a REQUEST, not a command - more below
Thread.sleep(200);
System.out.println("after System.gc() in THIS run: watcher.get() == null -> "
+ (watcher.get() == null));
}
}
Compiled and run on JDK 21 (Temurin 21.0.13) with GC logging on (-Xmx64m -Xlog:gc). Real output:
[0.001s][info][gc] Using Serial
[0.034s][info][gc] GC(0) Pause Young (Allocation Failure) 9M->2M(30M) 1.122ms
[0.035s][info][gc] GC(1) Pause Young (Allocation Failure) 11M->2M(30M) 0.518ms
...
allocated 6000 short-lived Page objects (~600 MB total)
after System.gc() in THIS run: watcher.get() == null -> true
[0.067s][info][gc] GC(69) Pause Full (System.gc()) 26M->2M(31M) 2.068ms
Read a log line like 9M->2M(30M) 1.122ms as: heap used went from 9 MB down to 2 MB (of 30 MB currently committed), and the pause lasted about a millisecond. (Allocation Failure) is the trigger's name, not an error - it means a new didn't fit, so the JVM collected and then continued. The headline result: 600 MB of objects flowed through a 64 MB heap and nothing ran out of memory. The collector reclaimed each round's dead pages before the next round needed the space. That is the entire promise of GC, in one number pair.
"Most objects die young" - and who collects them
Notice the pattern in the demo: every Page is dead by the end of its loop iteration. This turns out to be the normal case in most programs, and collectors exploit it with the generational hypothesis: collect the young part of the heap often and cheaply, because most of it is already garbage; examine long-lived objects rarely.
Treat generations as a strategy, not a JVM law - how it is implemented is collector-specific. JDK 21 ships several collectors, and the official tuning guide says the serial collector "uses a single thread to perform all garbage collection work" and is "selected by default on certain hardware and operating system configurations", while G1 "is selected by default on most hardware and operating system configurations". That is exactly what happened here: this small sandbox VM picked Serial (Using Serial in the log), while most server-class machines get G1.
Same program, one flag different (-XX:+UseG1GC), real output:
[0.001s][info][gc] Using G1
[0.036s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 3M->2M(34M) 1.013ms
...
Serial ran 69 young collections over the run; G1 ran 26 evacuation pauses for the same workload. Two honest caveats: these numbers are one machine, one run, one toy workload - not a benchmark and not a ranking - and G1's "evacuation" works differently inside (it copies live objects out of chosen heap regions rather than sweeping in place). The tuning guide's actual selection advice: serial for small data sets (up to ~100 MB) or single processors; G1 when "response time is more important than overall throughput and garbage collection pauses must be kept shorter"; ZGC when response time is a high priority.
Reclaiming objects is not returning memory to the OS
One distinction worth keeping straight: when the collector reclaims a dead object, that space returns to the Java heap, which the JVM already owns. Whether the JVM later hands memory back to the operating system is a separate decision - it depends on the collector and its flags, and the JVM is allowed to keep the memory for future allocations. So "GC freed my objects" and "my process shrank in Task Manager" are different events, and the first does not imply the second.
Where the kernel fits
The collector itself is ordinary JVM code running in user space - marking, sweeping, and copying are memory operations inside the process, no system calls needed. But it does not float above the OS:
-
Memory mapping and commit. The heap sits in address space the JVM obtained from the kernel (via
mmapon Linux - "creates a new mapping in the virtual address space of the calling process", per the man page). Growing or committing heap, and any return of memory to the OS, crosses that boundary. - Page faults. Touching freshly mapped pages traps into the kernel to back them with physical memory - during allocation, inside your code and the collector's alike.
- Thread scheduling and wakeups. GC work runs on threads (dedicated GC threads, and yours during stop-the-world pauses). The OS schedules them; parking and waking threads is kernel-assisted synchronization.
How much of this you feel depends on the collector and platform - a single-threaded Serial pause and G1's concurrent phases interact with the OS very differently.
System.gc(), WeakReference, and why nothing here is a promise
Two things in the demo deserve skepticism, and the docs back the skepticism. The JavaDoc for System.gc() says calling it "suggests that the Java Virtual Machine expend effort toward recycling unused objects" and warns: "There is no guarantee that this effort will recycle any particular number of unused objects ... or complete at any particular time, if at all." A WeakReference simply lets you observe the outcome: get() returns the object while it lives and null after the collector clears it.
In this run the request was honored - you can see Pause Full (System.gc()) in the log and the watcher went null. In another JVM, another run, or with -XX:+DisableExplicitGC, neither has to happen. Treat System.gc() as a hint you almost never need in production code.
What to take away
- Reachability decides life and death - from GC roots, not reference counts.
-
You almost never manage memory manually - but allocation rate is real cost; every
newis future GC work. -
Read your collector's log first.
-Xlog:gcis one flag and tells you trigger, sizes, and pause time. - Pick the collector for the workload with the official guide's rules of thumb, and distrust single-run numbers - including the ones in this article, quoted as one machine's experiment.
Sources
- Java 21 GC Tuning Guide: Available Collectors
- JVM Specification, 2.5.3: Heap
- Java 21 API: System.gc()
- Java 21 API: java.lang.ref.WeakReference
- Linux man-pages: mmap(2)
Written with AI assistance; every code example and GC log above comes from real runs on JDK 21 (Temurin 21.0.13), and the collector counts and timings are one machine's results, not benchmarks. The animation is an illustration of mark-and-sweep as a mental model, not a trace of any specific collector.

Top comments (0)