Garbage Collector — An Introduction

Friskovec Miha
Javarevisited
Published in
7 min readOct 10, 2021

How JVM handles garbage collection and how you can tune it.

The performance of your Java application is heavily dependent on garbage collection technology thus it’s not surprising there are quite a few collectors available for you to choose from. OpenJDK has three collectors suitable for production and some experimental ones that you probably don’t want to use in production.

All those collectors have quite different performance characteristics (more on that later) but they all share the basic concepts you should understand.

Java Garbage Collector
Garbage Collection in JVM

Garbage Collector

One of the big features in Java for developers is that there is no need to explicitly manage the life cycle of objects in the code. In Java objects are created when needed and when they are no longer in use, the JVM automatically removes them and frees the memory. In contrast to C or C++, where you have to manage this process programmatically which for most people is not “ideal”. If you ever spend any time optimizing Java programs — tweaking and customizing JVM can also be kind of a hassle, but I promise you when you understand how things work under the hood, you will be able to tune your applications fairly easy.

What is GC

The main mission of garbage collectors is finding objects that are in use and freeing the memory with remaining objects (ones that are not in use). This is sometimes misunderstood as finding the objects that have no references to them. But this is only partially true. If we take for example a linked list — each object in the list will point to the next object in said list. But if nothing refers to the head of the list, the entire list should be freed from the memory.

Tracking only references is insufficient, thus the JVM must perform a periodical search in the heap for unused objects. The starting point of the search is always an object that is GC root — an object accessible from the outside the heap (thread stack of system class). The GC then performs an algorithm that scans all objects that are reachable for the root object. Objects that are reachable are live objects, everything else is, as you’ve probably guessed — garbage.

When GC finds unused objects, the JVM can free up the memory occupied by those objects and use it to allocate space for new objects. However, because objects can have different sizes only freeing up the space is not enough. At some point, memory must be compacted to prevent memory fragmentation.

Take for example a program that allocates two arrays within a loop. One array has a size of 1024 bytes and the second one has only 16 bytes. Let’s see how the heap would look like.

Heap after allocation

Now let’s say arrays with 16 bytes are no longer in use and arrays with 1024 bytes are still in use. After the garbage collection process, we end up with a heap consisting of very small free areas that can only be filled with objects with 16 or fewer bytes. In the image below green areas are now free.

Heap after freeing objects

I think you can see the potential problems if JVM will left the memory structured like this. We would end up with a lot of “useless” memory space we cannot fill with new objects. Luckily the GC does one more thing besides finding and removing unused objects that solve this exact problem. After each memory clearing process, GC also compacts the heap. This means that active objects are relocated in the memory so we don’t end up with empty areas. To put it visually here is another image — the last step of the GC process.

Heap after compaction

Active objects are moved in the memory to fill up the empty areas leaving us with a bigger area at the end of the heap.

Different GC collectors handle the compaction processes differently. Some algorithms delay this process until it is absolutely necessary, some compact only a small part of the memory at the time when other compact the whole heap. These different approaches are the main reason why different algorithms have different performance characteristics.

Another big problem performing memory compaction is the actual running application. If there are application threads running it is not always safe to move objects around in memory. When tracking objects and performing memory compaction the GC must make sure that application threads are not using those objects. To make sure GC operations are safe the “stop-the-world” pause is performed. This pause stops all application threads for the duration of the GC process. As you can imagine those pauses have the greatest impact on the performance thus minimizing those pauses is an important consideration when tuning GC.

Generations

Most garbage collectors work by splitting the heap into generations. There are two main generations: the old generation (tenured) and the young generation. The young generation is further divided into two sections called: eden and survivor.

In a Java application, there are many objects that are used for a small period of time. Take for example the following piece of code that calculates profits from the order and adds it to the total sum.

var sum = new BigDecimal.ZERO
for (Order order: orders)) {
BigDecimal profit = order.getValue().subtract(order.getCost());
sum = sum.add(profit);
}

BigDecimal class is immutable meaning value can not be changed. Any time an arithmetic operation is performed on the object, a new object is created. If we were to run this simple loop with 1000 orders, 2000 BigDecimal objects are created in the heap — that's a lot of objects.

This kind of operation is common in Java, so the garbage collector can take advantage of the fact that most of the objects are only used for a short amount of time. This is where the old and young generation heaps come in. Objects are first allocated in the young generation section of the heap. When the young generation fills up, GC stops the application threads and empties out the young generation heap. Objects that are no longer in use are removed from the heap, and objects that are still in use are moved elsewhere.

This design has two performance advantages. The young generation is only a portion of the heap thus processing it is faster than processing the entire heap. This also means that application pauses are much shorter but more frequent. Small pauses are always better performance-wise than fewer longer pauses.

The other big performance advantage has to do with the previously mentioned memory compaction. When an object is created it is allocated in the eden part of the young generation. When the young generation is cleared active objects are moved to the survivor part of the young generation or to the old generation. Unused objects are simply removed leaving us with an empty heap. At the end of the GC process, eden and one of the survivor spaces are empty, and the objects that remain in the young generation are compacted with the other survivor space.

The Heap

Objects being constantly moved from the young to the old generation will eventually cause the old generation to fill up. Once this happened garbage collector will need to find and discard unused objects in the old generation. This is where GC algorithms have their biggest differences.

Full GC

This is a simpler algorithm that stops all application threads, finds unused objects, frees up the heap and compacts the memory. A full GC generally causes a long pause for the application threads.

Concurrent CG

Concurrent collectors are more computationally complex but allow us to find unused objects while application threads are running. Such collectors are sometimes also called low-pause collectors since they minimize the need to stop the application threads. Concurrent collectors also take different approaches to compact the old generation.

With concurrent collector your application with experience fewer and shorter pauses but there is also a quite significant tradeoff — the application will use more CPU. When fine-tuning concurrent collectors you might also experience difficulty achieving the best performance for your application (this is not as noticeable in the latest versions of OpenJDK).

How to choose the right GC

When considering which garbage collector to use for your application, think about the overall performance you want to be met — there will always be tradeoffs.

If we take for example a REST server application we might want to measure the response time of individual requests. Let’s see how choosing the right GC comes into play.

  • Requests will be impacted by pause times — most noticeably by long pauses for full GC. In order to minimize pause times thus improving response times, we want to use a concurrent collector. A little side note: full GC will only impact requests at the time of GC, other (most) requests won't be affected.
  • If average response time is more important than outliers (described above) a nonconcurrent collector might be a better choice.
  • It also comes down to your hardware, CPU especially. As mentioned previously concurrent collectors are CPU intensive thus require you to have more CPU power. If your machine lacks CPU power a nonconcurrent collector is the better choice.

Summary

  • The garbage collector is a process to find used and unused objects, remove unused objects from the heap and compact the memory after freeing up the space.
  • GC algorithms divide the heap into old and young generations.
  • GC algorithms employ a stop-the-world approach to clear the objects from the young generation.
  • We can use concurrent or nonconcurrent GC algorithms based on our application needs.

--

--