Layers of Logic

13.2

What Makes an Object Garbage

Nobody frees anything, and a program running alongside yours decides which of your objects are still needed. The rule it uses is not the obvious one, and knowing the real rule is what lets you find a leak in a language that has no free.

Core21 min read4 exercises
01

Previously on

Section 13.1 ended on a sentence with two unexplained halves:

It stays on the heap until nothing can reach it any more, and then the garbage collector takes it at some later moment of its choosing.

What does “nothing can reach it” mean, when objects point at each other? And what decides “some later moment”?

02

The problem

In C you allocate memory and you free it. Both are your job, and both are ways to break the program.

Free too early and you have a pointer to memory that has been given to something else. Free twice and you corrupt the allocator. Forget to free and the program grows until it dies. Decades of security holes come from these three mistakes.

Java removed the whole category by taking away free. You never release anything. Something else works out when an object is finished with.

The obvious way to do that does not work. Count how many references point at each object, and free it when the count hits zero.

Node x = new Node();
Node y = new Node();
x.other = y;      // y's count is 2
y.other = x;      // x's count is 2

x = null;         // x's count drops to 1
y = null;         // y's count drops to 1

Nothing in the program can reach either object. Both counts are one, from each other, and they will stay at one forever. Counting references never frees this pair.

That is not a rare shape. A parent holding children who hold the parent, a node in a doubly linked list, a listener holding the thing it listens to. Reference counting leaks all of them, and Python and old COM both shipped this problem.

Then there is when. Even with a correct rule for what, something has to decide when to look, and looking costs time your program is not running.

03

The idea

Java asks a different question. Not “how many things point at this”, but “can this be reached from somewhere known to be alive”.

GC roots are the starting points. Things that are alive by definition:

  • Local variables in every frame of every running thread’s stack.
  • Static fields, which live in the method area.
  • Objects currently being used as locks.
  • References held by the JVM itself.

The collector starts at the roots and walks every reference it can follow, marking as it goes. Anything not marked at the end cannot be reached by any running code, whatever points at it.

Which settles the circular pair. Verified, with a weak reference watching one of them:

circular pair after gc: COLLECTED
strongly held        : still alive

The two objects point at each other and no path leads to them from any root. Unreachable, so collected. Being pointed at is not what keeps an object alive. Being reachable is.

Now for when, and it starts with one observation.

Most objects die very young. A StringBuilder inside a method. A boxed Integer in a loop. The intermediate list from a stream. Overwhelmingly, objects are made, used within a few microseconds, and never touched again.

A small number live for the whole program: the caches, the configuration, the connection pool.

Very few are in between. This is the generational hypothesis, and it is an observation about real programs rather than a rule.

So the heap is split to take advantage of it.

AreaHow it is treated
Edenwhere new objects are bornfilled fast, collected often
Survivor 0 and 1objects that made it through a collectioncopied between the two, counting survivals
Old generationobjects that survived enough timescollected rarely, and it costs more when it happens

The life of a typical object

  1. Born in EdenAllocation is a pointer moving forward. Almost free.
  2. Eden fills upA minor collection runs. Everything still reachable is copied into a survivor space, and Eden is declared empty in one step.
  3. Survives a few moreEach collection copies it between the two survivor spaces and increases its age.
  4. PromotedPast a threshold it is moved to the old generation, on the assumption that something which has lived this long will keep living.
  5. Collected eventuallyThe old generation is collected far less often, and that collection is the expensive one.

The trick is that the work is proportional to what survives, not to what was allocated. Almost everything in Eden is already dead, so a minor collection copies out the few survivors and then treats the whole space as empty. Allocating a million short lived objects costs almost nothing to clean up.

04

Under the hood

Going deeper

Stop the world is the phrase to understand. For parts of a collection, every application thread is paused. Not slowed: stopped. The collector needs a stable picture of the heap, and it cannot get one while your code is moving references around.

That is why collectors are compared on pause times rather than throughput. A 200 ms pause is invisible in a batch job and a failure in a trading system.

Java 21 uses G1 by default. Asking the JVM directly:

