Java Tutorial
🔍

Java Garbage Collection

Java Garbage Collection

Java never asks you to call free(). Every object you create with new eventually stops being needed, and the JVM reclaims that memory on its own, without a single line of code from you telling it when or how. That automation is exactly why Java eliminated an entire category of bugs that plagued manually-managed languages, and exactly why "the garbage collector handles it" is only half the story - it handles memory, not judgment, and a service can still leak for months while the collector runs perfectly correctly the whole time.

What Is Garbage Collection?

Garbage collection is the JVM's automatic process for identifying objects on the heap that a running program can no longer reach, and reclaiming the memory those objects occupy. Nothing about it is manual - there is no equivalent of C's free() or C++'s delete anywhere in the language, by design.

Why Automatic Memory Management Matters

Languages that hand memory management to the developer create a specific, well-documented class of bugs that has nothing to do with business logic. Free an object too early, and any code still holding a reference to it touches memory that no longer means what it used to - a use-after-free bug. Free the same object twice, and the allocator's internal bookkeeping corrupts in ways that can crash a process far from where the actual mistake happened. Forget to free something at all, and memory grows without bound.

Java's designers made a deliberate tradeoff: give up manual control over exactly when memory gets reclaimed, in exchange for making use-after-free and double-free structurally impossible. The garbage collector decides when it is safe to reclaim an object, based on one question alone - can anything in the running program still reach it - and that question is answered automatically, continuously, for the entire life of the application.

How Garbage Collection Works Internally

Every collection cycle starts from a fixed set of starting points called GC roots - local variables and parameters on any thread's stack, static fields on loaded classes, and a small number of JVM-internal references. An object is reachable if it can be reached by following references starting from a GC root, however many hops that takes. Anything not reachable from any root, no matter how many other objects still point to it, is garbage.

One sentence before the diagram: the collector walks outward from GC roots marking everything it can reach, then reclaims everything it never touched.

GC Roots
(stack variables, static fields,
 active threads)
    |
    +--> Object A --> Object B        reachable, survives
    |
    +--> Object C                     reachable, survives

         Object D <--> Object E       not reached from any root -
                                       both eligible for collection,
                                       even though they still
                                       reference each other

This is the detail that trips up developers coming from a reference-counting background: Object D and Object E reference each other directly, yet neither is reachable from a GC root, so both get collected together. Java's collectors work by tracing reachability from roots, not by counting how many references point at an object, which is exactly why circular references are never a special case that needs manual breaking the way they are in a purely reference-counted system.

A minor GC collects the young generation - covered in depth in this series' dedicated Heap Memory article - and runs frequently, since most objects die quickly and this collection is cheap. A major GC, sometimes called a full GC, collects the old generation, or the entire heap depending on the collector, and runs far less often but costs considerably more per run, since it has more memory to examine.

Several garbage collectors ship with the JDK, and the right one depends on the workload. Serial GC is single-threaded and suits small applications with tight memory budgets. Parallel GC uses multiple threads to collect faster and was HotSpot's default on server-class machines through Java 8. G1 (Garbage First) has been the default collector since Java 9, and organizes the heap into regions to keep pause times more predictable as heaps grow larger. More recent low-latency collectors, ZGC and Shenandoah, target very large heaps with pause times that barely scale with heap size at all, at the cost of additional collector overhead.

Code Examples

Setting one reference variable to null only removes that one path to an object - it does nothing to the object itself if another reference still reaches it.

1// File: ReachabilityExample.java 2 3public class ReachabilityExample { 4 5 static class Node { 6 String label; 7 Node next; 8 9 Node(String label) { 10 this.label = label; 11 } 12 } 13 14 public static void main(String[] args) { 15 Node first = new Node("first"); 16 Node second = new Node("second"); 17 first.next = second; 18 19 Node alias = first; 20 21 first = null; 22 23 System.out.println("Reachable through alias: " + (alias != null)); 24 System.out.println("Original reference cleared: " + (first == null)); 25 System.out.println("Chain still intact: " + alias.next.label.equals("second")); 26 } 27}
Output:
Reachable through alias: true
Original reference cleared: true
Chain still intact: true

Clearing first only clears that one variable. alias still points at the exact same object, so the object - and everything reachable through it - stays fully alive. The same idea extends to two objects that reference each other directly.

1// File: CircularReferenceExample.java 2 3public class CircularReferenceExample { 4 5 static class Employee { 6 String name; 7 Employee manager; 8 Employee directReport; 9 10 Employee(String name) { 11 this.name = name; 12 } 13 } 14 15 public static void main(String[] args) { 16 Employee lead = new Employee("Priya"); 17 Employee member = new Employee("Rohit"); 18 19 lead.directReport = member; 20 member.manager = lead; 21 22 System.out.println("Lead's report: " + lead.directReport.name); 23 System.out.println("Report's manager: " + member.manager.name); 24 25 lead = null; 26 member = null; 27 28 System.out.println("Both local references cleared: " + (lead == null && member == null)); 29 } 30}
Output:
Lead's report: Rohit
Report's manager: Priya
Both local references cleared: true

lead and member reference each other for the entire first half of this method, and nothing about that relationship changes when both local variables are set to null at the end - the two Employee objects still point at each other exactly as before. What has changed is that no GC root anywhere in the program reaches either one of them anymore, which is the only fact a tracing collector actually cares about.

Real-World Example

A notification system lets stores subscribe to an order event bus so each one gets notified the moment a new order comes in - a textbook observer pattern, and a textbook place for a subtle leak to hide.

1// File: OrderListener.java 2 3public interface OrderListener { 4 void onOrderPlaced(String orderId); 5}
1// File: EventBus.java 2import java.util.*; 3 4public class EventBus { 5 private final List<OrderListener> listeners = new ArrayList<>(); 6 7 public void subscribe(OrderListener listener) { 8 listeners.add(listener); 9 } 10 11 public void publish(String orderId) { 12 for (OrderListener listener : listeners) { 13 listener.onOrderPlaced(orderId); 14 } 15 } 16 17 public int subscriberCount() { 18 return listeners.size(); 19 } 20}
1// File: EventBusDemo.java 2 3public class EventBusDemo { 4 public static void main(String[] args) { 5 EventBus eventBus = new EventBus(); 6 7 for (int i = 1; i <= 3; i++) { 8 String storeName = "Store-" + i; 9 eventBus.subscribe(orderId -> 10 System.out.println(storeName + " notified of order " + orderId)); 11 } 12 13 eventBus.publish("ORD-9001"); 14 15 System.out.println("Active subscribers: " + eventBus.subscriberCount()); 16 } 17}
Output:
Store-1 notified of order ORD-9001
Store-2 notified of order ORD-9001
Store-3 notified of order ORD-9001
Active subscribers: 3

EventBus.listeners is a static-lifetime GC root chain in everything but name - as long as the EventBus instance itself is reachable, every subscribed listener stays reachable too, along with anything each listener's lambda captured, in this case each store's name. A mistake that appears often in fresher pull requests is building exactly this subscribe pattern without ever writing the matching unsubscribe() method, because nothing about subscribe() alone looks dangerous and the demo above runs perfectly. In production, stores close, integrations get retired, and features get toggled off - and every listener registered for one of them stays fully reachable, and fully un-collectible, for as long as the EventBus itself keeps running, which for a long-lived service usually means for the life of the process. The fix teams following clean architecture reach for is pairing every subscribe() with an unsubscribe() called from wherever the subscriber's own lifecycle actually ends, or using a registration handle - often via AutoCloseable - that makes forgetting to clean up a compile-time-visible risk instead of a silent one.

Best Practices

Pair every registration-style API - listeners, subscribers, callbacks - with an explicit way to unregister, and make unregistering as easy to call correctly as registering was.

Reach for WeakReference or WeakHashMap deliberately when a structure genuinely should not, on its own, keep its entries alive - a cache keyed by objects whose real lifecycle is owned elsewhere is the classic case, though it is a narrow tool for a narrow problem, not a general leak-prevention habit.

Let the JVM's default collector choice stand until profiling shows a specific pause-time or throughput problem worth solving - collector tuning is a response to a measured issue, not a default step in setting up a new service.

Treat System.gc() as a request, never a guarantee, and avoid calling it as a substitute for fixing an actual reachability problem in the code.

Common Mistakes

Assuming garbage collection prevents memory leaks outright is the single most common misconception about it. It cannot - GC only reclaims what is unreachable, and a static field, an unbounded cache, or an observer registry with no matching unregister step, exactly like this article's event bus, keeps its contents reachable, and therefore un-collectible, indefinitely. The collector is doing its job correctly the entire time; the leak is a reachability problem the collector was never in a position to solve.

Assuming two objects that reference each other can never be collected is a second, closely related misconception, usually carried over from experience with reference-counted systems in other languages. As CircularReferenceExample demonstrates, Java's tracing collector reclaims a mutually-referencing pair the moment no GC root reaches either one, with no special handling required.

Interview Questions

Q1. What is garbage collection, and what problem does it solve?

