Guide contents

Lesson 47 of 71

Serial, Parallel, Epsilon, and GC Tuning

This lesson covers the remaining HotSpot collectors and how to choose and tune any of them. It builds on GC Fundamentals, G1 in Depth, and ZGC and Shenandoah. After it, a reader can predict which collector and heap size a JVM gets, read a GC log line, and follow a tuning procedure that starts with allocation instead of flags.

Topic Flags and APIs
Serial collector -XX:+UseSerialGC
Parallel collector -XX:+UseParallelGC, GCTimeRatio, MaxGCPauseMillis
Epsilon (no-op) -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC
Default selection ActiveProcessorCount, server-class machine rule
Heap sizing -Xms, -Xmx, MaxRAMPercentage, InitialRAMPercentage, MinRAMPercentage
Logging -Xlog:gc, -Xlog:gc*
Measurement GarbageCollectorMXBean, HotSpotDiagnosticMXBean, com.sun.management.ThreadMXBean

Defaults quoted in this lesson come from java -XX:+PrintFlagsFinal -version on the JDK 25 build used here.

Example 1: the Serial collector

The Serial collector does all GC work on one thread with the application stopped. Young collections copy live objects from eden to a survivor space ("Copy"); old collections mark, sweep, and compact the whole heap ("MarkSweepCompact"). It has the smallest memory and CPU overhead, and no barriers beyond the card-table write barrier.

Java
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryType;
import java.util.Arrays;
import java.util.TreeSet;

public class SerialBeans {
    static volatile byte[] last;

    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) last = new byte[256];
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.println(gc.getName() + " " + Arrays.toString(gc.getMemoryPoolNames()));
        }
        TreeSet<String> pools = new TreeSet<>();
        for (MemoryPoolMXBean p : ManagementFactory.getMemoryPoolMXBeans()) {
            if (p.getType() == MemoryType.HEAP) pools.add(p.getName());
        }
        System.out.println("heap pools: " + pools);
        long young = ManagementFactory.getGarbageCollectorMXBeans().get(0).getCollectionCount();
        System.out.println("young collections > 0: " + (young > 0));
    }
}
Output
Copy [Eden Space, Survivor Space]
MarkSweepCompact [Eden Space, Survivor Space, Tenured Gen]
heap pools: [Eden Space, Survivor Space, Tenured Gen]
young collections > 0: true

Serial fits heaps up to about a few hundred megabytes, single-core containers, and short command-line tools. Pause time grows with heap size, because one thread does all the work.

Example 2: the Parallel collector and its goals

The Parallel collector has the same layout and algorithms as Serial but uses many GC threads (ParallelGCThreads, 4 on the 4-CPU machine here). All work is still stop-the-world. It aims for throughput: the share of time spent running application code. GCTimeRatio=N sets the goal "GC time at most 1/(1+N) of total time"; the Parallel default is 99, so the goal is 1%. MaxGCPauseMillis has no limit by default for Parallel, and UseAdaptiveSizePolicy (default true) resizes generations to meet the goals.

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

public class ParallelGoals {
    static volatile byte[] last;

    public static void main(String[] args) {
        var diag = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
        int ratio = Integer.parseInt(diag.getVMOption("GCTimeRatio").getValue());
        System.out.println("GCTimeRatio = " + ratio);
        System.out.printf("GC time goal = %.1f%%%n", 100.0 / (1 + ratio));
        System.out.println("UseAdaptiveSizePolicy = "
                + diag.getVMOption("UseAdaptiveSizePolicy").getValue());

        for (int i = 0; i < 2_000_000; i++) last = new byte[256];
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.println(gc.getName() + " ran: " + (gc.getCollectionCount() > 0));
        }
    }
}
Output
GCTimeRatio = 99
GC time goal = 1.0%
UseAdaptiveSizePolicy = true
PS MarkSweep ran: false
PS Scavenge ran: true

The bean names "PS Scavenge" (young) and "PS MarkSweep" (full) come from the collector's old name, Parallel Scavenge. G1's default GCTimeRatio is 12 (about 8% GC time), which shows how differently the two collectors weigh throughput.

Example 3: Epsilon, the collector that does not collect

