The JVM’s garbage collector doesn’t actually "free" memory; it reclaims objects that are no longer reachable, making their memory available for reuse.
Let’s say you have a web server application. When a request comes in, it might create a Request object, a Handler object, and a Response object. These objects are part of a chain of references.
// Incoming HTTP request processing
Request request = new Request(httpRequest);
Handler handler = new Handler(request);
Response response = handler.handle();
// The 'response' object might be sent back over the network.
// Once the response is sent, the 'response' object is no longer directly referenced.
// The 'handler' object that created it might also become unreachable if its scope ends.
// And the 'request' object, if no longer needed, also becomes eligible for GC.
The JVM’s garbage collector runs periodically. It starts by identifying all objects that are "GC Roots" – these are typically things like active threads, static variables, and JNI references. Then, it traverses the graph of object references starting from these roots. Any object that can be reached through this traversal is considered "live." All other objects are deemed "unreachable" and are candidates for garbage collection. The collector then reclaims the memory occupied by these unreachable objects, making it available for new object allocations.
Here’s how a typical request lifecycle might look in terms of memory:
- Request arrives: A new
Requestobject is created. Memory is allocated on the heap. - Processing: The
Requestobject is used to create aHandlerobject, which in turn creates aResponseobject. More memory is allocated for these new objects. All these objects are reachable from the current thread of execution. - Response sent: The
Responseobject is sent back to the client. The reference to theResponseobject held by the handler might be cleared, or the handler itself might go out of scope. - GC opportunity: If the
Request,Handler, andResponseobjects are no longer referenced by any live object (including GC roots), they become unreachable. The next time the garbage collector runs, it will identify them and reclaim their memory.
Understanding this process is crucial for optimizing JVM performance. You can influence GC behavior through various JVM flags, but it’s essential to grasp the underlying mechanics first. For instance, knowing how object reachability works helps explain why certain patterns can lead to memory leaks, even with GC.
The JVM offers several garbage collectors, each with different strengths and weaknesses, often optimized for throughput, latency, or a balance between the two. Common ones include:
- Serial GC: Simple, single-threaded collector. Good for small heaps and single-processor machines, but can cause long pause times.
- Parallel GC (Throughput Collector): Uses multiple threads for collection, improving throughput by reducing GC pause times compared to Serial GC.
- CMS (Concurrent Mark Sweep): Aims for low pause times by doing most of its work concurrently with the application threads. However, it can suffer from fragmentation and is deprecated.
- G1 (Garbage-First): The default collector since Java 9. It partitions the heap into regions and aims to collect garbage in the regions that will yield the most garbage first, balancing throughput and pause times.
- ZGC and Shenandoah: Newer, low-latency collectors designed for very large heaps and extremely short pause times, often measured in milliseconds or microseconds.
To observe GC activity, you can use the -Xlog:gc flag. This will print detailed information about garbage collection events, including when GC cycles start and end, how much memory was reclaimed, and the duration of the pauses.
java -Xmx2g -Xlog:gc:file=gc.log MyApplication
This command starts your application with a maximum heap size of 2GB and logs all GC activity to gc.log. Analyzing this log can reveal patterns like frequent minor GCs, long major GC pauses, or excessive time spent in GC, all of which point to potential tuning opportunities.
A common misconception is that simply creating many short-lived objects is bad for performance. In reality, the JVM’s generational garbage collectors are highly optimized for this scenario. They quickly reclaim memory from young, short-lived objects in the "young generation" with minimal pause times. The real performance killer is often the creation of long-lived objects that survive multiple GC cycles and eventually move to the "old generation," where collection is more expensive and can lead to longer pauses.
When tuning GC, you’re not just adjusting memory limits; you’re influencing the trade-offs between application throughput and pause times. For instance, increasing the heap size (-Xmx) can reduce the frequency of GCs, potentially improving throughput, but it can also increase the duration of individual GC pauses, which might be unacceptable for latency-sensitive applications.
The JVM’s memory model is divided into several key areas: the Heap, the Metaspace (or PermGen in older JVMs), and Thread Stacks. The Heap is where all object instances are allocated. Metaspace stores class metadata, and Thread Stacks store local variables and method call information for each thread. Garbage collection primarily operates on the Heap.
The Heap itself is further divided into generations: the Young Generation (Eden and Survivor spaces) and the Old Generation (Tenured space). Objects are typically allocated in Eden. When Eden fills up, a minor GC occurs, moving surviving objects to a Survivor space. Objects that survive multiple minor GCs are eventually promoted to the Old Generation. Major GCs (or full GCs) collect garbage from both the Young and Old generations.
The most surprising thing about the JVM’s garbage collector is that its primary goal isn’t to free memory, but to identify and reclaim unreachable memory so that new memory can be allocated. The "freeing" is a side effect of making space for new allocations.
Let’s look at a simple application that generates a lot of short-lived objects and observe its GC behavior.
import java.util.ArrayList;
import java.util.List;
public class GCStressTest {
public static void main(String[] args) throws InterruptedException {
// Simulate creating many short-lived objects
for (int i = 0; i < 1000000; i++) {
createAndDiscardObject();
if (i % 10000 == 0) {
System.out.println("Processed " + i + " objects.");
Thread.sleep(10); // Little pause to allow GC to potentially run
}
}
System.out.println("Finished creating objects.");
// Keep the application running for a bit to observe potential old gen collections
Thread.sleep(5000);
}
public static void createAndDiscardObject() {
// Allocate a moderately sized object
byte[] data = new byte[1024]; // 1KB
// The 'data' array is local to this method. Once the method returns,
// the reference 'data' goes out of scope. If no other object holds a
// reference to this byte array, it becomes unreachable.
}
}
If you run this with -Xmx128m -Xms128m -Xlog:gc=debug:file=gc_stress.log and examine gc_stress.log, you’ll see a very high frequency of minor GC events in the young generation, with minimal pause times. The old generation will likely remain mostly empty because the byte[] objects are short-lived and get garbage collected before they can be promoted.
The key levers you control are:
- Heap Size (
-Xms,-Xmx): Determines the total memory available for objects. Larger heaps can reduce GC frequency but increase pause times. - GC Algorithm (
-XX:+UseG1GC,-XX:+UseParallelGC, etc.): Dictates how GC is performed, impacting throughput and latency. - Young Generation Sizing (
-XX:NewRatio,-XX:NewSize,-XX:MaxNewSize): Affects how much space is dedicated to short-lived objects, influencing minor GC frequency. - Metaspace Size (
-XX:MaxMetaspaceSize): Controls the memory for class metadata.
The one thing most people don’t realize is that the heap is not a monolithic block of memory being scanned. Modern GCs, like G1, divide the heap into many small regions. During a collection cycle, the GC identifies which regions have the most garbage and prioritizes collecting those first, aiming to meet a target pause time by collecting a "garbage-first" set of regions. This regional approach allows for more granular control and predictable pause times, even on very large heaps.
The next concept you’ll likely encounter is understanding how to profile your application to identify actual memory bottlenecks, rather than guessing at GC tuning parameters.