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:
- Insufficient heap configuration: The
-Xmxmaximum heap size is set too low for the application's working set - Memory leaks: Unintentional object retention prevents the garbage collector from freeing memory that is no longer logically needed
- Bloat through data volume: The application legitimately loads more data than anticipated — large result sets, unbounded caches, oversized collections
- Soft-reference buildup: Softly reachable objects accumulate because the GC only clears them when absolutely necessary, and by then it may be too late
- GC tuning mismatch: Aggressive throughput collectors may delay full collections too long, letting fragmentation or promotion pressure build up
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:
- Increasing full GC frequency with shrinking reclaim — each full collection frees less space
- Heap occupancy after full GC consistently above 90%
- "Allocation Failure" messages followed by promotion failures in the old generation
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:
- Histogram: Shows all classes sorted by retained heap size. Look for classes with unexpectedly high instance counts or memory consumption
- Dominator Tree: Reveals the largest object graphs. The dominator of a set of objects is the object in the reference chain that, if garbage collected, would cause all those objects to become unreachable
- Path to GC Roots: For a suspicious object, trace the chain of references back to a GC root (thread stack, static field, system class, etc.). This chain is what prevents collection
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
- Set identical -Xms and -Xmx in production to prevent heap resizing pauses and ensure consistent behavior. The JVM allocates the entire heap at startup, reducing allocation overhead
- Configure container-aware JVM options — use
-XX:MaxRAMPercentageinstead of absolute-Xmxin Docker/Kubernetes environments so heap scales with container limits - Establish memory budgets for caches and collections — every cache should have a maximum size and eviction policy. Use
Caffeine,Guava Cache, orEhcachewith strict size limits - Perform regular heap dump analysis in staging environments under realistic load — don't wait for production crashes
- Use try-with-resources and
finallyblocks religiously for cleanup — ensure ThreadLocals are cleared, streams are closed, and large buffers are nulled out when no longer needed - Profile allocation rate during load tests with JFR or allocation profilers — identify hot allocation sites and optimize object creation
- Prefer primitive types and flat data structures — arrays of primitives consume far less memory than collections of boxed objects. Libraries like
fastutilprovide memory-efficient collections for primitives - Monitor GC metrics continuously — integrate JMX metrics into Prometheus, Datadog, or similar monitoring to track heap occupancy trends before they become critical
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.