Performance optimization in Java has two levels: code-level (algorithms, data structures, avoiding allocation) and JVM-level (heap sizing, GC tuning, JIT hints). The most important rule: measure first, optimize second. Profiling reveals the actual bottleneck — which is almost never what you guessed.
Step 1 — The Performance Optimization Process
The Right Process (Never Skip Steps)
- 1. Define the goal: 'p99 latency < 50ms', 'throughput > 10,000 req/sec'. Vague goals produce vague results.
- 2. Establish a baseline: benchmark BEFORE any changes. Use JMH (Java Microbenchmark Harness) for code, Gatling/k6 for API load tests.
- 3. Profile: find the actual bottleneck (hot method, GC pauses, DB queries, thread contention). Use async-profiler, VisualVM, or Java Flight Recorder.
- 4. Optimize the bottleneck: the thing that consumes the most time. Optimizing the 2nd bottleneck first is wasted effort.
- 5. Measure again: verify improvement. Check for regressions. Repeat.
Step 2 — JVM Heap Tuning
JVM Flags for Productionbash
# Recommended production JVM configuration
java \
# Heap sizing
-Xms4g -Xmx4g # set initial = max to avoid resizing pauses
# Rule: max heap = 70-80% of available RAM
# Garbage Collector
-XX:+UseG1GC # G1 is default since Java 9, good for most apps
-XX:MaxGCPauseMillis=200 # G1 targets < 200ms pause (not guaranteed)
-XX:G1HeapRegionSize=16m # larger regions reduce overhead for big heaps
-XX:InitiatingHeapOccupancyPercent=45 # start concurrent GC at 45% heap full
# OR use ZGC for latency-critical apps (Java 15+)
# -XX:+UseZGC
# -XX:SoftMaxHeapSize=3g # ZGC prefers not to grow above this
# GC Logging (for diagnosis)
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=20m
# OOM debugging
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heap-$(date +%Y%m%d-%H%M%S).hprof
-XX:+ExitOnOutOfMemoryError # restart process instead of limping along
# Metaspace
-XX:MetaspaceSize=256m # initial metaspace
-XX:MaxMetaspaceSize=512m # cap metaspace (default is unlimited!)
# JIT compiler
-server # use server JIT (default on 64-bit JVM)
-XX:+TieredCompilation # use both C1 and C2 compilers (default Java 8+)
-XX:ReservedCodeCacheSize=256m # code cache for JIT-compiled methods
# Thread stack
-Xss512k # default 512k-1m; reduce if creating thousands of threads
# Performance monitoring
-XX:+FlightRecorder # enable Java Flight Recorder (JFR)
-XX:StartFlightRecording=duration=60s,filename=/tmp/myapp.jfrStep 3 — Understanding GC Pauses
Young Generation GC (Minor GC)
- When Eden space fills up, a Minor GC runs.
- Minor GC is FAST — usually < 5ms. Only young gen is collected.
- Short-lived objects (most objects) die in Eden — never promoted.
- Objects that survive enough Minor GCs are promoted to Old Gen.
- Optimization: fewer long-lived objects = less pressure on Old Gen.
Old Generation GC (Major/Full GC)
- Major GC: old gen is cleaned. Slower than Minor GC.
- Full GC: ENTIRE heap collected. Causes long stop-the-world pauses (100ms-seconds).
- Causes of Full GC: heap too small, memory leaks, large objects bypassing young gen.
- G1/ZGC run most GC concurrently (while app threads run), dramatically reducing pauses.
- If you see frequent Full GCs: increase heap, find memory leaks, reduce object allocation.
Analyzing GC Logsbash
# Reading GC log output (with -Xlog:gc*)
# [2026-06-22T10:30:01.234+0000][info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
# [2026-06-22T10:30:01.234+0000][info][gc] GC(42) Eden regions: 512->0
# [2026-06-22T10:30:01.234+0000][info][gc] GC(42) Survivor regions: 8->12
# [2026-06-22T10:30:01.234+0000][info][gc] GC(42) Old regions: 100->102
# [2026-06-22T10:30:01.234+0000][info][gc] GC(42) Pause 12.345ms
#
# Watch for:
# - Pause time > MaxGCPauseMillis: GC can't meet target
# - Old regions growing consistently: memory leak or not enough heap
# - Humongous allocations: objects > 50% of G1HeapRegionSize skip young gen
# Tool: GCeasy.io — paste GC log, get visual analysis and recommendations
# Tool: GCViewer — open source GC log analyzerStep 4 — Profiling with async-profiler
async-profiler Usagebash
# async-profiler: low-overhead sampling profiler
# Download from: https://github.com/async-profiler/async-profiler
# Profile CPU for 30 seconds, output flamegraph
./asprof -d 30 -f flamegraph.html <PID>
# Profile allocation (which code allocates most memory)
./asprof -e alloc -d 30 -f alloc.html <PID>
# Profile wall-clock time (includes I/O wait time)
./asprof -e wall -d 30 -f wall.html <PID>
# Attach to running JVM process
./asprof -e cpu -o flamegraph -f output.html $(pgrep -f MyApp)
# --- Reading Flamegraphs ---
# X-axis: alphabetical (NOT time). Width = time spent in that call.
# Y-axis: call stack depth (top = currently executing).
# Widest bars at the top = hot code paths to optimize.
# Look for: unexpected I/O, lock contention, excessive GC methods.
# --- Java Flight Recorder (built into JVM, low overhead) ---
jcmd <PID> JFR.start duration=60s filename=/tmp/recording.jfr
jcmd <PID> JFR.stop # stop early if needed
# Open .jfr file in JDK Mission Control (JMC) for analysisStep 5 — Code-Level Optimizations
CodeOptimizations.javajava
import java.util.*;
import java.util.stream.*;
public class CodeOptimizations {
// =====================================================
// 1. Avoid unnecessary object creation
// =====================================================
// BAD: creates new SimpleDateFormat on every call (expensive!)
static String badDateFormat(java.util.Date date) {
return new java.text.SimpleDateFormat("yyyy-MM-dd").format(date);
}
// GOOD: reuse or use DateTimeFormatter (thread-safe, reusable)
private static final java.time.format.DateTimeFormatter FORMATTER =
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd");
static String goodDateFormat(java.time.LocalDate date) {
return date.format(FORMATTER); // DateTimeFormatter is immutable, thread-safe
}
// =====================================================
// 2. String concatenation in loops
// =====================================================
static String badConcat(List<String> items) {
String result = "";
for (String item : items) {
result += item + ", "; // O(n²): creates new String each iteration!
}
return result;
}
static String goodConcat(List<String> items) {
return String.join(", ", items); // O(n): single pass
// OR: items.stream().collect(Collectors.joining(", "))
}
static String goodConcatBuilder(List<String> items) {
StringBuilder sb = new StringBuilder(items.size() * 10); // pre-size hint!
for (String item : items) sb.append(item).append(", ");
if (sb.length() > 2) sb.setLength(sb.length() - 2);
return sb.toString();
}
// =====================================================
// 3. Choose the right collection
// =====================================================
// Frequent contains() check -> use HashSet (O(1)) not ArrayList (O(n))
static boolean badContains(List<String> items, String target) {
return items.contains(target); // O(n) linear scan
}
static boolean goodContains(Set<String> items, String target) {
return items.contains(target); // O(1) hash lookup
}
// Convert List to Set once, then do all lookups
static void bulkLookup(List<String> items, List<String> toFind) {
Set<String> itemSet = new HashSet<>(items); // O(n) once
for (String s : toFind) {
if (itemSet.contains(s)) { // O(1) each time
System.out.println("Found: " + s);
}
}
}
// =====================================================
// 4. Pre-size collections when size is known
// =====================================================
static List<String> badList() {
return new ArrayList<>(); // starts with capacity 10, resizes (copies) as it grows
}
static List<String> goodList(int knownSize) {
return new ArrayList<>(knownSize); // avoids resizing
}
static Map<String, Integer> goodMap(int knownSize) {
// HashMap default load factor 0.75. To hold N entries without resize: capacity = N / 0.75
return new HashMap<>((int)(knownSize / 0.75) + 1);
}
// =====================================================
// 5. Use primitives, not boxed types, in hot paths
// =====================================================
static long badSum(List<Integer> nums) {
long sum = 0;
for (Integer n : nums) sum += n; // unboxing Integer->int each iteration!
return sum;
}
static long goodSum(int[] nums) {
long sum = 0;
for (int n : nums) sum += n; // no boxing/unboxing
return sum;
}
// IntStream: avoids boxing
static long streamSum(int[] nums) {
return IntStream.of(nums).asLongStream().sum();
}
// =====================================================
// 6. Lazy initialization
// =====================================================
private List<String> expensiveData;
// Compute only when first accessed
public List<String> getExpensiveData() {
if (expensiveData == null) {
expensiveData = loadFromDatabase(); // only called once
}
return expensiveData;
}
// =====================================================
// 7. Avoid premature abstraction and reflection in hot paths
// =====================================================
// Reflection (Class.forName, method.invoke) is 10-100x slower than direct calls.
// If you must use reflection, cache the Method/Field objects.
private static final java.lang.reflect.Method cachedMethod;
static {
try {
cachedMethod = String.class.getMethod("toUpperCase");
} catch (NoSuchMethodException e) { throw new RuntimeException(e); }
}
// =====================================================
// 8. Stream vs for-loop
// =====================================================
// Streams add overhead (lambda dispatch, boxing for primitives).
// For small collections (< 1000 elements): difference is negligible.
// For hot paths with large data: primitive IntStream/LongStream > boxed Stream.
// For parallel processing of large data: parallelStream() can help.
static long parallelSum(List<Long> data) {
return data.parallelStream().mapToLong(Long::longValue).sum();
// Uses ForkJoinPool.commonPool() — be careful in server environments!
}
private List<String> loadFromDatabase() { return new ArrayList<>(); }
}Step 6 — Detecting and Fixing Memory Leaks
A memory leak in Java occurs when objects are no longer needed but cannot be garbage collected because something still holds a reference to them. GC cannot collect reachable objects.
MemoryLeaks.java — Common patterns and fixesjava
import java.util.*;
public class MemoryLeaks {
// =====================================================
// LEAK 1: Unbounded static collections
// =====================================================
private static final Map<String, byte[]> CACHE = new HashMap<>();
static void leak1(String key) {
CACHE.put(key, new byte[1024 * 1024]); // 1MB per entry, never removed!
// Fix: use bounded cache (e.g., Caffeine, Guava with eviction)
}
// Fix: use WeakHashMap (entries auto-removed when key is GC'd)
private static final Map<String, byte[]> WEAK_CACHE = new WeakHashMap<>();
// Or better: use Caffeine with size or time-based eviction
// Cache<String, byte[]> cache = Caffeine.newBuilder()
// .maximumSize(1000)
// .expireAfterWrite(10, TimeUnit.MINUTES)
// .build();
// =====================================================
// LEAK 2: Event listeners not removed
// =====================================================
static class EventSource {
private final List<Runnable> listeners = new ArrayList<>();
public void addListener(Runnable r) { listeners.add(r); }
// BUG: no removeListener! All registered listeners live as long as EventSource
}
// Fix: always provide and call removeListener
static class FixedEventSource {
private final List<Runnable> listeners = new ArrayList<>();
public void addListener(Runnable r) { listeners.add(r); }
public void removeListener(Runnable r) { listeners.remove(r); } // crucial!
}
// =====================================================
// LEAK 3: Thread-local variables not removed
// =====================================================
private static final ThreadLocal<byte[]> THREAD_LOCAL = new ThreadLocal<>();
static void processRequest() {
THREAD_LOCAL.set(new byte[1024 * 1024]); // 1MB per thread
try {
// ... process ...
} finally {
THREAD_LOCAL.remove(); // MUST remove in finally! Thread pools reuse threads.
}
}
// =====================================================
// LEAK 4: Inner class holding outer class reference
// =====================================================
class Outer {
byte[] data = new byte[10_000_000]; // 10MB
class Inner { // non-static inner class holds reference to Outer!
void doWork() { System.out.println(data.length); }
}
}
// If Inner instance is long-lived, Outer (with its 10MB data) cannot be GC'd!
// Fix: make Inner a static nested class, pass only what it needs
}
// =====================================================
// Detecting leaks: heap dump analysis
// =====================================================
// 1. Trigger heap dump: jcmd <PID> GC.heap_dump /tmp/heap.hprof
// 2. Open in Eclipse Memory Analyzer (MAT) — free tool
// 3. Look for: objects with high retained heap, leak suspects report
// 4. Common culprits: char[] from Strings, byte[], HashMap.Entry objectsStep 7 — Microbenchmarking with JMH
StringConcatBenchmark.java — JMH Benchmarkjava
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.*;
import java.util.concurrent.TimeUnit;
// JMH: the ONLY correct way to benchmark Java code.
// Never use System.currentTimeMillis() for benchmarks — JIT warm-up invalidates results.
@BenchmarkMode(Mode.AverageTime) // measure: average time per operation
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread) // one state object per thread
@Warmup(iterations = 5, time = 1) // JVM warm-up: 5 x 1-second iterations (discarded)
@Measurement(iterations = 10, time = 1) // actual measurements: 10 x 1-second
@Fork(2) // run in 2 separate JVM processes (removes JVM bias)
public class StringConcatBenchmark {
@Param({"10", "100", "1000"}) // run each benchmark with these param values
private int size;
private List<String> items;
@Setup(Level.Trial) // run once before all benchmarks
public void setup() {
items = new java.util.ArrayList<>(size);
for (int i = 0; i < size; i++) items.add("item" + i);
}
@Benchmark
public String plusConcatenation() {
String result = "";
for (String item : items) result += item + ", ";
return result;
}
@Benchmark
public String stringJoin() {
return String.join(", ", items);
}
@Benchmark
public String stringBuilder() {
StringBuilder sb = new StringBuilder();
for (String item : items) sb.append(item).append(", ");
return sb.toString();
}
@Benchmark
public String streamCollect() {
return items.stream().collect(
java.util.stream.Collectors.joining(", "));
}
// Run: mvn clean package && java -jar target/benchmarks.jar
public static void main(String[] args) throws Exception {
Options opt = new OptionsBuilder()
.include(StringConcatBenchmark.class.getSimpleName())
.build();
new Runner(opt).run();
}
}
// Typical results (size=1000):
// plusConcatenation: ~1,200,000 ns (1.2ms)
// stringJoin: ~15,000 ns (0.015ms) — 80x faster
// stringBuilder: ~12,000 ns (0.012ms) — 100x faster
// streamCollect: ~18,000 ns (0.018ms) — 67x faster