Understanding memory management is crucial for building performant Java applications. In this codelab, you'll explore JVM memory architecture, garbage collection mechanisms, and learn to identify and prevent memory leaks using real-world profiling tools.
Memory profiling exercises including:
Let's dive deep into how JVM manages memory.
JVM Memory Structure
┌────────────────────────────────────────────────────────────┐
│ Runtime Data Areas │
├────────────────────────────────────────────────────────────┤
│ Method Area (Metaspace in Java 8+) │
│ - Class metadata │
│ - Static variables │
│ - Constant pool │
├────────────────────────────────────────────────────────────┤
│ Heap (Shared across all threads) │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Young Generation ││
│ │ ├─ Eden Space (new objects) ││
│ │ ├─ Survivor Space 0 (S0) ││
│ │ └─ Survivor Space 1 (S1) ││
│ ├─────────────────────────────────────────────────────────┤│
│ │ Old Generation (Tenured) ││
│ │ - Long-lived objects ││
│ └─────────────────────────────────────────────────────────┘│
├────────────────────────────────────────────────────────────┤
│ Stack (Per thread) │
│ - Method calls │
│ - Local variables │
│ - Partial results │
├────────────────────────────────────────────────────────────┤
│ PC Register (Per thread) │
│ - Current instruction pointer │
├────────────────────────────────────────────────────────────┤
│ Native Method Stack (Per thread) │
│ - Native method calls │
└────────────────────────────────────────────────────────────┘
Stack Memory:
Heap Memory:
public class MemoryDemo {
// Class variable (Method Area/Metaspace)
private static int classCounter = 0;
// Instance variable (Heap)
private String name;
private int value;
public MemoryDemo(String name, int value) {
this.name = name; // Heap
this.value = value; // Heap
classCounter++; // Method Area
}
public void demonstrateMemory() {
// Local primitive variable (Stack)
int localNumber = 42;
// Local object reference (Stack), object itself (Heap)
String localString = "Hello";
// Array reference (Stack), array object (Heap)
int[] localArray = new int[5];
System.out.println("Method execution:");
System.out.println(" localNumber (stack): " + localNumber);
System.out.println(" localString reference (stack), object (heap): " + localString);
System.out.println(" localArray reference (stack), array (heap): " + localArray.length);
}
public static void main(String[] args) {
// Object reference (Stack), object (Heap)
MemoryDemo demo = new MemoryDemo("Example", 100);
demo.demonstrateMemory();
// After method returns, localNumber, localString reference,
// and localArray reference are removed from stack
// But objects remain in heap until GC collects them
System.out.println("\nClass counter: " + classCounter);
}
}
public class ObjectLifecycle {
public static void main(String[] args) {
// 1. Object Creation - allocated in Eden space
Person person = new Person("Alice", 25);
// 2. Object in use - lives in heap
System.out.println(person.getName());
// 3. Object becomes unreachable - eligible for GC
person = null; // No more references to original object
// 4. Garbage Collection - JVM reclaims memory (eventually)
// We can suggest GC (but JVM decides when to actually run it)
System.gc();
// 5. finalize() called before reclamation (deprecated in Java 9+)
// Modern alternative: try-with-resources or Cleaner API
}
}
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
System.out.println("Person created: " + name);
}
public String getName() { return name; }
// Deprecated since Java 9 - shown for educational purposes
@Override
protected void finalize() throws Throwable {
System.out.println("Person finalized: " + name);
super.finalize();
}
}
public class MemoryUsageDemo {
public static void printMemoryStats() {
Runtime runtime = Runtime.getRuntime();
long maxMemory = runtime.maxMemory(); // -Xmx
long totalMemory = runtime.totalMemory(); // Current heap size
long freeMemory = runtime.freeMemory(); // Free heap
long usedMemory = totalMemory - freeMemory;
System.out.println("=== MEMORY STATISTICS ===");
System.out.println("Max Memory: " + formatBytes(maxMemory));
System.out.println("Total Memory: " + formatBytes(totalMemory));
System.out.println("Used Memory: " + formatBytes(usedMemory));
System.out.println("Free Memory: " + formatBytes(freeMemory));
System.out.println("Used %: " + (usedMemory * 100 / totalMemory) + "%");
}
private static String formatBytes(long bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024) + " KB";
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)) + " MB";
return (bytes / (1024 * 1024 * 1024)) + " GB";
}
public static void main(String[] args) {
printMemoryStats();
System.out.println("\nCreating 1 million objects...");
String[] strings = new String[1_000_000];
for (int i = 0; i < strings.length; i++) {
strings[i] = "String " + i;
}
printMemoryStats();
System.out.println("\nClearing references...");
strings = null;
System.gc(); // Suggest garbage collection
try {
Thread.sleep(100); // Give GC time to run
} catch (InterruptedException e) {
e.printStackTrace();
}
printMemoryStats();
}
}
Garbage Collection (GC) automatically reclaims memory occupied by unreachable objects.
GC runs when:
System.gc() is called (suggestion only)Key Observation: Most objects die young!
Young Generation (Minor GC):
Old Generation (Major GC):
1. New object → Eden Space
[Eden: ████░░░░░░] [S0: ] [S1: ]
2. Eden full → Minor GC → Survivors move to S0
[Eden: ░░░░░░░░░░] [S0: ██] [S1: ]
3. Next Eden full → Minor GC → Survivors move to S1
[Eden: ░░░░░░░░░░] [S0: ] [S1: ███]
4. After N cycles → Promote to Old Generation
[Eden: ░░░░░░░░░░] [S0: ] [S1: █] [Old: ██████]
public class GCDemo {
private static final int ITERATIONS = 10;
private static final int OBJECTS_PER_ITERATION = 100_000;
public static void main(String[] args) {
System.out.println("Starting GC demonstration...\n");
for (int i = 0; i < ITERATIONS; i++) {
System.out.println("--- Iteration " + (i + 1) + " ---");
allocateObjects();
printMemoryAndGC();
try {
Thread.sleep(500); // Pause between iterations
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private static void allocateObjects() {
// Create temporary objects
for (int i = 0; i < OBJECTS_PER_ITERATION; i++) {
String temp = new String("Temporary object " + i);
// Object becomes eligible for GC immediately after creation
}
}
private static void printMemoryAndGC() {
Runtime runtime = Runtime.getRuntime();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
System.out.printf("Used: %d MB, Free: %d MB, Total: %d MB%n",
usedMemory / (1024 * 1024),
freeMemory / (1024 * 1024),
totalMemory / (1024 * 1024)
);
}
}
Run with GC logging:
$ java -Xlog:gc* GCDemo
1. Serial GC (
-XX:+UseSerialGC
)
2. Parallel GC (
-XX:+UseParallelGC
)
3. G1 GC (
-XX:+UseG1GC
) - Default since Java 9
4. ZGC (
-XX:+UseZGC
)
5. Shenandoah GC (
-XX:+UseShenandoahGC
)
public class GCComparison {
private static final int HEAP_SIZE_MB = 512;
private static final int ITERATIONS = 100;
public static void main(String[] args) {
System.out.println("=== GC PERFORMANCE COMPARISON ===");
System.out.println("Heap Size: " + HEAP_SIZE_MB + " MB");
System.out.println("Iterations: " + ITERATIONS);
System.out.println("\nRun with different GC:");
System.out.println(" java -Xms512m -Xmx512m -XX:+UseSerialGC GCComparison");
System.out.println(" java -Xms512m -Xmx512m -XX:+UseParallelGC GCComparison");
System.out.println(" java -Xms512m -Xmx512m -XX:+UseG1GC GCComparison");
System.out.println();
long startTime = System.currentTimeMillis();
for (int i = 0; i < ITERATIONS; i++) {
createGarbage();
if (i % 10 == 0) {
System.out.println("Iteration " + i + " completed");
}
}
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
System.out.println("\n=== RESULTS ===");
System.out.println("Total time: " + duration + " ms");
System.out.println("Average per iteration: " + (duration / ITERATIONS) + " ms");
}
private static void createGarbage() {
// Create many short-lived objects
List<String> temp = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
temp.add(new String("Object " + i));
}
// temp goes out of scope - all objects eligible for GC
}
}
Memory leaks occur when objects are no longer needed but remain referenced, preventing GC from reclaiming them.
1. Forgotten Collections
public class LeakExample1 {
// BAD: Grows forever!
private static List<User> users = new ArrayList<>();
public void registerUser(String name) {
User user = new User(name);
users.add(user);
// Even if user logs out, reference remains!
}
}
Fix: Remove objects when no longer needed:
public void unregisterUser(User user) {
users.remove(user);
}
2. Unclosed Resources
public class LeakExample2 {
public void processFile(String filename) throws IOException {
FileInputStream fis = new FileInputStream(filename);
// Process file
// FORGOT to close! Memory and file descriptor leak
}
}
Fix: Use try-with-resources:
public void processFile(String filename) throws IOException {
try (FileInputStream fis = new FileInputStream(filename)) {
// Process file
} // Automatically closed
}
3. Static Collections
public class LeakExample3 {
// BAD: Static collection never garbage collected
private static Map<String, byte[]> cache = new HashMap<>();
public byte[] getData(String key) {
if (!cache.containsKey(key)) {
byte[] data = loadFromDatabase(key);
cache.put(key, data); // Grows forever!
}
return cache.get(key);
}
}
Fix: Use bounded cache with eviction:
private static Map<String, byte[]> cache = new LinkedHashMap<String, byte[]>(100, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > 100; // Max 100 entries
}
};
4. Anonymous Inner Classes
public class LeakExample4 {
private String largeData = "..." ; // Large string
public ActionListener createListener() {
// BAD: Anonymous class holds reference to outer class
return new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
System.out.println("Clicked");
// This holds reference to LeakExample4 instance
// and its largeData field!
}
};
}
}
Fix: Use static inner class or lambda:
public ActionListener createListener() {
// Lambda doesn't capture 'this' unless needed
return e -> System.out.println("Clicked");
}
5. ThreadLocal Misuse
public class LeakExample5 {
// BAD: ThreadLocal not cleaned up
private static ThreadLocal<HeavyObject> threadLocal = new ThreadLocal<>();
public void processRequest() {
threadLocal.set(new HeavyObject());
// Process request
// FORGOT to remove! Stays in thread pool threads
}
}
Fix: Always remove ThreadLocal values:
public void processRequest() {
try {
threadLocal.set(new HeavyObject());
// Process request
} finally {
threadLocal.remove(); // Always cleanup
}
}
import java.util.*;
public class MemoryLeakDetection {
// Intentional memory leak for demonstration
private static List<byte[]> leakyList = new ArrayList<>();
public static void main(String[] args) {
System.out.println("Starting memory leak demonstration...");
System.out.println("Monitor with jconsole or VisualVM");
System.out.println("Watch heap memory grow continuously\n");
Runtime runtime = Runtime.getRuntime();
int iteration = 0;
while (true) {
// Create 1MB of data each iteration
byte[] leak = new byte[1024 * 1024]; // 1 MB
leakyList.add(leak); // Never removed!
iteration++;
if (iteration % 100 == 0) {
long usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024);
long maxMemory = runtime.maxMemory() / (1024 * 1024);
System.out.printf("Iteration %d: Used %d MB / %d MB (%.1f%%)%n",
iteration, usedMemory, maxMemory,
(usedMemory * 100.0 / maxMemory));
}
try {
Thread.sleep(100); // 100ms delay
} catch (InterruptedException e) {
break;
}
// Eventually will throw OutOfMemoryError
}
}
}
Run and monitor:
$ java -Xmx256m MemoryLeakDetection
Let's learn to use professional monitoring tools to diagnose memory issues.
Starting JConsole:
$ jconsole
Or connect to a running process:
$ jps # List Java processes 12345 MyApplication $ jconsole 12345
What to Monitor:
Starting VisualVM:
$ jvisualvm
Key Features:
Step 1: Create Application to Profile
import java.util.*;
public class ProfileMe {
private static List<Customer> customers = new ArrayList<>();
private static Random random = new Random();
public static void main(String[] args) throws InterruptedException {
System.out.println("Application started. Profile with VisualVM!");
System.out.println("PID: " + ProcessHandle.current().pid());
while (true) {
simulateWork();
Thread.sleep(1000);
}
}
private static void simulateWork() {
// Simulate user registration
for (int i = 0; i < 100; i++) {
registerCustomer();
}
// Simulate some processing
processOrders();
// Simulate cleanup (prevents memory leak)
if (customers.size() > 10000) {
customers.subList(0, 5000).clear();
}
}
private static void registerCustomer() {
Customer customer = new Customer(
"Customer" + random.nextInt(1000000),
"customer" + random.nextInt(1000000) + "@email.com"
);
customers.add(customer);
}
private static void processOrders() {
// Simulate expensive computation
double sum = 0;
for (int i = 0; i < 100000; i++) {
sum += Math.sqrt(i);
}
}
}
class Customer {
private String name;
private String email;
private List<Order> orders;
public Customer(String name, String email) {
this.name = name;
this.email = email;
this.orders = new ArrayList<>();
}
}
class Order {
private String orderId;
private double amount;
public Order(String orderId, double amount) {
this.orderId = orderId;
this.amount = amount;
}
}
Step 2: Run and Profile
Taking Heap Dump:
$ jps # Find PID $ jmap -dump:format=b,file=heap.bin <PID>
Or use VisualVM: Right-click process → Heap Dump
Analyzing with VisualVM:
Taking Thread Dump:
$ jstack <PID> > threads.txt
Or use VisualVM: Right-click process → Thread Dump
Thread States:
public class ThreadDumpDemo {
public static void main(String[] args) throws InterruptedException {
// Runnable thread
Thread worker = new Thread(() -> {
while (true) {
try {
Thread.sleep(1000);
System.out.println("Working...");
} catch (InterruptedException e) {
break;
}
}
}, "Worker-Thread");
worker.start();
// Blocked thread (deadlock example)
final Object lock1 = new Object();
final Object lock2 = new Object();
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread1 locked lock1");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lock2) {
System.out.println("Thread1 locked lock2");
}
}
}, "Deadlock-Thread-1");
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread2 locked lock2");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lock1) {
System.out.println("Thread2 locked lock1");
}
}
}, "Deadlock-Thread-2");
thread1.start();
thread2.start();
System.out.println("Take thread dump to see deadlock!");
System.out.println("PID: " + ProcessHandle.current().pid());
// Keep main thread alive
Thread.sleep(Long.MAX_VALUE);
}
}
Let's explore strategies to write memory-efficient code.
Reuse expensive objects instead of creating new ones:
import java.util.concurrent.*;
public class ObjectPoolDemo {
// Thread pool - reuses threads
private static ExecutorService executor = Executors.newFixedThreadPool(10);
// Custom object pool
static class ExpensiveObject {
private byte[] data = new byte[1024 * 1024]; // 1 MB
public void reset() {
// Reset state for reuse
Arrays.fill(data, (byte) 0);
}
}
static class ObjectPool {
private BlockingQueue<ExpensiveObject> pool;
public ObjectPool(int size) {
pool = new ArrayBlockingQueue<>(size);
for (int i = 0; i < size; i++) {
pool.offer(new ExpensiveObject());
}
}
public ExpensiveObject borrow() throws InterruptedException {
return pool.take();
}
public void returnObject(ExpensiveObject obj) {
obj.reset();
pool.offer(obj);
}
}
public static void main(String[] args) throws InterruptedException {
ObjectPool pool = new ObjectPool(10);
// Reuse objects instead of creating new ones
for (int i = 0; i < 100; i++) {
ExpensiveObject obj = pool.borrow();
// Use object
pool.returnObject(obj);
}
System.out.println("Created only 10 objects, reused 100 times!");
}
}
Create objects only when needed:
public class LazyInitialization {
private HeavyResource resource; // Not initialized yet
public HeavyResource getResource() {
if (resource == null) {
resource = new HeavyResource(); // Create only when needed
}
return resource;
}
// Thread-safe lazy initialization
private volatile HeavyResource threadSafeResource;
public HeavyResource getThreadSafeResource() {
if (threadSafeResource == null) {
synchronized (this) {
if (threadSafeResource == null) { // Double-check
threadSafeResource = new HeavyResource();
}
}
}
return threadSafeResource;
}
// Best: Initialization-on-demand holder
private static class ResourceHolder {
private static final HeavyResource INSTANCE = new HeavyResource();
}
public static HeavyResource getInstance() {
return ResourceHolder.INSTANCE; // Lazy + thread-safe
}
}
class HeavyResource {
private byte[] data = new byte[10 * 1024 * 1024]; // 10 MB
public HeavyResource() {
System.out.println("HeavyResource created");
}
}
Allow objects to be garbage collected when memory is low:
import java.lang.ref.*;
import java.util.*;
public class WeakReferenceDemo {
// Strong reference - never GC'd while referenced
private Object strongRef = new Object();
// Weak reference - GC'd when memory needed
private WeakReference<Object> weakRef = new WeakReference<>(new Object());
// Soft reference - GC'd only when memory critically low
private SoftReference<Object> softRef = new SoftReference<>(new Object());
public static void main(String[] args) {
// Example: Image cache with soft references
Map<String, SoftReference<byte[]>> imageCache = new HashMap<>();
// Load images
for (int i = 0; i < 100; i++) {
byte[] image = new byte[1024 * 1024]; // 1 MB image
imageCache.put("image" + i, new SoftReference<>(image));
}
// Images stay in cache until memory pressure
// Then GC can reclaim them
System.out.println("Cache size: " + imageCache.size());
// Force GC
System.gc();
// Check what survived
int survived = 0;
for (SoftReference<byte[]> ref : imageCache.values()) {
if (ref.get() != null) {
survived++;
}
}
System.out.println("Images survived GC: " + survived);
}
}
public class StringOptimization {
public static void main(String[] args) {
// BAD: String concatenation in loop
String result = "";
long start = System.nanoTime();
for (int i = 0; i < 10000; i++) {
result += "x"; // Creates new String each time!
}
long end = System.nanoTime();
System.out.println("String concat: " + (end - start) / 1_000_000 + " ms");
// GOOD: StringBuilder
StringBuilder sb = new StringBuilder();
start = System.nanoTime();
for (int i = 0; i < 10000; i++) {
sb.append("x"); // Reuses buffer
}
String result2 = sb.toString();
end = System.nanoTime();
System.out.println("StringBuilder: " + (end - start) / 1_000_000 + " ms");
// String interning (use carefully)
String s1 = new String("Hello").intern();
String s2 = new String("Hello").intern();
System.out.println("Same instance: " + (s1 == s2)); // true
}
}
public class CollectionSizing {
public static void main(String[] args) {
// BAD: Default size, many resizes
List<Integer> list1 = new ArrayList<>(); // Initial capacity: 10
for (int i = 0; i < 10000; i++) {
list1.add(i); // Resizes multiple times!
}
// GOOD: Pre-sized
List<Integer> list2 = new ArrayList<>(10000); // No resizing needed
for (int i = 0; i < 10000; i++) {
list2.add(i); // Efficient
}
// HashMap sizing
// Default: 16 capacity, 0.75 load factor
// If you know size, specify: new HashMap<>(expectedSize / 0.75)
Map<String, String> map = new HashMap<>((int) (1000 / 0.75));
}
}
Follow these guidelines for optimal memory management.
1. Resource Management:
2. Object Creation:
3. Collections:
4. Static Members:
5. Monitoring:
# Heap size
-Xms2g # Initial heap size (2 GB)
-Xmx4g # Maximum heap size (4 GB)
# GC selection
-XX:+UseG1GC # G1 Garbage Collector (default Java 9+)
-XX:+UseZGC # Z Garbage Collector (low latency)
-XX:+UseSerialGC # Serial GC (single-threaded)
-XX:+UseParallelGC # Parallel GC (throughput)
# GC logging
-Xlog:gc*:file=gc.log # GC log to file
# Heap dump on OutOfMemoryError
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dumps
# Performance tuning
-XX:MaxGCPauseMillis=200 # Target GC pause time (G1 only)
-XX:G1HeapRegionSize=16m # G1 region size
# Metaspace (class metadata)
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
When investigating memory issues:
Congratulations! 🎉 You've mastered JVM memory management!
Congratulations on completing Section 1! Continue to:
Master memory management: