serial and parallel
the stop-the-world collectors, and when they're still the right call.
- chapter
- chapter 14.06
- reading time
- 7 min read
Serial and Parallel are the stop-the-world collectors: freeze every app thread, collect, let them go. both are generational (copy the young gen, mark-compact the old gen). Serial does the work on one GC thread, Parallel splits it across many.
no concurrent phases means no barriers to speak of and the least overhead of any collector. the price: every pause grows with your live data.
the picture
think of a pit stop. every car on the track (every app thread) pulls in and waits. Serial is one mechanic doing all four tyres. Parallel is a crew. nobody races while the crew works, but a crew gets the cars back out a lot sooner, and the race (your program) finishes earlier.
see the diminishing returns? one thread to two nearly halves the pause. four to eight still helps. eight to eleven does nothing: the young gen in this run only has a couple of milliseconds of copying in it, and splitting a small job across more threads adds coordination (handing out work, stealing it back, agreeing that everyone's done). meanwhile the CPU time keeps going up. that's Amdahl's law wearing a GC costume.
the numbers come from this toy service, run three times per setting with a fixed 200 MB young gen on jdk 25 (14 cores):
import java.util.*;
// a toy service: every request makes short-lived garbage,
// and a cache keeps the last 20,000 results alive for a while.
public class Service {
static final Map<Integer, byte[]> cache = new LinkedHashMap<>(16, 0.75f, true) {
protected boolean removeEldestEntry(Map.Entry<Integer, byte[]> e) { return size() > 20_000; }
};
public static void main(String[] args) {
Random rnd = new Random(42);
long sum = 0;
for (int req = 0; req < 3_000_000; req++) {
String body = ("request " + req + " ").repeat(8); // garbage in microseconds
sum += body.length();
cache.put(rnd.nextInt(100_000), new byte[2048]); // lives until evicted
if (req % 400_000 == 0) sum += new byte[4 << 20].length; // a 4 MB buffer
}
System.out.println(sum);
}
}java -XX:+UseSerialGC -Xms512m -Xmx512m -Xmn200m -Xlog:gc Service.java
java -XX:+UseParallelGC -XX:ParallelGCThreads=4 -XX:-UseAdaptiveSizePolicy \
-Xms512m -Xmx512m -Xmn200m -Xlog:gc Service.javahow everyone stops
a stop-the-world pause starts with a safepoint. the JVM sets a flag, and every running thread checks it at its next poll (on method returns and loop back-edges), parks itself, and waits. only when the last thread has arrived does the GC start. that waiting is time to safepoint, and one thread stuck in a long loop without a poll delays everybody. it shows up in your latency but not in the GC's own pause number. the full story is in safepoints.
then the collector runs exactly the algorithms from how GC works. a young GC copies eden and the survivors. a full GC is a mark-compact, and Parallel's log even names the passes:
GC(3) Pause Full (Allocation Failure)
GC(3) Marking Phase 0.908ms
GC(3) Summary Phase 0.017ms
GC(3) Forward 0.665ms
GC(3) Adjust Pointers 0.531ms
GC(3) Compaction Phase 0.922ms
GC(3) PSYoungGen: 76256K(76288K)->0K(76288K) Eden: 65536K(65536K)->0K(65536K) From: 10720K(10752K)->0K(10752K)
GC(3) ParOldGen: 135758K(175104K)->51402K(174592K)
GC(3) Pause Full (Allocation Failure) 207M->50M(245M) 3.367msmark, work out forwarding addresses ("Forward"), rewrite every pointer ("Adjust Pointers"), slide ("Compaction"). the same three passes as the mark-compact figure, now across 11 threads.
serial: one thread, almost no overhead
Serial is the simplest collector in the JDK and the smallest. its bookkeeping is a card table and not much else. with a 256 MB heap on an idle program, here's what each collector allocated for its own data structures (native memory tracking, jdk 25):
until java 27, the JVM picked Serial by itself on small machines (one CPU, or less than 1792 MB of memory), which in practice meant a lot of small containers. java 27 made G1 the default everywhere (JEP 523), since G1's footprint is now close enough. Serial is still there and still good: -XX:+UseSerialGC.
parallel: the throughput collector
Parallel (sometimes called the throughput collector) was the default in java 8. it has no pause goal unless you give it one. what it optimizes for is the fraction of time spent in GC: -XX:GCTimeRatio=99 means "at most 1 in 100". its adaptive size policy keeps resizing the young and old gens to hit that. if you'd rather bound the pauses, -XX:MaxGCPauseMillis=<ms> makes it shrink the young gen to keep pauses short, at the cost of collecting more often.
java 9 replaced it as the default with G1, which trades a little throughput for pauses that don't grow with the heap. but for work where nobody is waiting on a single request, Parallel is often still the fastest collector you can pick.
when they're still the right call
- batch jobs, ETL, builds, offline analytics. nobody cares about one pause, only total runtime. Parallel's low overhead wins.
- tiny containers and CLI tools. one or two cores, a few hundred MB of heap. Serial's pauses are short because the heap is small, and it doesn't spend memory on structures it can't use.
- short-lived JVMs. a process that runs for two seconds and does three young GCs doesn't need a concurrent collector.
- you measured. the right collector is the one whose log looks best under your real load. reading GC logs shows how.
- Serial and Parallel stop every app thread for every collection, young and full.
- both copy the young gen and mark-compact the old gen. Parallel just uses many threads.
- more GC threads shorten pauses until there isn't enough work to split, then just burn CPU.
- they have the lowest overhead of any collector: great for batch jobs, small heaps, short runs.
- their pauses grow with the live set, so big heaps with latency budgets need G1, ZGC or Shenandoah.