Skip to main content

JVM Performance Tuning

Reading Time: 12 Minutes
Difficulty: Intermediate


Topic Summaryโ€‹

Performance tuning is about making your Java application run faster, use less memory, and respond more quickly. This involves setting the right JVM flags, understanding how to detect and fix memory leaks, profiling your application with tools like VisualVM, and avoiding common coding mistakes that waste memory and CPU.


What You'll Learnโ€‹

  • Key JVM memory flags: -Xms, -Xmx, -Xss and what they control
  • How to detect memory leaks and common causes
  • How to profile a Java application with VisualVM
  • Performance anti-patterns: autoboxing, String concatenation in loops, unnecessary object creation

Prerequisitesโ€‹

  • JVM Memory Areas (Lesson 3)
  • Garbage Collection (Lesson 4)
  • GC Algorithms (Lesson 5)

Explanationโ€‹

JVM Flags for Memory Configurationโ€‹

JVM flags control how much memory the JVM uses and how it behaves. Here are the most important ones:

-Xms โ€” Initial Heap Sizeโ€‹

Sets the starting size of the Heap when the JVM launches.

java -Xms512m MyApp # Start with 512 MB heap

If not set, the JVM starts with a small default (usually 1/64 of RAM). It will grow as needed, but that resizing has a cost.

-Xmx โ€” Maximum Heap Sizeโ€‹

Sets the maximum size the Heap can grow to. If your app needs more, it throws OutOfMemoryError.

java -Xmx2g MyApp # Max 2 GB heap

Best Practice: In production, set -Xms equal to -Xmx to prevent heap resizing during runtime.

java -Xms2g -Xmx2g MyApp # Fixed 2 GB heap (no resizing overhead)

-Xss โ€” Thread Stack Sizeโ€‹

Sets the stack size per thread. Increasing this allows deeper recursion without StackOverflowError.

java -Xss512k MyApp # 512 KB stack per thread (conservative)
java -Xss2m MyApp # 2 MB stack per thread (allows deep recursion)

-XX:MetaspaceSize / -XX:MaxMetaspaceSizeโ€‹

Controls the Metaspace (class metadata memory).

java -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m MyApp

Useful when your app loads many classes (frameworks like Spring) or uses heavy reflection.

Other Useful Flagsโ€‹

# GC selection
-XX:+UseG1GC
-XX:+UseZGC
-XX:MaxGCPauseMillis=200

# GC logging (Java 9+)
-Xlog:gc*:gc.log:time,uptime

# Print all JVM flags being used
-XX:+PrintCommandLineFlags

# Heap dump on OutOfMemoryError (invaluable for debugging)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof

# JVM Flight Recorder
-XX:+FlightRecorder
-XX:StartFlightRecording=duration=60s,filename=recording.jfr

Complete Flag Reference Tableโ€‹

FlagPurposeExample
-Xms<size>Initial heap size-Xms512m
-Xmx<size>Max heap size-Xmx4g
-Xss<size>Thread stack size-Xss1m
-XX:MetaspaceSize=<size>Initial Metaspace-XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize=<size>Max Metaspace-XX:MaxMetaspaceSize=512m
-XX:+UseG1GCUse G1 GC
-XX:MaxGCPauseMillis=<ms>GC pause target-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryErrorDump heap on OOM
-XX:+PrintCommandLineFlagsPrint active flags
-Xlog:gc*GC logging (Java 9+)

Size suffixes: k=KB, m=MB, g=GB


Memory Leaks in Javaโ€‹

Java has automatic GC, but memory leaks still happen! In Java, a memory leak is when objects are still referenced (reachable from GC roots) but are no longer needed by the application.

Common Memory Leak Causesโ€‹

1. Static Collections that grow forever

public class LeakExample {
// This list is static โ€” lives forever, GC can never collect its items
private static final List<byte[]> cache = new ArrayList<>();

public void addData() {
cache.add(new byte[1024 * 1024]); // 1MB each call!
}
// Fix: Use a bounded cache (e.g., Guava Cache, Caffeine) or WeakReferences
}

2. Unclosed Resources

// BAD โ€” connection never closed = resource leak
Connection conn = dataSource.getConnection();
// ... use conn ...
// forgot conn.close()!

// GOOD โ€” try-with-resources auto-closes
try (Connection conn = dataSource.getConnection()) {
// use conn safely
} // conn.close() called automatically

3. Listeners/Observers Not Removed

// BAD โ€” EventBus/listeners hold references to objects
eventBus.register(myListener);
// if myListener is never unregistered, it's never GC'd

// GOOD โ€” always unregister when done
eventBus.register(myListener);
try {
// use
} finally {
eventBus.unregister(myListener);
}

4. Inner Class Holding Outer Class Reference

class Outer {
byte[] bigData = new byte[1024 * 1024]; // 1MB

class Inner {
// Inner class implicitly holds reference to Outer
// As long as Inner is alive, Outer (and its 1MB) can't be GC'd
}
// Fix: use static nested class if you don't need the outer reference
}

Detecting Memory Leaksโ€‹

Symptom: Your application's heap usage grows over time and never decreases, eventually causing OutOfMemoryError.

Step 1: Enable heap dump on OOM

java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof MyApp

Step 2: Analyze with Eclipse Memory Analyzer (MAT) or VisualVM to find which objects are consuming the most memory and who is holding references to them.

Step 3: Use GC logs to track heap growth patterns.


Profiling with VisualVMโ€‹

VisualVM is a free, powerful profiling tool bundled with the JDK.

How to use:

  1. Start your application with monitoring enabled (no special flags needed for local apps)
  2. Open VisualVM (jvisualvm command or download from https://visualvm.github.io/)
  3. Connect to your running Java process

What VisualVM shows:

  • Monitor tab: Real-time heap usage, CPU usage, thread count
  • Heap Dumps: Take a snapshot and analyze which objects are taking the most memory
  • CPU Profiler: See which methods are using the most CPU time
  • Thread dumps: See what each thread is doing (find deadlocks)
# Run VisualVM
jvisualvm

# Or enable JMX for remote profiling
java -Dcom.sun.management.jmxremote \
-Dcom.sun.management.jmxremote.port=9090 \
-Dcom.sun.management.jmxremote.authenticate=false \
MyApp

Common Performance Mistakes and Fixesโ€‹

Mistake 1: String Concatenation in Loopsโ€‹

// BAD โ€” creates a new String object on every iteration! O(nยฒ) memory
String result = "";
for (int i = 0; i < 10000; i++) {
result += "item-" + i; // creates a new String each time
}

// GOOD โ€” StringBuilder reuses internal buffer
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append("item-").append(i);
}
String result = sb.toString();

The + operator on Strings creates a new String object every time. In a loop, this creates thousands of temporary objects and puts huge pressure on the GC.


Mistake 2: Unnecessary Autoboxingโ€‹

// BAD โ€” autoboxes int โ†’ Integer on every iteration
Long sum = 0L; // Long (wrapper) instead of long (primitive)
for (int i = 0; i < 1_000_000; i++) {
sum += i; // each += creates a new Long object!
}

// GOOD โ€” use primitives for calculations
long sum = 0L;
for (int i = 0; i < 1_000_000; i++) {
sum += i; // no boxing, just primitive math
}

Autoboxing (int โ†” Integer, long โ†” Long, etc.) creates wrapper objects on the Heap. In tight loops, this generates millions of short-lived objects.


Mistake 3: String Interningโ€‹

The JVM has a String Pool โ€” a cache of string literals. String interning ensures that identical string values share one object in memory.

// String literals automatically go to the pool
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // true โ€” same object from pool

// new String() bypasses the pool
String s3 = new String("hello");
System.out.println(s1 == s3); // false โ€” different objects

// intern() puts it back in the pool
String s4 = new String("hello").intern();
System.out.println(s1 == s4); // true โ€” now using pool version

// Use case: intern when you have many duplicate strings (e.g., country codes)
String country = getUserCountry().intern(); // saves memory if many duplicates

Mistake 4: Creating Objects Inside Loops Unnecessarilyโ€‹

// BAD โ€” creates a new Random object on every call
public int getRandom() {
return new Random().nextInt(100); // wasteful!
}

// GOOD โ€” create once, reuse
private final Random random = new Random();
public int getRandom() {
return random.nextInt(100);
}

// BAD โ€” creates Pattern object on every call to matches()
if (email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) { ... }

// GOOD โ€” compile Pattern once
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$");

if (EMAIL_PATTERN.matcher(email).matches()) { ... }

Mistake 5: Not Sizing Collectionsโ€‹

// BAD โ€” ArrayList starts with capacity 10, resizes many times
List<String> list = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
list.add("item-" + i); // many resize operations!
}

// GOOD โ€” preallocate capacity if you know the size
List<String> list = new ArrayList<>(100_000);
for (int i = 0; i < 100_000; i++) {
list.add("item-" + i); // no resizing!
}

Real-World Analogyโ€‹

JVM performance tuning is like setting up a new office:

  • -Xms/-Xmx are like booking office space: book too little and you overflow; book too much and you waste rent. Setting them equal is like a fixed lease โ€” no surprise mid-year expansions.
  • Memory leaks are like employees who never throw away their mail โ€” their desk fills up until there's no room to work.
  • Autoboxing in loops is like wrapping every sheet of paper in a box, using the box once, and throwing it away. Efficient people just pass the papers directly.
  • VisualVM is like a building manager who can show you exactly which office is overflowing and who's been hoarding.

Code Exampleโ€‹

import java.util.*;

public class PerformanceDemo {

// Performance comparison: String + vs StringBuilder
public static void stringConcatComparison() {
int iterations = 50_000;

// BAD: String concatenation
long start = System.currentTimeMillis();
String badResult = "";
for (int i = 0; i < iterations; i++) {
badResult += i;
}
long badTime = System.currentTimeMillis() - start;

// GOOD: StringBuilder
start = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < iterations; i++) {
sb.append(i);
}
String goodResult = sb.toString();
long goodTime = System.currentTimeMillis() - start;

System.out.println("String +: " + badTime + "ms");
System.out.println("StringBuilder: " + goodTime + "ms");
System.out.println("StringBuilder is ~" + (badTime / Math.max(goodTime, 1)) + "x faster");
}

// BAD autoboxing example
public static long autoboxingBad() {
Long sum = 0L; // Long object, not long primitive!
for (int i = 0; i < 1_000_000; i++) {
sum += i; // creates a new Long object each time!
}
return sum;
}

// GOOD primitive example
public static long primitiveGood() {
long sum = 0L; // primitive long, no boxing
for (int i = 0; i < 1_000_000; i++) {
sum += i;
}
return sum;
}

public static void autoboxingComparison() {
long start = System.currentTimeMillis();
long r1 = autoboxingBad();
long badTime = System.currentTimeMillis() - start;

start = System.currentTimeMillis();
long r2 = primitiveGood();
long goodTime = System.currentTimeMillis() - start;

System.out.println("Autoboxing (Long): " + badTime + "ms, result: " + r1);
System.out.println("Primitive (long): " + goodTime + "ms, result: " + r2);
}

public static void main(String[] args) {
System.out.println("=== String Concatenation ===");
stringConcatComparison();

System.out.println("\n=== Autoboxing vs Primitive ===");
autoboxingComparison();
}
}

Output (approximate โ€” varies by machine)โ€‹

=== String Concatenation ===
String +: 4821ms
StringBuilder: 5ms
StringBuilder is ~964x faster

=== Autoboxing vs Primitive ===
Autoboxing (Long): 45ms, result: 499999500000
Primitive (long): 3ms, result: 499999500000

Common Mistakesโ€‹

  • โŒ Mistake: Setting -Xmx very high "just in case" โ†’ โœ… Fix: Size your heap based on actual profiling. An oversized heap means longer GC scan times and more wasted memory.
  • โŒ Mistake: Ignoring OutOfMemoryError types โ€” not all OOM errors mean heap is full โ†’ โœ… Fix: Read the full message: Java heap space = heap full; Metaspace = class metadata full; unable to create new native thread = too many threads.
  • โŒ Mistake: Using String += in any loop โ†’ โœ… Fix: Always use StringBuilder in loops.
  • โŒ Mistake: Using wrapper types (Integer, Long) for simple arithmetic variables โ†’ โœ… Fix: Use primitives (int, long) for arithmetic; wrappers only when needed (collections, generics, nullable).

Best Practicesโ€‹

  • Set -Xms = -Xmx in production to avoid heap resizing.
  • Always use StringBuilder for string building in loops.
  • Use primitives over wrapper types in computational code.
  • Pre-size collections when you know the expected size.
  • Always use try-with-resources for connections, streams, and other resources.
  • Use -XX:+HeapDumpOnOutOfMemoryError in production to capture memory snapshots.
  • Profile before optimizing โ€” don't guess; measure with VisualVM or JFR.

Interview Questionsโ€‹

Q: What is the difference between -Xms and -Xmx?
A: -Xms sets the initial heap size โ€” how much memory the JVM requests from the OS when it starts. -Xmx sets the maximum heap size โ€” the JVM will never exceed this. Setting -Xms equal to -Xmx prevents heap resizing overhead at runtime, which is recommended for production servers.

Q: What causes a Java memory leak and how do you detect it?
A: Java memory leaks happen when objects are still reachable from GC roots but are no longer needed. Common causes: objects stored in static collections that are never removed, unclosed resources, event listeners never unregistered, inner classes holding outer class references. Detection: enable -XX:+HeapDumpOnOutOfMemoryError, then analyze the heap dump with Eclipse MAT or VisualVM to find objects occupying disproportionate memory.

Q: Why is String += in a loop bad? What should you use?
A: Each += on a String creates a new String object because Strings are immutable in Java. In a loop with N iterations, this creates O(N) String objects and copies O(Nยฒ) characters total. Use StringBuilder.append() instead โ€” it maintains an internal mutable buffer and creates only one final String object, making it O(N) overall.

Q: What is autoboxing and why can it cause performance problems?
A: Autoboxing is the automatic conversion between Java primitives (int, long, etc.) and their wrapper types (Integer, Long, etc.). The problem occurs in tight loops: Long sum = 0L; sum += i; creates a new Long object on every iteration because Long is immutable. With a million iterations, you create a million temporary objects. Fix: use the primitive type long sum = 0L;.


Quick Revisionโ€‹

โœ” -Xms = initial heap; -Xmx = max heap; -Xss = per-thread stack size
โœ” Set -Xms = -Xmx in production to avoid heap resizing
โœ” Memory leaks in Java: objects still referenced but never needed again
โœ” Common leak causes: static collections, unclosed resources, unremoved listeners
โœ” Use StringBuilder in loops โ€” never String +=
โœ” Use primitives (int, long) not wrappers (Integer, Long) for arithmetic
โœ” Pre-size collections, reuse expensive objects, compile Patterns once


  • GC Algorithms (Lesson 5)
  • Garbage Collection Basics (Lesson 4)
  • String and StringBuilder

Next Lessonโ€‹

Phase 7, Lesson 1 โ€” Introduction to Design Patterns