Serial GC is HotSpot's oldest production collector and its simplest. It has one GC thread and two stop-the-world pause types, with no concurrent phases. It is easy to dismiss next to G1, ZGC and Shenandoah, but it still runs a large number of JVMs: command-line tools, build workers, small containers, serverless functions and, until very recently, any JVM that ergonomics judged too small for G1. Because nothing in it runs concurrently, it is also the clearest place to learn what every other HotSpot collector is optimising.

This article explains the young collection and the full collection step by step, with pseudocode. It covers the card table that links them, when the JVM picks Serial on its own (and how JDK 27 changed that), and how to size and read logs for a small heap. A worked container example and the trade-offs against Parallel and G1 follow. For the shared vocabulary of roots, barriers and generations, see JVM GC architecture.

Advertisement

One thread, two pauses

You enable it with -XX:+UseSerialGC. The heap is split into a young generation (eden plus two equal survivor spaces) and an old generation, laid out as in the diagram. By default NewRatio=2 makes old twice the size of young, so young is a third of the heap. SurvivorRatio=8 makes eden eight times the size of each survivor, so each survivor is a tenth of young.

Allocation is cheap. Each thread gets a thread-local allocation buffer (TLAB) carved from eden and allocates by bumping a pointer, with no locking. When eden cannot satisfy a TLAB refill, the allocating thread requests a collection. All Java threads are brought to a safepoint (see JVM safepoints), and the VM thread runs the collection alone. Application threads resume only when it finishes. That single thread is the defining property. Pause time does not shrink with more cores, but nothing ever competes with the application for CPU between pauses, and the collector itself has almost no memory overhead.

Serial GC heap (defaults: NewRatio=2, SurvivorRatio=8)Eden (8/10 of young)S0S1Old (tenured): 2/3 of heapYoung generation: 1/3 of heapMinor GC (copying)roots + dirty cardseden fulllive -> S1 (age+1)age >= threshold: promoteFull GC (mark-sweep-compact)whole heap, one threadold full / promotion failsCard table1 byte per 512-byte card of old genscannedEvery pause stops all application threads; only one GC thread does the work
Serial GC's generational layout and its two pause types. Minor collections copy live young objects between survivor spaces and promote old ones; a full collection marks and slides the entire heap with a single thread.

The minor collection: copying the survivors

A minor collection evacuates live objects out of eden and the current from-survivor space. It relies on the weak generational hypothesis: most objects die young, so copying the few survivors costs less than walking the dead. The algorithm is a Cheney-style breadth-first copy:

minor_gc():
    to = empty survivor space; scan = to.top
    for root in thread_stacks + globals + jni + dirty_cards_of_old_gen:
        for ref in root.references_into(young):
            ref.update(evacuate(ref.target))
    while scan < to.top:                     # copied objects are the work queue
        obj = object_at(scan)
        for ref in obj.references_into(young):
            ref.update(evacuate(ref.target))
        scan += obj.size
    clear(eden); clear(from); swap(from, to)

evacuate(obj):
    if obj.is_forwarded(): return obj.forwardee
    if obj.age + 1 >= tenuring_threshold or to.is_full():
        new = old_gen.allocate(obj.size)     # promotion
        if new is None: raise PromotionFailed  # becomes a full GC
    else:
        new = to.allocate(obj.size); new.age = obj.age + 1
    copy(obj, new); obj.forward_to(new)
    return new

Three consequences follow. First, pause time is proportional to the number of live young objects plus the dirty old-generation memory being scanned, not to eden's size. A larger eden means fewer minor pauses without much longer ones, as long as the survival rate stays low. Second, objects age by surviving collections and are promoted when they reach the tenuring threshold. That threshold is at most MaxTenuringThreshold (15 by default), and it is lowered dynamically when survivors overflow. Third, if the to-space overflows, survivors are promoted early. Medium-lived objects such as session caches or request batches then land in old, where only a full collection can reclaim them. Premature promotion is the most common reason a Serial-collected application suffers frequent full GCs.

Advertisement

The card table and the write barrier

