← Back to Advanced Java
Practical Work 7

JVM Performance

Learn to diagnose memory issues, analyze garbage collection, and optimize Java applications

Duration3-4 hours
DifficultyIntermediate
Session7 - JVM Performance

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

Memory Areas in Simple Terms

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

What is 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
OutOfMemoryError

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

What is Garbage Collection?

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 TypeWhat it doesImpact
Minor GCCleans Young Generation onlyFast (milliseconds)
Major GCCleans Old GenerationSlower
Full GCCleans entire heapSlowest, 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!

Key Takeaway

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!
Why is String + slow?

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
OperationArrayListLinkedList
get(index)O(1) FastO(n) Slow
add(end)O(1) FastO(1) Fast
add(0, elem)O(n) SlowO(1) Fast
contains()O(n)O(n)
When to use which?
  • 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

OptionPurposeExample
-XmsInitial heap size-Xms512m
-XmxMaximum heap size-Xmx2g
-XX:+UseG1GCUse G1 garbage collector
-XX:MaxGCPauseMillisTarget 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

GCBest ForTrade-off
G1GCGeneral purpose, large heapsBalanced throughput/latency
ZGCUltra-low latency (<10ms)Uses more CPU
Parallel GCMaximum throughputLonger pauses OK
Serial GCSmall heaps, single CPUSimple, 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

  1. Run the application: mvn exec:java -Dexec.mainClass="com.example.performance.MonitorableApp"
  2. Open VisualVM
  3. Find your application in the left panel (look for "MonitorableApp")
  4. Double-click to connect
  5. 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

  1. In VisualVM, right-click your application
  2. Select "Heap Dump"
  3. Explore "Classes" tab - shows memory by class type
  4. Look for classes with unexpectedly high instance counts
What to look for in heap dumps:
  • 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

Quick Reference:
  • Use StringBuilder for 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

Challenge 1: Object Pool

Create an object pool that reuses expensive objects instead of creating new ones each time.

Challenge 2: Memory-Efficient Cache

Implement a cache using WeakHashMap or SoftReference that allows GC to reclaim memory when needed.

Challenge 3: Profile a Real Application

Use VisualVM to profile one of your previous practical work applications. Find and fix any performance issues.