Skip to content

Fix: Java OutOfMemoryError – Java Heap Space, Metaspace, and GC Overhead

FixDevs · (Updated: )

Part of:  Java & JVM Errors

Quick Answer

How to fix Java OutOfMemoryError including heap space, Metaspace, GC overhead limit exceeded, and unable to create new native thread.

The Error

Your Java application crashes with one of these errors:

java.lang.OutOfMemoryError: Java heap space
    at java.base/java.util.Arrays.copyOf(Arrays.java:3512)
    at java.base/java.util.ArrayList.grow(ArrayList.java:237)
    at java.base/java.util.ArrayList.addAll(ArrayList.java:590)
    at com.example.service.DataProcessor.loadAll(DataProcessor.java:47)

Or the Metaspace variant:

java.lang.OutOfMemoryError: Metaspace
    at java.base/java.lang.ClassLoader.defineClass1(Native Method)
    at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1017)
    at java.base/java.security.SecureClassLoader.defineClass(SecureClassLoader.java:174)

Or the GC overhead error:

java.lang.OutOfMemoryError: GC overhead limit exceeded
    at java.base/java.util.HashMap.resize(HashMap.java:700)
    at java.base/java.util.HashMap.putVal(HashMap.java:658)
    at com.example.cache.InMemoryCache.put(InMemoryCache.java:93)

Or the native thread error:

java.lang.OutOfMemoryError: unable to create new native thread
    at java.base/java.lang.Thread.start0(Native Method)
    at java.base/java.lang.Thread.start(Thread.java:802)
    at java.base/java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:945)

The JVM has exhausted one of its memory regions and cannot allocate more. The application either needs more memory, has a memory leak, or is configured with limits that are too low.

Why This Happens

The JVM divides memory into several regions, and each OutOfMemoryError variant points to a different region running out of space:

Heap space (-Xmx) is where all Java objects live. When you create objects faster than the garbage collector can reclaim them, or when live objects simply exceed the maximum heap size, the JVM throws OutOfMemoryError: Java heap space. The default maximum heap is typically 1/4 of the physical memory, or 256 MB on some JVM distributions, which is often too small for production workloads.

Metaspace replaced PermGen in Java 8. It stores class metadata: class definitions, method bytecode, constant pools, and annotations. Every time a class is loaded, Metaspace grows. By default, Metaspace has no hard upper limit (it grows until the OS runs out of memory), but if you set -XX:MaxMetaspaceSize too low, or if classloaders keep loading classes without releasing old ones, you get OutOfMemoryError: Metaspace.

GC overhead limit exceeded means the garbage collector is spending more than 98% of CPU time on garbage collection and recovering less than 2% of the heap on each cycle. The JVM throws this to prevent the application from making near-zero progress while burning CPU. This is almost always a symptom of the heap being too small for the live data set, or a memory leak filling the heap to capacity.

Unable to create new native thread is an OS-level limit, not a JVM heap issue. Each Java thread requires a native OS thread, which consumes stack memory (typically 512 KB to 1 MB per thread). If the process hits the OS thread limit (ulimit -u on Linux) or runs out of virtual address space for thread stacks, this error occurs.

Understanding which variant you are dealing with determines the fix. If your application is running in a container, the situation is more nuanced because Docker and Kubernetes impose their own memory limits on top of the JVM settings, which can cause the process to be killed before the JVM even throws an error. For container-related crashes, see Fix: Docker Container Exited with Code 137 (OOMKilled).

Diagnostic Timeline

Here is how an experienced JVM operator actually diagnoses an OOM in production. The order matters because raising -Xmx blindly is the wrong first move four times out of five.

Minute 0 — first reaction: raise -Xmx. Every junior engineer’s instinct is to double the heap and redeploy. Sometimes it works. More often it delays the crash by an hour and you are back at 3 AM. Before raising anything, read the actual error class:

  • OutOfMemoryError: Java heap space — heap is full. -Xmx is a legitimate fix candidate.
  • OutOfMemoryError: Metaspace — class metadata region is full. -Xmx does nothing. You need -XX:MaxMetaspaceSize.
  • OutOfMemoryError: Direct buffer memory — off-heap NIO buffers are exhausted. -Xmx does nothing. You need -XX:MaxDirectMemorySize.
  • OutOfMemoryError: unable to create new native thread — OS thread limit hit. -Xmx is irrelevant. You need ulimit -u or a smaller -Xss.
  • OutOfMemoryError: GC overhead limit exceeded — heap is technically not full but GC is thrashing. Almost always a leak. -Xmx postpones the crash by minutes.