A minor collection must find every pointer from old objects into young without scanning the whole old generation. HotSpot keeps a card table: one byte for every 512-byte card of the old generation. Every reference store compiles to a small post-write barrier that marks the card containing the updated field as dirty. Roughly, card_table[addr >> 9] = DIRTY runs after each obj.field = value. At the next minor GC, dirty cards are scanned as extra roots, and cards that no longer hold young pointers are cleaned.

The barrier is a single unconditional store, cheaper than the barriers G1 needs for its region remembered sets and concurrent marking. This is part of why Serial and Parallel often post better raw throughput on small heaps. The cost moves to the pause instead: an application that writes references all over a large old generation dirties many cards, and each minor pause has to scan them.

The full collection: sliding mark-compact

When the old generation cannot absorb promotions, or an allocation is too large for anything else, Serial runs a full collection over the whole heap: young, old and class metadata. It uses a sliding mark-compact in four phases:

full_gc():
    # 1. mark: depth-first from roots, set mark bit on every reachable object
    for root in all_roots(): mark_recursive(root)
    # 2. compute new addresses: walk heap in address order, live objects slide down
    free = heap.bottom
    for obj in heap.objects_in_address_order():
        if obj.marked: obj.forwarding = free; free += obj.size
    # 3. adjust pointers: every reference (roots and live fields) -> target.forwarding
    for ref in all_roots() + live_object_fields(): ref.update(ref.target.forwarding)
    # 4. move: copy each live object to its forwarding address, clear marks
    for obj in heap.objects_in_address_order():
        if obj.marked: move(obj, obj.forwarding); obj.clear_mark()

Phase 1 costs time in proportion to the live data. Phases 2 to 4 walk the whole heap, so they cost time in proportion to its size. Sliding keeps objects in allocation order, which preserves locality, and it leaves a single contiguous free block, so the old generation never fragments. The price is that the entire heap is processed by one thread while the application stands still. A full pause is far longer than a minor one, and it grows with the heap. That is the main reason Serial suits small heaps: a full GC over a few hundred megabytes is tolerable, while one over tens of gigabytes can stall the application for seconds.

When the JVM picks Serial, and JDK 27&#x27;s change

Through JDK 26, HotSpot's ergonomics chose the collector automatically when you did not. A machine counted as server class if the JVM detected at least two processors and at least 1792 MB of physical memory, and then it got G1; anything smaller got Serial. Inside containers the JVM reads the cgroup limits, so a pod limited to one CPU, or to 1.5 GB of memory, silently ran Serial even on a 64-core host. Many teams discovered this only when a GC log printed Using Serial.

JDK 27, released in September 2026, implements JEP 523 (Make G1 the Default Garbage Collector in All Environments). When no collector is specified, the JVM now always selects G1, on the grounds that recent work made G1 competitive in small environments. Serial is not removed, and an explicit -XX:+UseSerialGC still works. In practice, a fleet on mixed JDK versions can run different collectors on identical pods, so check rather than assume:

$ java -Xlog:gc -version 2>&1 | head -1
[0.004s][info][gc] Using Serial

$ java -XX:+PrintFlagsFinal -version | grep -E 'Use(Serial|Parallel|G1|Z)GC '
     bool UseSerialGC   = true    {product} {ergonomic}

The {ergonomic} origin tells you the JVM chose it. Pin the collector explicitly in production images so that a JDK upgrade does not change it silently. Java in containers covers how CPU and memory limits feed these decisions.

Worked example: a one-CPU container

Consider a JSON API running in a container with one CPU and a 768 MB memory limit. The heap is -Xmx512m -Xms512m with Serial. The live set, measured after full GCs, is about 120 MB, and the service allocates about 60 MB/s at peak. With default ratios, young is about 170 MB, so eden is about 136 MB and each survivor about 17 MB. Old is about 341 MB.

