Native Image vs JVM at 128 MB: The Benchmark Nobody Warned Me About

The Memory Benchmark · Part 2

The mental model: native image uses less memory, therefore native image performs better under memory pressure.

This is wrong.

Setup

The app: Micronaut 5.0.0 Space Observatory. 4 HTTP clients, reactive R2DBC into PostgreSQL, 7-table schema, 16 REST endpoints. Real enough to matter. (Part 1 covers the build.)

Four scenarios, clean database each time (docker compose down -v):

  1. Native, unlimited memory – baseline
  2. Native, 128 MB hard limitmem_limit: 128m, swap disabled
  3. JVM, unlimited memory – same app, java -jar
  4. JVM, 128 MB hard limit – same container constraint

Load: Apache Bench, 1,000 requests x 20 concurrent, container-to-container. DB endpoints only – no external API calls polluting the numbers. Host: 16 cores, 32 GB RAM, Linux 7.0.9.

#!/usr/bin/env bash
run_scenario() {
  local label="$1" compose_file="$2" mem_flag="$3"

  docker compose -f "$compose_file" down -v
  if [ "$mem_flag" = "limited" ]; then
    export MEM_LIMIT="128m"
  else
    unset MEM_LIMIT
  fi

  docker compose -f "$compose_file" up -d postgres app
  # wait for healthy, then:
  docker compose -f "$compose_file" --profile loadtest \
    run --rm loadtest http://app:8181 20 1000 "$label"
}

run_scenario "native-unlimited" "docker-compose.yml" "unlimited"
run_scenario "native-128mb"    "docker-compose.yml" "limited"
run_scenario "jvm-unlimited"   "docker-compose-java.yml" "unlimited"
run_scenario "jvm-128mb"       "docker-compose-java.yml" "limited"

Memory: The Expected Part

ScenarioIdlePeak under load
Native unlimited72 MB113 MB
Native 128 MB51 MB49 MB
JVM unlimited~180 MB~250 MB
JVM 128 MB50 MB64 MB

Native uses 2.5x less memory at idle. No surprise – this is the headline number on every GraalVM marketing page. Under 128 MB constraint, both survive. Neither OOMs. Both stay well under the limit.

Native 128 MB actually shows lower memory at peak (49 MB) than at idle without limits (72 MB). The Serial GC panics early and often when it senses limited headroom, compacting aggressively. This looks good in a memory column. It does not look good in the next table.

The JVM at 128 MB holds steady at 50-64 MB. G1GC shrugs, shrinks its heap, carries on.

This is where the story should end. Native uses less memory. Done. Move on.

Throughput: The Part Where Everything Goes Sideways

EndpointNative unlimitedNative 128 MBJVM unlimitedJVM 128 MB
Observatory by code14,3408,45313,46813,731
All NEOs13,6678,24513,55813,757
NEO approaches14,4257,6859,0599,059
List observatories11,6015,2819,0369,680
List sessions10,0357,06211,2347,153
NEO hazardous9,7736,76513,19613,196
Health7,4084,2806,9338,562
Session detail5,3663,6865,5684,562

Read the “128 MB” columns.

JVM 128 MB beats native 128 MB on 5 of 8 endpoints. Not by a little. Observatory by code: 13,731 vs 8,453 – 62% faster. All NEOs: 13,757 vs 8,245 – 67% faster. Health endpoint: 8,562 vs 4,280. The simplest possible HTTP response. The JVM serves it exactly twice as fast.

Native unlimited is fast. Native constrained is not.

Why

Two reasons, both obvious in retrospect.

Serial GC vs G1GC

GraalVM Community Edition native images ship with the Serial GC. Not “default to the Serial GC” – ship with it. No G1GC. No ZGC. No Shenandoah. One garbage collector, and it stops the world for every collection.

On unlimited memory with a small heap, the pauses are brief and infrequent. Under a 128 MB ceiling with actual allocation pressure, every collection is a full stop-the-world event. The collector that looked frugal on the memory chart is now the bottleneck on the throughput chart.

The JVM gets G1GC by default. G1GC divides the heap into regions, collects incrementally, runs concurrent marking. Under the same 128 MB constraint, it performs more frequent but shorter pauses. Application threads spend more time doing actual work.

The tail latency makes this visceral:

EndpointNative 128 MB (p99)JVM 128 MB (p99)
Observatory by code9 ms4 ms
All NEOs7 ms3 ms
List observatories26 ms14 ms
Health53 ms22 ms
Session detail22 ms18 ms

Native p99 is 1.2x to 2.4x higher across the board. The health endpoint — a near-empty response body — shows the biggest gap: 53 ms vs 22 ms. On a handler that barely allocates, GC pause interference is the dominant explanation for a 2.4x latency difference.