Misreading the variant is the most common diagnostic failure. Read the line after OutOfMemoryError: carefully.

Minute 1 — second wrong suspicion: “it’s the framework.” Spring, Hibernate, Tomcat get blamed reflexively. Confirm with a quick check before you go down that road. If the process was killed by the OS (no Java stack trace in your logs, just a sudden SIGKILL and exit code 137 in your container logs), the JVM never got to throw — you have a container OOM, not a JVM OOM. The cgroup limit is below what the JVM is configured to use. Fix the container limit and -XX:MaxRAMPercentage, not your code.

Minute 2 — third wrong suspicion: “the GC is just slow.” You switch garbage collectors hoping G1 or ZGC will save you. Switching collectors rarely fixes an actual OOM. It can reduce pause times for an already-healthy heap, but if live data exceeds -Xmx, no collector can help — the live set is the floor. Skip GC tuning until you know the live set size.

Minute 3 — capture a heap dump on OOM. This is the diagnostic step everyone should run on day one in production and almost no one does. Add these flags to your JAVA_OPTS:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/heapdumps/
-XX:+ExitOnOutOfMemoryError

With these set, the JVM writes a .hprof file the instant OOM hits and exits cleanly so your orchestrator restarts the process. Without these, you get the crash but no evidence and you debug blind.

Minute 4 — check container-aware sizing. This is the single most common production OOM root cause in 2024+. Java 8u131 added container awareness, but only Java 10+ enables it by default. If your image runs Java 8 and you are not using -XX:+UseContainerSupport -XX:+UnlockExperimentalVMOptions, the JVM sees the host’s total RAM (say 64 GB on the Kubernetes node), sets -Xmx to 16 GB by default, and gets OOMKilled by the 2 GB container limit. The JVM is doing exactly what you asked. Fix with:

-XX:MaxRAMPercentage=75.0

This tells the JVM to use 75% of the container memory, leaving headroom for Metaspace, thread stacks, code cache, and native allocations. The remaining 25% is not waste — it is the off-heap budget the JVM needs to run.

Minute 5 — analyze the heap dump. Open the .hprof in Eclipse MAT and run Leak Suspects. Four common findings:

  1. A single dominator class holding 60-80% of the heap — you have a cache that grew unbounded. Look at the GC roots path.
  2. Many small instances of the same class — you have a per-request leak. Check for ThreadLocal that is never remove()’d.
  3. ClassLoader instances accumulating — you have a hot-redeploy leak (Tomcat, Jetty). Old WAR classloaders cannot be collected.
  4. Direct ByteBuffer instances dominating — off-heap leak. Netty, Kafka clients, or memory-mapped files. The fix is on the off-heap side, not the heap side.

Minute 6 — actual root cause discovery. By this point you know whether the cause is sizing (-Xmx or MaxRAMPercentage too low), a true memory leak (cache, ThreadLocal, classloader, off-heap), or a misclassified variant (Metaspace, direct memory, threads). Each has a different fix below. The wrong move is to ship a -Xmx bump without doing this analysis — the leak will fill the new ceiling within hours.

Fix 1: Increase Heap Space with -Xmx and -Xms

The most direct fix for OutOfMemoryError: Java heap space is to give the JVM more memory.

Set the maximum and initial heap size:

java -Xms512m -Xmx2g -jar myapp.jar
  • -Xms512m sets the initial heap to 512 MB. The JVM allocates this much memory at startup.
  • -Xmx2g sets the maximum heap to 2 GB. The heap can grow up to this limit before throwing OutOfMemoryError.

Setting -Xms equal to -Xmx is a common production practice. It avoids the overhead of heap resizing at runtime and makes memory behavior more predictable:

java -Xms4g -Xmx4g -jar myapp.jar

For Spring Boot applications, set these via JAVA_OPTS or JAVA_TOOL_OPTIONS:

export JAVA_TOOL_OPTIONS="-Xms1g -Xmx4g"
java -jar myapp.jar

JAVA_TOOL_OPTIONS is picked up automatically by the JVM without needing to modify launch scripts.

In Maven or Gradle:

# Maven
export MAVEN_OPTS="-Xmx2g"
mvn clean install

