← Back to DevBytes

Fix Java 'OutOfMemoryError: Java heap space': Complete Troubleshooting Guide

Understanding OutOfMemoryError: Java Heap Space

Few runtime exceptions strike fear into a Java developer's heart like java.lang.OutOfMemoryError: Java heap space. It signals that the JVM has exhausted all available memory in the heap — the region where objects are allocated — and cannot reclaim enough space even after a full garbage collection cycle. This error crashes applications, corrupts data pipelines, and leaves production systems in a degraded state until a restart intervenes.

The heap is the JVM's primary memory pool, holding every object your application instantiates. When the heap fills beyond its configured maximum and the garbage collector cannot free sufficient space, the JVM throws this fatal error. Unlike exceptions, OutOfMemoryError is an Error subclass — it is not meant to be caught and handled, because recovery is usually impossible at that point.

What Triggers the Error?

The direct cause is simple: the JVM needs to allocate memory for a new object but the heap is full. However, the underlying reasons fall into several categories:

Why Diagnosing Heap Exhaustion Matters

Simply increasing -Xmx without investigation is a band-aid. The real problem may be a leak that will eventually consume any heap size you provide, or a design flaw that processes far more data than necessary. Proper diagnosis saves infrastructure costs (excessive RAM provisioning), prevents cascading failures in microservice architectures, and reveals architectural improvements that make the application faster and more resilient.

Immediate Triage: Capturing Diagnostic Data

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

When the error strikes, your first priority is gathering forensic evidence. Three mechanisms give you visibility into what occupied the heap at the moment of failure:

1. Automatic Heap Dumps on OutOfMemoryError

Add these JVM flags to capture a heap dump automatically when the error occurs. The dump file is a snapshot of every object in the heap, their references, and their sizes:

# Essential flags for automatic heap dump capture
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/crash/dumps/
-XX:+ExitOnOutOfMemoryError

# Complete example for a typical server application
java -Xms2g -Xmx4g \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/opt/app/dumps/ \
     -XX:+ExitOnOutOfMemoryError \
     -XX:+PrintGCDetails \
     -XX:+PrintGCDateStamps \
     -Xloggc:/opt/app/logs/gc.log \
     -jar myapp.jar

The -XX:+ExitOnOutOfMemoryError flag causes the JVM process to terminate after writing the dump, allowing orchestration tools like Kubernetes or systemd to restart it cleanly. Without this flag, threads may continue running in a crippled state.

2. Generating Heap Dumps On Demand

For running processes that haven't yet crashed, you can capture a dump manually using jcmd or jmap:

# List Java processes to find the PID
jcmd -l
# or
ps aux | grep java

# Generate a heap dump using jcmd (preferred, less intrusive)
jcmd <PID> GC.heap_dump /path/to/dump.hprof

# Alternative using jmap
jmap -dump:live,format=b,file=/path/to/dump.hprof <PID>

The live option in jmap forces a full GC before the dump, which can help distinguish between truly reachable objects and garbage waiting to be collected. However, this pause can be lengthy on large heaps.

3. GC Log Analysis for Pattern Recognition

GC logs reveal the progression toward exhaustion. Enable detailed logging with:

# For Java 8 and earlier
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log

# For Java 9+
-Xlog:gc*:file=gc.log:time,level,tags:filecount=10,filesize=100m

In the GC log, watch for these danger signs:

Analyzing Heap Dumps to Find the Root Cause

Heap dumps are binary files in HPROF format. Several tools can parse and visualize them. The goal is to identify objects that collectively consume the majority of heap space and determine why they are still reachable.

Using Eclipse Memory Analyzer (MAT)

MAT is the industry-standard free tool for heap analysis. After opening the dump, it automatically runs a leak suspect report. Key workflows:

Programmatic Heap Analysis with Built-in Tools

For server environments without GUI access, use the command-line tools bundled with the JDK:

# Print a histogram of object counts and sizes from a dump file
jmap -histo:live /path/to/dump.hprof | head -40

# For a running process, print the histogram directly
jcmd <PID> GC.class_histogram | head -40

# Example output analysis script
jcmd $(pgrep -f myapp.jar) GC.class_histogram | \
  awk 'NR>3 {print $3, $2, $4}' | \
  sort -rn | \
  head -20

The histogram shows instance count, total shallow size (memory for the objects themselves excluding referenced objects), and the class name. Look for classes where the instance count or shallow size seems disproportionate.

Common Heap Dump Patterns and Their Fixes

Pattern 1: Leaking collections — HashMap, ArrayList, ConcurrentHashMap

Collections are the most common leak vector. Objects added to a map or list are never removed, or the collection itself is held in a static field or long-lived cache.

