JVM Performance
Learn to diagnose memory issues, analyze garbage collection, and optimize Java applications
What You Will Learn
Understanding how Java manages memory is crucial for writing efficient applications. In this practical work, you'll learn:
- JVM Memory - How Java organizes and uses memory
- Garbage Collection - How Java automatically frees unused memory
- Memory Leaks - What they are and how to find them
- Performance Optimization - Common patterns to avoid
- JVM Tuning - Configuring memory and GC for better performance
Understanding JVM Memory
Think of the JVM like an office building:
- Heap - The main workspace where objects live (like desks for employees)
- Stack - Quick notes for each method call (like sticky notes)
- Metaspace - Storage for class definitions (like the building blueprints)
┌─────────────────────────────────────────────────────────┐
│ JVM Memory │
├───────────────────────────────────────────────────┬─────┤
│ HEAP │META │
│ ┌─────────────────┐ ┌─────────────────────────┐ │SPACE│
│ │ Young Generation│ │ Old Generation │ │ │
│ │ ┌─────┬───┬───┐│ │ │ │Class│
│ │ │Eden │S0 │S1 ││ │ Long-lived objects │ │ Data│
│ │ │ │ │ ││ │ │ │ │
│ │ └─────┴───┴───┘│ └─────────────────────────┘ │ │
│ └─────────────────┘ │ │
├───────────────────────────────────────────────────┴─────┤
│ STACK (per thread) - Local variables, method calls │
└─────────────────────────────────────────────────────────┘
Eden = Where new objects are created
S0/S1 = Survivor spaces (objects that survived GC)
Old = Objects that have been around for a while
Prerequisites
- Java 17+ installed
- Basic Java knowledge
- VisualVM installed (download from visualvm.github.io)
Part 1: Project Setup
Step 1.1: Create Project
Create folder jvm-performance-lab:
jvm-performance-lab/
├── pom.xml
└── src/
└── main/
└── java/
└── com/
└── example/
└── performance/
Step 1.2: Create pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>jvm-performance-lab</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
</plugin>
</plugins>
</build>
</project>
Part 2: Create a Memory Leak
A memory leak happens when your program keeps references to objects it no longer needs. The garbage collector can't free them because they're still "reachable".
Imagine a hotel that never cleans out rooms after guests leave - eventually, you run out of rooms!
Step 2.1: Create a Leaking Application
Create src/main/java/com/example/performance/MemoryLeakDemo.java:
package com.example.performance;
import java.util.*;
public class MemoryLeakDemo {
// (#1:This list will grow forever - MEMORY LEAK!)
private static final List<byte[]> leakyList = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
System.out.println("Starting memory leak demo...");
System.out.println("Watch memory grow in VisualVM!");
System.out.println("Press Ctrl+C to stop.\n");
int count = 0;
while (true) {
// (#2:Allocate 1MB and add to list)
byte[] data = new byte[1024 * 1024]; // 1 MB
leakyList.add(data); // (#3:Never removed = leak!)
count++;
System.out.printf("Allocated: %d MB, List size: %d%n",
count, leakyList.size());
// (#4:Report memory usage)
Runtime runtime = Runtime.getRuntime();
long used = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024);
long max = runtime.maxMemory() / (1024 * 1024);
System.out.printf("Memory: %d MB / %d MB%n%n", used, max);
Thread.sleep(500); // Wait 500ms
}
}
}
Step 2.2: Run and Observe the Leak
Run with limited memory to see the crash faster:
cd jvm-performance-lab
mvn compile
# Run with only 100MB max heap
mvn exec:java -Dexec.mainClass="com.example.performance.MemoryLeakDemo" \
-Dexec.args="" \
"-Dexec.vmargs=-Xmx100m"
After about 100 iterations, you'll see:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
This is the JVM saying "I can't allocate any more memory!" - a critical error that usually crashes your application.
Part 3: Enable GC Logging
The Garbage Collector (GC) is like a cleaner that automatically removes objects you're no longer using. You don't call it directly - it runs automatically when the JVM decides it's needed.
Step 3.1: Run with GC Logging
# Java 17+ GC logging
mvn exec:java -Dexec.mainClass="com.example.performance.MemoryLeakDemo" \
"-Dexec.vmargs=-Xmx100m -Xlog:gc*:file=gc.log:time,uptime,level,tags"
This creates a gc.log file with detailed GC information.
Step 3.2: Understand GC Log Output
Open gc.log and look for lines like:
[0.123s][info][gc] GC(0) Pause Young (Normal) 24M->8M(100M) 5.123ms
^^^^^ ^^^ ^^^ ^^^^^
before after max time
Explanation:
- 24M->8M: Memory went from 24MB to 8MB (freed 16MB)
- (100M): Total heap is 100MB
- 5.123ms: GC took about 5 milliseconds
As the leak progresses, you'll see:
[10.5s][info][gc] GC(42) Pause Full (Allocation Failure) 99M->99M(100M) 234.567ms
^^^^^^^^^^^^^^^^^^^^^
Full GC couldn't free anything!
Step 3.3: Understanding GC Types
| GC Type | What it does | Impact |
|---|---|---|
| Minor GC | Cleans Young Generation only | Fast (milliseconds) |
| Major GC | Cleans Old Generation | Slower |
| Full GC | Cleans entire heap | Slowest, stops application |
Part 4: Fix the Memory Leak
Step 4.1: Create Fixed Version
Create src/main/java/com/example/performance/MemoryLeakFixed.java:
package com.example.performance;
import java.util.*;
public class MemoryLeakFixed {
// (#1:Use a bounded cache with max size)
private static final int MAX_CACHE_SIZE = 10;
private static final LinkedList<byte[]> cache = new LinkedList<>();
public static void main(String[] args) throws InterruptedException {
System.out.println("Starting FIXED memory demo...");
System.out.println("Memory stays stable!\n");
int count = 0;
while (true) {
byte[] data = new byte[1024 * 1024]; // 1 MB
// (#2:Add to cache)
cache.addLast(data);
// (#3:Remove oldest if cache is full - THIS IS THE FIX!)
if (cache.size() > MAX_CACHE_SIZE) {
cache.removeFirst(); // Allow old data to be garbage collected
}
count++;
System.out.printf("Processed: %d, Cache size: %d (max: %d)%n",
count, cache.size(), MAX_CACHE_SIZE);
Runtime runtime = Runtime.getRuntime();
long used = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024);
System.out.printf("Memory: ~%d MB (stable!)%n%n", used);
Thread.sleep(500);
}
}
}
Step 4.2: Run and Compare
mvn exec:java -Dexec.mainClass="com.example.performance.MemoryLeakFixed" \
"-Dexec.vmargs=-Xmx100m -Xlog:gc*"
Memory stays stable around ~15MB instead of growing forever!
Always ensure collections don't grow unbounded. Use:
- Bounded collections (max size)
- Weak references for caches
- Clear references when done
Part 5: String Performance
Step 5.1: String Concatenation Problem
Create src/main/java/com/example/performance/StringPerformanceDemo.java:
package com.example.performance;
public class StringPerformanceDemo {
public static void main(String[] args) {
int iterations = 100_000;
// (#1:BAD - String concatenation in loop)
System.out.println("=== BAD: String + operator ===");
long start = System.currentTimeMillis();
String result1 = "";
for (int i = 0; i < iterations; i++) {
result1 = result1 + "a"; // (#2:Creates new String each time!)
}
long bad = System.currentTimeMillis() - start;
System.out.println("Time: " + bad + "ms");
System.out.println("Length: " + result1.length() + "\n");
// (#3:GOOD - StringBuilder)
System.out.println("=== GOOD: StringBuilder ===");
start = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < iterations; i++) {
sb.append("a"); // (#4:Modifies existing buffer)
}
String result2 = sb.toString();
long good = System.currentTimeMillis() - start;
System.out.println("Time: " + good + "ms");
System.out.println("Length: " + result2.length() + "\n");
System.out.println("StringBuilder is " + (bad / Math.max(good, 1)) + "x faster!");
}
}
Step 5.2: Run and Compare
mvn exec:java -Dexec.mainClass="com.example.performance.StringPerformanceDemo"
Expected output:
=== BAD: String + operator ===
Time: 4523ms
Length: 100000
=== GOOD: StringBuilder ===
Time: 3ms
Length: 100000
StringBuilder is 1507x faster!
Strings in Java are immutable (cannot change). Each + creates a NEW String object, copying all previous characters. With 100,000 iterations, that's billions of character copies!
StringBuilder maintains a mutable buffer that grows as needed.
Part 6: Collection Performance
Step 6.1: ArrayList vs LinkedList
Create src/main/java/com/example/performance/CollectionPerformanceDemo.java:
package com.example.performance;
import java.util.*;
public class CollectionPerformanceDemo {
public static void main(String[] args) {
int size = 100_000;
// Prepare data
ArrayList<Integer> arrayList = new ArrayList<>();
LinkedList<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < size; i++) {
arrayList.add(i);
linkedList.add(i);
}
System.out.println("=== Random Access (get by index) ===");
// (#1:ArrayList random access - FAST)
long start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
int index = (int) (Math.random() * size);
arrayList.get(index); // O(1) - constant time
}
System.out.println("ArrayList: " + (System.currentTimeMillis() - start) + "ms");
// (#2:LinkedList random access - SLOW)
start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
int index = (int) (Math.random() * size);
linkedList.get(index); // O(n) - must traverse list!
}
System.out.println("LinkedList: " + (System.currentTimeMillis() - start) + "ms\n");
System.out.println("=== Add at Beginning ===");
// (#3:ArrayList add at start - SLOW)
ArrayList<Integer> al = new ArrayList<>();
start = System.currentTimeMillis();
for (int i = 0; i < 50000; i++) {
al.add(0, i); // O(n) - must shift all elements
}
System.out.println("ArrayList: " + (System.currentTimeMillis() - start) + "ms");
// (#4:LinkedList add at start - FAST)
LinkedList<Integer> ll = new LinkedList<>();
start = System.currentTimeMillis();
for (int i = 0; i < 50000; i++) {
ll.addFirst(i); // O(1) - just update pointers
}
System.out.println("LinkedList: " + (System.currentTimeMillis() - start) + "ms");
}
}
Step 6.2: Run and Analyze
mvn exec:java -Dexec.mainClass="com.example.performance.CollectionPerformanceDemo"
Expected output:
=== Random Access (get by index) ===
ArrayList: 2ms
LinkedList: 3456ms
=== Add at Beginning ===
ArrayList: 1234ms
LinkedList: 5ms
| Operation | ArrayList | LinkedList |
|---|---|---|
| get(index) | O(1) Fast | O(n) Slow |
| add(end) | O(1) Fast | O(1) Fast |
| add(0, elem) | O(n) Slow | O(1) Fast |
| contains() | O(n) | O(n) |
- ArrayList: Most cases. Random access, iteration.
- LinkedList: Many insertions/deletions at beginning or middle.
- HashSet: Need fast contains() checks.
Part 7: JVM Tuning Basics
Step 7.1: Common JVM Options
| Option | Purpose | Example |
|---|---|---|
-Xms | Initial heap size | -Xms512m |
-Xmx | Maximum heap size | -Xmx2g |
-XX:+UseG1GC | Use G1 garbage collector | |
-XX:MaxGCPauseMillis | Target max GC pause | -XX:MaxGCPauseMillis=200 |
Step 7.2: Create a Benchmark Application
Create src/main/java/com/example/performance/GCBenchmark.java:
package com.example.performance;
import java.util.*;
public class GCBenchmark {
public static void main(String[] args) throws InterruptedException {
System.out.println("JVM: " + System.getProperty("java.version"));
System.out.println("Max Memory: " + Runtime.getRuntime().maxMemory() / (1024 * 1024) + " MB");
System.out.println("Starting GC benchmark...\n");
List<byte[]> data = new ArrayList<>();
Random random = new Random();
long totalPauseTime = 0;
int iterations = 100;
for (int i = 0; i < iterations; i++) {
// Allocate random amount (1-10 MB)
int size = (1 + random.nextInt(10)) * 1024 * 1024;
data.add(new byte[size]);
// Keep only last 50 items (simulate cache)
while (data.size() > 50) {
data.remove(0);
}
// Measure GC pause
long before = System.currentTimeMillis();
System.gc(); // Suggest GC (not guaranteed)
long pause = System.currentTimeMillis() - before;
totalPauseTime += pause;
if (i % 20 == 0) {
long used = (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / (1024 * 1024);
System.out.printf("Iteration %d: Used %d MB, GC pause %d ms%n", i, used, pause);
}
}
System.out.println("\nTotal GC pause time: " + totalPauseTime + " ms");
System.out.println("Average pause: " + (totalPauseTime / iterations) + " ms");
}
}
Step 7.3: Compare Different GC Configurations
Test 1: Default settings
mvn exec:java -Dexec.mainClass="com.example.performance.GCBenchmark" \
"-Dexec.vmargs=-Xmx512m"
Test 2: G1 GC with pause target
mvn exec:java -Dexec.mainClass="com.example.performance.GCBenchmark" \
"-Dexec.vmargs=-Xmx512m -XX:+UseG1GC -XX:MaxGCPauseMillis=50"
Test 3: ZGC (Java 15+) for low latency
mvn exec:java -Dexec.mainClass="com.example.performance.GCBenchmark" \
"-Dexec.vmargs=-Xmx512m -XX:+UseZGC"
Compare the "Average pause" times for each configuration!
Step 7.4: Understanding GC Choices
| GC | Best For | Trade-off |
|---|---|---|
| G1GC | General purpose, large heaps | Balanced throughput/latency |
| ZGC | Ultra-low latency (<10ms) | Uses more CPU |
| Parallel GC | Maximum throughput | Longer pauses OK |
| Serial GC | Small heaps, single CPU | Simple, low overhead |
Part 8: Using VisualVM
Step 8.1: Install VisualVM
Download from visualvm.github.io and install.
Step 8.2: Create a Long-Running Application
Create src/main/java/com/example/performance/MonitorableApp.java:
package com.example.performance;
import java.util.*;
public class MonitorableApp {
private static final List<Object> cache = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
System.out.println("Application started. PID: " + ProcessHandle.current().pid());
System.out.println("Open VisualVM and connect to this process.");
System.out.println("Press Ctrl+C to stop.\n");
while (true) {
// Simulate work
simulateWork();
Thread.sleep(100);
}
}
private static void simulateWork() {
// Create some objects
for (int i = 0; i < 1000; i++) {
String s = "Object-" + System.currentTimeMillis() + "-" + i;
if (Math.random() < 0.01) { // 1% chance to cache (leak)
cache.add(s);
}
}
// Occasionally clear cache
if (cache.size() > 10000) {
cache.clear();
System.out.println("Cache cleared");
}
}
}
Step 8.3: Monitor with VisualVM
- Run the application:
mvn exec:java -Dexec.mainClass="com.example.performance.MonitorableApp" - Open VisualVM
- Find your application in the left panel (look for "MonitorableApp")
- Double-click to connect
- Explore the tabs:
- Monitor: CPU, memory, threads over time
- Threads: See all running threads
- Sampler: Find CPU hotspots
- Heap Dump: Analyze memory contents
Step 8.4: Take a Heap Dump
- In VisualVM, right-click your application
- Select "Heap Dump"
- Explore "Classes" tab - shows memory by class type
- Look for classes with unexpectedly high instance counts
- Classes with millions of instances
- Retained size (memory held by object and what it references)
- GC roots (why objects can't be collected)
Deliverables Checklist
- MemoryLeakDemo crashes with OutOfMemoryError
- GC log file generated and analyzed
- MemoryLeakFixed runs with stable memory
- StringPerformanceDemo shows StringBuilder is 1000x+ faster
- CollectionPerformanceDemo shows when to use ArrayList vs LinkedList
- GCBenchmark tested with different GC algorithms
- VisualVM connected and heap dump taken
Performance Best Practices Summary
- Use
StringBuilderfor string concatenation in loops - Choose the right collection (ArrayList for most cases)
- Set initial capacity for collections when size is known
- Avoid creating unnecessary objects in loops
- Use primitives instead of wrappers when possible
- Close resources (streams, connections) properly
- Bound your caches and collections
- Profile before optimizing - measure, don't guess!
Bonus Challenges
Create an object pool that reuses expensive objects instead of creating new ones each time.
Implement a cache using WeakHashMap or SoftReference that allows GC to reclaim memory when needed.
Use VisualVM to profile one of your previous practical work applications. Find and fix any performance issues.