Eden fills every 136 / 60, roughly 2.3 seconds, so you get a minor GC about every 2.3 s. If 4 MB survive each one, the pause copies 4 MB plus a few dirty cards, typically a few milliseconds on one core. Old has about 220 MB of room above the live set. If 1 MB is promoted per minor GC, a full GC comes roughly every 220 x 2.3, or about 8 minutes. Its pause covers 512 MB of heap with 120 MB live and might take on the order of 100 to 300 ms on one core. That figure is an illustration; measure yours.

Suppose the log instead shows full GCs every 40 seconds and survivors overflowing. Medium-lived objects are being promoted early. Raising the survivor size (-XX:SurvivorRatio=6) or the young size (-Xmn200m) lets them die young. Watch whether full GC frequency falls and whether minor pause time stays within budget. With unified logging (-Xlog:gc*:file=gc.log:time,uptime,level,tags), the lines to track look like this (illustrative values):

[12.481s][info][gc] GC(41) Pause Young (Allocation Failure) 264M->132M(494M) 3.912ms
[13.107s][info][gc] GC(42) Pause Full (Allocation Failure) 488M->131M(494M) 212.774ms

The number after the arrow on a full GC is your live set plus floating garbage. Size the heap to about three to four times it for comfortable full-GC intervals, and see Java heap tuning for the general method.

The flags that matter

FlagEffectWhen to touch it
-Xms / -XmxHeap boundsSet equal in containers to avoid resize pauses
-Xmn or -XX:NewRatioYoung generation sizeLong full-GC intervals, short-lived garbage
-XX:SurvivorRatioEden versus survivor sizeSurvivor overflow, premature promotion
-XX:MaxTenuringThresholdMax age before promotionRarely; lower it if survivors are copied many times then promoted anyway
-XX:MaxRAMPercentageHeap as percent of container memoryWhen you prefer a ratio to a fixed -Xmx

Trade-offs and failure modes

Serial versus Parallel: Parallel GC uses the same generational design but with many GC threads, so on four or more cores it shortens the same pauses several-fold. On one core it gains nothing and pays thread-coordination overhead, which is where Serial wins. Serial versus G1: G1 bounds pauses with regions and incremental mixed collections and does marking concurrently. It pays for that with more barrier work, remembered-set memory and background CPU (see G1 architecture). On one core, that background work competes directly with the application.

  • Choose Serial for heaps up to a few hundred megabytes, a single CPU, many JVMs packed onto one host, short-lived processes such as CLIs and build steps, and batch jobs that tolerate pauses.
  • Avoid it for multi-gigabyte heaps, latency SLOs below the expected full-GC pause, or services with large, slowly churning caches that keep promoting into old.
  • Failure modes: back-to-back full GCs as old fills with live data (ending in OutOfMemoryError: Java heap space); container OOM kills when native memory plus heap exceeds the limit; and latency spikes after a JDK upgrade or a change in CPU limits switches the collector silently.

What to do next

  1. Run java -Xlog:gc -version inside each production image and record which collector each service actually uses and why ({ergonomic} or explicit).
  2. Pin the collector with an explicit flag in every image, especially before moving to JDK 27, where the default for small pods changes to G1.
  3. Enable -Xlog:gc* to a rotated file and chart minor pause time, full GC frequency and post-full-GC heap.
  4. Measure the live set and size -Xmx to three to four times it; set -Xms equal in containers.
  5. If full GCs are frequent and post-GC heap is low, enlarge young or survivors and remeasure. If post-GC heap keeps rising, look for a leak, not a tuning fix.
  6. Compare Serial with G1 on your one-CPU pods under realistic load: p99 latency, throughput and RSS. Keep whichever wins, and write down the result.
  7. Continue with JVM GC architecture and G1 to see how the same ideas scale to concurrent, region-based collectors.
Key takeaway: Serial GC is a single-threaded, stop-the-world generational collector: a copying minor GC whose cost tracks live young objects, and a sliding mark-compact full GC whose cost grows with the heap. It wins on one CPU and small heaps and loses on large heaps or tight latency targets. Through JDK 26 ergonomics picked it for small machines and pods; JDK 27 picks G1, so pin your collector explicitly and size the heap from the measured live set.