skip to content

A native-image Spring service is throughput-bound under sustained load. Why is the default GC a problem, and what would you change?

level: seniorimportance: should knowfreq 40%

answer

  1. Default = Serial GC (single-threaded, low footprint)
  2. --gc=G1 for throughput (Oracle, Linux)
  3. Epsilon = no-op, short jobs only
  4. GC fixed at build time
  5. G1 costs footprint

basics

~10 s

Native images default to the Serial GC: single-threaded, low footprint, but stop-the-world pauses that hurt throughput on larger heaps under load. For a throughput-bound service on Oracle GraalVM (Linux), switch to G1 with --gc=G1.

solid answer

~40 s

By default a GraalVM native image uses the Serial GC — a single-threaded collector optimized for small heaps and low memory footprint. That's perfect for short-lived CLIs or tightly memory-constrained containers, but for a long-running, throughput-bound service with a sizeable heap its stop-the-world, single-threaded collections become a throughput ceiling. On Oracle GraalVM (Linux amd64/aarch64) you can switch to the G1 GC by adding the build arg --gc=G1. G1 is a parallel, generational, mostly-concurrent collector that scales collection work across cores and keeps pauses bounded, which is what a throughput-bound service needs. The tradeoff is higher memory footprint and larger binaries, partly eroding native image's footprint advantage, plus the Oracle GraalVM requirement. There's also Epsilon (--gc=epsilon), a no-op collector for ultra-short-lived jobs. I'd pair G1 with PGO and -O3 for the full throughput package.

code

kotlin · 17 lines
kotlin
// build.gradle.kts — throughput-bound native build: G1 + O3 (+ PGO profile)
import org.graalvm.buildtools.gradle.dsl.GraalVMExtension

configure<GraalVMExtension> {
    binaries {
        named("main") {
            buildArgs.add("--gc=G1")   // parallel/concurrent GC (Oracle GraalVM, Linux)
            buildArgs.add("-O3")        // aggressive optimization
            // buildArgs.add("--pgo=${'$'}{rootDir}/default.iprof") // from the PGO run
        }
    }
}
// Maven equivalent (native-maven-plugin):
// <configuration><buildArgs>
//   <buildArg>--gc=G1</buildArg>
//   <buildArg>-O3</buildArg>
// </buildArgs></configuration>

go deeper

for a junior

Know the default is Serial GC and G1 exists for throughput.

for a middle

Explain Serial vs G1 vs Epsilon and that --gc is a build arg.

for a senior

Reason about the footprint tradeoff, Oracle-GraalVM/Linux constraint, and build-time-only GC selection.

for a principal

Decide GC per service class, measure the footprint cost against the throughput gain, and combine GC with PGO/-O3 as one tuning strategy.

## The default: Serial GC A GraalVM native image ships with the **Serial GC** by default. It is: - **single-threaded** — one thread does all collection work; - **generational** with a small footprint; - excellent for **short-lived processes** (CLIs, functions) and **memory-constrained** containers because it wastes almost no memory on GC bookkeeping. The cost: collections are **stop-the-world and single-threaded**, so as the heap grows and allocation rate rises under sustained load, GC pauses lengthen and cap throughput. That's the wrong profile for a **long-running, throughput-bound** service. ## The fix: G1 GC On **Oracle GraalVM** (Linux amd64/aarch64), add the build arg **`--gc=G1`** to build the image with the **G1 (Garbage-First) collector**: - **parallel** — uses multiple threads for collection, scaling with cores; - **generational** and **region-based** with **mostly-concurrent** marking, keeping pause times bounded even on larger heaps; - the throughput-oriented choice for load-bearing services. **Tradeoffs of G1:** higher **memory footprint** and larger binaries (you give back some of native image's headline footprint win), and it's **Oracle GraalVM + Linux only** — Community Edition doesn't offer it. ## The third option: Epsilon GC **`--gc=epsilon`** is a **no-op collector** — it allocates but never reclaims. Useful only for **extremely short-lived jobs** that exit before exhausting the heap (e.g. a batch that runs and dies). Never for a long-running service; it will OOM. ## Decision guide | Workload | GC | |----------|----| | Short-lived CLI / serverless, tiny heap | Serial (default) | | Memory-constrained container, startup/footprint priority | Serial (default) | | Long-running, throughput-bound, cores + heap to spare | **G1** (`--gc=G1`, Oracle GraalVM/Linux) | | Ultra-short batch that exits fast | Epsilon (`--gc=epsilon`) | ## Wiring into Spring Boot Pass through the Native Build Tools plugin `buildArgs`: Gradle `buildArgs.add("--gc=G1")`, Maven `<buildArg>--gc=G1</buildArg>`. GC choice is a **build-time** decision baked into the binary — you can't switch collectors at runtime as you can on the JVM. ## Gotchas - `--gc=G1` is **Oracle GraalVM, Linux only**; a portable build or Community Edition can't use it. - G1 **raises footprint** — measure; you may partly lose the reason you went native. - GC is fixed **at build time** — no runtime `-XX:+UseG1GC` equivalent to flip later. - G1 alone isn't the whole story: combine with **PGO** and **`-O3`** for maximum throughput; GC addresses pause/allocation overhead, PGO addresses code quality. - Epsilon on a long-running service = guaranteed OutOfMemoryError.

  • Can you switch a native image from Serial to G1 at runtime like -XX:+UseG1GC on the JVM?
    No. The collector is chosen at build time via --gc and baked into the binary. To change GC you rebuild the image.
  • When is the default Serial GC actually the right choice?
    Short-lived processes (CLIs, serverless) and memory-constrained containers where low footprint matters more than sustained throughput — exactly native image's classic sweet spot.
  • Why not use Epsilon GC to maximize throughput?
    Epsilon never reclaims memory; a long-running service will exhaust the heap and OOM. It only suits jobs that exit before filling the heap.

saying these in an interview costs you the question

  • Thinking you can flip GC at runtime with a JVM-style flag
  • Using Epsilon GC for a long-running service
  • Assuming G1 is free — ignoring its footprint cost and Oracle/Linux constraint

context