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.jfr

Step 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 analyzer

Step 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 analysis

Step 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 objects

Step 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