Back to Advanced Topics
Q7Question 7 of 41

A service's RSS grows but heap dumps and GC logs look clean: how do you investigate an off-heap leak?

Key Concepts
  • If RSS grows while heap dumps and GC logs are clean, the leak is off-heap and heap tools are structurally blind to it.
  • The most common off-heap culprit is leaked DirectByteBuffers or Netty pooled buffers with missing release() calls.
  • Enable Native Memory Tracking with -XX:NativeMemoryTracking and query with jcmd VM.native_memory to see which region is growing.
  • Set -XX:MaxDirectMemorySize to cap direct buffer allocation so a runaway leak throws a clear error instead of eating RSS silently.
  • Thread-stack growth from thread leaks and Metaspace from classloader leaks both appear as native memory growth.
  • On glibc Linux, native allocator fragmentation can inflate RSS even without a true leak; confirm with MALLOC_ARENA_MAX or jemalloc.

Definition

If RSS grows while heap dumps and GC logs are clean, the leak is off-heap, so switch from heap tools to Native Memory Tracking, jcmd, pmap, and async-profiler, and cap direct memory with MaxDirectMemorySize.
  • Off-heap leak – memory growth outside the Java object heap; heap dumps and GC logs show nothing because they only describe the heap.
  • Direct buffers – ByteBuffer.allocateDirect() allocates off-heap; freed only when the Java wrapper is GC'd and the cleaner runs.
  • Netty pooled buffers – ByteBuf backed by pooled arenas; a missing release() leaks pooled native memory.
  • Native Memory Tracking – enabled with -XX:NativeMemoryTracking=summary and queried with jcmd VM.native_memory.
  • pmap – OS tool showing the process's memory map and growing anonymous regions.
  • async-profiler – sampling profiler that in native allocation mode (-e malloc) shows which call sites allocate native memory.

Imagine National Bank of India's main vault (the Java heap) looks perfectly tidy on every inspection, yet the building's total weight on the foundation keeps increasing. The extra weight is in the external warehouses nobody was auditing: the off-site document depot (direct buffers), the staff locker rooms that grow with every new teller (thread stacks), and the rulebook archive (Metaspace). The heap auditor's camera (a heap dump) only sees the main vault, so it reports everything is fine. To find the real growth you need the building-wide weight scale (NMT and pmap).

Understanding the Concepts

Why does RSS grow while the heap is clean?

A heap dump and GC logs only describe the Java object heap. The JVM's total footprint is the heap plus many native regions: direct buffers, Metaspace, thread stacks, JNI allocations, code cache, and internal data structures. Any of them can grow.

DIAGRAM
JVM Process RSS = Java Heap + Metaspace + Direct Buffers
                  + Thread Stacks + Code Cache + JNI
                  + GC Internal + Other Native

If Heap is stable but RSS grows → leak is in one of the native regions

What is the most common off-heap culprit?

Direct and mapped byte buffers. A DirectByteBuffer allocates memory outside the heap and frees it only when the small Java wrapper is garbage-collected and its cleaner runs.

JAVA
package com.bank.diagnostic;

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

public class DirectBufferLeak {
    public static void main(String[] args) {
        List<ByteBuffer> leakedBuffers = new ArrayList<>();

        // Simulates a service that allocates direct buffers
        // and forgets to release them
        for (int i = 0; i < 1000; i++) {
            ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 1 MB
            leakedBuffers.add(buffer); // keeps the wrapper alive → never freed
        }

        System.out.println("Leaked " + leakedBuffers.size()
                + " direct buffers (" + leakedBuffers.size() + " MB off-heap)");
        // Heap dump shows only tiny wrapper objects
        // RSS shows 1 GB growth
    }
}

Netty makes this worse with pooled buffers: if you forget to release() a ByteBuf, its pooled arena memory is never returned.

JAVA
// Netty: WRONG - missing release()
ByteBuf buf = ctx.alloc().buffer(1024);
// ... use buf ...
// Forgot buf.release(); → pooled arena memory leak

// CORRECT
ByteBuf buf = ctx.alloc().buffer(1024);
try {
    // ... use buf ...
} finally {
    buf.release(); // always release
}

How do you use Native Memory Tracking?

Enable NMT and query it with jcmd to see which category is growing.

BASH
# Enable NMT (adds ~5-10% overhead)
java -XX:NativeMemoryTracking=summary -jar bank-service.jar

# Take a baseline
jcmd <pid> VM.native_memory baseline

# Wait for RSS to grow, then compare
jcmd <pid> VM.native_memory summary.diff
DIAGRAM
Native Memory Tracking:
  Total: reserved=2048MB, committed=1200MB

  -        Java Heap (reserved=512MB, committed=512MB)
  -            Class (reserved=128MB, committed=98MB)      (+12MB)
  -           Thread (reserved=256MB, committed=256MB)     (+32MB)
  -             Code (reserved=64MB, committed=45MB)
  -               GC (reserved=32MB, committed=32MB)
  -         Internal (reserved=128MB, committed=120MB)     (+80MB) ← growing!
  -           Symbol (reserved=8MB, committed=8MB)

  Internal includes direct buffers and JNI allocations.
  +80MB in Internal → likely direct buffer or JNI leak.

How do you cap direct memory?

Set -XX:MaxDirectMemorySize so a runaway allocation throws a clear error instead of silently eating RSS.

BASH
# Cap direct memory at 256 MB
java -XX:MaxDirectMemorySize=256m -jar bank-service.jar

# If exceeded:
# java.lang.OutOfMemoryError: Direct buffer memory
# This is better than silently eating all available RAM
JAVA
package com.bank.diagnostic;

import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;

