Java Garbage Collection Basics and Object Lifecycle

Introduction In Java you create objects with new, but you never delete them. Instead, the JVM's garbage collector finds objects which can no longer be used and frees their memory automatically. This removes a whole class of bugs - freeing memory too early, or forgetting to free it - that plague languages with manual memory management. This tutorial explains Java garbage collection basics and the lifecycle of an object. We will start with the lifecycle of an object, from creation to collection. We will then see how the garbage collector decides what is garbage, using reachability. Next we will watch the garbage collector at work with GC logging, and look at generations and the collectors available. Lastly, we will look at what you can and cannot control - System.gc(), finalization - and how memory leaks still happen.

(Note - This tutorial builds on Stack vs Heap Memory and Object ReferencesClick to read tutorial: Stack vs Heap Memory in Java and Object References.) The lifecycle of an object
  1. Created - new allocates memory on the heap, sets the fields to their default values, and runs the constructor.
  2. In use (reachable) - the program holds at least one reference to it, directly or through other objects.
  3. Unreachable - the last reference to it is gone: the variable went out of scope, was set to null or pointed at another object, or the object holding it became unreachable too. The program can never use the object again.
  4. Collected - at some later point, the garbage collector reclaims its memory. When this happens is up to the JVM.
Ways an object becomes unreachable
//Lifecycle.java
import java.util.ArrayList;
import java.util.List;

public class Lifecycle {
  static List<String> cache = new ArrayList<>();

  static void makeTemporary() {
    StringBuilder temp = new StringBuilder("only used here");   // unreachable when the method returns
    System.out.println("temp length: " + temp.length());
  }

  public static void main(String[] args) {
    makeTemporary();                                   // 1. local variable went out of scope

    String report = new String("first report");
    report = new String("second report");              // 2. first object lost its only reference
    String draft = new String("draft");
    draft = null;                                      // 3. reference cleared explicitly

    cache.add(new String("cached"));                   // reachable from a static field - stays alive
    System.out.println("report = " + report + ", draft = " + draft + ", cache = " + cache);
  }
}
 OUTPUT of the above code
temp length: 14
report = second report, draft = null, cache = [cached]
The StringBuilder, the "first report" object and the "draft" object are now unreachable, and eligible for collection. The "cached" string stays alive for as long as the static cache list holds it. Reachability - how the GC finds garbage The garbage collector does not count references. It starts from a set of GC roots - local variables in the stack frames of every running thread, static fields, and a few JVM-internal references - and follows every reference from them, marking each object it reaches. Anything not marked is unreachable, and is garbage.

A useful consequence: objects which refer only to each other - a cycle - are still collected once nothing reachable points into the cycle. Reference counting could never free them, but reachability does.
Objects in a cycle are still collected
//CycleDemo.java
import java.lang.ref.WeakReference;

public class CycleDemo {
  static class Node {
    Node next;
    byte[] payload = new byte[1024 * 1024];
  }

  public static void main(String[] args) throws InterruptedException {
    Node a = new Node();
    Node b = new Node();
    a.next = b;
    b.next = a;                                    // a and b refer to each other
    WeakReference<Node> watchA = new WeakReference<>(a);   // watches a without keeping it alive

    System.out.println("before: a is alive = " + (watchA.get() != null));
    a = null;
    b = null;                                      // the cycle is now unreachable from any root
    System.gc();                                   // a request, not a command - see below
    Thread.sleep(200);
    System.out.println("after : a is alive = " + (watchA.get() != null));
  }
}
 OUTPUT of the above code (collection timing can vary)
before: a is alive = true
after : a is alive = false
A WeakReference lets us watch an object without keeping it reachable. After the last strong references were cleared, the garbage collector freed both nodes, even though each still pointed at the other.
Watching the garbage collector - GC logging The -Xlog:gc option makes the JVM print a line for each collection. This program creates a stream of short-lived objects in a small heap.
Creating many short-lived objects
//GcLog.java
public class GcLog {
  public static void main(String[] args) {
    long total = 0;
    for (int i = 0; i < 300; i++) {
      byte[] chunk = new byte[200 * 1024];      // 200 KB, garbage after each iteration
      total += chunk.length;
    }
    System.out.println("Allocated " + total / (1024 * 1024) + " MB in total");
  }
}
 OUTPUT with -Xmx32m -Xlog:gc (timings and sizes vary)
[0.011s][info][gc] Using G1
[0.056s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 18M->1M(32M) 1.413ms
[0.057s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 14M->1M(32M) 0.366ms
[0.059s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 18M->1M(32M) 0.308ms
Allocated 58 MB in total
Explanation of the output
  • The program allocated far more memory in total than the 32 MB heap, yet never ran out - each collection freed the chunks which were no longer reachable.
  • Each Pause Young line is one collection of the young generation, showing heap usage before and after it (before->after(total)), and how long the application was paused - a few milliseconds for the first collection, and under a millisecond for the ones after it. After each collection only about 1 MB was still in use.
  • The first line names the collector in use - G1, the default since Java 9.
Generations and collectors Most objects die young - temporary strings, iterators, intermediate results. The HotSpot JVM exploits this with generational collection -
  • Young generation: new objects are created here. It is collected often and quickly, because most of it is garbage - only the few survivors are copied.
  • Old generation: objects which survive several young collections are promoted here. It is collected less often.
HotSpot offers several collectors, chosen with a command-line option -
For most applications the default is the right choice; tune only after measuring a real problem.
What you cannot control - System.gc() and finalize()
  • System.gc() only requests a collection; the JVM may ignore it, and it can be disabled entirely with -XX:+DisableExplicitGC. Calling it in application code usually makes performance worse. It is used above only to make the demonstration predictable.
  • finalize() was meant to run cleanup code before an object is collected, but there was no guarantee when - or whether - it would run. It is deprecated for removal since Java 18. Never rely on garbage collection to release files, connections or locks; close them explicitly with try-with-resourcesClick to read tutorial: Java try-with-resources and AutoCloseable Explained. For cleanup tied to an object's collection, java.lang.ref.Cleaner is the modern replacement.
Memory leaks still happen Garbage collection frees unreachable objects. An object you no longer need but still refer to is not garbage - and if such objects keep accumulating, the heap fills up and the program ends with an OutOfMemoryError, as shown in Stack vs HeapClick to read tutorial: Stack vs Heap Memory in Java and Object References. Common causes are -
  • static collections and caches which only ever grow - give caches a size limit or an expiry;
  • listeners and callbacks which are registered but never removed;
  • long-lived objects holding references to short-lived ones, such as a session object keeping every request it has seen.
Tools such as VisualVM, JDK Mission Control and heap dumps (jcmd <pid> GC.heap_dump) show which objects fill the heap and what keeps them reachable. Summary In this tutorial we first followed an object through its lifecycle - created, reachable, unreachable and collected - and saw the ways an object loses its last reference. We then learned that the garbage collector works by reachability from GC roots, which is why even cycles of objects are collected. Next we watched collections happen with -Xlog:gc, and looked at the young and old generations and the G1, ZGC, Shenandoah and Parallel collectors. We then saw why System.gc() and finalize() should not be relied on, and lastly how memory leaks still happen when unneeded objects stay reachable. In the next tutorial, we will deal with the most common runtime error in Java - null and NullPointerExceptionClick to read tutorial: null and NullPointerException in Java: How to Avoid It.