Garbage Collector — An Introduction
One of Java's big features is that you don't explicitly manage the life cycle of objects. Objects are created when needed, and when they are no longer in use the JVM removes them and frees the memory — unlike C or C++, where you manage that yourself. Tuning the JVM can be a hassle, but once you understand how things work under the hood, tuning your applications gets much easier.
What GC actually does
The garbage collector's job is to find objects that are in use and free the memory held by the rest. This is often misunderstood as "find objects with no references." That is only partly true. In a linked list, each node points to the next; if nothing refers to the head, the entire list should be freed even though the nodes still reference each other.
Tracking references alone is insufficient, so the JVM periodically searches the heap. The starting point is always a GC root — an object reachable from outside the heap, such as a thread stack or system class. The GC scans everything reachable from the roots. Reachable objects are live; everything else is garbage.
Freeing space is not enough on its own. Because objects have different sizes, repeatedly freeing and allocating leaves the heap fragmented — many small free areas that can only hold small objects, and a lot of effectively useless space. So after clearing, the GC also compacts the heap: live objects are relocated to fill the gaps, leaving one larger free area at the end.
Different collectors compact differently — some delay it until absolutely necessary, some compact a small region at a time, some compact the whole heap. Those choices are the main reason collectors have different performance characteristics.
Moving objects while application threads are running is not always safe. To guarantee safety, the GC performs a stop-the-world pause that halts all application threads for the duration of the work. These pauses have the greatest impact on performance, so minimizing them is a central concern when tuning.
Generations
Most collectors split the heap into generations: the old (tenured) generation and the young generation, with the young generation further divided into eden and survivor spaces.
Many objects live for a very short time. Consider summing profit across orders:
var sum = BigDecimal.ZERO;
for (Order order : orders) {
BigDecimal profit = order.getValue().subtract(order.getCost());
sum = sum.add(profit);
}
BigDecimal is immutable, so every arithmetic operation creates a new object. Run this over 1000 orders and you create roughly 2000 short-lived BigDecimal objects.
The collector exploits this. Objects are first allocated in eden. When the young generation fills up, the GC stops the application threads and clears it: unused objects are removed, survivors are moved to a survivor space or promoted to the old generation. Because the young generation is only a portion of the heap, processing it is fast — pauses are shorter but more frequent, which is generally better for performance. Compaction is cheap here too: after collection, eden and one survivor space are empty, and the remaining objects are compacted into the other survivor space.
Objects promoted to the old generation eventually fill it up. Collecting the old generation is where algorithms differ most.
Full GC
A simpler algorithm: stop all application threads, find unused objects, free the heap, compact the memory. It generally causes a long pause.
Concurrent GC
Concurrent (low-pause) collectors are more complex but find unused objects while application threads run, and take different approaches to compacting the old generation. You get fewer and shorter pauses, at the cost of significantly more CPU — and tuning them for best performance can be harder (less so in recent OpenJDK versions).
Choosing a collector
There is always a trade-off. For a REST server measuring per-request response time:
- Requests are hurt most by long full-GC pauses. To minimize pause times and improve tail latency, use a concurrent collector.
- If average response time matters more than outliers, a non-concurrent collector may be a better choice.
- Hardware matters. Concurrent collectors are CPU-intensive; if the machine lacks CPU headroom, a non-concurrent collector is better.
Summary
- The garbage collector finds used and unused objects, removes the unused ones, and compacts the memory afterward.
- GC algorithms divide the heap into old and young generations.
- They use a stop-the-world approach to clear the young generation.
- Choose concurrent or non-concurrent algorithms based on your application's needs and available CPU.
Originally published on Medium.