Lesson 03 / 11
Garbage Collectors
Who takes on the cleanup of an object that has lost its reachability: not the caller, not a thread the program created — a party that never appears in any record at all. Cleanup does not run while a strong reference stands and runs once it is removed; a weak and a phantom reference get enqueued within a few requests; a soft reference resists as long as there is no memory pressure. `System.gc()` is a request, not a command.
Published
In this lesson
- Measures, with its own record, which thread takes on the cleanup of an object that has lost its reachability
- Measures when each of the four reference strengths (strong, soft, weak, phantom) opens an object to collection; counts three of them with the reference queue
- Shows that a call to `System.gc()` is a request, and that only who runs it can be measured, not when it will run
Contents
The previous lesson assumed that values sitting on the heap were still reachable. But the last reference to an object can also be lost; the program drops an object, no frame and no static field can reach it anymore, and what remains on the heap is a field nobody can read. This lesson asks who takes on the cleanup of that field.
Tracing garbage collection itself — the root set, mark-and-sweep, generational collection — was established as a concept in the Operating System Concepts course and is not repeated here. This lesson does not measure the theory, it measures the outcome: which thread runs the cleanup of an object that has lost its reachability, and how does this work change with a reference’s strength?
The question itself cannot be answered by looking at the program’s code. Who creates an object, what we write into its fields, where we carry its reference — all of that is visible in the source. But what happens once that object becomes unreachable, which thread will deal with it, is never written in the source at all; the program can only make a suggestion, and it can see neither what decides the outcome nor when it decides it.
Who Takes On Cleanup for an Object That Has Lost Its Reachability
“Reachability” ties directly to the four places the previous lesson established. An object is reachable if there is a path to it: that path can start from a local variable in a call frame, from a class’s static field, or from the field of another reachable object. If the path cannot be built from any of these three starting points, the object counts as unreachable — the program itself can no longer reach it, but the object keeps sitting on the heap until someone cleans it up. A registered cleanup action can be attached to an object: once the object becomes eligible for collection, that action runs. But the thread that runs the action is neither the caller thread nor a thread the program created itself — it is a party that never appears in any record at all. This is a third case where the two-sided distinction (caller / created) the previous lesson built for class initialization is not enough.
Eligibility for collection itself depends on a single rule: as long as a strong reference to an object stands, the object cannot be collected — this is not a heuristic decision made by the JVM, it is a guarantee given by the language itself. Once the strong reference is removed, the object becomes eligible for collection, but the moment it will be collected is not guaranteed; the program can only make a request. This two-part structure — reachability is not a fact fixed at compile time but a state that keeps changing at runtime, and the decision made from that state’s outcome sits outside the program too — explains why the measurement needs a latch and a loop: the answer to “has it been cleaned up” is not found in a single read, but in a repeated poll.
- JV12 — Whether an object has been collected is tracked with a cleanup action we build ourselves; the action drops a latch when it runs, and the latch does not drop as long as it has not run.
- JV13 —
System.gc()is a request, not a command; the measurement repeats this request at short intervals and reads, through the latch, whether the request found a match — no duration or pause is ever measured. - JV14 — The identity of the party that runs the cleanup action is read only through our own record; every thread not in the record counts as “unknown.”
// Identity.java — which thread runs an action: read with our own record
import java.util.*;
class Identity {
static final Map<Thread, String> labels = Collections.synchronizedMap(new IdentityHashMap<>());
static void register(String label) { labels.put(Thread.currentThread(), label); }
static String read() {
String l = labels.get(Thread.currentThread());
return l == null ? "unknown" : l;
}
}
// Cleanup.java — cleanup does not run while a strong reference stands; it runs once removed, and the runner is unknown
import java.lang.ref.Cleaner;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class Cleanup {
public static void main(String[] args) throws InterruptedException {
Identity.register("caller");
Cleaner cleaner = Cleaner.create();
CountDownLatch done = new CountDownLatch(1);
String[] whoRan = { null };
Object object = new Object();
cleaner.register(object, () -> { whoRan[0] = Identity.read(); done.countDown(); });
for (int i = 0; i < 3; i++) {
System.gc();
boolean ran = done.await(50, TimeUnit.MILLISECONDS);
System.out.println("strong reference standing, request " + (i + 1) + ": " + (ran ? "ran" : "did not run"));
}
object = null;
while (!done.await(50, TimeUnit.MILLISECONDS)) System.gc();
System.out.println("once the strong reference was removed : ran");
System.out.println("party that ran the cleanup action : " + whoRan[0]);
System.out.println("is the runner the caller : " + "caller".equals(whoRan[0]));
}
}
strong reference standing, request 1: did not run strong reference standing, request 2: did not run strong reference standing, request 3: did not run once the strong reference was removed : ran party that ran the cleanup action : unknown is the runner the caller : false
As long as the object variable holding the reference stayed in hand, all three separate
requests went unanswered — this is not an unexpected delay, it is the language’s
guarantee: while a strong reference stands, collection cannot happen, no matter how
many times it is requested. After the object = null line, the loop kept making requests
and this time got an answer. The identity of the party that ran the cleanup action was
read with Identity.read(), and it came back “unknown” — the action ran neither in the
caller thread that runs main, nor is there any explanation for it beyond being a party
the program never created at all. The program never created this thread, never named it,
never registered it; yet it was the one that ran our own cleanup code.
In the previous two lessons, two registered parties were enough: a class’s initialization
was run either by the caller or by a thread the program created itself, and neither
lesson ever stepped outside those two. This lesson forces a third party, because the
thread that runs the cleanup action was never produced with new Thread(...) anywhere in
this program’s lines. The “unknown” label is not a claim about ownership — the thread
itself really exists inside the JVM and really runs, it has no counterpart at all in our
own record. A program’s runtime surface is not limited to the threads the programmer wrote
themselves.
Four Reference Strengths and the Reference Queue
Access to an object does not come in a single strength either. A strong reference, as
seen above, blocks collection entirely. A soft reference holds the object, but tells
the JVM it may be dropped under memory pressure. A weak reference does not hold the
object at all; the moment no other strong reference remains, it is open to collection. A
phantom reference is the weakest of all: a call to get() always returns null,
even before collection — a phantom reference does not exist to read an object, but to
learn the moment it was collected, from a queue.
A reference can be attached to a ReferenceQueue; once the object becomes eligible for
collection, the reference itself gets enqueued — the queue is the only way to read the
answer to “which reference has just gone empty,” because the code holding the reference
cannot find out on its own the moment it loses the object. The order of the four strengths
is not arbitrary: each shows less resistance than the one before it. A strong reference is
never dropped under any condition; a soft reference is dropped only if the JVM is
genuinely under pressure; a weak reference is dropped at the first opportunity; a phantom
reference never holds anything in the first place, it only carries the news that
collection happened. The measurement below sets up three references of three different
strengths to three target objects, and breaks the last strong link to all of them.
- JV15 — All three reference objects are set up in the same run, against three
separate targets; which reference gets enqueued is told apart by object identity
(
==), not by order.
// ReferenceExperiment.java — four reference strengths: weak and phantom get enqueued, soft resists
import java.lang.ref.*;
public class ReferenceExperiment {
public static void main(String[] args) throws Exception {
ReferenceQueue<Object> queue = new ReferenceQueue<>();
Object weakTarget = new Object();
Object softTarget = new Object();
Object phantomTarget = new Object();
WeakReference<Object> weak = new WeakReference<>(weakTarget, queue);
SoftReference<Object> soft = new SoftReference<>(softTarget, queue);
PhantomReference<Object> phantom = new PhantomReference<>(phantomTarget, queue);
System.out.println("phantom.get() even before collection: " + phantom.get());
weakTarget = null;
softTarget = null;
phantomTarget = null;
boolean weakEnqueued = false, phantomEnqueued = false;
for (int i = 0; i < 50 && !(weakEnqueued && phantomEnqueued); i++) {
System.gc();
Reference<?> r;
while ((r = queue.poll()) != null) {
if (r == weak) weakEnqueued = true;
if (r == phantom) phantomEnqueued = true;
}
if (!(weakEnqueued && phantomEnqueued)) Thread.sleep(20);
}
System.out.println("was the weak reference enqueued : " + weakEnqueued);
System.out.println("was the phantom reference enqueued : " + phantomEnqueued);
System.out.println("weak.get() (after collection) : " + weak.get());
System.out.println("was soft ever enqueued : " + (queue.poll() != null));
System.out.println("soft.get() (no memory pressure) : " + (soft.get() != null ? "still reachable" : "null"));
}
}
phantom.get() even before collection: null was the weak reference enqueued : true was the phantom reference enqueued : true weak.get() (after collection) : null was soft ever enqueued : false soft.get() (no memory pressure) : still reachable
phantom.get() returning null even on the first line is not the result of collection,
it is the contract itself: a phantom reference never hands the object back, it only says
“collected” by getting enqueued. After the request loop repeated a few times, both the
weak and the phantom reference got enqueued; the weak reference’s get() also started
returning null from that point on — since it never held the object, it became eligible
for collection the moment the strong link broke. The soft reference, across the same
request rounds, never got enqueued at all, and its get() still returned the object: the
JVM chose not to break the soft reference as long as memory was not under pressure. The
last strong link to all three targets broke at the same moment; the only thing that made
the difference was the reference’s strength.
These three results also explain why the three reference types serve different jobs. A
record that holds an object nowhere else, and only watches whether it “still exists,”
wants a weak reference — the record itself must not extend the object’s lifetime. A record
caching a result that can be recomputed but is expensive to compute wants a soft reference
— hold the result while memory is free, drop it once memory tightens. Code waiting for the
news that “the object is now definitely gone” wants a phantom reference — it never wants
the object back, only the news from the queue. All three use the same ReferenceQueue
mechanism; only the reference’s own strength makes the difference.
This mechanism comes from the same foundation as the Cleaner measured in the previous
section: the standard library’s own documentation says that Cleaner is built on phantom
reachability — every registered cleanup action is tracked in the background with exactly
the phantom-reference-and-queue pair shown above. Both measurements ask the same question
at two separate levels: Cleanup measures the question “who runs it” through a ready-made
API, while ReferenceExperiment measures “when and at what strength does it run” by
building the underlying mechanism by hand.
A Bounding Measurement: A Request Does Not Say When It Will Run
Both measurements share the same pattern: System.gc() was not called once, it was
repeated inside a loop. The reason for this is not delay but the nature of the call
itself — System.gc() is a request, not a command; the JVM may ignore this request,
defer it, or answer it in a different form. A single call going unanswered does not mean
“collection did not happen,” it only means “not on this request.” This is the same shape
as the blind spot in the first lesson: there, an empty line meant “not triggered”; here,
an unanswered request means not “refused” but “has not found an answer yet.” The one
difference is that class initialization, in the first lesson, was something certain to
happen once requested (it would necessarily run if its condition was true); the collection
request here depends on the JVM’s own discretion even when its condition is true (even
when the object is genuinely eligible for collection).
This is why the lesson repeats the request until a latch drops, and never measures when it runs — only who runs it once it does. This is not arbitrary: “when” depends on the machine’s memory state at that moment and on how much load other programs share, and it changes from run to run, giving no deterministic number to record. “Who” does not change: whatever the machine or the load, the party that runs the cleanup action is always outside the record, because this follows from the JVM’s own design, not from the current load. The distinction is an example of this course’s general rule: what depends on the run (when) stays out of the table; what stays fixed run to run (who, how many, which party) goes in.
Neither measurement goes beyond this: which collector algorithm ran, how long a pause lasted, how much a given generation grew, how much memory was reclaimed — none of this is written in this lesson, because all of it depends on the implementation and on the machine it runs on. The word “Performance” appearing in the course’s name does not change this: what is counted here is only the party that runs and the number of references that get enqueued.
Summary
- The party that runs the cleanup of an object that has lost its reachability is neither the caller thread nor a thread the program created — it is an “unknown” party that never appears in any record.
- An object cannot be collected as long as a strong reference to it stands; this is not a heuristic but the language’s guarantee. Once the strong reference is removed, the object opens to collection, but the moment it will be collected is not guaranteed.
- A weak and a phantom reference get enqueued within a few requests after the strong link
breaks; a soft reference resists as long as there is no memory pressure. A phantom
reference’s
get()always returnsnull, even before collection. System.gc()is a request, not a command; that is why the measurement repeats the request in a loop and measures only who runs it once it does, not when it runs.- Generational collection, the root set, and mark-and-sweep theory were not repeated in this lesson; this lesson measured only the outcome of reachability — who cleans up, and when each reference opens to collection.
- Collector implementation, pause duration, generation size, and the amount of memory
reclaimed were not written in this lesson; all of it is environment-dependent.
Cleaner, too, is built on phantom reachability with exactly theReferenceQueuemechanism used in this measurement — the two measurements are two separate levels of the same question.
Next Step
This lesson saw a decision that is not found in the class file: which object gets collected and when was a decision made by the runtime, not by the program itself, and who runs that decision could not be read from the program’s own record — it could only be measured with our own record. There is another decision not found in the class file — this time it concerns not cleanup but speed. While a method’s body sits unchanged in the class file, the same method may not run the same way on its first call as on its thousandth. The next lesson measures that difference: the party that decides is again not the program itself, and again invisible by reading the code, but this time what it accumulates is not garbage — it is an observation gathered while running.
To keep your progress and take notes, Log in
My notes
Log in to take notes.