p50 latency is 1-4 ms across all scenarios. The median request is fine. The tail is where Serial GC bleeds.

JIT Warmup

The JVM’s JIT compiler profiles code at runtime and compiles hot paths to optimized machine code. During a 1,000-request benchmark, the first ~100 requests are warmup. After that, the JVM gets progressively faster as C2 kicks in.

Native image has no JIT. The code is compiled ahead of time with no profiling data. What you get at request 1 is what you get at request 1,000. No warmup penalty, but no warmup reward either.

This makes the “native vs JVM” throughput comparison benchmark-length-dependent. Short bursts favor native (no warmup penalty). Sustained load favors JVM (JIT keeps optimizing). At 1,000 requests, the JVM’s late-test acceleration partially compensates for its slow start. At 100,000, the gap would widen further.

Native Unlimited vs JVM Unlimited

With unlimited memory, the picture shifts:

EndpointNative unlimitedJVM unlimitedDelta
Observatory by code14,34013,468Native +6%
All NEOs13,66713,558Tie
NEO approaches14,4259,059Native +59%
List observatories11,6019,036Native +28%
List sessions10,03511,234JVM +12%
NEO hazardous9,77313,196JVM +35%
Session detail5,3665,568Tie

A split. Native wins some endpoints by 6-59%. JVM wins others by 12-35%. The Serial GC is adequate when it has room to breathe. The JIT advantage is real but not dominant at 1,000 requests.

The endpoints where JVM wins unlimited – NEO hazardous, list sessions – return larger result sets. More object allocation per request means the GC difference shows up even without memory pressure. The JIT also gets better optimization opportunities on repeated patterns: iterating collections, serializing similar objects.

What About Oracle GraalVM?

Oracle GraalVM (formerly Enterprise) ships G1GC for native images. The Serial GC limitation is specific to GraalVM CE. If you’re using Oracle GraalVM, the constrained-memory throughput story is likely different.

Different binary. Different license. Different benchmark. This test used GraalVM CE 25.0.2 – the version you download freely, the version most open-source projects will use.

The External API Endpoints

For completeness:

EndpointNative unlimitedJVM unlimited
ISS + Weather (10 req)3.1 req/s7.6 req/s
Collect pipeline (3 req)3.2 req/s4.1 req/s

These numbers are meaningless for comparing native vs JVM. The ISS API responds in 200-800 ms. NASA’s DEMO_KEY rate limits to 30 req/hour. Throughput here is API-bound, not app-bound. The JVM “winning” is noise.

Don’t benchmark your app through someone else’s latency.

Startup and Image Size

MetricNativeJVM
Startup to healthy~1s~3-5s
Binary/artifact size131 MB~200 MB (JRE layer)
Build time~90s (native-image)~15s (javac + package)

Native starts 3-5x faster. This matters for serverless cold starts, scale-to-zero (Knative, Cloud Run), CI/CD where containers spin up and die, health check failover timing.

It does not matter for a long-running service that restarts once a month.

Errors

ErrorCountScenariosExpected?
duplicate key "observatory_site_code_key"49all 4Yes – load test sends same code 50x
ISS API timeout/refused1-9all 4Yes – external API
NASA Too Many Requestsvariesall 4Yes – DEMO_KEY rate limit

Zero unexpected errors in any scenario. No OOM kills. No crashes. Native 128 MB held steady at 49 MB across the entire test – no memory leak, no creep. Both runtimes are production-stable under this workload.

When to Choose What

Native image earns its place when startup time is the constraint. Serverless, scale-to-zero, dense multi-instance deployments where 72 MB vs 180 MB per replica adds up across 50 pods.

JVM earns its place when the service runs for hours under sustained load. Better tail latency under memory pressure. Better throughput when the heap is tight. The JIT keeps optimizing paths the AOT compiler couldn’t profile.

The uncomfortable middle: a long-lived service with unlimited memory. Here it’s genuinely a coin flip. Native is slightly faster on some endpoints, JVM on others. Pick based on startup needs and operational preferences, not throughput claims.

The Number That Matters

At 128 MB, the JVM serves 13,731 req/s on the observatory-by-code endpoint. Native serves 8,453. Same app. Same code. Same container. Same database.

The JVM is 62% faster under memory pressure. G1GC handles a constrained heap better than Serial GC. The JIT optimizes code paths that native-image compiled ahead of time without profiling data.

“Native image is faster” is not a general truth. It’s a conditional one: faster to start, smaller at idle, faster under load when memory is abundant. Take the memory away, and the JVM’s runtime sophistication – the same overhead that makes it slower to start and heavier to idle – becomes its advantage.

The 131 MB binary that starts in one second and idles at 72 MB is a genuinely good piece of engineering. Just don’t assume it wins every benchmark. Especially not the one where memory is tight and the load doesn’t stop.

Sources