[G1 Young Generation, G1 Concurrent GC, G1 Old Generation]

G1 divides the heap into a few thousand equal regions instead of three fixed areas. Each region is Eden, survivor or old at any moment, and the role can change. That lets it collect the regions with the most garbage first, which is where the name comes from: garbage first.

The other collectors, in one line each:

CollectorWhat it is for
Serialone thread does everythingsmall heaps, containers with one core
Parallelmany threads, still stops the worldbest total throughput, if pauses do not matter
G1regions, mostly concurrentthe default. Aims for a pause target you can set
ZGCpauses under a millisecondvery large heaps where pauses are unacceptable

Weak and soft references are how you say “keep this only if it is convenient”.

SoftReference<byte[]> soft = new SoftReference<>(new byte[10 * 1024 * 1024]);
WeakReference<byte[]> weak = new WeakReference<>(new byte[10 * 1024 * 1024]);
System.gc();
after one gc: soft alive, weak gone

A weak reference does not keep anything alive. The next collection takes it. That makes it right for a lookup table keyed on objects, where the entry should disappear when the key does, which is what WeakHashMap is.

A soft reference is kept until memory is actually short. That makes it right for a cache: keep it while there is room, give it back under pressure.

A memory leak in Java is a reference you forgot about. The collector is working correctly; those objects are genuinely reachable.

The common shapes:

static Map<String, User> cache = new HashMap<>();     // nothing is ever removed
button.addListener(this);                             // never unregistered
static List<Exception> errors = new ArrayList<>();    // kept for reporting

The third one is the worst. An exception holds a stack trace, and a trace holds references to the objects in those frames. So a single stored exception can keep an entire object graph alive. That is the connection back to Section 12.1.

System.gc() is a request, not a command. The JVM is free to ignore it, and in production it usually should. Calling it forces a full collection, which is the most expensive kind, and the collector’s own timing is nearly always better than yours. It is a tool for demonstrating behaviour, like the measurements in this section, and not for tuning.

05

What it costs

You gave up control, and the trade is worth stating rather than assuming.

Timing is not yours. You cannot know when a collection will happen or how long it will take, which makes Java a poor fit for anything with a hard real time deadline. Most software does not have one.

Pauses affect everything at once. Because a pause stops all application threads, adding threads does not help you through it. Your service handling a thousand requests a second stops handling all of them for the duration.

Memory usage is higher than the equivalent C program, and by more than the object headers suggest. A collector needs room to work. Copying survivors needs space to copy into, and a heap kept near full collects constantly. The usual guidance is a heap around twice the live set, which is a lot to hold in reserve.

Leaks are still possible and they are harder to see than the C kind. There is no missing free to look for. There is a HashMap somewhere that has been growing since startup, and finding it means reading a heap dump rather than reading code.

Tuning is a real skill and mostly a trap. There are hundreds of flags. Almost every performance problem people try to fix with GC flags is an allocation problem in their own code. The honest first step is to allocate less, not to collect faster.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. Two objects point at each other and nothing else points at either one. Are they garbage?

    Show the answer

    Yes, and they are collected. Verified: make two objects, point each at the other, drop both variables, and a weak reference watching one of them reports it gone after the next collection.

    If Java counted references, neither would ever be freed. Each has a count of one, from the other, forever. That is the classic leak in reference counted languages.

    Java asks a different question: can this be reached from a GC root? Roots are things known to be live, like local variables in every running frame and static fields. The collector walks out from them and marks everything it can touch.

    Two objects in a ring with no path from any root are unreachable together, however many times they point at each other. Being pointed at is not what keeps an object alive. Being reachable is.

  2. Why is the heap split into a young part and an old part?

    Show the answer

    Because of one observation that turns out to hold almost everywhere: most objects die very young. A string built inside a method, a temporary list, the box around an int. Nearly all of them are unreachable moments after being made.

    So the collector puts new objects in a small young area and collects that area often. Because almost everything there is already dead, it only has to copy out the few survivors. The work is proportional to what lived, not to what was allocated.

    Objects that keep surviving get promoted to the old area, which is collected rarely, because things that have already lived a long time usually keep living.

    Without the split, every collection would have to examine the entire heap, including the long lived objects you already know are fine.

  3. How can a Java program leak memory when nobody allocates or frees anything?

    Show the answer

    By keeping references to things it no longer needs. The collector is doing exactly its job: those objects are reachable, so they stay.

    The usual shape is a collection that only ever grows. A static Map used as a cache with nothing ever removed, listeners added and never unregistered, a list of exceptions kept for later reporting.

    That last one is worse than it looks. Every exception holds a stack trace, and a trace holds references to the objects in those frames, so one stored exception can keep a whole graph alive.

    A leak here is never the collector failing. It is your code being more attached to something than you realised, and the fix is to remove the reference.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

