OutOfMemoryError in production is the kind of crash that makes you simultaneously angry and grateful—angry because it shouldn't happen in a tested codebase, grateful because at least you found it before it happened 3am on Friday. When the JVM can't allocate more heap space, services die hard, and debugging the root cause is often a detective story spanning multiple logs, thread dumps, and heap analysis. This guide walks you through the tools and techniques that actually work for hunting down OutOfMemoryError Java heap space issues in live systems.
The challenge with OutOfMemoryError is that it's rarely simple. Your code might be holding onto objects it shouldn't, or a legitimate spike in traffic is overwhelming a heap configuration that worked fine for months. You need both visibility into what crashed and a methodology to find the leak.
Understanding the Heap and Why It Fails
The Java heap is the memory region where objects live during runtime. When the JVM allocates an object and there's no free space left—even after a full garbage collection—you get OutOfMemoryError: Java heap space. This is different from other OOM variants like PermGen or MetaSpace errors; heap space errors mean your live object count or total object size has exceeded the -Xmx limit.
The JVM garbage collector (GC) runs periodically to reclaim unreferenced objects. If GC can't free enough memory and the next allocation request fails, you're done. The hard part: determining whether the problem is misconfigured heap size, a genuine memory leak, or abnormal load.
Set your JVM to dump heap on OutOfMemoryError by passing -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/heaps/ at startup. This automatically generates a .hprof file when the error strikes, capturing the heap state for later analysis.
Detecting and Capturing OOM Events
When OutOfMemoryError hits production, the first priority is capturing evidence. Instrument your application with a Sentry SDK (or LightTrace's Sentry-compatible endpoint) so crashes are automatically reported with stack traces, GC logs, and context.
Here's a minimal Java setup:
import io.sentry.Sentry;
public class Application {
static {
Sentry.init(options -> {
options.setDsn("https://<key>@light-trace.robomiri.com/1");
options.setEnvironment("production");
options.setTracesSampleRate(0.1);
options.setServerName(InetAddress.getLocalHost().getHostName());
});
}
public static void main(String[] args) {
try {
// application logic
} catch (OutOfMemoryError e) {
Sentry.captureException(e);
System.exit(1); // fail fast
}
}
}
The OutOfMemoryError will be reported before the JVM terminates, giving you a timestamped crash record in your error dashboard. More importantly, grouping by fingerprint means all OOM events from the same code path cluster together, helping you spot repeating patterns. Read how error tracking best practices unlock visibility into production crashes.
Enable GC Logging to Find the Pattern
GC logs reveal the heap's breathing pattern. If GC is running constantly without freeing much memory, that's a leak signal. Enable GC logging at startup:
java \
-Xms2g -Xmx4g \
-XX:+UnlockDiagnosticVMOptions \
-XX:+TraceClassLoading \
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags \
-Xlog:gc+stats:file=/var/log/gc-stats.log:time,uptime,level,tags \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/heaps/ \
-jar app.jar
Parse the resulting gc.log to look for:
- Full GC every few minutes: Heap is at capacity; each full GC barely recovers space.
- Heap used after GC grows over time: Reclaimable objects are shrinking, survivor objects are piling up.
- "GC overhead limit exceeded": JVM is spending >98% of time in GC and recovering under 2% of heap—classic leak symptom.
Tools like GCEasy.io can visualize GC logs and spot these patterns in seconds. If you see full GC every 30 seconds and heap usage climbing, you've got a leak; if heap stabilizes after GC, it's likely a configuration or load issue.
Analyzing Heap Dumps
When the -XX:+HeapDumpOnOutOfMemoryError flag does its job, you get a .hprof file (typically 50% of your heap size). Use a heap analyzer like Eclipse MAT or JProfiler to open it.
The first query you run: Leak Suspects. Most tools auto-detect the top object hierarchies consuming memory. Look for:
- Unbounded collections: HashMaps that never get cleared, or Lists with millions of objects.
- Thread-local storage:
ThreadLocalobjects that aren't properly cleaned up, especially in app servers with thread pools. - Cache without eviction: Custom caches that grow unbounded because the eviction logic never runs.
Here's a tell-tale pattern in code:
public class DataCache {
private static final Map<String, byte[]> cache = new HashMap<>();
public static void put(String key, byte[] data) {
cache.put(key, data); // ⚠️ Never removed; grows forever
}
}
The fix is straightforward: add an eviction policy. Use a library like Caffeine:
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;
public class DataCache {
private static final Cache<String, byte[]> cache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
public static void put(String key, byte[] data) {
cache.put(key, data); // ✓ Bounded; old entries evicted
}
}
Common Memory Leak Patterns in Java
Not all leaks are obvious caches. Watch for:
Event Listener Registration Without Unregistration
button.addActionListener(event -> {
// handler
});
// ⚠️ If button is removed from UI but listener isn't unregistered,
// button stays in memory
Always unregister listeners when objects are destroyed, or use weak references.
Static Collections Holding Context
public class UserSessionManager {
private static final List<User> activeSessions = new ArrayList<>();
public static void addSession(User u) {
activeSessions.add(u); // ⚠️ Never explicitly removed
}
}
Static fields live for the JVM lifetime. If you populate them without removing entries, they'll grow until OOM.
Connection Pools Not Returning Connections
Database connection leaks are silent killers. A pool configured for 20 connections can exhaust heap if connections aren't properly returned:
Connection conn = dataSource.getConnection();
try {
// query
} finally {
conn.close(); // ✓ Always close in finally or use try-with-resources
}
// Better:
try (Connection conn = dataSource.getConnection()) {
// query
} // Automatically closed
Tuning Heap and GC for Production
Sometimes the leak is legitimate traffic. When you've ruled out code issues, tune the heap and GC:
-
Right-size the heap: Start with heap usage after a full GC in production, then add 30-50% headroom. If peak usage is 3GB, set
-Xmx4.5g. -
Choose the right GC algorithm:
- G1GC (default in Java 9+): Low-latency, good for heap >4GB. Set
-XX:+UseG1GC -XX:MaxGCPauseMillis=200. - ZGC: Ultra-low pause times (<10ms), ideal for sub-100ms SLA latencies. Requires Java 15+.
- G1GC (default in Java 9+): Low-latency, good for heap >4GB. Set
-
Monitor GC pause times: Use tools like New Relic or Datadog to alert if GC pauses exceed your service SLA (e.g., <100ms).
JVM GC tuning is an empirical process. Change one parameter at a time, run your app under realistic load, and measure pause time and throughput. What works for a batch job won't work for an API server.
Correlating Crashes with Tracing
The real debugging superpower emerges when you correlate OOM events with distributed traces. If you're already reading stack traces and capturing spans, you can see which request path (or sequence of requests) led to memory exhaustion.
For example, if every OOM crash contains a span from a specific endpoint that allocates huge temporary buffers, you've identified your culprit. Instrument high-allocation zones with span attributes:
Span span = Sentry.getSpan();
if (span != null) {
span.setAttribute("buffer_size_mb", buffer.capacity() / (1024 * 1024));
span.setAttribute("request_size_bytes", inputStream.available());
}
This way, when the crash arrives in your dashboard, you can drill into the trace and see heap state at the moment of the OOM.
Building Sustainable Monitoring
Catching OOM after the fact is reactive. Build proactive alarms:
- Heap usage >85%: Alert before you hit the wall. Most app servers can recover from 85%, but rarely from 95%.
- GC pause time spikes: If median GC pause jumps from 50ms to 500ms, a leak is probably starting.
- Full GC frequency: More than one full GC per minute in production is a warning sign.
Wire these into alert rules in your error tracking platform. LightTrace's alert system lets you threshold on event frequency (e.g., "alert if OOM occurs more than once per hour"), helping you catch recurring leaks before they cascade.
Learn how to debug production errors using crash aggregation and alerting to stay ahead of reliability issues.
Recap: Your OOM Debugging Workflow
- Enable heap dumps (
-XX:+HeapDumpOnOutOfMemoryError). - Capture crashes using Sentry SDK (LightTrace-compatible).
- Analyze GC logs to distinguish real leaks from load spikes.
- Open heap dumps in Eclipse MAT or JProfiler and hunt for unbounded collections.
- Fix the leak or right-size heap + GC tuning.
- Monitor proactively with heap-usage and GC-pause alarms.
The engineers who own reliability don't just fix OOM after incidents; they instrument early, correlate signals, and iterate on heap configuration under realistic load. That's the discipline that keeps services standing.
Start tracking errors in minutes
Ready to catch OutOfMemoryError and other production crashes before they wreck your SLA? LightTrace captures full stack traces, aggregates errors, and alerts you in real time. Start free and see your Java crashes grouped and explained.