KodewerkJava Performance

Nulling Out References Won't Help Your Garbage Collector

One of the misconceptions that I continuously run into is that nulling out references in Java helps garbage collection. This attitude is particularly prevalent from those developers used to C/C++ where delete ptr becomes ref = null. To be fair, it’s a reasonable thing to believe. It’s also wrong, and the way it’s wrong tells you most of what’s worth knowing about how a tracing collector actually works. That is, the vast majority of data allocated in a tracing garbage collected runtime becomes collectable as soon as a variable falls out of scope.

The collector doesn’t delete anything

Let’s look at the name, garbage collection, because that’s where the trouble starts. The term “Garbage Collector” leaves one with the mental model that once an object is no longer in use, garbage collection works to free the memory used by that object making it available for reuse. While this is what a garbage collector does, it doesn’t do it in the way that the name suggests. Modern JVM collectors are known as copying collectors. Copying collectors trace live references to find and copy live data from one memory pool to another. After all of the live data is copied from the “from” memory pool to the “to” memory pool, the from memory pool is considered to be empty and ready to be reused. I prefer to think of this as “Live Object Harvesting” as opposed to garbage collection. Live object harvesting best describes what the garbage collector does. Once one starts to think about garbage collection as live object harvesting, you quickly realize that the collector doesn’t delete anything. Instead it preserves all the data that it can reach by tracing live references. The impact of how data is scoped on garbage collection is key to understanding why ref = null isn’t helpful

Most objects die young

Garbage collection is triggered when the accumulation of memory consumed by an application hits a threshold. Once triggered, the garbage collector starts the process of cleaning memory by first finding all of the garbage collection roots (GC roots). A GC root is a pointer that is external to the memory pool being collected and points to data in that memory pool. A pointer that fits this description is known as an external pointer and is a live GC root by definition. The process to find external pointers in the JVM is known as “Scan for roots”. For completeness, an internal pointer is one that exists completely internal to a memory pool. Figure 1 visualizes an external and internal pointer.

         ┌─────┐
         │ foo │
         └─────┘
            │           pool A
       ═════╪═══════════════════════════════
            │           pool to be collected
            │external
            │
            ▼
         ┌─────┐   internal    ┌─────┐
         │ bar │──────────────▶│ baz │
         └─────┘               └─────┘


            Figure 1. External vs internal pointer

While GC roots are found in a number of JVM data structures, the data structure of interest in this instance is the Java stack. Let’s take a deeper dive into how Java stacks function to understand their role in object liveness. Here is some code to consider.


public class StackExample {
    static void main(String[] args) {
        var stackExample = new StackExample();
        stackExample.runit(args);
    }
    
    void runit(String[] args) {
        String value = call1(args[0], "_suffix");
        System.out.println(value);
    }
    
    String call1(String input, String ending) {
        String output;
        output = input + ending;
        return output;
    }
}

            Listing 1. Sample code used to explore the Stack for the main thread.

In the JVM, stacks are per-thread and private. In contrast, data in Java heap is shared by every thread. As the JVM’s main thread executes the code above, it will push a stackframe onto the stack for each method call. The stackframe will hold the parameters passed to the method, all of the local variables and the return value. Only Java primitive values or references are pushed onto the stack

Thread - main stack — main() is the only frame (bottom of the stack)

┌─ main() ────────────────────────────┐
│ return address:  JVM launcher       │   ← where main() resumes; the launcher is native
├─ locals ─────┬───────────┬──────────┤
│ args         │ reference │ String[] │   ← a reference — the array is on the heap
│ stackExample │ reference │ (unset)  │   ← slot reserved, not yet assigned
├─ operands ───┼───────────┼──────────┤
│ (empty)      │           │          │   ← working stack space
└──────────────┴───────────┴──────────┘

            Figure 2. Stack with initial Stack Frame

