Choosing a Garbage Collector for Your Java/Kotlin Application: Things I Wish I Knew Back Then

Introduction

When I first started building Java and Kotlin applications, I didn’t really pay much attention to garbage collection. It was this magical process that "just worked." But as I moved into more complex systems—batch processing, high-throughput APIs, and distributed architectures—I realized that choosing the right garbage collector could make or break my application’s performance, and also prevent some later production incidents.

Some of my early APIs even experienced breakdowns due to memory leaks, leading to unresponsive systems under heavy load. These episodes taught me the critical importance of understanding how GC works and how to configure it for specific workloads. Failing to consider GC for high-throughput APIs, for example, can lead to severe latency spikes, memory fragmentation, or outright crashes.

This article is a guide for those who, like me, wish they had a clearer understanding of JVM garbage collectors earlier. I will try to cover:

  1. How garbage collection works in the JVM.
  2. The different types of GCs available.
  3. Real-world use cases and configs for each GC.
  4. Choosing the right garbage collector (references for informed decision-making).
  5. Conclusion & Exercises ;-).

Let’s dive in and make garbage collection work for you, not against you.


How Garbage Collection Works in the JVM

Garbage collection in the JVM is all about managing heap memory(imagine it's the playground where all your objects live). When objects are no longer referenced, they become eligible for garbage collection, freeing up memory for new allocations. But the process isn’t always seamless—GC pauses and overhead can significantly impact performance.

Key Concepts

Heap Memory

  1. Eden Space (in the Young Generation):

    • Purpose: This is where new objects are first allocated.
    • Garbage Collection Behavior: Objects in Eden are short-lived and quickly collected during a minor GC cycle if they are no longer in use.
    • Example: Suppose you’re creating multiple instances of a Minion class. And those minions are from League of Legends or Despicable Me—your choice:
      for (int i = 0; i < 1000; i++) {
          Minion minion = new Minion("Minion " + i);
      }
      
      All these minions will initially be created in the Eden space. If they are not referenced anymore after their creation, they will be collected during the next minor GC.
  2. Survivor Spaces (in the Young Generation):

    • Purpose: Objects that survive one or more minor GC cycles in Eden are moved to Survivor spaces.
    • Garbage Collection Behavior: Survivor spaces act as a staging area before objects are promoted to the Old Generation.
    • Example: In a game application, temporary data like dead minions or player movement logs might survive for a short time in Survivor spaces before being discarded or promoted if reused frequently.
  3. Old Generation:

    • Purpose: Objects that have a long lifespan or survive multiple minor GC cycles are moved to the Old Generation.
    • Garbage Collection Behavior: Garbage collection here is less frequent but more time-consuming.
    • Example: Imagine you’re building a game where each Player represents a connected user on the match. These objects are long-lived compared to temporary data like minions or projectiles and may look like this:
      public class Player {
          private final String name;
          private final Inventory inventory;
      
          public Player(String name) {
              this.name = name;
              this.inventory = new Inventory();
          }
      }
      
      A Player object, which holds data such as the player’s inventory and stats, will likely reside in the Old Generation as it persists for the entire application session.
  4. Metaspace:

    • Purpose: Think of Metaspace as the library(outside the heap) of your application—it keeps the blueprints (class metadata) for all the objects your application creates.
    • Garbage Collection Behavior: Metaspace grows dynamically as new class loaders are introduced and is cleaned up when those class loaders are no longer needed. This ensures that unused blueprints don’t mess up your libraries.
    • Example: Imagine you’re running a game that supports mods, and players can load new heroes dynamically. Each mod represents a new class dynamically loaded at runtime:
      Class<?> heroClass = Class.forName("com.game.dynamic.Hero");
      Object hero = heroClass.getDeclaredConstructor().newInstance();
      
      The blueprint for the Hero class will be stored in Metaspace. When the mod is unloaded or the player exits the game, the class loader is no longer needed, and the JVM will clean up the associated Metaspace memory. This ensures that your application remains efficient, even with dynamic features.

Garbage Collector Phases

  1. Mark:

    • Purpose: Identify live objects by traversing references starting from the root set (e.g., static fields, local variables).
    • Practical Example: Consider this code:
      Player player = new Player("Hero");
      player.hitMinion();
      
      The player object is reachable because it’s referenced in the method. During the Mark phase, the GC identifies player and its dependencies as live objects.
  2. Sweep:

    • Purpose: Reclaim memory occupied by objects not marked as live.
    • Practical Example: If the player reference is set to null:
      player = null;
      
      The next GC cycle’s Sweep phase will reclaim the memory occupied by the player object and its associated data.
  3. Compact:

    • Purpose: Reduce fragmentation by moving objects closer together in memory.
    • Practical Example: After reclaiming memory, gaps may exist in the heap. Compacting ensures efficient allocation for future objects:
      // Before compaction: [Minion 1][   ][Minion 3][   ]
      // After compaction:  [Minion 1][Minion 3][       ]
      
      This step is particularly important in systems with frequent allocations and deallocations(Related to CPU efficiency).

For a deep understanding, the JVM GC documentation provides wider insights (source).


Types of JVM Garbage Collectors

1. Serial Garbage Collector (Serial GC)

Overview:

The Serial GC is single-threaded and optimized for simplicity. It processes the Young and Old Generations one at a time, pausing application threads during GC.

When to Use:

  • VERY SMALL applications with SINGLE-THREAD workloads.
  • Low-memory environments (e.g., embedded systems).

Limitations:

  • Not suitable for high-concurrency, high-throughput systems.

  • Maximum throughput is low due to its single-threaded nature.

Example:

Consider a system managing API calls for IoT devices that periodically send sensor data (e.g., room temperature). Each device sends minimal data in a predictable pattern, and the system handles only one request per thread. The Serial GC ensures predictable, low-overhead memory management, making it an ideal choice for such an environment.

Docker Example:

FROM openjdk:17-jdk-slim
CMD java -XX:+UseSerialGC -Xmx512m -jar app.jar

2. Parallel Garbage Collector (Parallel GC)

Overview:

Parallel GC, also known as the Throughput Collector, uses multiple threads to speed up garbage collection. It aims to maximize application throughput by minimizing the total GC time. You can check some crazy a** graphs and get better explanation at the official documentation here.

When to Use:

  • Batch processing systems.
  • Applications prioritizing throughput over low latency.

Example:

Imagine a financial service API that consolidates transactions into daily reports. Since the workload prioritizes throughput over latency, Parallel GC is ideal for processing large transaction sets efficiently.

Docker Example:

FROM openjdk:17-jdk-slim
CMD java -XX:+UseParallelGC -Xmx2g -jar app.jar

3. G1 Garbage Collector (G1GC)

Overview:

G1GC divides the heap into regions and collects garbage incrementally, making it a good balance between throughput and low latency.

When to Use:

  • General-purpose applications.
  • Systems requiring predictable pause times.

Example:

Any SaaS platform serving user requests in under 200ms with moderate traffic spikes.

Docker Example:

FROM openjdk:17-jdk-slim
CMD java -XX:+UseG1GC -Xmx4g -XX:MaxGCPauseMillis=200 -jar app.jar

Important considerations about G1GC:

You might be wondering: "If G1GC supports both good throughput and low latency, why not use it for every application? Sounds like a no-brainer..."

But well, not quite. While G1GC is a fantastic general-purpose garbage collector, it’s not the universal solution for all workloads. Think of it as the "jack of all trades" of GCs—good at many things, but not necessarily the best at any one thing. Poof! Now that you’re out of the cave, let’s analyze:

  • Throughput-Focused Applications: If your application doesn’t care about pause times—for example, batch processing systems or data aggregation pipelines—why would you burden it with G1GC’s incremental collection overhead? Parallel GC is better suited here, offering raw performance without worrying about predictable pauses.

  • Ultra-Low Latency Needs: If you’re building a real-time trading system or managing huge heaps (think terabytes), G1GC might struggle to meet your strict latency requirements. Collectors like ZGC or Shenandoah GC are designed specifically for these use cases, offering sub-10ms pause times.

In short, G1GC is like that versatile tool in your toolbox—it works well for a variety of tasks, especially if you’re building the classic CRUD API (yes pretty much all of your messy simple Spring CRUDs). But if you’re running specialized workloads, you’ll want to pick a collector that’s optimized to your needs.


4. Z Garbage Collector (ZGC)

Overview:

ZGC is designed for ultra-low-latency applications with large heaps (up to terabytes). Its pause times are typically under 10 milliseconds.

When to Use:

  • Real-time systems.
  • Applications with very large heaps.

When to DO NOT use:

  • Imagine you have a batch processing system using ZGC. There is very high chance of facing inceased CPU utilization($$$) without any latency benefit. For example, a data ingestion pipeline optimized for high throughput but insensitive to pause times would waste resources managing unnecessary low-latency GC cycles.

Example:

A trading system processing market data streams in real time.

Docker Example:

FROM openjdk:17-jdk-slim
CMD java -XX:+UseZGC -Xmx16g -jar app.jar

5. Shenandoah Garbage Collector

Overview:

Shenandoah GC minimizes pause times by performing concurrent compaction. It’s ideal for latency-sensitive applications.

When to Use:

  • Payment gateways with strict SLA requirements for latency.
  • APIs with spiky traffic patterns, such as social media feeds or live voting systems.
  • Applications where reducing GC pause time is critical to user experience, such as gaming servers or interactive web applications.

When to DO NOT use:

Using Shenandoah GC for batch processing systems or workloads optimized for high throughput over low latency (e.g., nightly data aggregation) may lead to inefficient CPU utilization. The additional overhead of concurrent compaction provides no benefits when predictable pauses are acceptable, reducing overall throughput compared to Parallel GC.

For exampe, a financial reconciliation batch process configured with Shenandoah might experience reduced throughput due to unnecessary focus on low pause times, delaying report generation.

Example:

A payment processing API handling high transaction volumes cannot afford GC-induced latency spikes during peak hours. Shenandoah’s low-pause nature ensures that transaction processing continues smoothly even under heavy load.

Another example is a real-time multiplayer gaming server, where latency spikes could lead to a poor player experience. Shenandoah ensures consistent frame updates and server responsiveness.

Docker Example:

FROM openjdk:17-jdk-slim
CMD java -XX:+UseShenandoahGC -Xmx8g -XX:+UnlockExperimentalVMOptions -jar app.jar

Choosing the Right Garbage Collector

Here you can find a cheatsheet. But remember... you should always evaluate your own workload before choosing it's garbage collector.

Garbage Collector Best For JVM Version Support
Serial GC Small, single-threaded apps All versions
Parallel GC High-throughput batch systems All versions
G1GC General-purpose apps Java 9+
ZGC Real-time, large heap apps Java 11+
Shenandoah GC Low-latency apps Java 11+

Conclusion

Choosing the right garbage collector for your application requires some knowledge over the tools I discussed in this post. But once you learn about it, you may have the power of taking decisions, and this is extremely valuable in Software Engineering field, also, by selecting the right GC you can significantly improve performance, stability and save some costs for your future applications based on JVM. Don’t let GC be a black box—embrace it, tune it, and let it work for you.


Training: Real-World Scenarios and Solutions

Scenario 1: Payment Gateway Latency

You are building a payment gateway API that must process transactions in real-time with strict SLA requirements. The workload is spiky, with heavy traffic during sales events or specific times of the day. Which garbage collector would you choose to ensure low latency?

Scenario 2: Batch Data Processing System

Your application processes daily financial reconciliation batches, which involve large amounts of data. Latency is not a concern, but throughput must be maximized to complete processing as fast as possible. Which garbage collector fits this use case?

Scenario 3: Real-Time Multiplayer Game

You are designing a server for a real-time multiplayer game. The server must manage thousands of players, each generating events continuously. Latency spikes during garbage collection are unacceptable as they could lead to lag and a poor user experience. What GC configuration would you use?


Solutions

Solution 1: Payment Gateway Latency

Use Shenandoah GC to ensure low latency and consistent response times. Its concurrent compaction minimizes pause times, making it ideal for latency-sensitive workloads.

Solution 2: Batch Data Processing System

Use Parallel GC to maximize throughput. Since latency isn’t a concern, the Parallel GC’s focus on high efficiency during garbage collection fits this workload.

Solution 3: Real-Time Multiplayer Game

Use ZGC to achieve ultra-low latency and scale with large heaps. It ensures that garbage collection does not interfere with real-time gameplay.

References:

  1. Java Garbage Collection Basics - Oracle