It is the JVM's automatic process for reclaiming heap memory occupied by objects a running program can no longer reach, removing the need for explicit manual deallocation and the use-after-free and double-free bugs that come with it. Interviewers are listening for whether you connect this to a real class of bugs Java avoids, not just a one-line definition.

Q2. What are GC roots, and why do they matter?

GC roots are the fixed starting points a collector traces reachability from - local variables and parameters on any thread's stack, static fields, and a small number of JVM-internal references. They matter because reachability, and therefore an object's eligibility for collection, is defined entirely in terms of whether it can be reached from one of these roots, not by anything about the object itself.

Q3. Can two objects that reference each other still be garbage collected in Java?

Yes. Java's collectors trace reachability from GC roots rather than counting references, so two objects that reference each other are collected together the moment neither is reachable from any root - exactly as demonstrated in this article's CircularReferenceExample. The nuance interviewers listen for is whether you know this is not a special case requiring extra handling.

Q4. What is the difference between a minor GC and a major/full GC?

A minor GC collects the young generation, runs frequently, and is cheap because most objects there die young. A major or full GC collects the old generation, or the whole heap depending on the collector, runs far less often, and costs considerably more per run since it examines much more memory. Interviewers want to hear that frequency and cost trade off directly against each other here.

Q5. Does setting a reference to null guarantee the object is collected immediately?

No, on two separate counts. It only removes that one path to the object - if any other reference still reaches it, as shown in this article's ReachabilityExample, the object stays fully alive regardless. And even once truly unreachable, an object is only reclaimed whenever the collector next runs, not the instant it becomes eligible.

Q6. Why can a Java application still leak memory despite GC running correctly?

Because GC only reclaims what is unreachable, and application code can accidentally keep objects reachable forever - an unbounded cache, a static collection nothing ever clears, or a listener registry with no corresponding unregister step, exactly like this article's event bus real-world example. Interviewers listen for whether you can name a concrete reachability pattern, not just repeat that leaks are "still possible."

Q7. What garbage collector does the JVM use by default, and has that changed across versions?

Parallel GC was HotSpot's default on server-class machines through Java 8. G1 has been the default since Java 9, trading a little throughput for more predictable pause times as heaps grow. Interviewers listen for whether you know the default has changed at all, since many candidates assume it never has.

FAQs

Does calling System.gc() force garbage collection to run?

No. It is documented as a hint that suggests now might be a good time to collect - the JVM is free to run a collection, delay it, or effectively ignore the call entirely, so it should never be relied on as a guarantee.

Is garbage collection implemented the same way across all JVM implementations?

No. The JVM Specification requires that unreachable memory eventually be reclaimed, but leaves the actual collection algorithm entirely up to each implementation - HotSpot's several collectors, Eclipse OpenJ9's collector, and others differ internally while remaining spec-compliant.

Does garbage collection run on a separate thread from my application?

Generally yes, GC work happens on dedicated collector threads distinct from application threads, though many collectors still pause some or all application threads for at least part of a collection cycle - exactly how much depends on which collector is active.

Can I disable garbage collection in Java?

Not in any general-purpose way. Java 11 did add Epsilon, an experimental no-op collector that allocates memory but never reclaims it, intended specifically for performance testing and very short-lived processes, not for production use.

Is reference counting used anywhere in Java's garbage collection?

No. HotSpot's collectors, and mainstream JVM collectors generally, use tracing, reachability-based collection rather than reference counting, which is exactly why a circular reference between two objects is never a special case in Java the way it can be in a reference-counted system.

Does garbage collection pause my whole application every time it runs?

It depends entirely on the collector and the phase of collection. Some phases in some collectors run concurrently with application threads; others are stop-the-world and pause every application thread until they finish - this varies enough by collector that it is not something to generalize about without naming which one is in use.

What is the difference between garbage collection and Metaspace cleanup?

Garbage collection reclaims heap memory - the objects your program creates. Metaspace, covered in this series' dedicated Metaspace vs PermGen article, stores class metadata rather than object instances, and is reclaimed on its own cycle tied to classloader reachability, separate from heap GC.

Summary

Garbage collection answers exactly one question, continuously, for every object on the heap - can anything still reach this - and reclaims whatever the answer is no. Tracing from GC roots is what makes circular references a non-issue, and the minor-versus-major split is what lets the collector spend most of its effort on cheap, frequent work instead of scanning the whole heap constantly.

The event bus in this article is worth remembering specifically for what it is not: not a cache, not a static field left in by accident, just an ordinary registration pattern missing its other half. GC was never going to catch that, because from its perspective nothing was ever wrong.

What to Read Next