Epsilon allocates memory and never reclaims it. When the heap is full, the JVM exits. It is experimental, so it needs -XX:+UnlockExperimentalVMOptions; without it the JVM refuses to start with VM option 'UseEpsilonGC' is experimental and must be enabled via -XX:+UnlockExperimentalVMOptions. With Epsilon, ExitOnOutOfMemoryError is true, so an OutOfMemoryError ends the process with Terminating due to java.lang.OutOfMemoryError: Java heap space instead of being catchable.

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

public class EpsilonBudget {
    static volatile byte[] last;

    public static void main(String[] args) {
        MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
        var gc = ManagementFactory.getGarbageCollectorMXBeans().get(0);
        System.out.println("collector: " + gc.getName());

        long before = mem.getHeapMemoryUsage().getUsed();
        for (int i = 0; i < 40_000; i++) last = new byte[1024];   // about 40 MB, all garbage
        long after = mem.getHeapMemoryUsage().getUsed();

        System.out.println("garbage still counted as used: " + (after - before > 40_000_000));
        System.out.println("collections: " + gc.getCollectionCount());
    }
}
Output
collector: Epsilon Heap
garbage still counted as used: true
collections: 0

The 40 MB of dead arrays stay in the heap, because Epsilon never runs a collection. Uses of Epsilon:

Use Reason
Performance tests Measures code without GC noise; shows the pure barrier-free baseline
Allocation budgets in tests A test fails if code allocates more than the heap size
Short-lived jobs Process exits before the heap fills, no GC cost at all
Garbage-free latency-critical services Heap sized for a full trading day, restarted daily

Default collector and heap size

When no collector flag is given, HotSpot picks G1 on a server-class machine and Serial otherwise. A server-class machine has at least 2 active processors and a minimum amount of physical memory. Example 4 shows the processor rule; on this machine, lowering -XX:MaxRAM did not change the selection, because the memory test uses physical memory, not MaxRAM. In a container, UseContainerSupport (default true) makes the JVM read the container's CPU and memory limits, so a container limited to one CPU gets Serial.

The default maximum heap is MaxRAMPercentage (default 25%) of available memory, and the initial heap is InitialRAMPercentage (default 1.5625%). For small memory, MinRAMPercentage (default 50%) is used instead, which is why a 200 MB machine gets a 100 MB heap. -Xmx and -Xms override the percentages. Measured with -XX:MaxRAM here:

Setting MaxHeapSize
-XX:MaxRAM=1g 256 MB (25%)
-XX:MaxRAM=1g -XX:MaxRAMPercentage=50 512 MB
-XX:MaxRAM=200m 100 MB (50%, small-memory rule)

Example 4: one CPU selects Serial

-XX:ActiveProcessorCount=1 makes the JVM believe it has one CPU, as in a container with a 1-CPU limit. -XX:MaxRAM=1g sets the memory the heap percentages use. The flags are read back through HotSpotDiagnosticMXBean.

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

public class DefaultSelection {
    public static void main(String[] args) {
        var diag = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
        for (String flag : new String[] {"UseSerialGC", "UseG1GC", "UseParallelGC"}) {
            System.out.println(flag + " = " + diag.getVMOption(flag).getValue());
        }
        long maxHeap = Long.parseLong(diag.getVMOption("MaxHeapSize").getValue());
        System.out.println("MaxHeapSize MB = " + (maxHeap >> 20));
        System.out.println("collector bean: "
                + ManagementFactory.getGarbageCollectorMXBeans().get(0).getName());
    }
}
Output
UseSerialGC = true
UseG1GC = false
UseParallelGC = false
MaxHeapSize MB = 256
collector bean: Copy

A service in a 1-CPU container therefore runs Serial with a quarter of the container memory as heap unless flags say otherwise. Production deployments set the collector and -Xmx (or MaxRAMPercentage) explicitly so that behavior does not change with the machine. Selecting two collectors fails at startup with Multiple garbage collectors selected, and removed collectors are rejected: -XX:+UseConcMarkSweepGC gives Unrecognized VM option 'UseConcMarkSweepGC' on JDK 25.

Latency, throughput, and footprint

Every collector trades three goals against each other:

Goal Meaning Collector that favors it
Latency Short pauses and stalls ZGC, Shenandoah
Throughput Most CPU time for the application Parallel
Footprint Least memory and fewest GC threads Serial
Balance Pause target with good throughput G1

Improving one usually costs another. A larger heap reduces GC frequency (better throughput) but raises footprint; low-pause collectors need more headroom and CPU for barriers.