// BAD: Static map that grows indefinitely
public class SessionRegistry {
    private static final Map<String, UserSession> SESSIONS = new HashMap<>();

    public static void addSession(String token, UserSession session) {
        SESSIONS.put(token, session);  // never evicted!
    }
    // Missing: removal on logout, timeout, or weak reference wrappers
}

// GOOD: Using a bounded, evicting cache with WeakHashMap or Caffeine
public class SessionRegistry {
    private static final Cache<String, UserSession> SESSIONS = 
        Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterAccess(Duration.ofHours(1))
            .removalListener((key, session, cause) -> session.cleanup())
            .build();
    
    public static void addSession(String token, UserSession session) {
        SESSIONS.put(token, session);
    }
}

Pattern 2: Uncleared ThreadLocals

ThreadLocal variables can cause leaks in thread-pooled environments. When a thread is reused, its ThreadLocal map retains references to objects from the previous task, preventing garbage collection.

// BAD: ThreadLocal never cleared in thread pool
public class RequestContext {
    private static final ThreadLocal<Map<String, Object>> CONTEXT = 
        ThreadLocal.withInitial(HashMap::new);
    
    public static void set(String key, Object value) {
        CONTEXT.get().put(key, value);
    }
    // Missing: cleanup after request processing
}

// GOOD: Always clear ThreadLocal in a finally block
public class RequestContext {
    private static final ThreadLocal<Map<String, Object>> CONTEXT = 
        ThreadLocal.withInitial(HashMap::new);
    
    public static void set(String key, Object value) {
        CONTEXT.get().put(key, value);
    }
    
    public static void clear() {
        CONTEXT.remove();  // critical: removes the entire map for this thread
    }
}

// Usage in thread-pool executor
executor.submit(() -> {
    try {
        RequestContext.set("userId", 12345);
        processRequest();
    } finally {
        RequestContext.clear();  // always execute
    }
});

Pattern 3: Substring and ByteBuffer retention from large backing arrays

In older Java versions (pre-JDK 7 update 6), String.substring() retained a reference to the original char array. Even in modern JDK versions, ByteBuffer.slice() and certain NIO operations share the backing buffer. Processing large messages and retaining small slices can keep multi-megabyte buffers alive.

// BAD: Retaining small slice prevents GC of large buffer
ByteBuffer hugeBuffer = ByteBuffer.allocateDirect(100_000_000);
ByteBuffer tinySlice = hugeBuffer.slice();  // shares backing memory
hugeBuffer = null;  // still not collectible because tinySlice references it
processSlice(tinySlice);  // tinySlice keeps 100MB alive

// GOOD: Copy the needed portion and discard the original
ByteBuffer hugeBuffer = ByteBuffer.allocateDirect(100_000_000);
byte[] neededData = new byte[4096];
hugeBuffer.get(neededData);
ByteBuffer independentBuffer = ByteBuffer.wrap(neededData);
hugeBuffer = null;  // now collectible
processBuffer(independentBuffer);

Pattern 4: Inflated inner classes holding outer references

Non-static inner classes and anonymous classes hold an implicit reference to their enclosing outer instance. If instances of the inner class are stored in long-lived collections, the entire outer instance (which may be large) is retained.

// BAD: Anonymous inner class retains outer HeavyProcessor instance
public class HeavyProcessor {
    private byte[] largeBuffer = new byte[50_000_000];
    private Map<String, Listener> listeners = new HashMap<>();

    public void register(String event) {
        listeners.put(event, new Listener() {  // anonymous inner class
            @Override
            public void onEvent() {
                process(largeBuffer);  // implicit reference to HeavyProcessor.this
            }
        });
    }
    // Even if HeavyProcessor should be collected, listeners map prevents it
}

// GOOD: Use static inner class or lambda that doesn't capture HeavyProcessor.this
public class HeavyProcessor {
    private byte[] largeBuffer = new byte[50_000_000];
    private Map<String, Listener> listeners = new HashMap<>();

    public void register(String event) {
        // Static inner class takes explicit reference to needed data only
        listeners.put(event, new StaticListener(largeBuffer));
        // Or use a method reference that doesn't capture 'this'
    }
    
    private static class StaticListener implements Listener {
        private final byte[] buffer;
        StaticListener(byte[] buffer) { this.buffer = buffer; }
        @Override
        public void onEvent() { process(buffer); }
    }
}

Structural Fixes: Right-Sizing and Architecture

Increasing Heap Size Correctly

If analysis confirms the application legitimately needs more memory (no leaks, just larger working sets), increase the heap with careful benchmarking:

