For low-latency JVM services, the biggest gains usually come from reducing allocation rate, avoiding long stop-the-world pauses, and sizing the heap appropriately—not from obscure JVM flags. The "best" GC also depends on your latency target and heap size.
Here's what has worked well in production across different latency requirements.
| Latency target | Recommended GC | Typical use case |
|---|
| <200 ms | G1GC | Most microservices |
| <50 ms | G1GC or ZGC | API gateways, trading support services |
| <10 ms | ZGC or Shenandoah | Very latency-sensitive systems |
| <1 ms | ZGC + application optimization | Specialized systems |
1. G1GC (default recommendation)
For Java 17+ or Java 21+, G1 is an excellent default.
Typical flags:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=50
-Xms4g
-Xmx4g
-XX:+AlwaysPreTouch
-XX:+ParallelRefProcEnabled
-XX:+UseStringDeduplication
Why these work:
- Fixed heap (
Xms == Xmx) avoids runtime expansion.
AlwaysPreTouch faults in memory during startup instead of during requests.
MaxGCPauseMillis gives G1 a latency goal.
- String deduplication can reduce memory for text-heavy services.
Avoid setting dozens of G1 tuning knobs unless you've measured a problem.
2. ZGC (my preferred choice for low latency)
For Java 21+, ZGC is mature enough for many production workloads.
-XX:+UseZGC
-Xms8g
-Xmx8g
-XX:+AlwaysPreTouch
Advantages:
- Pause times typically well below 1 ms.
- Heap size has much less effect on pause time.
- Very predictable latency.
Trade-offs:
- Slightly lower throughput than G1 in some workloads.
- Uses more CPU.
- Requires enough free memory.
3. Shenandoah
If you're on a distribution that supports it well (for example some builds from Red Hat), Shenandoah is another excellent low-pause collector.
Similar benefits:
- Concurrent compaction
- Very short pauses
- Stable latency
Heap sizing
One of the biggest tuning mistakes is undersizing the heap.
Instead of:
-Xms512m
-Xmx16g
Prefer:
-Xms8g
-Xmx8g
Benefits:
- No heap resizing
- More predictable GC behavior
- Better memory locality
Object allocation
GC tuning won't fix excessive allocation.
Common latency killers:
stream()
.collect(...)
.map(...)
.filter(...)
inside hot request paths.
Often replacing these with:
for (...) {
...
}
reduces allocations dramatically.
Other sources:
- temporary
String objects
- boxing/unboxing
- excessive JSON object creation
- regex compilation per request
- unnecessary collections
GC logging
Always enable GC logs in production.
Java 17+:
-Xlog:gc*:gc.log:time,uptime,level,tags
Then inspect:
- pause duration
- allocation rate
- promotion failures
- concurrent cycle duration
Escape analysis
Keep it enabled (it's on by default).
The JIT can eliminate many allocations:
Point p = new Point(x, y);
may never actually allocate if it doesn't escape the method.
NUMA and large servers
On large machines (32+ cores), consider:
-XX:+AlwaysPreTouch
NUMA-related tuning (-XX:+UseNUMA) is much less commonly recommended on modern JVMs and should be evaluated with benchmarking on your specific hardware and JVM version rather than enabled by default.
Container deployments
Use modern JDKs that are container-aware.
Typical configuration:
-Xms2g
-Xmx2g
-XX:+UseG1GC
and ensure your container memory limit comfortably exceeds the Java heap to leave room for:
- metaspace
- thread stacks
- direct buffers
- native libraries
- JIT code cache
A common operational issue is setting -Xmx equal to the container memory limit, which can lead to the process being terminated by the container runtime due to overall memory usage.
Benchmark before tuning
Use tools like:
- JFR (Java Flight Recorder) for allocation and pause analysis
- async-profiler for CPU and allocation profiling
- JMH for microbenchmarks
- Load-testing tools (for example, k6 or Gatling) to observe p95/p99 latency under realistic traffic
Optimize based on measured bottlenecks rather than expected ones.
My general starting points
For a typical REST microservice (Java 21):
-server
-Xms4g
-Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=50
-XX:+AlwaysPreTouch
-Xlog:gc*:gc.log:time,uptime,level,tags
For a service with very strict latency requirements (single-digit milliseconds or lower):
-server
-Xms8g
-Xmx8g
-XX:+UseZGC
-XX:+AlwaysPreTouch
-Xlog:gc*:gc.log:time,uptime,level,tags
The biggest improvements I've seen in practice typically come from reducing object allocation and tuning application behavior rather than adding more JVM flags. Modern collectors like G1 and ZGC are highly optimized out of the box, so it's often better to start with a minimal, well-understood configuration and iterate using profiling and production metrics (especially p95/p99 latency and GC logs).