# Gradle
export GRADLE_OPTS="-Xmx2g"
gradle build

Build tools themselves can run out of memory during compilation of large projects.

Real-world scenario: A team’s CI builds started failing with OutOfMemoryError: Java heap space after upgrading a dependency that pulled in a much larger transitive dependency graph. The Maven compiler needed more memory to process the expanded classpath. Adding -Xmx2g to MAVEN_OPTS in the CI configuration fixed it without any code changes. This is separate from your application’s runtime heap.

How much heap to allocate depends on your workload. A good starting point is to monitor actual heap usage with -XX:+PrintGCDetails or -Xlog:gc* (Java 11+), then set -Xmx to roughly 1.5 to 2 times the peak live data set size. Overallocating wastes memory and can increase GC pause times, while underallocating causes frequent collections and eventual OOM.

Fix 2: Increase Metaspace Size

If you see OutOfMemoryError: Metaspace, the JVM has loaded more class metadata than the Metaspace limit allows.

Increase the Metaspace limit:

java -XX:MaxMetaspaceSize=512m -XX:MetaspaceSize=128m -jar myapp.jar
  • -XX:MetaspaceSize=128m sets the threshold at which a full GC is triggered to unload classes. This is not the initial allocation.
  • -XX:MaxMetaspaceSize=512m sets the hard upper limit. Once reached, the JVM throws OutOfMemoryError: Metaspace.

Common causes of Metaspace exhaustion:

  1. Classloader leaks. Web applications redeployed without a server restart often leak classloaders. Each redeployment loads all classes again, but the old classloader (and all its classes) cannot be garbage collected because something still holds a reference to it. In Tomcat, this is the most common cause of OutOfMemoryError: Metaspace after several hot redeploys.

  2. Dynamic proxy generation. Frameworks like Hibernate, Spring AOP, and CGLIB create proxy classes at runtime. Large applications with thousands of entities and aspects can generate thousands of proxy classes.

  3. Scripting engines and expression languages. Groovy, JSP compilation, and other scripting engines compile source code into classes at runtime. If scripts are compiled repeatedly (e.g., per request), Metaspace fills up.

To diagnose classloader leaks, use -verbose:class or -Xlog:class+load=info (Java 11+) to log every class loading event:

java -Xlog:class+load=info -jar myapp.jar 2>&1 | grep -c "defineClass"

If the count keeps growing across redeployments, you have a classloader leak.

Fix 3: Fix GC Overhead Limit Exceeded

GC overhead limit exceeded means the garbage collector is working hard but barely freeing any memory. The application is stuck in a near-infinite loop of allocation and collection.

Short-term fix: increase the heap:

java -Xmx4g -jar myapp.jar

This buys time but does not fix the underlying problem if you have a memory leak.

Disable the GC overhead check (not recommended for production, but useful for debugging):

java -XX:-UseGCOverheadLimit -Xmx4g -jar myapp.jar

This lets the application continue running even when GC is consuming most of the CPU. It can help you capture a heap dump or identify the problem, but the application will be essentially unresponsive.

Switch to a different garbage collector. The G1 collector (default since Java 9) handles large heaps better than the older Parallel collector:

java -XX:+UseG1GC -Xmx4g -jar myapp.jar

For applications with very large heaps (8 GB+), consider ZGC or Shenandoah for low-latency collection:

# ZGC (Java 15+)
java -XX:+UseZGC -Xmx16g -jar myapp.jar

# Shenandoah (Java 12+, not available in all JDK distributions)
java -XX:+UseShenandoahGC -Xmx16g -jar myapp.jar

The real fix is to find and eliminate the memory leak causing the heap to fill up. See Fix 5 below.

Fix 4: Fix Unable to Create New Native Thread

This error is about OS-level thread limits, not JVM heap.

Check and increase the thread limit on Linux:

# Check current limits
ulimit -u        # max user processes (includes threads)
ulimit -a        # all limits

# Increase temporarily
ulimit -u 65535

# Increase permanently in /etc/security/limits.conf
# Add these lines:
# appuser  soft  nproc  65535
# appuser  hard  nproc  65535

Reduce the stack size per thread to fit more threads into the available memory:

java -Xss512k -jar myapp.jar

The default stack size is typically 1 MB. Reducing it to 512 KB or even 256 KB lets you create roughly twice as many threads in the same memory footprint. Be cautious: if your application uses deep recursion, a small stack causes StackOverflowError.

