Fix Common Errors

Debugging OutOfMemoryError in Production Java

Debugging OutOfMemoryError in Java production: heap dump analysis, GC tuning, and leak detection strategies for keeping services reliable.

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: ThreadLocal objects 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:

  1. 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.

  2. 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+.
  3. 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

  1. Enable heap dumps (-XX:+HeapDumpOnOutOfMemoryError).
  2. Capture crashes using Sentry SDK (LightTrace-compatible).
  3. Analyze GC logs to distinguish real leaks from load spikes.
  4. Open heap dumps in Eclipse MAT or JProfiler and hunt for unbounded collections.
  5. Fix the leak or right-size heap + GC tuning.
  6. 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.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.