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.
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));
}
}Copy [Eden Space, Survivor Space]
MarkSweepCompact [Eden Space, Survivor Space, Tenured Gen]
heap pools: [Eden Space, Survivor Space, Tenured Gen]
young collections > 0: trueSerial 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.
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));
}
}
}GCTimeRatio = 99
GC time goal = 1.0%
UseAdaptiveSizePolicy = true
PS MarkSweep ran: false
PS Scavenge ran: trueThe 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.
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());
}
}collector: Epsilon Heap
garbage still counted as used: true
collections: 0The 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.
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());
}
}UseSerialGC = true
UseG1GC = false
UseParallelGC = false
MaxHeapSize MB = 256
collector bean: CopyA 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.
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"));
}
}names collector: true
young pauses > 0: true
full pause for System.gc(): trueA representative -Xlog:gc line from a Serial run here:
[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
- Define the goal as numbers: p99 or p99.9 latency, throughput, and memory limit.
- Measure with GC logs and the MXBeans: allocation rate, live set after full or major collections, pause and stall durations.
- Reduce allocation rate first. Fewer allocated bytes means fewer collections with every collector. This often helps more than any flag.
- Size the heap: at least about three times the live set for G1, more for ZGC and Shenandoah to give headroom. Set
-Xmsequal to-Xmxto avoid resizing. - Pick the collector from the goal (table above).
- 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.
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));
}
}same sum: true
boxed MB allocated >= 40: true
primitive MB allocated: 15
boxed caused more collections: trueThe 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.
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());
}
}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
-Xmxexplicitly in production; defaults change with the machine. -Xlog:gcgives one line per collection with cause, before and after sizes, and duration.- Reduce allocation rate before tuning flags; measure it with
getThreadAllocatedBytesor JFR. - CMS was removed;
-XX:+UseConcMarkSweepGCis unrecognized on JDK 25.
Summary
- Serial beans:
Copy,MarkSweepCompact; poolsEden Space,Survivor Space,Tenured Gen. - Parallel beans:
PS Scavenge,PS MarkSweep; poolsPS Eden Space,PS Survivor Space,PS Old Gen. - Epsilon bean and pool:
Epsilon Heap;ExitOnOutOfMemoryErroristrueunder Epsilon. - JDK 25 defaults here:
MaxRAMPercentage25,InitialRAMPercentage1.5625,MinRAMPercentage50,GCTimeRatio99 (Parallel) and 12 (G1),MaxGCPauseMillis200 (G1),AlwaysPreTouchfalse,UseContainerSupporttrue. - Tuning order: goal, measurement, allocation rate, heap size, collector, one flag at a time.