Check how many threads your application is actually creating:

# Count threads for a running JVM process
jcmd <pid> Thread.print | grep -c "^\""

# Or use jstack
jstack <pid> | grep -c "^\""

If you see thousands of threads, the root cause is usually a thread pool misconfiguration or unbounded thread creation. Fix the application code:

// BAD: creating a new thread per request
new Thread(() -> handleRequest(request)).start();

// GOOD: use a bounded thread pool
ExecutorService executor = Executors.newFixedThreadPool(50);
executor.submit(() -> handleRequest(request));

// BETTER: use a thread pool with a bounded queue and rejection policy
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    10, 50, 60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(1000),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

This pattern of unbounded resource creation is a common source of runtime errors across languages. Ensure your thread-pool sizing configuration is loaded from environment or properties before the pool is initialized — late-loaded config is a common reason production runs with development defaults.

Fix 5: Detect and Fix Memory Leaks

If increasing memory only delays the crash, you have a memory leak. Java has garbage collection, but objects that are still reachable (referenced) cannot be collected, even if your code no longer needs them.

Step 1: Capture a heap dump.

Generate a heap dump when OOM occurs automatically:

java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -Xmx2g -jar myapp.jar

Or capture one manually from a running process:

# Using jmap
jmap -dump:live,format=b,file=/tmp/heapdump.hprof <pid>

# Using jcmd (preferred on modern JDKs)
jcmd <pid> GC.heap_dump /tmp/heapdump.hprof

Step 2: Analyze the heap dump.

Open the dump in Eclipse Memory Analyzer (MAT):

  1. Download MAT from eclipse.org/mat.
  2. Open the .hprof file.
  3. Run the Leak Suspects report. MAT identifies objects that dominate a large portion of the heap.
  4. Inspect the Dominator Tree to see which objects hold the most memory and trace their reference chains back to GC roots.

Alternatively, use jvisualvm (bundled with JDK 8, available as a standalone download for later versions) to connect to a running JVM and monitor heap usage in real time:

jvisualvm

In VisualVM, you can perform heap dumps, view object allocation, and run a sampler to identify which classes are consuming the most memory.

Step 3: Use GC logging to monitor allocation over time.

# Java 11+
java -Xlog:gc*:file=/var/log/app-gc.log:time,uptime,level,tags -Xmx2g -jar myapp.jar

# Java 8
java -verbose:gc -Xloggc:/var/log/app-gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xmx2g -jar myapp.jar

Upload the GC log to GCEasy or GCViewer for visualization. Look for a steadily rising baseline heap usage after each GC cycle, which indicates a leak.

Fix 6: Fix Common Memory Leak Patterns

Here are the most frequent causes of memory leaks in Java applications.

Static collections that grow without bounds:

// LEAK: static map grows forever
public class EventCache {
    private static final Map<String, Event> cache = new HashMap<>();

    public static void addEvent(String id, Event event) {
        cache.put(id, event);  // entries never removed
    }
}

Fix by using a bounded cache, weak references, or an eviction policy:

// FIX 1: Use a bounded cache with LRU eviction
private static final Map<String, Event> cache = new LinkedHashMap<>(100, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, Event> eldest) {
        return size() > 10_000;
    }
};

// FIX 2: Use WeakHashMap (entries are GC'd when keys are no longer referenced)
private static final Map<String, Event> cache = new WeakHashMap<>();

// FIX 3: Use Caffeine or Guava Cache with size/time limits
private static final Cache<String, Event> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(30))
    .build();

Unclosed resources (streams, connections, result sets):

// LEAK: InputStream never closed if an exception occurs
public byte[] readFile(String path) throws IOException {
    FileInputStream fis = new FileInputStream(path);
    byte[] data = fis.readAllBytes();
    fis.close();  // never reached if readAllBytes() throws
    return data;
}

Fix with try-with-resources:

// FIX: try-with-resources guarantees close()
public byte[] readFile(String path) throws IOException {
    try (FileInputStream fis = new FileInputStream(path)) {
        return fis.readAllBytes();
    }
}

This applies to database connections, HTTP connections, JDBC ResultSets, and any AutoCloseable resource. Connection pool exhaustion from unclosed connections can also trigger OutOfMemoryError indirectly.