Example 5: unified logging to a file

Unified logging (-Xlog, Java 9) is the one logging system of the JVM. -Xlog:gc prints one line per collection; -Xlog:gc* adds every GC tag (phases, heap, ages). The output can go to a file with :file=<name>. This program writes a Serial log and then reads it.

Java
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public class ReadGcLog {
    static volatile byte[] last;

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 1_000_000; i++) last = new byte[256];
        System.gc();
        List<String> lines = Files.readAllLines(Path.of("serial-gc.log"));
        System.out.println("names collector: " + lines.stream().anyMatch(l -> l.contains("Using Serial")));
        System.out.println("young pauses > 0: "
                + (lines.stream().filter(l -> l.contains("Pause Young (Allocation Failure)")).count() > 0));
        System.out.println("full pause for System.gc(): "
                + lines.stream().anyMatch(l -> l.contains("Pause Full (System.gc())")));
        Files.delete(Path.of("serial-gc.log"));
    }
}
Output
names collector: true
young pauses > 0: true
full pause for System.gc(): true

A representative -Xlog:gc line from a Serial run here:

Text
[0.474s][info][gc] GC(1) Pause Young (Allocation Failure) 21M->17M(61M) 14.784ms
Part Meaning
[0.474s] Uptime
GC(1) Collection number
Pause Young Kind of collection
(Allocation Failure) Cause: eden had no room
21M->17M(61M) Heap used before, after, and committed heap size
14.784ms Pause duration

Production settings usually add rotation: -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m.

Tuning procedure

  1. Define the goal as numbers: p99 or p99.9 latency, throughput, and memory limit.
  2. Measure with GC logs and the MXBeans: allocation rate, live set after full or major collections, pause and stall durations.
  3. Reduce allocation rate first. Fewer allocated bytes means fewer collections with every collector. This often helps more than any flag.
  4. Size the heap: at least about three times the live set for G1, more for ZGC and Shenandoah to give headroom. Set -Xms equal to -Xmx to avoid resizing.
  5. Pick the collector from the goal (table above).
  6. Change one flag at a time and measure again under production-like load.

Example 6: allocation rate is the first lever

com.sun.management.ThreadMXBean.getThreadAllocatedBytes reports how many bytes the current thread has allocated. Summing a List<Long> boxes each value; summing a long[] allocates nothing in the loop.

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

public class AllocationRate {
    static long collections() {
        long n = 0;
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) n += gc.getCollectionCount();
        return n;
    }

    public static void main(String[] args) {
        var threads = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
        long id = Thread.currentThread().threadId();
        int n = 2_000_000;

        long a0 = threads.getThreadAllocatedBytes(id), c0 = collections();
        List<Long> boxed = new ArrayList<>();
        for (long i = 0; i < n; i++) boxed.add(i * 1000);       // outside the Long cache
        long boxedSum = 0;
        for (Long v : boxed) boxedSum += v;
        long boxedBytes = threads.getThreadAllocatedBytes(id) - a0;
        long boxedGcs = collections() - c0;
        boxed = null;

        long a1 = threads.getThreadAllocatedBytes(id), c1 = collections();
        long[] prim = new long[n];
        for (int i = 0; i < n; i++) prim[i] = i * 1000L;
        long primSum = 0;
        for (long v : prim) primSum += v;
        long primBytes = threads.getThreadAllocatedBytes(id) - a1;
        long primGcs = collections() - c1;

        System.out.println("same sum: " + (boxedSum == primSum));
        System.out.println("boxed MB allocated >= 40: " + (boxedBytes >> 20 >= 40));
        System.out.println("primitive MB allocated: " + (primBytes >> 20));
        System.out.println("boxed caused more collections: " + (boxedGcs > primGcs));
    }
}
Output
same sum: true
boxed MB allocated >= 40: true
primitive MB allocated: 15
boxed caused more collections: true

The primitive version allocates one array of 16,000,000 bytes (2,000,000 times 8) and nothing else; the program prints 15 because >> 20 divides by 1,048,576. The boxed version allocates a 16-byte Long per element plus the list's growing backing arrays. Other sources of allocation to remove: iterator and lambda objects in hot loops, string building for logs that are disabled, and temporary collections.

Example 7: GC choice for a trading system