public class MonitorDirectBuffers {
    public static void main(String[] args) {
        for (BufferPoolMXBean pool :
                ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
            System.out.println(pool.getName()
                    + ": count=" + pool.getCount()
                    + ", used=" + (pool.getMemoryUsed() / 1024 / 1024) + "MB"
                    + ", capacity=" + (pool.getTotalCapacity() / 1024 / 1024) + "MB");
        }
    }
}
TEXT
direct: count=42, used=84MB, capacity=84MB
mapped: count=3, used=12MB, capacity=12MB

How do pmap and async-profiler help?

At the OS level, pmap shows the process memory map and growing anonymous regions. async-profiler in native allocation mode shows which call sites allocate native memory.

BASH
# pmap: show memory regions
pmap -x <pid> | sort -k3 -n | tail -20

# async-profiler: native allocation sampling
./profiler.sh -e malloc -d 60 -f malloc-profile.html <pid>
# Shows which native call sites are allocating
# Pinpoints the leaking library or JNI path

What other sources cause off-heap growth?

Thread stacks, Metaspace, unclosed zip/deflate streams, memory-mapped files, and JNI allocations all hold native memory.

JAVA
// Thread leak: each thread reserves a native stack
for (int i = 0; i < 10_000; i++) {
    new Thread(() -> {
        try { Thread.sleep(Long.MAX_VALUE); }
        catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }).start();
}
// 10,000 threads × 1 MB stack = 10 GB native memory
// Heap dump shows nothing
BASH
# Check thread count
jcmd <pid> Thread.print | grep -c "nid="

What about glibc allocator fragmentation?

On glibc Linux, native allocation fragmentation and per-thread arenas can inflate RSS even without a true leak.

BASH
# Reduce glibc arena count to limit fragmentation
export MALLOC_ARENA_MAX=4
java -jar bank-service.jar

# Or switch to jemalloc with profiling
LD_PRELOAD=/usr/lib/libjemalloc.so MALLOC_CONF=prof:true \
  java -jar bank-service.jar

What should a senior engineer know?

Always separate the regions with NMT before blaming any one library, because "RSS grew" is a symptom, not a diagnosis. Heap errors need a heap dump; native memory errors need NMT, pmap, and async-profiler. The heap dump is blind to native memory.

BASH
# Complete off-heap investigation checklist:
# 1. Confirm heap is clean: GC logs, jstat
# 2. Enable NMT and take baseline + diff
# 3. Check direct BufferPool MBean
# 4. Check thread count: jcmd Thread.print
# 5. Check Metaspace: jcmd VM.classloader_stats
# 6. pmap for growing anonymous regions
# 7. async-profiler -e malloc for allocation sites
# 8. Test MALLOC_ARENA_MAX for fragmentation

Visual Overview

DIAGRAM
  Off-Heap Leak Investigation Pipeline:

  Symptom: RSS grows, heap dump clean
           │
           ▼
  ┌────────────────────────────────────┐
  │ 1. Confirm heap is clean            │
  │    jstat, GC logs: stable           │
  └──────────────┬─────────────────────┘
                 │
  ┌──────────────▼─────────────────────┐
  │ 2. Enable NMT                       │
  │    -XX:NativeMemoryTracking=summary │
  │    jcmd VM.native_memory baseline   │
  │    jcmd VM.native_memory summary.diff│
  │    → Which category is growing?     │
  └──────────────┬─────────────────────┘
                 │
        ┌────────┼────────┐
        ▼        ▼        ▼
  ┌──────────┐┌────────┐┌──────────┐
  │ Internal ││ Thread ││  Class   │
  │ (direct  ││ (stack ││(Metaspace│
  │  buffers)││  leak) ││  leak)   │
  └────┬─────┘└────┬───┘└────┬─────┘
       │           │         │
       ▼           ▼         ▼
  BufferPool   Thread     Class count
  MBean        dump       jcmd stats
  pmap         ulimit
  async-profiler

Key Takeaway

The whole scenario turns on one diagnostic realization: a heap dump and GC logs only describe the Java object heap. If the heap is clean and the GC logs are stable but the operating system's resident set size keeps climbing, the leak is in native memory and heap tools are structurally blind to it. The JVM's real footprint is the heap plus many native regions: direct buffers, Metaspace, thread stacks, JNI allocations, and library native memory.

The most common off-heap culprit is direct byte buffers. A DirectByteBuffer allocates off-heap and frees it only when the Java wrapper is garbage-collected and the cleaner runs, so if you keep the wrappers alive or allocate faster than GC runs, native memory grows while the heap stays tiny. Netty pooled buffers are the same story: a missing release() leaves pooled arena memory unreturned.

The investigation switches tools entirely. Enable Native Memory Tracking and query with jcmd VM.native_memory summary.diff to see which category is growing. Cap direct memory with MaxDirectMemorySize as a guardrail. Use pmap for growing anonymous regions, and run async-profiler in native allocation mode to pinpoint the leaking call site.

At National Bank of India the document service that streamed statement PDFs had exactly this profile: RSS climbing for days while every heap dump was clean. NMT showed the Internal region growing, async-profiler traced it to retained direct buffers in the streaming path, and the fix was to release Netty buffers properly plus cap direct memory as a guardrail. The senior nuance is that on glibc Linux, native allocator fragmentation can inflate RSS even without a true leak, so confirm by testing MALLOC_ARENA_MAX or jemalloc.

Remember one sentence: if RSS grows while heap dumps are clean, the leak is off-heap, so switch from heap dumps to Native Memory Tracking, jcmd, pmap, and async-profiler to find which native region is growing.

---

Share
Use ← → arrow keys to navigate