Classloader leaks in web applications:

When a web application is redeployed in Tomcat or Jetty, the old classloader should be garbage collected. But if any thread, static field, or JVM-level reference holds a reference to an object loaded by the old classloader, the entire classloader and all its loaded classes remain in Metaspace. Common culprits:

  • ThreadLocal variables that store objects from the web application
  • JDBC drivers registered with DriverManager (a JVM-level static registry)
  • Logging frameworks that keep references to appenders loaded by the web application classloader
  • Shutdown hooks registered on Runtime
// FIX: Deregister JDBC drivers on undeploy
@WebListener
public class AppContextListener implements ServletContextListener {
    @Override
    public void contextDestroyed(ServletContextEvent sce) {
        Enumeration<Driver> drivers = DriverManager.getDrivers();
        while (drivers.hasMoreElements()) {
            Driver driver = drivers.nextElement();
            if (driver.getClass().getClassLoader() == getClass().getClassLoader()) {
                try {
                    DriverManager.deregisterDriver(driver);
                } catch (SQLException e) {
                    // log the error
                }
            }
        }
    }
}

Listeners and callbacks that are never unregistered:

// LEAK: observer is never removed
public class Dashboard {
    public Dashboard(EventBus bus) {
        bus.register(this);  // holds a reference to Dashboard forever
    }
}

Fix by unregistering when the object is no longer needed, or use weak references in the event bus implementation.

Fix 7: Configure Memory Limits in Docker Containers

When running Java in Docker, the container has a memory limit set by Docker or Kubernetes. If the JVM heap exceeds the container limit, the Linux OOM killer terminates the process (exit code 137) without the JVM ever getting a chance to throw OutOfMemoryError.

Set container memory and JVM heap together:

# docker-compose.yml
services:
  app:
    image: myapp:latest
    deploy:
      resources:
        limits:
          memory: 2g
    environment:
      JAVA_OPTS: "-Xms1g -Xmx1536m"

The JVM heap (-Xmx) should be lower than the container limit because the JVM uses memory beyond the heap (Metaspace, thread stacks, native memory, direct byte buffers, code cache). A common rule is to set -Xmx to 70-80% of the container memory limit.

Use container-aware JVM settings (Java 10+):

java -XX:MaxRAMPercentage=75.0 -jar myapp.jar

This tells the JVM to use 75% of the detected container memory as the maximum heap. The JVM automatically detects Docker memory limits starting from Java 10 (backported to Java 8u191+).

# Verify the JVM sees the container limits correctly
docker run --memory=2g myapp:latest java -XX:+PrintFlagsFinal -version 2>&1 | grep MaxHeapSize

If your Kubernetes pod gets killed during the JVM startup phase rather than at runtime, the initial heap (-Xms) plus reserved code cache may already exceed the container limit before any application code runs. Lower -Xms so it fits inside the limit comfortably, or raise the limit.

In Kubernetes, set both requests and limits:

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          image: myapp:latest
          resources:
            requests:
              memory: "1Gi"
            limits:
              memory: "2Gi"
          env:
            - name: JAVA_OPTS
              value: "-XX:MaxRAMPercentage=75.0"

Fix 8: Tune Garbage Collection for Large Heaps

For heaps larger than 4 GB, the default G1 collector may cause long pause times. Tuning GC can reduce memory pressure and delay or prevent OOM errors.

G1GC tuning for large heaps:

java -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:G1HeapRegionSize=16m \
     -XX:InitiatingHeapOccupancyPercent=35 \
     -Xmx8g \
     -jar myapp.jar
  • -XX:MaxGCPauseMillis=200 tells G1 to aim for 200 ms pauses (it is a target, not a guarantee).
  • -XX:G1HeapRegionSize=16m sets the region size. Larger regions are more efficient for large heaps.
  • -XX:InitiatingHeapOccupancyPercent=35 starts concurrent marking earlier (default is 45). This gives the GC more time to reclaim memory before the heap fills up.

Enable GC logging in production so you can diagnose issues after the fact:

java -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=50m \
     -Xmx8g -jar myapp.jar

This writes GC logs to rotating files (10 files, 50 MB each) so they do not consume unlimited disk space.

Still Not Working?

Identify which memory region is exhausted

Use jcmd to get a full breakdown of JVM memory usage:

jcmd <pid> VM.native_memory summary

This requires starting the JVM with -XX:NativeMemoryTracking=summary:

java -XX:NativeMemoryTracking=summary -Xmx2g -jar myapp.jar

The output shows heap, Metaspace, thread stacks, code cache, GC overhead, and internal memory. This tells you exactly which region is consuming more than expected.

Pro Tip: If your heap dump is too large to open in Eclipse MAT, run jmap -histo <pid> | head -30 on the live process instead. This shows the top classes by instance count and byte size without generating a full dump, and it is often enough to identify the leak.

Off-heap memory leaks

If the JVM process uses far more memory than -Xmx would suggest, the leak may be in native memory. Common causes:

  • Direct ByteBuffers (ByteBuffer.allocateDirect()) allocated outside the heap. Limit them with -XX:MaxDirectMemorySize=256m.
  • JNI code or native libraries that allocate memory without going through the JVM.
  • Memory-mapped files that hold large regions mapped into the process address space.

Use jcmd <pid> VM.native_memory detail for a fine-grained breakdown.

Application profiling

If heap dumps are not conclusive, use a profiler to track allocations over time:

  • Java Flight Recorder (JFR): Built into the JDK since Java 11 (backported to Java 8u262+). Start a recording:
jcmd <pid> JFR.start duration=60s filename=/tmp/recording.jfr

Open the recording in JDK Mission Control to see allocation hot spots, memory usage trends, and GC behavior.

  • async-profiler: A low-overhead sampling profiler that can track allocations:
./profiler.sh -e alloc -d 30 -f /tmp/alloc-flamegraph.html <pid>

This generates a flame graph showing which code paths are allocating the most memory.

Check for known framework issues

Some frameworks have known patterns that trigger OOM:

  • Hibernate: Loading large result sets without pagination (query.list() on millions of rows) pulls everything into memory. Use setMaxResults() and setFirstResult(), or scroll with ScrollableResults.
  • Jackson/JSON parsing: Parsing very large JSON documents into a DOM tree (ObjectMapper.readTree()) requires the entire document in memory. Use streaming with JsonParser for large inputs.
  • Apache POI: Reading large Excel files (.xlsx) with XSSFWorkbook loads the entire file into memory. Use SXSSFWorkbook for writing or the SAX-based event model for reading.

Confirm the heap actually grew, not just -Xms

jcmd <pid> GC.heap_info shows the current committed and used heap. If you bumped -Xmx from 2 GB to 8 GB but Total: 2147483648 still shows in the output, your launch command did not apply the new flag. Check whether your process manager (systemd unit, Dockerfile ENTRYPOINT, Kubernetes manifest) overrides JAVA_OPTS. A common trap: setting JAVA_OPTS in your shell but starting the app via systemctl which does not inherit your shell environment.

Native memory tracking shows unaccounted “Other”

If jcmd <pid> VM.native_memory summary reports a large “Internal” or “Other” region growing over time, you have a native leak that no Java profiler will catch. Common causes: JNI libraries, GraalVM image building, native image libraries (libjpeg, libpng), or zlib instances created by direct JNI calls. Use pmap -X <pid> on Linux to see anonymous memory regions and correlate growth with library load events in your application log.

G1 humongous allocations silently fragmenting the heap

If you allocate objects larger than half the G1 region size (default 1-32 MB depending on heap), G1 marks them as humongous and pins entire regions for them. Many humongous allocations fragment the old generation and lead to OOM even with apparently free space. Check with -Xlog:gc+heap=info and look for humongous regions: N growing over time. Either reduce the size of large arrays/byte buffers or increase -XX:G1HeapRegionSize (must be power of 2, max 32 MB) so the threshold rises.

Stack size + thread count > address space on 32-bit JVMs

If you are stuck on a 32-bit JVM (legacy systems), the entire process address space is ~3 GB and thread stacks consume it. 1000 threads at 1 MB stack each = 1 GB gone before any heap allocation. Drop stack size with -Xss256k and migrate to 64-bit at the earliest opportunity. There is no reason to be on a 32-bit JVM in 2026+.

If your Java application loads dependencies dynamically and the class itself is not found, that is a different error entirely. See Fix: java.lang.ClassNotFoundException for resolving classpath issues. If a Node.js service in the same fleet is also crashing with heap exhaustion, see Fix: Node.js JavaScript heap out of memory — the diagnostic flow is similar but the JVM-specific flags do not apply.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles