Guide contents

Lesson 45 of 71

G1 in Depth

G1 (Garbage-First) is the default HotSpot collector on server-class machines and the collector most production Java services run. This lesson applies the ideas from GC Fundamentals (generations, copying, card tables, SATB marking) to G1's design. After it, a reader can read a G1 log, explain each pause type, and observe G1 from code.

Topic Key facts on JDK 25
Regions Heap split into equal regions, 1 MB to 32 MB chosen by ergonomics
Humongous objects Larger than half a region, allocated in contiguous old regions
Collections Young (eden and survivors), mixed (young plus some old regions), full (fallback)
Remembered sets Per-region record of incoming references, built from dirty cards
Marking Concurrent SATB marking cycle with short Remark and Cleanup pauses
Pause goal MaxGCPauseMillis = 200 by default; a target, not a guarantee
IHOP InitiatingHeapOccupancyPercent = 45, adaptive by default

Every example fixes the collector and heap with jvm= flags (-XX:+UseG1GC -Xmx64m) and prints checks rather than counts or times.

Example 1: regions and region size

G1 divides the heap into equal-sized regions. Each region has a role at any moment: eden, survivor, old, humongous, or free. The young generation is a set of regions, not a fixed address range, so G1 can resize it after every collection. The region size is the G1HeapRegionSize flag.

Java
import com.sun.management.HotSpotDiagnosticMXBean;
import java.lang.management.ManagementFactory;

public class RegionSize {
    public static void main(String[] args) {
        var diag = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
        long region = Long.parseLong(diag.getVMOption("G1HeapRegionSize").getValue());
        long max = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getMax();
        System.out.println("region size KB: " + region / 1024);
        System.out.println("regions in heap: " + max / region);
        System.out.println("power of two: " + (Long.bitCount(region) == 1));
        for (String flag : new String[] { "MaxGCPauseMillis", "InitiatingHeapOccupancyPercent",
                "G1UseAdaptiveIHOP", "G1ReservePercent", "G1MixedGCCountTarget", "G1HeapWastePercent" }) {
            System.out.println(flag + " = " + diag.getVMOption(flag).getValue());
        }
    }
}
Output
region size KB: 1024
regions in heap: 64
power of two: true
MaxGCPauseMillis = 200
InitiatingHeapOccupancyPercent = 45
G1UseAdaptiveIHOP = true
G1ReservePercent = 10
G1MixedGCCountTarget = 8
G1HeapWastePercent = 5

When the size is not set, ergonomics aims for about 2048 regions and rounds to a power of two between 1 MB and 32 MB. Measured with PrintFlagsFinal on JDK 25: -Xmx64m gives 1 MB, -Xmx8g gives 4 MB, and -Xmx64g gives 32 MB. An explicit -XX:G1HeapRegionSize accepts values up to 512 MB; 1g is rejected as outside the allowed range. The other flags printed here are explained in the examples that follow.

Example 2: humongous objects

A humongous object is an object larger than half a region. G1 allocates it directly into one or more contiguous free regions marked humongous, which count as old generation. The rest of the last region stays unused, so a humongous object of slightly more than one region wastes almost a whole region. The program measures the old-generation pool before and after allocating arrays around the boundary. A byte[] has a 16-byte header, so new byte[524272] occupies exactly 512 KB.

Java
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;

public class Humongous {
    public static void main(String[] args) {
        MemoryPoolMXBean old = ManagementFactory.getMemoryPoolMXBeans().stream()
                .filter(p -> p.getName().equals("G1 Old Gen")).findFirst().orElseThrow();
        int[] lengths = { 1024, 524_272, 524_280, 1_048_576 };
        for (int n : lengths) {
            long before = old.getUsage().getUsed();
            byte[] array = new byte[n];
            long grew = old.getUsage().getUsed() - before;
            System.out.printf("byte[%,d] (%,d bytes with header): old gen +%d KB%n",
                    array.length, n + 16, grew / 1024);
        }
    }
}
Output
byte[1,024] (1,040 bytes with header): old gen +0 KB
byte[524,272] (524,288 bytes with header): old gen +0 KB
byte[524,280] (524,296 bytes with header): old gen +1024 KB
byte[1,048,576] (1,048,592 bytes with header): old gen +2048 KB

The 512 KB array is not humongous (the rule is strictly larger than half a region) and goes to eden. The array one word larger takes a whole 1 MB region, and a 1 MB array plus header takes two. Humongous objects cause three problems: wasted region tails, old-generation growth from short-lived data, and the need for contiguous free regions. G1 reclaims dead humongous objects at young collections when no references to them remain (eager reclaim), and also at the end of marking. Larger regions, set with -XX:G1HeapRegionSize, turn humongous allocations into ordinary ones.

Example 3: young collections and the three collector beans

A young collection in G1 evacuates (copies) live objects from all eden and survivor regions into new survivor or old regions, then frees the source regions. It is stop-the-world and parallel. The set of regions a collection evacuates is the collection set. G1 registers three collector beans:

Bean Counts
G1 Young Generation Every young collection, including mixed collections
G1 Concurrent GC The Remark and Cleanup pauses of concurrent marking cycles
G1 Old Generation Full collections only
Java
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.Arrays;
import java.util.Map;
import java.util.TreeMap;

public class YoungOnly {
    public static void main(String[] args) {
        long sum = 0;
        for (int i = 0; i < 1_000_000; i++) sum += new byte[256].length;   // all garbage
        System.out.println("allocated MB: " + sum / (1024 * 1024));

        Map<String, Long> counts = new TreeMap<>();
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            counts.put(gc.getName(), gc.getCollectionCount());
            System.out.println(gc.getName() + " pools: " + Arrays.toString(gc.getMemoryPoolNames()));
        }
        System.out.println("young collections > 0: " + (counts.get("G1 Young Generation") > 0));
        System.out.println("concurrent pauses: " + counts.get("G1 Concurrent GC"));
        System.out.println("full collections: " + counts.get("G1 Old Generation"));
    }
}
Output
allocated MB: 244
G1 Young Generation pools: [G1 Eden Space, G1 Survivor Space, G1 Old Gen]
G1 Concurrent GC pools: [G1 Old Gen]
G1 Old Generation pools: [G1 Eden Space, G1 Survivor Space, G1 Old Gen]
young collections > 0: true
concurrent pauses: 0
full collections: 0

The arrays have 256 elements on purpose. Escape analysis in the JIT can remove allocations of small arrays that never leave a loop, up to EliminateAllocationArraySizeLimit (64) elements, and then no collection runs at all. With only short-lived garbage, old occupancy never reaches the marking threshold, so no concurrent cycle and no full collection run. The "G1 Concurrent GC" bean was added in Java 20; on earlier releases the Remark and Cleanup pauses were reported under "G1 Old Generation".

Example 4: remembered sets and card refinement

G1 collects any subset of regions, so for each region it must know which other regions hold references into it. A remembered set (RSet) is that per-region record. G1 keeps RSets for young regions (needed at every young collection) and for old regions chosen as candidates for mixed collections.

The write barrier from GC Fundamentals is extended in G1:

  1. The post-write barrier filters out stores within the same region and stores of null, then dirties the card and enqueues it.
  2. Concurrent refinement threads take dirty cards from the queues and add the referencing card to the target region's RSet. The log line Concurrent Refinement Workers: 4 reports their maximum number.
  3. At the start of a pause ("Merge Heap Roots" in the log), cards not yet refined are merged with the RSets of collection-set regions, and those cards are scanned as roots.

The program builds many old-to-young references and checks that young collections keep every referenced young object alive.

Java
import java.lang.management.ManagementFactory;

public class RememberedSets {
    static final class Slot {
        Object value;
    }

    public static void main(String[] args) {
        Slot[] oldSlots = new Slot[10_000];
        for (int i = 0; i < oldSlots.length; i++) oldSlots[i] = new Slot();
        System.gc();                                  // promote the slots to old regions

        long youngBefore = youngCount();
        for (int round = 0; round < 50; round++) {
            for (int i = 0; i < oldSlots.length; i++) {
                oldSlots[i].value = new int[] { round, i };   // old -> young store, barrier runs
            }
            for (int g = 0; g < 20_000; g++) { byte[] junk = new byte[256]; }
        }
        long valid = 0;
        for (int i = 0; i < oldSlots.length; i++) {
            int[] v = (int[]) oldSlots[i].value;
            if (v[0] == 49 && v[1] == i) valid++;
        }
        System.out.println("young collections ran: " + (youngCount() > youngBefore));
        System.out.println("young objects reachable only from old regions: " + valid);
    }

    static long youngCount() {
        return ManagementFactory.getGarbageCollectorMXBeans().stream()
                .filter(gc -> gc.getName().equals("G1 Young Generation"))
                .mapToLong(gc -> gc.getCollectionCount()).sum();
    }
}
Output
young collections ran: true
young objects reachable only from old regions: 10000

Every one of the 10,000 arrays survives because each store dirtied a card that the next young collection scanned. RSets cost memory (several percent of the heap for applications with many cross-region references) and refinement costs CPU on concurrent threads. Applications that store many references from old objects into new ones, such as caches updated in place, pay most.

Example 5: the concurrent marking cycle, IHOP, and mixed collections

Old regions are reclaimed only by mixed collections: young collections whose collection set also includes some old regions with much garbage. To know which old regions are mostly garbage, G1 first runs a concurrent marking cycle:

Phase STW or concurrent Work
Concurrent Start STW, piggybacks on a young pause Marks objects directly reachable from roots
Root region scan Concurrent Scans survivor regions copied in that pause
Concurrent Mark Concurrent SATB marking of the whole heap
Remark STW Finishes SATB buffers, processes references, unloads classes
Rebuild remembered sets Concurrent Builds RSets for candidate old regions
Cleanup STW Picks mixed-collection candidates, frees empty regions

The cycle starts when old-generation occupancy crosses the IHOP (initiating heap occupancy percent). InitiatingHeapOccupancyPercent (45) is the starting value; with G1UseAdaptiveIHOP (true) G1 then adjusts the threshold from measured marking time and old-generation allocation rate, keeping G1ReservePercent (10) of the heap in reserve. After Cleanup, G1 runs up to G1MixedGCCountTarget (8) mixed collections and stops when the reclaimable space falls below G1HeapWastePercent (5) of the heap.

Java
import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;

public class MarkingCycle {
    public static void main(String[] args) {
        List<byte[]> window = new ArrayList<>();
        for (int i = 0; i < 400_000; i++) {
            byte[] b = new byte[1024];
            if (i % 4 == 0) window.add(b);                       // medium-lived data
            if (window.size() > 30_000) window.subList(0, 15_000).clear();
        }
        long young = count("G1 Young Generation");
        long concurrent = count("G1 Concurrent GC");
        System.out.println("window size: " + window.size());
        System.out.println("young (and mixed) collections > 0: " + (young > 0));
        System.out.println("concurrent cycle pauses > 0: " + (concurrent > 0));
    }

    static long count(String name) {
        return ManagementFactory.getGarbageCollectorMXBeans().stream()
                .filter(gc -> gc.getName().equals(name))
                .mapToLong(gc -> gc.getCollectionCount()).sum();
    }
}
Output
window size: 25000
young (and mixed) collections > 0: true
concurrent cycle pauses > 0: true

The window keeps about 15 to 30 MB alive. Data that survives long enough is promoted, old occupancy crosses the IHOP, and marking cycles run. A representative excerpt of the same workload with -Xlog:gc from a JDK 25 run (times differ on every run):