# Balanced heap configuration for a 16GB container
java -Xms8g -Xmx12g \
     -XX:MaxMetaspaceSize=512m \
     -XX:MetaspaceSize=256m \
     -XX:ReservedCodeCacheSize=256m \
     -jar myapp.jar

# For containerized environments (Docker, Kubernetes), align with cgroup limits
# Use -XX:MaxRAMPercentage to set heap as percentage of container memory
java -XX:MaxRAMPercentage=75.0 \
     -XX:InitialRAMPercentage=50.0 \
     -XX:MinRAMPercentage=25.0 \
     -jar myapp.jar

Never set -Xmx equal to total container memory — the JVM needs headroom for native memory (thread stacks, NIO buffers, metaspace, code cache, compressed class space, and the garbage collector's own structures). A safe rule is 75% for heap, leaving 25% for native allocations.

Switching to a Different GC Algorithm

Some garbage collectors handle large heaps and high allocation rates better than others. The parallel collector can cause long pauses on multi-gigabyte heaps, while G1 and ZGC are designed for low-latency on large memory:

# G1GC: balanced latency/throughput, good for 4-32GB heaps
java -Xmx16g -Xms8g \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:G1HeapRegionSize=16m \
     -XX:ConcGCThreads=4 \
     -XX:ParallelGCThreads=8 \
     -jar myapp.jar

# ZGC: ultra-low latency, terabyte-scale heaps (Java 11+)
java -Xmx32g -Xms16g \
     -XX:+UseZGC \
     -XX:+ZGenerational \
     -XX:ConcGCThreads=8 \
     -jar myapp.jar

# Shenandoah: low pause, good for large heaps (Java 12+, certain builds)
java -Xmx16g -Xms8g \
     -XX:+UseShenandoahGC \
     -XX:ShenandoahGCHeuristics=compact \
     -jar myapp.jar

Stream Processing Instead of Loading Everything

Often the fix is not about heap tuning but about changing the data access pattern. Loading entire datasets into memory is the root cause:

// BAD: Loading all records into a list, consuming gigabytes
public List<Customer> getAllCustomers() {
    return jdbcTemplate.query(
        "SELECT * FROM customers",
        new CustomerRowMapper()
    );  // 10 million rows → OutOfMemoryError
}

// GOOD: Stream processing with RowCallbackHandler or cursor-based iteration
public void processAllCustomers(CustomerProcessor processor) {
    jdbcTemplate.query(
        "SELECT * FROM customers",
        rs -> {
            while (rs.next()) {
                Customer c = new Customer();
                c.setId(rs.getLong("id"));
                c.setName(rs.getString("name"));
                processor.process(c);  // process and discard immediately
            }
        }
    );
}

// Also good: Use Spring Batch or a paginated approach
public void processCustomersInBatches(int batchSize) {
    int offset = 0;
    List<Customer> batch;
    do {
        batch = jdbcTemplate.query(
            "SELECT * FROM customers LIMIT ? OFFSET ?",
            new CustomerRowMapper(),
            batchSize, offset
        );
        batch.forEach(this::processCustomer);
        batch.clear();  // allow GC
        offset += batchSize;
    } while (!batch.isEmpty());
}

This pattern applies equally to file processing, API responses, and message queues — never materialize unbounded data into a single collection.

Advanced Diagnostic Techniques

Tracking Object Allocation with Flight Recorder

Java Flight Recorder (JFR) profiles allocations in production with negligible overhead. Enable it to see exactly which code paths allocate the most memory:

# Start a JFR recording focused on memory allocation
jcmd <PID> JFR.start name=memprof settings=profile \
  duration=10m filename=/tmp/allocation.jfr

# Dump the recording for analysis
jcmd <PID> JFR.dump name=memprof filename=/tmp/allocation.jfr

# Analyze allocation hotspots using jfr command-line tool
jfr print --events ObjectAllocationInNewTLAB \
  --stack-depth 10 /tmp/allocation.jfr | \
  grep -A 5 "allocationClass" | \
  sort | uniq -c | sort -rn | head -20

JFR reveals allocation rate by class and method, showing you which code paths to optimize. Combined with heap dumps, you get both the "what" and the "where" of memory consumption.

Monitoring Heap Usage Programmatically

Embed runtime checks to detect approaching exhaustion and take graceful action:

public class HeapMonitor {
    private static final Runtime RUNTIME = Runtime.getRuntime();
    private static final long WARNING_THRESHOLD_MB = 500;  // 500MB remaining
    
    public static void checkHeap() {
        long usedMemory = RUNTIME.totalMemory() - RUNTIME.freeMemory();
        long maxMemory = RUNTIME.maxMemory();
        long remainingMB = (maxMemory - usedMemory) / (1024 * 1024);
        
        if (remainingMB < WARNING_THRESHOLD_MB) {
            System.err.printf(
                "WARNING: Heap nearly exhausted. Used: %dMB, Max: %dMB, Remaining: %dMB%n",
                usedMemory / (1024 * 1024),
                maxMemory / (1024 * 1024),
                remainingMB
            );
            // Trigger circuit breaker, shed load, or reject new work
        }
    }
    
    // Call periodically from a scheduled thread or before large allocations
    public static boolean isSafeToAllocate(long bytesNeeded) {
        long freeMemory = RUNTIME.freeMemory() + 
            (RUNTIME.totalMemory() < RUNTIME.maxMemory() ? 
             RUNTIME.maxMemory() - RUNTIME.totalMemory() : 0);
        return freeMemory > bytesNeeded * 1.2;  // 20% safety margin
    }
}

Using Finalization and Phantom References for Cleanup

For resources that must be cleaned when objects become unreachable, use Cleaner (Java 9+) or PhantomReference instead of finalize(). Finalizers delay reclamation and can cause accumulation in the finalizer queue:

// BAD: finalize() delays GC and can cause OOM from finalizer backlog
public class ResourceHolder {
    private NativeResource resource;
    
    @Override
    protected void finalize() throws Throwable {
        resource.release();  // may never be called, or called too late
        super.finalize();
    }
}

// GOOD: Use java.lang.ref.Cleaner for predictable cleanup
public class ResourceHolder implements AutoCloseable {
    private NativeResource resource;
    private static final Cleaner CLEANER = Cleaner.create();
    private final Cleaner.Cleanable cleanable;
    
    public ResourceHolder() {
        resource = NativeResource.allocate();
        cleanable = CLEANER.register(this, () -> resource.release());
    }
    
    @Override
    public void close() {
        cleanable.clean();  // explicit cleanup, removes from cleaner registry
    }
}

Best Practices for Preventing Heap Exhaustion

Monitoring Configuration Example with JMX and Prometheus

# Enable JMX for remote monitoring
java -Dcom.sun.management.jmxremote \
     -Dcom.sun.management.jmxremote.port=9010 \
     -Dcom.sun.management.jmxremote.authenticate=false \
     -Dcom.sun.management.jmxremote.ssl=false \
     -Djava.rmi.server.hostname=myapp.example.com \
     -jar myapp.jar

# Example Prometheus JMX exporter configuration for heap metrics
# prometheus-jmx-config.yml
rules:
- pattern: java.lang.Memory.*.HeapMemoryUsage.used
  name: jvm_heap_used_bytes
- pattern: java.lang.GarbageCollector.*.CollectionCount
  name: jvm_gc_collection_count
- pattern: java.lang.GarbageCollector.*.CollectionTime
  name: jvm_gc_collection_time_seconds

Handling OutOfMemoryError Gracefully (When Necessary)

While catching OutOfMemoryError is generally discouraged, some frameworks implement last-resort recovery for non-critical operations:

// Last-resort pattern: isolate memory-intensive work
public class IsolatedProcessor {
    public Result processLargeDataset(InputStream data) {
        try {
            // Attempt processing with a dedicated thread and heap context
            return ForkJoinPool.commonPool()
                .submit(() -> intensiveProcessing(data))
                .get(30, TimeUnit.SECONDS);
        } catch (OutOfMemoryError e) {
            // The JVM may be in an inconsistent state after OOM
            // Immediately trigger a heap dump and restart if possible
            System.err.println("FATAL: OOM during processing, initiating graceful shutdown");
            triggerEmergencyShutdown();
            throw new UnrecoverableException("Memory exhausted", e);
        }
    }
    
    private void triggerEmergencyShutdown() {
        // Dump heap, close connections, flush logs, signal orchestrator
        Runtime.getRuntime().halt(1);  // hard exit, no shutdown hooks
    }
}

This pattern is risky — the OOM may have left shared data structures in inconsistent states. Use it only for truly isolated computations where failure containment is possible, and always prefer the -XX:+ExitOnOutOfMemoryError approach for clean restarts.

Conclusion

Fixing OutOfMemoryError: Java heap space demands a structured approach: capture diagnostic data with automatic heap dumps and GC logging, analyze the heap to distinguish genuine memory requirements from leaks or bloat, then apply targeted fixes — whether that means patching a leaky collection, clearing ThreadLocals, switching to stream processing, or right-sizing heap configuration. The most resilient Java applications combine proper heap tuning, bounded data structures, continuous monitoring, and a healthy skepticism toward any collection that grows without bound. By embedding these practices into your development lifecycle and treating memory as a first-class operational concern, you transform heap exhaustion from a mysterious production outage into a well-understood, preventable condition.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles