---
title: 'Garbage Collectors'
source: 'https://academia.sh/en/courses/jvm-and-concurrency/garbage-collectors'
course: 'JVM, Concurrency and Performance'
language: en
updated: '2026-09-13T05:09:02+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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."

```java
// 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;
    }
}
```

```java
// 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.

```java
// 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 returns `null`, 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 the `ReferenceQueue`
  mechanism 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.