4 exercises110 pointsabout 110 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Collect a Ring

Real work·25 min·25 points

checkedex-13-2-a

Build the shape that breaks reference counting and watch Java handle it.

Both objects have something pointing at them at all times. Neither count ever reaches zero. If Java worked the obvious way, this pair would sit in memory until the program ended.

Then add one outside reference and check again. One path in from a root keeps the entire ring alive, which is the same rule read from the other direction, and it is exactly how a leak survives.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Ring {
    static boolean pairSurvives()
    static boolean heldPairSurvives()
    static int survivorsOfRing(int size)
    static boolean javaCountsReferences()
}

Node is a nested class pointing at another Node and holding some bulk so collection is worth doing. Each method builds its shape, watches it with a WeakReference, drops what it should, collects, and reports what survived.

What your program must do

  • Build two objects pointing at each other and confirm they are collected
  • Say what a reference counting collector would have done with them
  • Keep one path in from outside and show the pair survives
  • Build a ring of five and confirm the whole ring goes together
Ring.java
import java.lang.ref.*;
import java.util.*;

public class Ring {

    static class Node {
        Node other;
        byte[] bulk = new byte[256 * 1024];
    }

    static void collect() { }  // TODO: System.gc a few times, with small pauses

    // Two Nodes pointing only at each other, nothing outside reaching them.
    static boolean pairSurvives() { return true; }  // TODO

    // The same, but something outside still holds one of them.
    static boolean heldPairSurvives() { return false; }  // TODO

    // A ring of `size` Nodes, each pointing at the next, nothing pointing in.
    static int survivorsOfRing(int size) { return -1; }  // TODO

    // Does Java free an object when nothing points at it any more?
    static boolean javaCountsReferences() { return true; }  // TODO

    public static void main(String[] args) {
        // TODO: predict all three before running
    }
}
Hint 1
A WeakReference lets you watch an object without keeping it alive. When get() starts returning null, it has been collected.
Hint 2
Each object in the pair has exactly one thing pointing at it, forever. A collector that counted references would never free either, which is the classic leak in reference counted languages.
Hint 3almost the answer
Java asks whether an object can be REACHED from a root, not how many things point at it. A ring with no path in is unreachable however tangled, and the whole group goes in one collection.
What this is really testing

Whether you can tell reachability from reference counting. Two objects pointing at each other is the case that separates them, and Java gets it right.

B

Weak, Soft, and Neither

Real work·25 min·25 points

checkedex-13-2-b

Two words, two behaviours, and the difference only shows up under pressure.

Run one collection and you already see them separate. Then squeeze the heap and watch the soft one give way too, which is exactly the behaviour a cache wants and a lookup table does not.

The WeakHashMap at the end is the one worth sitting with. An entry removes itself when nothing else refers to its key, which means the map cannot become the thing keeping your objects alive. That is a whole class of leak that stops being possible.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Strengths {
    static boolean weakSurvivesOneCollection()
    static boolean softSurvivesOneCollection()
    static boolean strongSurvivesCollection()
    static int weakMapSizeAfterKeyDropped()
    static int weakMapSizeWhileKeyHeld()
    static String rightFor(String job)
}

Each survives method makes a reference of that strength, collects, and reports whether the object is still there. rightFor takes "cache", "lookup keyed on objects" or "must not disappear" and answers soft, weak or strong.

