Native Image vs JVM at 128 MB: The Benchmark Nobody Warned Me About
Contents
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):
- Native, unlimited memory – baseline
- Native, 128 MB hard limit –
mem_limit: 128m, swap disabled - JVM, unlimited memory – same app,
java -jar - 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
| Scenario | Idle | Peak under load |
|---|---|---|
| Native unlimited | 72 MB | 113 MB |
| Native 128 MB | 51 MB | 49 MB |
| JVM unlimited | ~180 MB | ~250 MB |
| JVM 128 MB | 50 MB | 64 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
| Endpoint | Native unlimited | Native 128 MB | JVM unlimited | JVM 128 MB |
|---|---|---|---|---|
| Observatory by code | 14,340 | 8,453 | 13,468 | 13,731 |
| All NEOs | 13,667 | 8,245 | 13,558 | 13,757 |
| NEO approaches | 14,425 | 7,685 | 9,059 | 9,059 |
| List observatories | 11,601 | 5,281 | 9,036 | 9,680 |
| List sessions | 10,035 | 7,062 | 11,234 | 7,153 |
| NEO hazardous | 9,773 | 6,765 | 13,196 | 13,196 |
| Health | 7,408 | 4,280 | 6,933 | 8,562 |
| Session detail | 5,366 | 3,686 | 5,568 | 4,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:
| Endpoint | Native 128 MB (p99) | JVM 128 MB (p99) |
|---|---|---|
| Observatory by code | 9 ms | 4 ms |
| All NEOs | 7 ms | 3 ms |
| List observatories | 26 ms | 14 ms |
| Health | 53 ms | 22 ms |
| Session detail | 22 ms | 18 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:
| Endpoint | Native unlimited | JVM unlimited | Delta |
|---|---|---|---|
| Observatory by code | 14,340 | 13,468 | Native +6% |
| All NEOs | 13,667 | 13,558 | Tie |
| NEO approaches | 14,425 | 9,059 | Native +59% |
| List observatories | 11,601 | 9,036 | Native +28% |
| List sessions | 10,035 | 11,234 | JVM +12% |
| NEO hazardous | 9,773 | 13,196 | JVM +35% |
| Session detail | 5,366 | 5,568 | Tie |
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:
| Endpoint | Native unlimited | JVM unlimited |
|---|---|---|
| ISS + Weather (10 req) | 3.1 req/s | 7.6 req/s |
| Collect pipeline (3 req) | 3.2 req/s | 4.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
| Metric | Native | JVM |
|---|---|---|
| Startup to healthy | ~1s | ~3-5s |
| Binary/artifact size | 131 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
| Error | Count | Scenarios | Expected? |
|---|---|---|---|
duplicate key "observatory_site_code_key" | 49 | all 4 | Yes – load test sends same code 50x |
| ISS API timeout/refused | 1-9 | all 4 | Yes – external API |
| NASA Too Many Requests | varies | all 4 | Yes – 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
- GraalVM CE Native Image Memory Management – Serial GC documentation and configuration
- G1GC Tuning Guide (JDK 21) – G1GC internals and heap region management
- GraalVM Native Image Performance – Oracle GraalVM vs CE feature comparison, G1GC availability
- Apache Bench – load testing tool used for all scenarios
- netty/netty#15762 – SharedArena support issue affecting native image concurrency
- Part 1: From mn create-app to Native Binary – build walkthrough and gotchas