The stack above contains a single stack frame. Within the frame are a local variables array and an operand stack; each slot, in either one, holds a Java primitive value or a reference. A reference is a pointer to an array or object on the Java heap that can be passed, assigned, or compared. It can never be inspected or directly manipulated. The frame above is in its initial state: the locals array has a slot for args and a slot for stackExample. The args slot holds a reference to the String[] on the Java heap, while stackExample is still unset. As the code is executed, new StackExample() will get executed. Prior to the assignment to stackExample, the reference to the new instance will live on the operands stack. It is imperative to not lose references by keeping them in the stack at all times, even if the reference is duplicated.


Thread - main stack — call1() return value is pushed to the operands stack 

┌─ call1() ───────────────────────────────┐
│ return address:  runit()                │
├─ locals ─────┬───────────┬──────────────┤
│ this         │ reference │ StackExample │
│ input        │ reference │ String       │
│ ending       │ reference │ String       │
│ output       │ reference │ String       │
├─ operands ───┼───────────┼──────────────┤
│              │ reference │ String       │
└──────────────┴───────────┴──────────────┘
┌─ runit() ───────────────────────────────┐
│ return address:  main()                 │
├─ locals ─────┬───────────┬──────────────┤
│ this         │ reference │ StackExample │
│ args         │ reference │ String[]     │
│ value        │ reference │ (unset)      │
├─ operands ───┼───────────┼──────────────┤
│ (empty)      │           │              │
└──────────────┴───────────┴──────────────┘
┌─ main() ────────────────────────────────┐
│ return address:  JVM launcher           │
├─ locals ─────┬───────────┬──────────────┤
│ args         │ reference │ String[]     │
│ stackExample │ reference │ StackExample │
├─ operands ───┼───────────┼──────────────┤
│ (empty)      │           │              │
└──────────────┴───────────┴──────────────┘
            Figure 3. Stack with Stack Frame for each call

In Figure 3 we can see the state of the stack for the call chain main() -> runit() -> call1(). All of the code has been executed in call1 and the return value, reference to output, has been pushed onto the operand stack.

In terms of scoping, all of the variables in the stackframe are local to the method that the stack frame was created for. Thus when the method returns the top of stack will be repositioned to the top of the previous method in the call chain and all of the local variables will fall out of scope.

From a garbage collection point of view, all references in the stack are external pointers and by definition, GC roots. Consequently, when the garbage collector scans a stack, it needs to know where the top of stack currently is. All references below top of stack will be added to the GC root set whereas all references above the top of stack will not be visited. In a typical Java application, the vast majority of references fall into the category of being local scoped. Given that the stack will change millions of times per second, the vast majority of local references will never be seen by the garbage collector. In fact, this is the reason for the observation that most objects “die young” which is the basis for the “Weak Generational Hypothesis”.

You may be wondering, if the stack frames are changing millions of times per second, how does the garbage collector get a stable GC root set? The answer to that is it briefly pauses execution using a mechanism known as safepointing. For concurrent collectors, this stable view won’t be true for very long but that doesn’t matter all that much. The important thing to know is, references that have dropped out of scope can’t magically come back into scope and we can allow concurrent collectors to treat some objects as live for an individual GC cycle knowing that the memory will be recovered in a future cycle. Since this data typically represents a small portion of overall heap, the “waste” is acceptable given the benefits.

ref = null is dead code

Now that we’ve briefly explored how the garbage collector interacts with thread stacks and local variables it seems obvious that setting ref to null when it will soon fall out of scope, is unnecessary. It does nothing to help the garbage collector. A leak isn’t a forgotten free, it’s an object that the collector can still reach, a static collection that keeps growing, a listener you registered and never unregistered, a cache entry that is never evicted. The object is live because, by the rules of reachability, it genuinely still is. Finding the reference chain and the logic that causes data to be retained for longer than it is semantically useful is the first step towards a fix.

In short, ref = null is a bandaid fix for a deeper problem that should never appear in an application’s code base. You may ask, is that true? Because never is a very long time and in that long time you’re likely to run into some exceptions. Remember the brief mention of the Weak Generational Hypothesis? It turns out that using this hypothesis for the basis for optimizing garbage collection is so powerful that it cannot be ignored.