What your program must do

  • Predict which strength survives a single collection, then check
  • Show a WeakHashMap entry disappearing when its key is dropped
  • Show it staying while the key is held
  • Say which strength suits a cache and which an object keyed lookup
Strengths.java
import java.lang.ref.*;
import java.util.*;

public class Strengths {

    static void collect() { }  // TODO

    // PREDICT all three before running.
    static boolean weakSurvivesOneCollection()   { return true; }  // TODO
    static boolean softSurvivesOneCollection()   { return false; } // TODO
    static boolean strongSurvivesCollection()    { return false; } // TODO

    // A WeakHashMap entry with its key dropped, and one with the key still held.
    static int weakMapSizeAfterKeyDropped() { return -1; }  // TODO
    static int weakMapSizeWhileKeyHeld()    { return -1; }  // TODO

    // "soft", "weak" or "strong".
    static String rightFor(String job) { return ""; }  // TODO

    public static void main(String[] args) {
        // TODO: predict which survives one collection, then check
        // TODO: put the JVM under memory pressure with -Xmx128m and try again
    }
}
Hint 1
A weak reference keeps nothing alive and the next collection takes it. A soft reference is kept while there is room, so it survives a collection that is not under pressure.
Hint 2
For the WeakHashMap, drop the key variable and collect. The entry removes itself, which means the map cannot become the thing keeping your objects alive.
Hint 3almost the answer
Soft suits a cache: keep it while memory allows, give it back under pressure. Weak suits a lookup keyed on objects, where the entry should vanish when the key does.
What this is really testing

Whether you can pick the right strength of reference for a cache. The two behave differently under memory pressure, and that difference is the entire reason there are two.

C

Leak On Purpose

Hard·30 min·30 points

checkedex-13-2-c

Write a leak in a language that made leaks impossible.

All three are ordinary code that any review would pass, and in all three the collector behaves perfectly. The objects are reachable. Something you wrote is still holding them.

The second one is worth the most thought. The list is small and the memory it holds is not, because each exception drags its whole stack trace along, and a trace holds every object those frames were pointing at.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Leak {
    static void reset()
    static int leakByCaching(int n)
    static int leakByStoringExceptions(int n)
    static int leakByListeners(int n)
    static boolean storedExceptionKeepsATrace()
    static boolean reachableFromARoot()
    static boolean collectorIsAtFault()
    static String fixFor(String leak)
    static int cacheSizeAfterCollection()
    static String whatEveryFixDoes()
}

The three collections are static and only ever grow. cacheSizeAfterCollection fills the cache, forces a collection, and returns the size, showing that nothing was freed. fixFor takes "cache", "exceptions" or "listeners".

What your program must do

  • Write all three leaks and watch a collection free none of them
  • For each, name the exact reference keeping the objects alive
  • Explain why the stored exceptions leak more than their own size
  • Fix all three and say what the fixes have in common
Leak.java
import java.lang.ref.*;
import java.util.*;

public class Leak {

    static final Map<String, byte[]> cache = new HashMap<>();
    static final List<Exception> errors = new ArrayList<>();
    static final List<Object> listeners = new ArrayList<>();

    static void reset() { }    // TODO
    static void collect() { }  // TODO

    // Three leaks. All three are ordinary code that any review would pass.
    static int leakByCaching(int n)           { return 0; }  // TODO
    static int leakByStoringExceptions(int n) { return 0; }  // TODO
    static int leakByListeners(int n)         { return 0; }  // TODO

    // Does a stored exception hold anything beyond its own message?
    static boolean storedExceptionKeepsATrace() { return false; }  // TODO

    // Fill the cache, force a collection, and report the size.
    static int cacheSizeAfterCollection() { return -1; }  // TODO

    static boolean reachableFromARoot()  { return false; }  // TODO
    static boolean collectorIsAtFault()  { return true; }   // TODO

    static String fixFor(String leak)    { return ""; }  // TODO
    static String whatEveryFixDoes()     { return ""; }  // TODO

