This guide provides an overview of what ZGC is and how it works.

What Is ZGC and Why It Matters for Java Performance

ZGC is a garbage collector for the JVM that aims to be both scalable and fast. Modern applications must effectively manage memory to handle millions of users. This overview demonstrates how large systems achieve this and prepares you to learn about ZGC.

ZGC was introduced as an experimental feature in Java 11 and became an official feature in JDK 15. It works well for web apps and data-heavy systems, often keeping pause times under 1 millisecond.

ZGC requires a significant amount of CPU power, which may make it unsuitable for smaller systems. It works with Java 15 or later, requires several CPU cores, and has sufficient resources. If your setup doesn’t meet these needs, G1 GC is still a good option. ZGC tracks which objects are still in use with a method called reference coloring. Instead of using a separate map, ZGC adds extra information to each reference. Most bits show the object’s memory address, while a few store details for garbage collection. This allows ZGC to track object states, such as marking or moving them, without requiring extra memory. Each ZGC reference holds both the object’s location and its status.One challenge is that the status bits in the reference appear to be part of the address, but they don’t actually change where the object is stored.

Understanding Multi-Mapping and When to Use ZGC

This can result in several references that appear different but still point to the same object. Although the habits differ, they all refer to the same memory location. This is known as multi-mapping, where different keys lead to the same destination. To keep pauses short when moving objects in large heaps, ZGC moves objects at the same time using multiple threads. If a thread tries to access an object at its old address, a load barrier checks the status bits and redirects it to the new address if needed. In the background, we can examine ZGC’s internal structures to understand how its components work together here.

ZGC Marking Phases Explained

ZGC breaks marking into three phases.

Stop-the-World Marking Phase in ZGC

The first phase is a stop-the-world phase. In this phase, we look for root references and mark them. Root references are the starting points for reaching objects in the heap, such as local variables or static fields. Since the number of root references is usually small, this phase is short.

Concurrent Marking Phase in ZGC

The next phase is concurrent. In this phase, we traverse the object graph, starting from the root references. We mark every object we reach. Additionally, when a load barrier detects an unmarked reference, it also marks it.

Final Stop-the-World Phase for Weak References

The last phase is also a stop-the-world phase to handle edge cases, such as weak references.

At this point, we know which objects we can reach.

ZGC uses the marked0 and marked1 metadata bits for marking.

How ZGC Uses Reference Coloring to Track Objects

A reference indicates the position of a byte in virtual memory. Not all bits in a reference are needed for this purpose; some bits can represent properties of the reference. This approach is called reference coloring.

A 32-bit reference can address up to 4 gigabytes of memory. Since most computers now have more memory, none of these 32 bits can be used for coloring. As a result, ZGC uses 64-bit references and is only available on 64-bit platforms.

ZGC references use 42 bits to represent the address itself. As a result, ZGC references can address 4 terabytes of memory space.

On top of that, we have 4 bits to store reference states:

  • finalizable bit – the object is only reachable through a finalizer
  • remap bit – the reference is up to date and points to the current location of the object (see relocation)
  • marked0 and marked1 bits – these are used to mark reachable objects

We also called these bits metadata bits. In ZGC, precisely one of these metadata bits is 1.

ZGC Relocation Process and Phases

In ZGC, relocation consists of the following phases:

Concurrent Relocation Phase

A concurrent phase, which looks for blocks we want to relocate and puts them in the relocation set.

Stop-the-World Relocation of Root References

A stop-the-world phase relocates all root references in the relocation set and updates their references.

Concurrent Relocation of Remaining Objects

A concurrent phase relocates all remaining objects in the relocation set and stores the mapping between the old and new addresses in the forwarding table.

In-Place Relocation in JDK 16+

The rewriting of the remaining references happens in the next marking phase. This way, we don’t have to traverse the object tree twice. Alternatively, load barriers can do it, as well.

Before JDK 16, it performed relocation by using a heap reserve. However, starting JDK 16, ZGC got support for in-place relocation, and it helps avoid OutOfMemoryError situations when garbage collection is required on a completely filled heap.

Remapping and Load Barriers in ZGC

Note that in the relocation phase, we didn’t rewrite most of the references to the relocated addresses. Therefore, using those references, we wouldn’t access the objects we wanted to. Even worse, we could access garbage.

ZGC uses load barriers to solve this issue. Load barriers fix the references pointing to relocated objects with a technique called remapping.

