外观
从 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 排查手段
在动手之前,先明确不同工具的适用边界,避免一上来就加一堆参数导致线上服务雪崩。
- 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 占用解耦,问题闭环。
避坑总结:来自线上的四条经验
只设
-Xmx不设MaxDirectMemorySize:现象是容器 RSS 一路涨到 limit 被静默 Kill,应用日志里没有任何 OOM。修正方法是显式设置-XX:MaxDirectMemorySize,并保证容器 limit ≥ Xmx + MaxDirectMemory + Metaspace + 线程栈 + 约 30% Native 余量。Netty 解码器中
byteBuf.toString()后忘记 release:常见错误堆栈是LEAK: ByteBuf.release() was not called before it's garbage-collected,业务却以为转成 String 后就与 ByteBuf 无关。实际上toString只是复制内容,原 ByteBuf 仍需在 finally 中释放,或使用SimpleChannelInboundHandler由框架自动释放。缓存淘汰与引用计数脱节:表现为 DirectMemory 随运行时长线性增长、重启后清零。Guava Cache、Caffeine 的
removalListener是异步执行的,监听器里释放 ByteBuf 可能滞后,应在put覆盖路径与invalidate路径同步 release,并对缓存关闭流程做兜底。LeakDetector 长期开 PARANOID 导致 CPU 飙升:该级别会对每次分配做栈追踪,压测时 QPS 可能直接腰斩。正确姿势是预发用 PARANOID 快速抓栈,生产默认 SIMPLE(约 1% 采样),并把泄漏告警接入日志关键字监控,既保留发现能力又不影响吞吐。
堆外内存问题看似玄学,本质上仍是资源所有权问题。只要把「谁分配、谁持有、谁释放」这条链路讲清楚,再配合上限参数与常态化指标,DirectMemory OOM 完全可以在演变成线上事故之前被发现。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。