    public static void main(String[] args) {
        // TODO: run each leak with -Xmx128m and watch it fill
    }
}
Hint 1
Every one of these is reachable from a static field, and static fields are GC roots. The collector is not failing: those objects are genuinely still reachable.
Hint 2
An exception holds a stack trace, and a trace holds references to the objects that were in those frames. One stored exception can keep a whole object graph alive.
Hint 3almost the answer
All three fixes are the same shape: remove the reference. Bound the cache or use soft values, keep a count instead of the exception objects, and unregister the listener. There is no other kind of fix, because there is no free.
What this is really testing

Whether you can write a leak in a language with no free. All three of these look like ordinary code, and the collector is doing its job correctly in every one.

D

Watch It Collect

Hard·30 min·30 points

checkedex-13-2-d

The JVM will narrate its own collections if you ask.

Start with short lived garbage and read the log. You should see frequent young collections that free nearly everything and take almost no time. That is the generational hypothesis in the output, not in a diagram.

Then keep things alive and watch the shape change. Promotions start happening, the old generation grows, and the pauses get longer. Switching collectors at the end shows you what each one is choosing to trade.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Watching {
    static void reset()
    static long totalCollections()
    static long totalPauseMillis()
    static List<String> collectorNames()
    static long collectionsForShortLived(int mb)
    static long collectionsForLongLived(int mb)
    static boolean generationalHypothesis()
}

Use java.lang.management.ManagementFactory.getGarbageCollectorMXBeans() to read the counts and pause times, which works without parsing log output. collectionsForShortLived allocates and immediately drops that many megabytes. collectionsForLongLived keeps them in a static list and clears it afterwards.

What your program must do

  • Ask the JVM which collectors are running
  • Allocate short lived garbage and watch the collection count move
  • Do the same with long lived objects and describe the difference
  • Compare pause times across at least two different collectors
Watching.java
import java.lang.management.*;
import java.util.*;

public class Watching {

    static final List<byte[]> longLived = new ArrayList<>();

    static void reset() { }  // TODO

    // Ask the JVM directly, rather than parsing log output.
    static long totalCollections()  { return 0; }  // TODO
    static long totalPauseMillis()  { return 0; }  // TODO
    static List<String> collectorNames() { return List.of(); }  // TODO

    // Allocate and immediately drop that many megabytes.
    static long collectionsForShortLived(int mb) { return 0; }  // TODO

    // Allocate and KEEP them, then clear afterwards.
    static long collectionsForLongLived(int mb) { return 0; }  // TODO

    static boolean generationalHypothesis() { return false; }  // TODO

    public static void main(String[] args) {
        // TODO: print the collector names and watch the counts move
        // TODO: run again with -Xlog:gc and read the lines it prints
        // TODO: try -XX:+UseSerialGC and -XX:+UseParallelGC and compare pauses
    }
}
Hint 1
ManagementFactory.getGarbageCollectorMXBeans() gives you the names, counts and total pause time without parsing anything.
Hint 2
-Xlog:gc prints one line per collection and -Xlog:gc* gives far more, including which generation was collected and how long the pause was.
Hint 3almost the answer
Short lived objects produce many young collections that free almost everything and take almost no time. That is the generational hypothesis showing up in the output rather than in a diagram.
What this is really testing

Whether the young and old generations are a diagram or something you can see happening. The JVM will tell you, in detail, if you ask it to.

08

After the credits

Two facts from this phase are about to become the whole story.

Each thread has its own stack. All of them share one heap.

A pause stops every application thread at once.

The first is why anything a thread keeps to itself is safe without effort, and why anything on the heap is not. The second is why adding more threads has a ceiling nobody mentions.

Phase XIV is eleven sections on what happens when more than one thread is running. It opens with the failure you have already seen:

IntStream.range(0, 100_000).parallel().forEach(list::add);   // 40787 of 100000

Sixty thousand values lost, no exception, a different number every run. Section 11.6 showed you that and said the explanation was coming. This is where it arrives. What a thread actually is, why two threads writing to one field can lose a write, and the several tools Java gives you for stopping it.

ForkJoinPool, the pool that has quietly been running every parallel stream you have written, is in the last section of it.

Garbage collection will return in Phase XIV. Concurrency