Text
[0.743s][info][gc] GC(6) Pause Young (Concurrent Start) (G1 Evacuation Pause) 52M->40M(64M) 1.499ms
[0.745s][info][gc] GC(7) Concurrent Mark Cycle
[0.748s][info][gc] GC(8) Pause Young (Normal) (G1 Evacuation Pause) 54M->43M(64M) 1.978ms
[0.765s][info][gc] GC(7) Pause Remark 58M->47M(64M) 1.934ms
[0.780s][info][gc] GC(7) Pause Cleanup 59M->59M(64M) 0.033ms
[0.780s][info][gc] GC(22) Pause Young (Prepare Mixed) (G1 Evacuation Pause) 59M->56M(64M) 0.683ms
[0.781s][info][gc] GC(7) Concurrent Mark Cycle 36.018ms
[0.782s][info][gc] GC(23) Pause Young (Mixed) (G1 Evacuation Pause) 59M->54M(64M) 1.206ms

Young collections continue during marking (GC(8)). "Prepare Mixed" is the last young-only pause after the cycle, and "Mixed" pauses evacuate old candidate regions too.

Example 6: MaxGCPauseMillis and pause prediction

MaxGCPauseMillis (default 200) is a soft goal: G1 sizes the young generation and picks the number of old regions in each mixed collection so that the predicted pause fits the goal. Predictions come from decaying averages of measured costs, such as time per copied byte, per card scanned, and per region. The young generation can range from G1NewSizePercent (5) to G1MaxNewSizePercent (60) of the heap; both are experimental flags. A lower goal means a smaller young generation, so collections run more often and promote more.

Java
import com.sun.management.HotSpotDiagnosticMXBean;
import java.lang.management.ManagementFactory;

public class PauseGoal {
    public static void main(String[] args) {
        var diag = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
        var option = diag.getVMOption("MaxGCPauseMillis");
        System.out.println("MaxGCPauseMillis = " + option.getValue() + " (" + option.getOrigin() + ")");
        System.out.println("GCPauseIntervalMillis = " + diag.getVMOption("GCPauseIntervalMillis").getValue());

        long sum = 0;
        for (int i = 0; i < 1_000_000; i++) sum += new byte[256].length;
        var young = ManagementFactory.getGarbageCollectorMXBeans().get(0);
        System.out.println(young.getName() + " ran: " + (young.getCollectionCount() > 0) + " (" + sum + ")");
    }
}
Output
MaxGCPauseMillis = 20 (VM_CREATION)
GCPauseIntervalMillis = 21
G1 Young Generation ran: true (256000000)

getOrigin() returns VM_CREATION, meaning the value was set when the JVM was created (on the command line). GCPauseIntervalMillis, the time slice in which the pause goal applies, is set by ergonomics to the goal plus one millisecond when not given. The goal does not bound the full collection, and it cannot be met when live data to copy is larger than the machine copies in the target time. Very low goals cause frequent small collections and lower throughput.

Example 7: evacuation failure and full GC fallback

An evacuation failure happens when G1 has no free region to copy a surviving object into. G1 then keeps the object in place, marks its region as old, and finishes the pause; the log shows (Evacuation Failure: Allocation). When a later allocation still cannot be satisfied, G1 runs a full GC: a stop-the-world, parallel mark-compact of the whole heap, logged as Pause Full (G1 Compaction Pause). If that does not free enough memory, the allocation throws OutOfMemoryError.

Java
import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;

public class FullGcFallback {
    public static void main(String[] args) {
        List<byte[]> live = new ArrayList<>();
        boolean oom = false;
        try {
            while (true) live.add(new byte[64 * 1024]);       // live data grows to the limit
        } catch (OutOfMemoryError e) {
            int held = live.size();
            live = null;                                     // drop everything before printing
            oom = true;
            System.out.println("live data before OOM > 48 MB: " + (held * 64L * 1024 > 48L << 20));
        }
        long full = ManagementFactory.getGarbageCollectorMXBeans().stream()
                .filter(gc -> gc.getName().equals("G1 Old Generation"))
                .mapToLong(gc -> gc.getCollectionCount()).sum();
        System.out.println("OutOfMemoryError thrown: " + oom);
        System.out.println("full collections ran first: " + (full > 0));
    }
}
Output
live data before OOM > 48 MB: true
OutOfMemoryError thrown: true
full collections ran first: true

G1 runs full collections before it gives up, because an allocation fails only after the full GC (including a second one that clears soft references) cannot make room. A representative excerpt from a churning workload in a 64 MB heap:

Text
[1.236s][info][gc] GC(46) Pause Young (Normal) (G1 Humongous Allocation) (Evacuation Failure: Allocation) 61M->61M(64M) 0.904ms
[1.250s][info][gc] GC(47) Pause Full (G1 Compaction Pause) 61M->3M(27M) 12.820ms
[1.260s][info][gc] GC(48) Pause Full (System.gc()) 6M->6M(24M) 7.778ms

Full GCs in production logs usually mean live data is too close to -Xmx, marking starts too late for the allocation rate, or humongous allocations fragment the heap. The responses are a larger heap, a lower IHOP or larger G1ReservePercent, fewer humongous objects, or larger regions.

Example 8: a GC monitor with notifications and occupancy thresholds

Production services export GC data to metrics. Each GarbageCollectorMXBean is also a NotificationEmitter; after every collection it sends a notification whose data GarbageCollectionNotificationInfo decodes (name, action, cause, duration, usage before and after). A MemoryPoolMXBean collection-usage threshold reports when a pool is still above a level right after a collection, which signals live data growth rather than garbage.

Java
import com.sun.management.GarbageCollectionNotificationInfo;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
import javax.management.NotificationEmitter;
import javax.management.openmbean.CompositeData;

public class GcMonitor {
    public static void main(String[] args) throws InterruptedException {
        Set<String> actions = ConcurrentHashMap.newKeySet();
        LongAdder events = new LongAdder();
        for (var gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            ((NotificationEmitter) gc).addNotificationListener((n, handback) -> {
                if (!n.getType().equals(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION)) return;
                var info = GarbageCollectionNotificationInfo.from((CompositeData) n.getUserData());
                actions.add(info.getGcName() + ": " + info.getGcAction());
                events.increment();
            }, null, null);
        }
        MemoryPoolMXBean old = ManagementFactory.getMemoryPoolMXBeans().stream()
                .filter(p -> p.getName().equals("G1 Old Gen")).findFirst().orElseThrow();
        old.setCollectionUsageThreshold(16L << 20);          // alert above 16 MB after a GC

        List<byte[]> cache = new ArrayList<>();
        for (int i = 0; i < 96; i++) cache.add(new byte[256 * 1024]);   // 24 MB live
        long sum = 0;
        for (int i = 0; i < 1_000_000; i++) sum += new byte[256].length;
        System.gc();
        Thread.sleep(200);                                   // notifications arrive on another thread

        System.out.println("cache entries: " + cache.size() + " (" + sum + ")");
        System.out.println("saw minor GC: " + actions.contains("G1 Young Generation: end of minor GC"));
        System.out.println("saw major GC: " + actions.contains("G1 Old Generation: end of major GC"));
        System.out.println("events > 0: " + (events.sum() > 0));
        System.out.println("old gen above threshold after GC: " + old.isCollectionUsageThresholdExceeded());
    }
}
Output
cache entries: 96 (256000000)
saw minor GC: true
saw major GC: true
events > 0: true
old gen above threshold after GC: true

The notifications are delivered asynchronously on a JMX thread, so the program waits briefly and prints only set membership. G1 reports Remark and Cleanup pauses with the action "end of concurrent GC pause". In a real monitor the listener records duration per action into a histogram and alerts on full collections and on a rising post-GC old occupancy.

G1 changes in Java 21 to 25

Release Change
Java 22 Region pinning (JEP 423): a JNI critical region pins only the regions holding the accessed array instead of blocking all collections with the GC locker
Java 24 Late barrier expansion (JEP 475): G1 barriers are expanded late in the C2 compiler pipeline, which simplifies C2 and reduces compile overhead

