跳到主内容

从 3.8GB 到 900MB:行情缓存 DirectMemory OOM 排查实录 ​

问题背景:堆内正常,进程却被 OOM Killer 干掉 ​

某证券行情网关在开盘后约 40 分钟开始出现异常:机器物理内存持续上涨,容器最终被 cgroup OOM Killer 终止,但 JVM 堆使用率始终稳定在 40% 左右,GC 日志也没有任何 Full GC 迹象。重启后现象复现,且与行情订阅连接数呈正相关。

这类问题的典型特征是:java.lang.OutOfMemoryError 不一定出现在业务线程堆栈里,更多时候进程是被操作系统直接杀掉,或者在 Netty 的 IO 线程中抛出 OutOfDirectMemoryError。行情缓存场景之所以高发,是因为 Level-2 快照、逐笔成交数据单条体积大、推送频率高,大量使用 ByteBuf 走零拷贝路径,一旦引用释放链路有缺口,泄漏的就是堆外内存。

方案对比:四种 DirectMemory 排查手段 ​

在动手之前,先明确不同工具的适用边界,避免一上来就加一堆参数导致线上服务雪崩。

现象确认RSS 高 / 堆内正常限制 MaxDirectMemory让错误快速暴露开启泄漏检测Netty LeakDetectorJMX / NMT 定位区分 JVM 与 Native修复引用计数缺口
  • JMX 指标法:通过 BufferPoolMXBean 观察 java.nio:type=BufferPool,name=direct 的 count 与 memoryUsed,零开销,适合先做趋势确认。
  • NMT(Native Memory Tracking):加 -XX:NativeMemoryTracking=summary,用 jcmd <pid> VM.native_memory summary 区分 Internal、Other 与 Direct 各占多少,缺点是有约 5%~10% 性能损耗。
  • Netty LeakDetector:把 -Dio.netty.leakDetection.level=PARANOID 打开,对每个 ByteBuf 采样并打印分配栈,是定位业务代码缺口最直接的方式,但全量采样开销大,建议先在预发或单实例灰度。
  • jemalloc + jeprof:替换 glibc 的 malloc,通过 profile 堆外分配调用栈,适合怀疑存在 JNI、压缩库等非 Netty 路径泄漏时使用。

本次故障中,先用 JMX 确认 direct 池从启动时的 80MB 单调上涨到 3.6GB,再用 LeakDetector 在预发环境抓到分配栈,整个过程不到一小时。

实操步骤:可运行的复现与修复 ​

第一步:用受限参数让问题快速暴露 ​

生产环境常见的错误是只设置了 -Xmx 而没有限制堆外内存,导致 DirectMemory 默认上限接近 -Xmx 值,问题被掩盖到物理内存耗尽。建议显式加上:

bash
java -Xms1g -Xmx1g \
     -XX:MaxDirectMemorySize=512m \
     -XX:NativeMemoryTracking=summary \
     -Dio.netty.leakDetection.level=SIMPLE \
     -jar quote-gateway.jar

这样一旦堆外突破 512MB,会立刻在申请线程抛出 OutOfDirectMemoryError,并带上分配现场,而不是让容器被静默杀死。

第二步:复现一个典型的行情缓存泄漏 ​

下面这段代码模拟了故障现场:解码出的行情快照被放入本地缓存,但 ByteBuf 引用计数没有随缓存淘汰正确释放。

java
import io.netty.buffer.ByteBuf;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * 行情快照缓存:错误示范
 * 问题:put 新值时,旧 ByteBuf 没有 release,堆外内存随 LRU 淘汰持续泄漏
 */
public class LeakyQuoteCache {
    private static final int MAX_ENTRIES = 10_000;

    private final Map<String, ByteBuf> cache = new LinkedHashMap<>(64, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<String, ByteBuf> eldest) {
            // 坑点:条目被淘汰时仅从 Map 移除,底层堆外内存并未归还
            return size() > MAX_ENTRIES;
        }
    };

    public void put(String symbol, ByteBuf snapshot) {
        cache.put(symbol, snapshot); // snapshot.retain() 与 release 未成对
    }

    public ByteBuf get(String symbol) {
        return cache.get(symbol);
    }
}

行情推送每秒上万条,10 分钟内被淘汰但未释放的 ByteBuf 就足以吃光默认堆外额度。

第三步:用引用计数成对原则修复 ​

修复的核心是明确所有权(ownership):进入缓存时 retain,被覆盖或淘汰时 release,读取方借用后不得释放。

java
import io.netty.buffer.ByteBuf;
import java.util.LinkedHashMap;
import java.util.Map;

/** 修复后的行情缓存:ByteBuf 引用计数严格成对 */
public class SafeQuoteCache {
    private static final int MAX_ENTRIES = 10_000;

    private final Map<String, ByteBuf> cache = new LinkedHashMap<>(64, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<String, ByteBuf> eldest) {
            boolean overflow = size() > MAX_ENTRIES;
            if (overflow) {
                releaseQuietly(eldest.getValue()); // 淘汰即释放
            }
            return overflow;
        }
    };

    public synchronized void put(String symbol, ByteBuf snapshot) {
        snapshot.retain();                 // 缓存取得所有权
        ByteBuf old = cache.put(symbol, snapshot);
        releaseQuietly(old);               // 覆盖旧值时释放
    }

    /** 读取方只借用,用完不要调用 release;如需长持有请自行 retain */
    public synchronized ByteBuf peek(String symbol) {
        return cache.get(symbol);
    }

    public synchronized void close() {
        cache.values().forEach(SafeQuoteCache::releaseQuietly);
        cache.clear();
    }

    private static void releaseQuietly(ByteBuf buf) {
        if (buf != null && buf.refCnt() > 0) {
            buf.release();
        }
    }
}

第四步:接入监控,让趋势可见 ​

不要只在出问题时手工排查,把 DirectMemory 指标常态化暴露给 Prometheus:

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

public class DirectMemoryProbe {
    public static long usedBytes() {
        List<BufferPoolMXBean> pools = ManagementFactory
                .getPlatformMXBeans(BufferPoolMXBean.class);
        return pools.stream()
                .filter(p -> "direct".equals(p.getName()))
                .mapToLong(BufferPoolMXBean::getMemoryUsed)
                .findFirst()
                .orElse(0L);
    }
}

配合 Micrometer 即可输出 jvm_buffer_memory_used_bytes{id="direct"}。本次修复后,该指标在行情高峰稳定在 850MB 上下,不再单调爬升,Full GC 次数与 Direct 占用解耦,问题闭环。

避坑总结:来自线上的四条经验 ​

  1. 只设 -Xmx 不设 MaxDirectMemorySize:现象是容器 RSS 一路涨到 limit 被静默 Kill,应用日志里没有任何 OOM。修正方法是显式设置 -XX:MaxDirectMemorySize,并保证容器 limit ≥ Xmx + MaxDirectMemory + Metaspace + 线程栈 + 约 30% Native 余量。

  2. Netty 解码器中 byteBuf.toString() 后忘记 release:常见错误堆栈是 LEAK: ByteBuf.release() was not called before it's garbage-collected,业务却以为转成 String 后就与 ByteBuf 无关。实际上 toString 只是复制内容,原 ByteBuf 仍需在 finally 中释放,或使用 SimpleChannelInboundHandler 由框架自动释放。

  3. 缓存淘汰与引用计数脱节:表现为 DirectMemory 随运行时长线性增长、重启后清零。Guava Cache、Caffeine 的 removalListener 是异步执行的,监听器里释放 ByteBuf 可能滞后,应在 put 覆盖路径与 invalidate 路径同步 release,并对缓存关闭流程做兜底。

  4. LeakDetector 长期开 PARANOID 导致 CPU 飙升:该级别会对每次分配做栈追踪,压测时 QPS 可能直接腰斩。正确姿势是预发用 PARANOID 快速抓栈,生产默认 SIMPLE(约 1% 采样),并把泄漏告警接入日志关键字监控,既保留发现能力又不影响吞吐。

堆外内存问题看似玄学,本质上仍是资源所有权问题。只要把「谁分配、谁持有、谁释放」这条链路讲清楚,再配合上限参数与常态化指标,DirectMemory OOM 完全可以在演变成线上事故之前被发现。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。