Garbage collectors in Java: G1, ZGC and how to choose
1. Overview
Garbage collector (GC) determines how long your Java application pauses and how much CPU it consumes to clean memory. If you choose wrong, you might encounter latency spikes even though your code hasn’t changed.
This article explains how G1 and ZGC work in Java 21, how to enable and read GC logs, how to check which GC is running via code and tests. At the end are criteria for choosing GC for real-world applications.
2. Available GCs in Java 21
HotSpot JDK 21 has the following main GCs:
| GC | Flag | Purpose |
|---|---|---|
| Serial | -XX:+UseSerialGC |
Small heap, 1 CPU, low footprint |
| Parallel | -XX:+UseParallelGC |
Maximum throughput, accepts long pauses |
| G1 | -XX:+UseG1GC |
Balance between throughput and pause |
| ZGC | -XX:+UseZGC |
Ultra-short pauses, large heap |
| Shenandoah | -XX:+UseShenandoahGC |
Short pauses (only in certain builds) |
If you don’t pass any flags, the JVM chooses automatically. On “server-class” machines (at least 2 CPUs and around 1792 MB RAM), the JVM uses G1. Otherwise, it uses Serial.
Note: In containers, the JVM reads CPU and memory limits from cgroups. A Pod limited to 1 CPU will be treated by the JVM as non-server-class and silently run Serial GC. This is a common reason why services on Kubernetes experience unexpectedly long pauses.
3. How G1 Works
G1 (Garbage-First) divides the heap into multiple equal-sized regions, typically from 1 MB to 32 MB. Each region plays a role: Eden, Survivor, Old, or Humongous (for large objects).
The basic cycle consists of:
- Young GC: stops all application threads (stop-the-world), copies live objects from Eden to Survivor or Old.
- Concurrent marking: when Old fills to a threshold, G1 marks live objects concurrently with the application.
- Mixed GC: collects both young and the Old regions with the most garbage first. This is the meaning of “Garbage-First”.
The strength of G1 is that you can set a pause target:
java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms2g -Xmx2g -jar order-service.jar
MaxGCPauseMillis=100 asks G1 to keep each pause under 100 ms (default is 200 ms). G1 adjusts the young generation size to meet this target. This is a soft goal, not a guarantee. G1 pauses still increase with the number of live objects that need to be copied.
4. How ZGC Works
ZGC does almost all heavy lifting (marking, relocation, reference updates) concurrently with the application. It uses colored pointers and load barriers: each time code reads a reference, the barrier checks if the object has been moved and fixes the reference if needed.
The result is pauses typically under 1 ms and not dependent on heap size. A 2 GB or 2 TB heap both give similar pauses.
4.1. Generational ZGC in Java 21
Java 21 introduces Generational ZGC (JEP 439). This version separates young and old generation, making short-lived object collection much more efficient. In Java 21, you must enable an additional flag:
java -XX:+UseZGC -XX:+ZGenerational -Xmx4g -jar order-service.jar
Without -XX:+ZGenerational, Java 21 runs ZGC in the old (non-generational) mode. From JDK 23, generational is the default. From JDK 24, non-generational mode has been removed. Therefore, if you use ZGC on Java 21, you should enable generational right away.
4.2. The Price to Pay
Short pauses come at a cost:
- Load barriers slow down each reference read, so throughput is typically a few percent lower than G1.
- ZGC needs headroom, meaning free heap space. The application still allocates while GC runs concurrently. If the heap fills before GC finishes, threads get blocked (“Allocation Stall”). Then latency spikes, contrary to the original goal.
- GC threads run concurrently, so they contend with the application for CPU.
5. Experiment with a Small Program
The program below creates many short-lived Order objects and keeps some to simulate a cache:
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class OrderLoadSimulator {
record Order(String id, String customer, byte[] payload) {}
public static void main(String[] args) {
List<Order> cache = new ArrayList<>();
long start = System.currentTimeMillis();
for (int i = 0; i < 5_000_000; i++) {
Order order = new Order(UUID.randomUUID().toString(), "customer-" + (i % 1000), new byte[512]);
if (i % 100 == 0) {
cache.add(order); // keep 1% -> promote to old generation
}
if (cache.size() > 20_000) {
cache.subList(0, 10_000).clear(); // discard some, create garbage in old
}
}
System.out.printf("Done in %d ms, cache=%d%n",
System.currentTimeMillis() - start, cache.size());
}
}
Each loop iteration creates an Order with a 512-byte payload. Most objects die immediately, consistent with the “most objects die young” hypothesis. About 1% are kept in cache long enough to be promoted to old generation. The subList(...).clear() command occasionally frees them.
Run with G1 and enable GC logging using unified logging (-Xlog):
java -XX:+UseG1GC -Xmx512m -Xlog:gc OrderLoadSimulator.java
Output will look like:
[0.008s][info][gc] Using G1
[0.312s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 25M->3M(512M) 2.814ms
[0.401s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 28M->4M(512M) 1.957ms
...
Done in 3120 ms, cache=15000
Each Pause Young line shows heap before and after GC (25M->3M), total committed heap (512M), and pause time (2.814ms).
Now run with Generational ZGC:
java -XX:+UseZGC -XX:+ZGenerational -Xmx512m -Xlog:gc OrderLoadSimulator.java
[0.010s][info][gc] Using The Z Garbage Collector
[0.420s][info][gc] GC(0) Minor Collection (Allocation Rate) 60M(12%)->14M(3%) 0.021s
[0.610s][info][gc] GC(1) Minor Collection (Allocation Rate) 72M(14%)->16M(3%) 0.018s
...
Done in 3350 ms, cache=15000
The time at the end of each ZGC line (0.021s) is the duration of the entire GC cycle, most of which runs concurrently with the application. It’s not the time the application is paused. To see actual pauses, use -Xlog:gc,gc+phases=debug and look for lines like Pause Mark Start, Pause Mark End, Pause Relocate Start. These pauses are typically only tens of microseconds.
Exact numbers will differ on your machine. The typical trend is that ZGC completes slightly slower in total time but has no long pauses.
6. Check Which GC is Running via Code
In production environments, you should verify the actual GC instead of trusting the configuration. GarbageCollectorMXBean tells you that:
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;
public class GcInspector {
public static List<String> activeCollectors() {
return ManagementFactory.getGarbageCollectorMXBeans().stream()
.map(GarbageCollectorMXBean::getName)
.toList();
}
public static void main(String[] args) {
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.printf("%s: count=%d, time=%d ms%n",
gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
}
}
}
Each GC registers one or more MXBeans with a distinctive name. For example with G1:
G1 Young Generation: count=0, time=0 ms
G1 Concurrent GC: count=0, time=0 ms
G1 Old Generation: count=0, time=0 ms
With Generational ZGC, the names will be ZGC Minor Cycles, ZGC Minor Pauses, ZGC Major Cycles, and ZGC Major Pauses.
6.1. Write Tests to Verify GC Configuration
You can lock down GC configuration with tests to prevent anyone from accidentally changing JVM options. First, configure Surefire to run tests with ZGC:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>-XX:+UseZGC -XX:+ZGenerational</argLine>
</configuration>
</plugin>
Then write a JUnit 5 test combined with AssertJ:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class GcInspectorTest {
@Test
void whenRunningWithZgcFlag_thenZgcCollectorsAreActive() {
var collectors = GcInspector.activeCollectors();
assertThat(collectors)
.isNotEmpty()
.allMatch(name -> name.startsWith("ZGC"));
}
@Test
void whenGenerationalZgc_thenMinorCyclesBeanExists() {
assertThat(GcInspector.activeCollectors())
.contains("ZGC Minor Cycles");
}
}
The first test checks that all collectors belong to ZGC. The second test ensures generational mode is enabled. If someone deletes -XX:+ZGenerational, the bean name changes to ZGC Cycles and the test fails.
Note: This test depends on the JVM options of the test process. If your IDE doesn’t read
argLinefrom Surefire, the test will fail when run directly in the IDE.
7. Configure GC for Spring Boot Applications
Spring Boot doesn’t choose GC itself. You pass flags to the JVM depending on your deployment method. When running locally with the Maven plugin:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<jvmArguments>-XX:+UseZGC -XX:+ZGenerational -Xmx2g</jvmArguments>
</configuration>
</plugin>
In a container, the simplest way is to use the JAVA_TOOL_OPTIONS environment variable. The JVM automatically reads this on startup:
docker run -e JAVA_TOOL_OPTIONS="-XX:+UseG1GC -XX:MaxRAMPercentage=75 -Xlog:gc*:stdout:time" \
--cpus=2 --memory=2g payment-service:1.0
MaxRAMPercentage=75 sets max heap to 75% of container memory. The rest is reserved for metaspace, thread stacks, and direct buffers. Always explicitly specify -XX:+UseG1GC or -XX:+UseZGC instead of letting the JVM choose, for reasons mentioned in section 2.
If you already have Spring Boot Actuator and Micrometer, the jvm.gc.pause metric gives you the distribution of pause time by GC type. Monitor p99 and max of this metric before and after changing GC.
8. How to Choose a GC
Start from your application’s requirements, not from the “latest” GC:
Choose G1 when:
- You don’t have a specific reason to change. G1 is a reasonable default choice for most REST APIs and services.
- Heap from a few hundred MB to roughly tens of GB.
- Pauses of tens to 200 ms are acceptable.
- CPU is tightly limited and you need good throughput.
Choose ZGC (generational) when:
- p99 or p999 latency requirements are very tight, e.g., trading, game servers, APIs with SLAs under 50 ms.
- Large heap (tens of GB or more), when G1 pauses start getting long due to many live objects.
- You have extra CPU and memory available for headroom.
Choose Parallel when:
running batch jobs, ETL, offline data processing. Total time matters more than pause.
Choose Serial when:
running small CLIs, serverless functions with heap under around 100 MB and 1 CPU.
Note: Don’t use ZGC on small containers (1 CPU, 512 MB heap). GC threads will contend with the application for CPU, and limited heap headroom easily causes Allocation Stall. The result can be worse than G1.
Common mistakes when tuning:
- Copying old flags like
-XX:NewRatioor-XX:SurvivorRatiointo G1. These flags interfere with the adaptive mechanism to reachMaxGCPauseMillis. You should start with only-Xmxand pause target. - Setting
MaxGCPauseMillistoo low, e.g., 10 ms, for G1. G1 will shrink the young generation, GC runs frequently, and throughput drops significantly. If you need pauses that short, switch to ZGC. - Changing GC to fix a memory leak. No GC can free objects still being referenced. Analyze heap dump first.
- Evaluating with a few-second benchmark. Measure with production-like load for at least tens of minutes and compare p99 latency, CPU, and memory.
9. Conclusion
G1 is the balanced default choice, suitable for most applications. ZGC, especially the generational version in Java 21, gives pauses under milliseconds with very large heaps, but at the cost of extra CPU and more headroom needed.
Always explicitly declare your GC, enable -Xlog:gc, and verify which GC is running via GarbageCollectorMXBean or tests. Only change GC when p99 latency metrics show that pauses are actually the problem.