These release facts come from the JEP texts; the effects are not visible in the examples above. With region pinning, a thread inside GetPrimitiveArrayCritical no longer delays young collections, which matters for applications that call native code on large arrays.

Reading a G1 log

-Xlog:gc* prints one block per pause. A representative young pause from a JDK 25 run of the Example 5 workload:

Text
[0.515s][info][gc,start    ] GC(0) Pause Young (Normal) (G1 Evacuation Pause)
[0.515s][info][gc,task     ] GC(0) Using 2 workers of 4 for evacuation
[0.520s][info][gc,phases   ] GC(0)   Pre Evacuate Collection Set: 0.08ms
[0.520s][info][gc,phases   ] GC(0)   Merge Heap Roots: 0.03ms
[0.520s][info][gc,phases   ] GC(0)   Evacuate Collection Set: 4.03ms
[0.520s][info][gc,phases   ] GC(0)   Post Evacuate Collection Set: 0.86ms
[0.520s][info][gc,heap     ] GC(0) Eden regions: 28->0(27)
[0.520s][info][gc,heap     ] GC(0) Survivor regions: 0->3(4)
[0.520s][info][gc,heap     ] GC(0) Old regions: 2->2
[0.520s][info][gc,heap     ] GC(0) Humongous regions: 0->0
[0.520s][info][gc          ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 29M->3M(64M) 5.534ms
[0.521s][info][gc,cpu      ] GC(0) User=0.01s Sys=0.00s Real=0.00s
Field Meaning
Pause Young (Normal) Pause type: Normal, Concurrent Start, Prepare Mixed, or Mixed
(G1 Evacuation Pause) Cause: eden full; other causes include G1 Humongous Allocation and System.gc()
Merge Heap Roots Merging RSets and unrefined cards into the root set
Eden regions: 28->0(27) Before, after, and (target for the next cycle)
29M->3M(64M) 5.534ms Heap used before and after, committed heap, pause time
User/Sys/Real CPU time of GC threads versus wall time; User much larger than Real means parallel work

A rising "Old regions" count after each pause shows promotion; a growing number of humongous regions points to large arrays.

Interview points

  • G1 splits the heap into regions (1 MB to 32 MB by ergonomics, about 2048 regions, explicit up to 512 MB); generations are sets of regions.
  • Humongous objects exceed half a region, go straight to contiguous old regions, and waste the tail of the last region.
  • Young and mixed collections are STW evacuations of a collection set; mixed adds old regions with the most garbage first, which gives G1 its name.
  • Remembered sets track incoming cross-region references; the post-write barrier enqueues dirty cards and refinement threads process them.
  • The marking cycle starts at the IHOP (45 initially, adaptive by default) and has short Remark and Cleanup pauses; marking uses SATB.
  • MaxGCPauseMillis (200) is a goal that drives young-generation size and mixed collection-set size through cost prediction.
  • Evacuation failure leaves objects in place; a full GC is a parallel STW compaction and indicates an undersized heap or late marking.
  • MXBeans: "G1 Young Generation" counts young and mixed pauses, "G1 Concurrent GC" counts Remark and Cleanup, "G1 Old Generation" counts full GCs.
  • Java 22 added region pinning for JNI critical regions; Java 24 moved G1 barrier expansion late in C2.

Summary

  • JDK 25 defaults: MaxGCPauseMillis = 200, InitiatingHeapOccupancyPercent = 45, G1UseAdaptiveIHOP = true, G1ReservePercent = 10, G1MixedGCCountTarget = 8, G1HeapWastePercent = 5.
  • An object is humongous when its size is strictly greater than half the region size.
  • A concurrent cycle runs Concurrent Start, root region scan, Concurrent Mark, Remark, RSet rebuild, and Cleanup, then mixed collections follow.
  • G1 runs full collections, including one that clears soft references, before throwing OutOfMemoryError.
  • GC notifications and collection-usage thresholds give a program the same information as the GC log.
  • Log lines name pause type, cause, region counts before and after, heap before and after, and pause time.