When ref = null can help

The Weak Generational Hypothesis tells us that most objects are live for a very short period of time. It is the basis for why modern Java heaps are organized into generational spaces. That is, these spaces are designed to organize the data by age. The common case is to GC each memory pool separately though in some cases they maybe combined and collected all together. In the former (common) case, each of the other spaces becomes a source of roots for the space being collected. In rare cases, this can lead to a degenerative condition known as object nepotism.

Object nepotism occurs when a fragmented data structure (think linkedList) has pieces that span more than 1 memory space. In this case nulling out references may have a positive impact on garbage collection. That said, eventually all memory will be recycled unless there is some pathology preventing that from happening. If you’re seeing a growth in memory consumption over time, your application is suffering from some sort of data lifecycle logic bug which we typically call a memory leak. While ref = null may be an easy tactical hack to work around the problem, it’s best to fix the faulty logic that is responsible for the leak. With Object nepotism, ref = null is a signal that says, I’m done with this part of this fragmented data structure. Let’s consider the case of LinkedList.

LinkedList is a fragmented data structure that consists of a head and a series of nodes that are linked together in a chain. If we use LinkedList as a FIFO queue, adding a node adds to the tail but taking a node takes from the head. If we build up the queue before taking, it is likely that the head node will end up in a tenured space whereas the most recently added nodes may remain in the nursery. If we execute a series of takes, it is possible to have the LinkedList head point to a node in the nursery while the taken nodes are also pointing to a chain of nodes that eventually point to the node in the nursery. This leaves two nodes in tenured space will be GC roots for a collection of the nursery. Consequently, the garbage collector will reach all of the zombie nodes in the nursery. Eventually this situation will unravel as soon as tenured is collected. However, that can cause the collector to perform more collections of tenured than it should. In this case adding a next = null breaks the nepotism and allows everything to be cleaned up. But as previously mentioned, this is an exceptional case and it’s unlikely that you’ll encounter one. One last point, the nodes are all pointing to another object and it is generally that object that consumes a great deal of memory issues as the node it’s self is a thin wrapper that doesn’t consume much memory.


  ┌─ LinkedList ─┐
  │ first        ┤────────────────────────────────────┐
  │ size = 3     │                                    │
  └──────────────┘                                    │
                  Heap boundary                       │
                       ┆                              ▼
  ┌──────┐    ┌──────┐ ┆  ┌──────┐    ┌──────┐    ┌──────┐    ┌──────┐    ┌──────┐
  │ node │──▶ │ node │──▶ │ node │──▶ │ node │──▶ │ node │──▶ │ node │──▶ │ node │──▶ null
  └──────┘    └──────┘ ┆  └──────┘    └──────┘    └──────┘    └──────┘    └──────┘
    dead        dead   ┆   zombie      zombie       live        live        live

            Figure 4. LinkedList Nepotism

Conclusion

The takeaway is a short one. As part of the method return, the top of stack is adjusted so that the current stack frame is no longer in scope. With this adjustment, all of the local references to data stored in that stack frame will be lost. Ironically, this loss of references, something you’d fight to avoid in C/C++, is exactly what needs to happen in the Java runtime.” It is this loss of references that allows the collector to reclaim memory without the aid of a ref = null from you. And given that in most applications, the vast majority of data will be locally scoped, doing nothing is the best course of action. Also, when memory consumption does climb, that isn’t the collector failing; it’s the collector faithfully retaining something your application is still holding, and the fix is to find that reference chain and correct the data lifecycle, not to null the symptom.

With that I hope that this helps your understanding of how intricate and amazing the JVM is and how much cognitive load that it takes on so you rarely have to think about things that steal your focus away from application logic.

If you have a persistent problem with memory or perhaps you’d like to know how to use less of it, I can help. My heap analysis tooling is designed to work with any size heap. Moreover, it is configurable so that it can be tuned specifically to answer questions about your application.