Trading systems that must not pause during market hours combine three techniques: pre-allocation (create all objects at startup and reuse them), a heap that holds a full day of the remaining allocation, and either ZGC or Epsilon. This program pre-allocates a pool of order objects and processes a million messages without allocating in the hot loop, under Epsilon.

Java
import java.lang.management.ManagementFactory;

public class PreallocatedOrders {
    static final class Order {
        long id;
        long priceTicks;
        int quantity;
        boolean buy;

        void set(long id, long priceTicks, int quantity, boolean buy) {
            this.id = id; this.priceTicks = priceTicks; this.quantity = quantity; this.buy = buy;
        }
    }

    static final int POOL = 1 << 14;
    static final Order[] pool = new Order[POOL];

    public static void main(String[] args) {
        for (int i = 0; i < POOL; i++) pool[i] = new Order();          // startup allocation
        var threads = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
        long id = Thread.currentThread().threadId();

        long buyQty = 0, sellQty = 0, notional = 0;
        long before = threads.getThreadAllocatedBytes(id);
        for (long seq = 0; seq < 1_000_000; seq++) {
            Order o = pool[(int) (seq & (POOL - 1))];                  // reuse, no new
            o.set(seq, 10_000 + (seq % 50), 1 + (int) (seq % 10), (seq & 1) == 0);
            if (o.buy) buyQty += o.quantity; else sellQty += o.quantity;
            notional += o.priceTicks * o.quantity;
        }
        long hotLoopBytes = threads.getThreadAllocatedBytes(id) - before;

        System.out.println("buy qty: " + buyQty + ", sell qty: " + sellQty);
        System.out.println("notional ticks: " + notional);
        System.out.println("hot loop allocated under 1 KB: " + (hotLoopBytes < 1024));
        System.out.println("collections: "
                + ManagementFactory.getGarbageCollectorMXBeans().get(0).getCollectionCount());
    }
}
Output
buy qty: 2500000, sell qty: 3000000
notional ticks: 55143000000
hot loop allocated under 1 KB: true
collections: 0

-XX:+AlwaysPreTouch (default false) makes the JVM touch every heap page at startup, so the operating system does not add page-fault delays on first use; Epsilon's startup log suggests it. With -Xms equal to -Xmx the heap never grows. If a garbage-free design is not possible, ZGC with a large heap keeps pauses well below a millisecond while still collecting. The choice:

Situation Choice
Hot path allocates nothing, day's allocation fits in RAM, daily restart Epsilon with large -Xms/-Xmx and AlwaysPreTouch
Hot path allocates a little, sub-millisecond tail target ZGC with generous headroom, plus pre-allocation
Latency target in tens of milliseconds G1 with MaxGCPauseMillis
Batch risk or end-of-day jobs Parallel

Interview points

  • Serial: one thread, stop-the-world, smallest footprint; chosen by default on machines that are not server-class, such as 1-CPU containers.
  • Parallel: same algorithms as Serial with many threads; throughput goal from GCTimeRatio (99 means 1% GC time).
  • Epsilon never collects; experimental; useful for benchmarks, allocation tests, and garbage-free services with planned restarts.
  • Default heap is 25% of available memory (MaxRAMPercentage), 50% on small machines (MinRAMPercentage); containers are detected by default.
  • Set the collector and -Xmx explicitly in production; defaults change with the machine.
  • -Xlog:gc gives one line per collection with cause, before and after sizes, and duration.
  • Reduce allocation rate before tuning flags; measure it with getThreadAllocatedBytes or JFR.
  • CMS was removed; -XX:+UseConcMarkSweepGC is unrecognized on JDK 25.

Summary

  • Serial beans: Copy, MarkSweepCompact; pools Eden Space, Survivor Space, Tenured Gen.
  • Parallel beans: PS Scavenge, PS MarkSweep; pools PS Eden Space, PS Survivor Space, PS Old Gen.
  • Epsilon bean and pool: Epsilon Heap; ExitOnOutOfMemoryError is true under Epsilon.
  • JDK 25 defaults here: MaxRAMPercentage 25, InitialRAMPercentage 1.5625, MinRAMPercentage 50, GCTimeRatio 99 (Parallel) and 12 (G1), MaxGCPauseMillis 200 (G1), AlwaysPreTouch false, UseContainerSupport true.
  • Tuning order: goal, measurement, allocation rate, heap size, collector, one flag at a time.