Step-by-Step ZGC Remapping Process

When the application loads a reference, it triggers the load barrier, which then follows the following steps to return the correct reference:

  1. Checks whether the remap bit is set to 1. If so, it means that the reference is up to date, so can safely we return it.
  2. Then we check whether the referenced object was in the relocation set or not. If it wasn’t, that means we didn’t want to relocate it. To avoid this check next time we load this reference, we set the remap bit to 1 and return the updated reference.
  3. Now we know that the object we want to access was the target of relocation. The only question is whether the relocation happened or not? If the object has been relocated, we skip to the next step. Otherwise, we relocate it now and create an entry in the forwarding table, which stores the new address for each relocated object. After this, we continue with the next step.
  4. Now we know that the object was relocated. Either by ZGC, us in the previous step, or the load barrier during an earlier hit of this object. We update this reference to the new location of the object (either with the address from the previous step or by looking it up in the forwarding table), set the remap bit, and return the reference.

These steps ensure that each time an object is accessed, the most recent reference is used. Every time a reference is loaded, it triggers the load barrier, which can reduce application performance, especially on the first access to a relocated object. However, this trade-off allows for shorter pause times. Since these steps are relatively fast, the overall impact on application performance is minimal.

How to Enable ZGC in Your Java Application

We can enable ZGC for JDK 15 and newer versions by using the -XX:+UseZGC VM flag as part of the command-line options:

java -XX:+UseZGC <java_application>

However, before JDK 15, ZGC was an experimental feature, so we also need to add the XX:+UnlockExperimentalVMOptions VM flag to use it when running our application:

java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC <java_application>

Understanding a Single ZGC Event from Logs

A single ZGC event in the log spans multiple lines and typically includes:

  • Timestamp – when the event ran (e.g., 16.916s since JVM start).
  • Event count – which number event this is since JVM start.
  • GC reason – why the cycle started (e.g., high allocation rate).
  • Time taken per phase – Pause Mark, Concurrent phases, Pause Relocate, etc.
  • Metaspace size – metadata footprint after the event.
  • Heap usage before/after – e.g., 352 MB → 80 MB after the cycle.

Fig: Garbage Collection Logs

Reading and Analyzing ZGC Logs Efficiently

Looking through thousands of events by hand takes a lot of time. Tools like GCeasy can read ZGC logs and create an analysis report with:

Key Performance Indicators (KPIs) to Monitor

Key performance indicators (KPIs) you actually tune for (throughput, pause p95/p99, allocation rate, live-set size).

Fig: Key Performance Indicator

Heap Usage and Pause Time Graphs

Heap usage graphs showing how memory drops after each GC (and rebounds under load).

Fig: Heap Size vs Time Graph

Pause time timelines that confirm ZGC’s typical ~0.5–2 ms pauses under healthy conditions.

Fig: Garbage Collector Pause Time Graph

Actionable Recommendations and JVM Flag Suggestions

Actionable recommendations and JVM flag suggestions specific to your workload.

For a concise walkthrough on enabling the log and interpreting key fields, see this step-by-step guide on ZGC log analysis by GCeasy: reading & analyzing ZGC logs.

Quick ZGC Tuning Tips for Optimal Performance

  • Establish baseline KPIs (allocation rate, live set, pause p99) from your log report before changing flags.
  • Tackle allocation spikes first (object pooling, reducing short-lived churn) if cycles are triggered by allocation pressure.
  • Check if in-place relocation (available since JDK 16) is keeping pause times low, even when your heap is almost full. If you notice pauses getting longer, look for areas in your application where large objects are clustered together. They might be causing issues.
  • Change only one thing at a time, and after making the change, run the workload again and analyze how the results change. With this approach, you can see how much each small adjustment affects performance and understand the entire tuning process much more easily.

Maximizing ZGC for High-Performance Java Applications

That’s all you need to know to start using ZGC in your Java applications. Using the latest LTS version of the JDK ensures you get the newest features and fixes, including improvements to in-place relocation and overall performance. Once ZGC is enabled, it’s important to keep an eye on your application’s memory usage, pause times, and allocation patterns. By monitoring these metrics and making small, careful adjustments to JVM flags, you can maintain consistent low-latency performance even under heavy load. With ZGC handling the complex work of moving and marking objects in the background, you can focus on building applications that scale efficiently without worrying about long garbage collection pauses.