Java Performance Tuning: Optimization Guide
Java performance tuning is a systematic process of measuring, identifying bottlenecks, and applying targeted optimizations. Modern JVMs (Java 17+, 21+) include sophisticated JIT compilers, garbage collectors, and diagnostic tools that make many traditional optimizations unnecessary. This guide focuses on the techniques that actually matter in production.
Understanding JVM Performance
The Java Virtual Machine is a self-optimizing runtime. Hot code paths are compiled from bytecode to native machine code by the JIT compiler (C1 and C2 tiers). The JVM also applies automatic optimizations like inlining, lock elision, escape analysis, and dead code elimination.
Key Performance Metrics
- Latency (response time) — p50, p95, p99, p999 percentiles
- Throughput (requests/second) — transactions per unit time
- GC pause time — stop-the-world pause duration and frequency
- Allocation rate (MB/sec) — how fast the application creates objects
- CPU utilization — system vs user time, context switches
- Memory footprint (heap size) — committed vs used heap
Before any optimization, establish baseline measurements. Without data, you are guessing.
Common Performance Anti-Patterns
- Premature optimization — optimizing code that is not a bottleneck
- Ignoring the GC log — GC behavior reveals allocation patterns
- Over-abstracting — unnecessary indirection confuses the JIT compiler
- Thread pool misconfiguration — too few threads underutilize CPU; too many cause context switching overhead
- Unbounded caches — cause GC pressure and eventual OutOfMemoryError
JVM Tuning Flags
Heap Configuration
# Basic heap sizing
-Xms4g -Xmx4g # initial and max heap (set equal to avoid resizing)
-Xss1m # thread stack size (reduce for many threads)
-XX:MaxMetaspaceSize=256m # class metadata limit
# Young generation tuning
-XX:NewRatio=2 # old:young ratio (default 2 = 1/3 young)
-XX:SurvivorRatio=8 # eden:survivor ratio (default 8)
-XX:+AlwaysPreTouch # commit all memory at startup (prevents OS page faults)Setting -Xms equal to -Xmx avoids heap resizing overhead. AlwaysPreTouch forces the OS to commit physical pages immediately rather than on first access, eliminating latency spikes from page faults during peak load.
Garbage Collector Selection
# G1GC (default, good for most applications — heap < 100GB)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100 # target pause time (soft goal)
-XX:G1HeapRegionSize=4m # region size (auto-tuned by default)
-XX:G1NewSizePercent=5 # min young gen size
-XX:G1MaxNewSizePercent=60 # max young gen size
-XX:+ParallelRefProcEnabled # parallel reference processing
# ZGC (ultra-low latency, heap < 16TB)
-XX:+UseZGC
-XX:ConcGCThreads=4 # concurrent GC threads
-XX:SoftMaxHeapSize=8g # GC triggers before heap is full
# Shenandoah (low pause, good for large heaps)
-XX:+UseShenandoahGC
-XX:ShenandoahGCPauseMillis=50 # target pause time
-XX:ShenandoahGCHeuristics=adaptive
# Parallel (maximum throughput, batch processing)
-XX:+UseParallelGC
-XX:ParallelGCThreads=4 # GC threadsGC Logging and Analysis
# Unified GC logging (Java 9+)
-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=10m
-Xlog:gc+heap=debug
-Xlog:gc+age=traceAnalyze GC logs with gceasy.io, GCViewer, or janal. Look for:
- Full GC events (should be rare or zero)
- Long pause times (> 100ms)
- Concurrent mode failures (G1)
- Frequent young GC indicating high allocation rate
Profiling in Production
Modern profilers use asynchronous sampling and require minimal overhead.
async-profiler
# CPU profiling
./profiler.sh -d 30 -f profile.html <pid>
# Allocation profiling
./profiler.sh -d 30 -e alloc -f alloc.html <pid>
# Wall-clock profiling (includes I/O waits)
./profiler.sh -d 30 -e wall -f wall.html <pid>async-profiler is the gold standard. It runs on production with ~1% overhead and produces flame graphs that immediately reveal hot methods, allocation-heavy code paths, and lock contention.
JDK Flight Recorder (JFR)
JFR is built into the JDK and collects events with minimal overhead:
# Start a recording
jcmd <pid> JFR.start name=profile duration=60s filename=profile.jfr
# Dump without stopping
jcmd <pid> JFR.dump name=profile filename=profile.jfr
# Analyze with JDK Mission Control
jmc profile.jfrJFR events include thread allocations, GC pauses, monitor contention, I/O operations, and code cache usage.
Memory Optimization
Reducing Object Allocations
Object allocation is cheap (fast-path TLAB allocation), but garbage collection is not. The JVM must trace and reclaim allocated objects.
// Before: creates temporary objects
String result = firstName + " " + lastName;
// After: explicit StringBuilder
StringBuilder sb = new StringBuilder(firstName.length() + lastName.length() + 1);
sb.append(firstName).append(' ').append(lastName);
String result = sb.toString();Modern javac compilers optimize simple concatenation to StringBuilder automatically. Manual optimization only helps in tight loops.
// Before: autoboxing
Integer sum = 0;
for (int i = 0; i < 1_000_000; i++) {
sum += i; // creates 1M Integer objects
---
// After: avoid autoboxing
int sum = 0;
for (int i = 0; i < 1_000_000; i++) {
sum += i;
---Object Pooling
Object pooling is rarely beneficial in modern Java. The JVM’s escape analysis may eliminate short-lived allocations entirely, and pooled objects add complexity and root-set scanning overhead. Only pool objects that are:
- Expensive to create (database connections, thread pools)
- Short-lived but allocated at very high rates
- Not susceptible to memory leaks from the pool itself
Array Sizing
Preallocate collections when the size is known:
// Good: preallocated
List<String> names = new ArrayList<>(1000);
// Bad: grows incrementally
List<String> names = new ArrayList<>();ArrayList doubles its capacity when full, creating discarded backing arrays. The allocation cost for 1000 insertions into a default ArrayList is roughly 10 backing array copies versus 1 with preallocation.
Avoid Useless Work
// Before: boxed streams for primitive operations
List<Integer> list = items.stream()
.map(Item::getCount)
.collect(Collectors.toList());
// After: primitive streams
int sum = items.stream()
.mapToInt(Item::getCount)
.sum();
// Before: filtering when empty check suffices
if (items.stream().anyMatch(item -> item.isActive())) { ... }
// After: early exit
boolean hasActive = false;
for (Item item : items) {
if (item.isActive()) { hasActive = true; break; }
---Concurrency Optimization
Thread Pool Tuning
// CPU-bound tasks
int poolSize = Runtime.getRuntime().availableProcessors();
// I/O-bound tasks (blocking)
int poolSize = Runtime.getRuntime().availableProcessors() * (1 + waitTime / computeTime);
// Configure with bounded queues
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize, maxPoolSize, keepAliveTime, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(capacity)
);The optimal pool size depends on the ratio of compute to wait time. I/O-heavy workloads benefit from more threads, while CPU-bound workloads saturate at the number of cores.
Lock Contention
// High contention: synchronized method
public synchronized void increment() { count++; }
// Lower contention: striped locks or atomic primitives
private final AtomicLong count = new AtomicLong();
public void increment() { count.incrementAndGet(); }For read-heavy workloads, use ReadWriteLock or StampedLock:
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
public int read() {
rwLock.readLock().lock();
try { return data; }
finally { rwLock.readLock().unlock(); }
---
public void write(int value) {
rwLock.writeLock().lock();
try { data = value; }
finally { rwLock.writeLock().unlock(); }
---Code Optimization
JIT Compiler Friendly Code
// Good for inlining: small, frequently called methods
public int getId() { return id; }
// Bad: large methods prevent inlining
public void complexMethod() { /* 100+ lines */ }
// Profile-guided: commonly-taken branches should be first
if (likelyFastPath) {
return handleFast();
---
return handleSlow();The JIT compiler inlines methods up to a threshold (default 325 bytes of bytecode). Keep hot methods small to maximize inlining. Write code that matches the “happy path” first pattern.
String Operations
// Use StringBuilder in loops
StringBuilder sb = new StringBuilder(estimatedLength);
for (String item : items) {
sb.append(item).append(',');
---
String result = sb.toString();
// Use StringBuilder methods for specific lengths
sb.setLength(0); // reuse
sb.append(prefix).append(value);Database and I/O
- Use connection pooling (HikariCP is the fastest)
- Batch database operations
- Use pagination instead of loading all results
- Enable prepared statement caching
- Use asynchronous I/O for non-blocking operations
- Configure socket timeouts explicitly
FAQ
Q: Should I use -Xmx equal to -Xms? A: Yes. Setting them equal prevents JVM heap resizing during operation, which reduces latency spikes.
Q: Which garbage collector should I use? A: G1GC for most applications (heap under 100GB, acceptable pause targets of 50-200ms). ZGC or Shenandoah for ultra-low-latency requirements. Parallel GC for batch processing where throughput matters more than latency.
Q: Is object pooling still recommended? A: Rarely. Modern JVMs handle short-lived objects efficiently through TLAB allocation and generational GC. Pooling adds complexity and can cause GC root issues.
Q: How do I find memory leaks? A: Use heap dump analysis (jmap + Eclipse MAT or JProfiler). Look for unexpected retention paths, especially around collections, caches, and thread-local storage.
Q: What is the ideal young generation size? A: Large enough to avoid frequent minor GCs but small enough that minor GC pauses remain acceptable. Monitor allocation rate and GC frequency. A common starting point is 25-40% of the total heap.
Q: How do I profile a production application? A: Use async-profiler for CPU/allocation profiling and JFR for comprehensive event recording. Both have minimal overhead (~1%) suitable for production use.
Q: What causes long GC pauses? A: Large object allocation, promotion of live objects to old generation, concurrent mode failure (G1), and full GC (compacting old generation). Monitor promotion rate and object size.
For a comprehensive overview, read our article on Java 17 21 Features.
For a comprehensive overview, read our article on Java Basics Tutorial.