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.
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”?
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 1Nothing 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.
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 aliveThe 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.
| Area | How it is treated | |
|---|---|---|
| Eden | where new objects are born | filled fast, collected often |
| Survivor 0 and 1 | objects that made it through a collection | copied between the two, counting survivals |
| Old generation | objects that survived enough times | collected rarely, and it costs more when it happens |
The life of a typical object
- Born in EdenAllocation is a pointer moving forward. Almost free.
- Eden fills upA minor collection runs. Everything still reachable is copied into a survivor space, and Eden is declared empty in one step.
- Survives a few moreEach collection copies it between the two survivor spaces and increases its age.
- PromotedPast a threshold it is moved to the old generation, on the assumption that something which has lived this long will keep living.
- 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.
Under the hood
Going deeperStop 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:
| Collector | What it is for | |
|---|---|---|
| Serial | one thread does everything | small heaps, containers with one core |
| Parallel | many threads, still stops the world | best total throughput, if pauses do not matter |
| G1 | regions, mostly concurrent | the default. Aims for a pause target you can set |
| ZGC | pauses under a millisecond | very 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 goneA 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 reportingThe 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.
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.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
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.
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.
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
Mapused 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.
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
Collect a Ring
ex-13-2-aBuild 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
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
WeakReference lets you watch an object without keeping it alive. When get() starts returning null, it has been collected.Hint 2
Hint 3almost the answer
Weak, Soft, and Neither
ex-13-2-bTwo 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
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
Hint 2
Hint 3almost the answer
Leak On Purpose
ex-13-2-cWrite 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
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
Hint 2
Hint 3almost the answer
Watch It Collect
ex-13-2-dThe 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
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
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 100000Sixty 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