编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • JVM内存模型与对象
      • 类加载与双亲委派
      • 垃圾回收与GC调优
      • 异常体系与JVM机制
      • 字节码指令集javap实战
      • JIT编译与去优化机制
      • JVM性能诊断工具链
      • OOM八大现场全景剖析
      • JVM参数调优全景图
      • GraalVM与AOT编译原理
      • HashMap底层哈希设计
      • String不可变与常量池
      • ArrayList与LinkedList源码
      • ConcurrentHashMap并发
      • TreeMap与红黑树原理
      • LinkedHashMap与LRU实现
      • Java数字类型原理
      • Object通用方法的契约
      • 泛型擦除与类型系统
      • 枚举原理与最佳实践
      • 注解原理与编译期处理
      • Lambda与引用底层原理
      • Stream原理与流水线设计
      • Optional设计原理
      • Record密封类与模式
      • 反射机制与动态代理
      • MethodHandle与VarHandle
      • 三大字节码框架对比
      • JavaAgent与Instrumentation机制
      • AOP三种实现路线对比
      • synchronized与锁升级
      • volatile与JMM内存模型
      • 线程池核心源码设计
      • Thread线程生命周期
      • AQS同步框架源码
      • 并发锁三剑客
      • CAS和Atomic深入分析
      • 五大同步器对比
      • CompletableFuture异步
      • IO模型演进BIO到AIO
      • ByteBuffer与堆外内存
        • 1. 案例引入
          • 1.1 一段反常代码
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 三大子模块
          • 2.2 为什么这么切
        • 3. 状态机四指针
          • 3.1 四个核心指针
          • 3.2 三大不变式
          • 3.3 flip切换读写
          • 3.4 compact残料压缩
          • 3.5 rewind与clear
        • 4. Heap与Direct双形态
          • 4.1 类继承全景图
          • 4.2 HeapByteBuffer结构
          • 4.3 DirectByteBuffer结构
          • 4.4 IOUtil的临时拷贝
          • 4.5 临时缓冲区池
        • 5. 堆外内存分配
          • 5.1 Unsafe的allocateMemory
          • 5.2 Bits全局账簿
          • 5.3 MaxDirectMemorySize限额
          • 5.4 触发SystemGC回退
        • 6. 堆外回收链路
          • 6.1 Cleaner幻引用机制
          • 6.2 Deallocator回收任务
          • 6.3 JDK9前后的Cleaner
          • 6.4 引用处理器线程
        • 7. 堆外泄漏排查
          • 7.1 四大泄漏现场
          • 7.2 NMT原生跟踪
          • 7.3 BufferPoolMXBean读数
          • 7.4 jcmd与pmap实战
          • 7.5 容器场景的坑
        • 8. 零拷贝家族
          • 8.1 mmap内存映射
          • 8.2 sendfile文件传输
          • 8.3 splice管道拷贝
          • 8.4 三者对应的Java API
        • 9. Netty为何自造ByteBuf
          • 9.1 ByteBuffer五大短板
          • 9.2 双指针读写分离
          • 9.3 引用计数与池化
          • 9.4 CompositeByteBuf零拷贝
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一个Buffer的一生
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • 序列化原理与替代方案
      • 文件IO与NIO.2
      • 面向对象的真意
      • JDK设计模式上
      • JDK设计模式下
      • SPI与模块化设计
  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Java入门精通
  • 专栏博客
杨充
2026-06-02
目录

ByteBuffer与堆外内存

# 41.ByteBuffer与堆外内存

# 目录介绍

  • 1. 案例引入
    • 1.1 一段反常代码
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 三大子模块
    • 2.2 为什么这么切
  • 3. 状态机四指针
    • 3.1 四个核心指针
    • 3.2 三大不变式
    • 3.3 flip切换读写
    • 3.4 compact残料压缩
    • 3.5 rewind与clear
  • 4. Heap与Direct双形态
    • 4.1 类继承全景图
    • 4.2 HeapByteBuffer结构
    • 4.3 DirectByteBuffer结构
    • 4.4 IOUtil的临时拷贝
    • 4.5 临时缓冲区池
  • 5. 堆外内存分配
    • 5.1 Unsafe的allocateMemory
    • 5.2 Bits全局账簿
    • 5.3 MaxDirectMemorySize限额
    • 5.4 触发SystemGC回退
  • 6. 堆外回收链路
    • 6.1 Cleaner幻引用机制
    • 6.2 Deallocator回收任务
    • 6.3 JDK9前后的Cleaner
    • 6.4 引用处理器线程
  • 7. 堆外泄漏排查
    • 7.1 四大泄漏现场
    • 7.2 NMT原生跟踪
    • 7.3 BufferPoolMXBean读数
    • 7.4 jcmd与pmap实战
    • 7.5 容器场景的坑
  • 8. 零拷贝家族
    • 8.1 mmap内存映射
    • 8.2 sendfile文件传输
    • 8.3 splice管道拷贝
    • 8.4 三者对应的Java API
  • 9. Netty为何自造ByteBuf
    • 9.1 ByteBuffer五大短板
    • 9.2 双指针读写分离
    • 9.3 引用计数与池化
    • 9.4 CompositeByteBuf零拷贝
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一个Buffer的一生
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 1. 案例引入

# 1.1 一段反常代码

我们在做一个网关项目,转发后端 HTTP 响应。压测时一切正常,但跑到第 8 个小时,容器被 K8s 以 OOMKilled 杀掉。诡异的是 -Xmx2g,而容器 limit 是 4g,从堆视角看离 OOM 还远得很。

// 简化后的转发核心代码
public void forward(SocketChannel down, ByteBuffer src) throws IOException {
    // src 是 HeapByteBuffer,业务层从字符串编码而来
    while (src.hasRemaining()) {
        down.write(src);  // 大量业务线程调用,QPS 8k
    }
}

// 启动参数
// -Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxMetaspaceSize=512m
// 没有显式设置 -XX:MaxDirectMemorySize
1
2
3
4
5
6
7
8
9
10
11

我们 dump 了堆,堆用得不到 800MB,元空间 200MB,加起来不过 1GB。但 pmap -x <pid> 发现 RSS 已经 3.8GB,多出来的 2.8GB 不知道躲在哪。

# 1.2 顺藤摸到根因

执行 jcmd <pid> VM.native_memory summary(提前加了 -XX:NativeMemoryTracking=detail),看到 Internal 区域占用 2.6GB。再用 BufferPoolMXBean 读 direct 池:

ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class).forEach(p ->
    System.out.printf("%s count=%d used=%d capacity=%d%n",
            p.getName(), p.getCount(), p.getMemoryUsed(), p.getTotalCapacity()));
// direct count=2,418,663 used=2,789,103,616 capacity=2,789,103,616
1
2
3
4

direct 池 240 万个对象、2.6GB——这数字不可能是业务直接分配的。我们没写一行 ByteBuffer.allocateDirect,凭空多出来的 DirectByteBuffer 是哪儿来的?

# 1.3 我们要回答什么

这个事故把我们逼到了 ByteBuffer 的腹地,必须回答:

  1. HeapByteBuffer 写到 Channel,明明是堆内字节,为什么会扯出 DirectByteBuffer?
  2. 没设 -XX:MaxDirectMemorySize,堆外内存的上限到底是多少?
  3. DirectByteBuffer 的内存什么时候释放?是 GC 回收吗?回收链路上谁是关键?
  4. 为什么 240 万个 DirectByteBuffer 没有被及时回收?是什么阻断了回收?
  5. 堆外泄漏在 NMT、pmap、jcmd、BufferPoolMXBean 这几个工具里分别长什么样?
  6. 既然 DirectByteBuffer 这么坑,Netty 的 ByteBuf 到底改进了什么?

带着这 6 个疑问进入正文。

# 2. 架构概览

# 2.1 三大子模块

ByteBuffer 看似一个类,实则横跨三层架构:

┌────────────────────────────────────────────────────────┐
│ 用户态视图:Buffer 抽象 + 状态机                        │
│   position / limit / capacity / mark + flip/compact    │
└────────────────────────────────────────────────────────┘
                          │
        ┌─────────────────┴─────────────────┐
        ▼                                   ▼
┌──────────────────┐             ┌──────────────────────┐
│ HeapByteBuffer   │             │ DirectByteBuffer     │
│   byte[] hb      │             │   long address       │
│   GC 管理         │             │   Unsafe 管理        │
└──────────────────┘             └──────────────────────┘
                                          │
                                          ▼
                                ┌──────────────────────┐
                                │ Cleaner + Bits 账簿   │
                                │ MaxDirectMemorySize   │
                                └──────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  • 状态机层:Buffer 抽象类持有四个 int 指针,定义读写位置语义
  • 存储层:HeapByteBuffer 用 byte[],DirectByteBuffer 用 native 内存
  • 回收层:DirectByteBuffer 走 Cleaner + PhantomReference,不在 GC 直接掌管的领地内

# 2.2 为什么这么切

疑惑:为什么 NIO 不直接用 byte[] 解决一切?

论证:

  1. 系统调用要求连续内存。read/write 系统调用需要传一段连续物理地址给内核,而 byte[] 在堆里可能被 GC 移动(G1/CMS 都会移动对象),地址不稳定
  2. GC 不能 pin 内存到内核的 IO 期间。如果非要用 byte[],需要在 IO 期间禁止移动,会卡住 GC
  3. 拷贝代价高于"借一块堆外"。直接在堆外开一块永远不动的内存,让内核读写,比每次 IO 都暂停 GC 划算得多

结论:DirectByteBuffer 不是为了"快",而是为了让 GC 和系统调用解耦。HeapByteBuffer 是用户友好的接口,DirectByteBuffer 是内核友好的接口,二者通过 IOUtil 桥接。

# 3. 状态机四指针

# 3.1 四个核心指针

java.nio.Buffer 的四个 int 字段,是整个 NIO 的灵魂:

指针 含义 范围
mark 标记点(书签) -1 表示未设
position 当前读/写位置 0 ≤ position ≤ limit
limit 读/写上界 0 ≤ limit ≤ capacity
capacity 容量(不变) 创建时确定
        ┌─────────────────────────────────────────┐
        │  0      mark   position   limit  capacity│
        │  │       │        │         │        │  │
        ▼  ▼       ▼        ▼         ▼        ▼  ▼
        ├───────────────────────────────────────────┤
buffer  │ A B C D │ E F G H │ I J K L M │ . . . . . │
        ├───────────────────────────────────────────┤
                  └─已读─┘  └─待读/待写─┘  └─空闲─┘
1
2
3
4
5
6
7
8

# 3.2 三大不变式

源码 Buffer 类构造函数明确写死的约束:

// java.nio.Buffer
Buffer(int mark, int pos, int lim, int cap, int elementSizeShift) {
    if (cap < 0) throw new IllegalArgumentException("Negative capacity: " + cap);
    this.capacity = cap;
    limit(lim);     // 内部会校验 lim ≤ cap
    position(pos);  // 内部会校验 pos ≤ lim
    if (mark >= 0) {
        if (mark > pos) throw new IllegalArgumentException(...);
        this.mark = mark;
    }
}
1
2
3
4
5
6
7
8
9
10
11

不变式(任何时刻必须成立):

0 ≤ mark ≤ position ≤ limit ≤ capacity
1

疑惑:为什么要四个指针,不能两个搞定?

论证:

  1. 单一指针只能描述"读到哪"或"写到哪",无法表达"已写多少 + 当前读到哪"
  2. 两个指针(pos + cap)对读写共用,每次切换状态需要外部记录长度
  3. 四指针 + flip 操作把"模式切换"封装在 Buffer 内部,调用方无需自己记账
  4. mark 单独抽出来,是为了支持 reset 语义(解析协议时回退)

结论:四指针是把"模式 + 状态 + 书签"三件事压缩到一个对象里的最小完备表达。

# 3.3 flip切换读写

写完之后要读,必须调 flip():

// java.nio.Buffer.flip()
public Buffer flip() {
    limit = position;   // limit 移到刚写到的位置
    position = 0;       // position 归零,从头读
    mark = -1;          // 丢弃书签
    return this;
}
1
2
3
4
5
6
7

图示:

flip 之前(写模式):
  ├──写入数据──┤      (空闲)
  0          position              capacity=limit

flip 之后(读模式):
  ├──待读数据──┤      (空闲,但 limit 挡住)
  0          limit                 capacity
position=0
1
2
3
4
5
6
7
8

疑惑:为什么不用两套 API(writePos/readPos)而要 flip?

论证:写和读对底层数组操作完全相同,区别只在"边界在哪"。flip 通过移动 limit 把边界从"容量上限"切换为"已写入量",无需再开一套字段,省一半内存且 API 对称。

# 3.4 compact残料压缩

读完一半的 Buffer 想继续接收新数据,需要 compact:

// java.nio.HeapByteBuffer.compact() (DirectByteBuffer 类似)
public ByteBuffer compact() {
    System.arraycopy(hb, ix(position()), hb, ix(0), remaining());
    position(remaining());  // position 移到残料末尾
    limit(capacity());      // limit 拉回容量
    mark = -1;
    return this;
}
1
2
3
4
5
6
7
8
compact 之前(读模式):
  ├─已读─┤├─未读─┤
         pos    limit

compact 之后(写模式):
  ├─未读─┤              ├─空闲─┤
        pos             limit=capacity
        ↑
        新数据从这里写入
1
2
3
4
5
6
7
8
9

compact 是 TCP 拆包的核心套路:解析掉一个完整包后,把残料挪到头部,让 Channel 继续往后写。

# 3.5 rewind与clear

四件套小结:

方法 作用 典型场景
flip() limit=pos, pos=0 写完准备读
compact() 拷贝残料到头部 读了一半还要继续接收
rewind() pos=0, limit 不变 重读一遍
clear() pos=0, limit=cap 完全重置回写模式

注意:clear() 不擦数据,只重置指针——老数据还在,新写入会覆盖。这一点常被误用。

# 4. Heap与Direct双形态

# 4.1 类继承全景图

                Buffer(abstract)
                   │
                ByteBuffer
                   │
        ┌──────────┼──────────────────────┐
        │          │                      │
HeapByteBuffer  MappedByteBuffer    DirectByteBuffer
        │          │                      │
   byte[] hb     mmap 区             long address
                                          │
                                  Cleaner + PhantomRef
1
2
3
4
5
6
7
8
9
10
11

# 4.2 HeapByteBuffer结构

// java.nio.HeapByteBuffer
class HeapByteBuffer extends ByteBuffer {
    HeapByteBuffer(int cap, int lim) {
        super(-1, 0, lim, cap, new byte[cap], 0);  // 关键:new byte[cap]
    }
    public byte get() { return hb[ix(nextGetIndex())]; }
    public ByteBuffer put(byte x) { hb[ix(nextPutIndex())] = x; return this; }
}
1
2
3
4
5
6
7
8

底层就是普通 byte[],归 GC 管。优点是分配/回收都极快(TLAB 分配 + GC 自动回收),缺点是地址会移动,IO 时必须临时拷贝。

# 4.3 DirectByteBuffer结构

// java.nio.DirectByteBuffer
class DirectByteBuffer extends MappedByteBuffer {
    long address;       // 堆外内存起始地址(关键字段,继承自 Buffer)
    Cleaner cleaner;    // 回收钩子

    DirectByteBuffer(int cap) {
        super(-1, 0, cap, cap);
        boolean pa = VM.isDirectMemoryPageAligned();
        int ps = Bits.pageSize();
        long size = Math.max(1L, (long)cap + (pa ? ps : 0));
        Bits.reserveMemory(size, cap);   // ① 全局账簿预留
        long base;
        try {
            base = unsafe.allocateMemory(size);   // ② 真正分配堆外
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
        unsafe.setMemory(base, size, (byte) 0);
        address = base;
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));  // ③ 注册回收钩子
        att = null;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

三步骤:预留 → 分配 → 挂回收钩子。

# 4.4 IOUtil的临时拷贝

回到第 1 章疑问 ①:HeapByteBuffer 写到 Channel 为什么会扯出 DirectByteBuffer?看源码:

// sun.nio.ch.IOUtil.write(...)
static int write(FileDescriptor fd, ByteBuffer src, ...) throws IOException {
    if (src instanceof DirectBuffer)
        return writeFromNativeBuffer(fd, src, ...);   // 直接写

    // src 是 HeapByteBuffer
    int pos = src.position();
    int lim = src.limit();
    int rem = (pos <= lim ? lim - pos : 0);

    ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);  // ★ 临时申请一块堆外
    try {
        bb.put(src);   // 把堆内数据拷到堆外
        bb.flip();
        src.position(pos);
        int n = writeFromNativeBuffer(fd, bb, ...);
        if (n > 0) src.position(pos + n);
        return n;
    } finally {
        Util.offerFirstTemporaryDirectBuffer(bb);   // 归还到 ThreadLocal 池
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

结论:HeapByteBuffer 走系统调用前,JDK 强制把数据拷到一块临时 DirectBuffer,因为内核需要稳定地址。这就是案例里 240 万 DirectBuffer 的来源——业务层每次 write 都触发临时分配。

# 4.5 临时缓冲区池

JDK 试图缓解这个开销:

// sun.nio.ch.Util
private static ThreadLocal<BufferCache> bufferCache = new ThreadLocal<>();

static ByteBuffer getTemporaryDirectBuffer(int size) {
    if (isBufferTooLarge(size)) {
        return ByteBuffer.allocateDirect(size);  // 太大不缓存
    }
    BufferCache cache = bufferCache.get();
    ByteBuffer buf;
    if (cache != null && (buf = cache.get(size)) != null) {
        return buf;          // 命中缓存
    }
    if (cache == null) {
        cache = new BufferCache();
        bufferCache.set(cache);
    }
    return ByteBuffer.allocateDirect(size);   // 未命中
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

坑点:bufferCache 是 ThreadLocal,每个线程一份。Tomcat、Netty、自定义线程池里有多少线程,最多就缓存多少倍。Tomcat 默认 200 工作线程 × 3 个槽 × 平均 64KB ≈ 38MB 堆外起步,且永不回收。

回到案例:业务用了一个 Executors.newCachedThreadPool(),瞬时高峰创建了上万临时线程,每个线程的 ThreadLocal 都被植入了 DirectBuffer,线程死了但 ThreadLocal 的 Entry 因为没被清理而泄漏 —— 这是泄漏的第一只手。

# 5. 堆外内存分配

# 5.1 Unsafe的allocateMemory

// sun.misc.Unsafe(OpenJDK 实现委托给 os::malloc)
public native long allocateMemory(long bytes);
public native void freeMemory(long address);
public native void setMemory(long address, long bytes, byte value);
1
2
3
4

这就是简化版的 malloc/free,绕过 GC,直接调 glibc。返回值是一个 long 型地址,就是 DirectByteBuffer.address 字段的来源。

# 5.2 Bits全局账簿

JVM 不能让用户无限分配堆外内存,否则物理内存秒被吃光。java.nio.Bits 维护一本全局账簿:

// java.nio.Bits
private static final AtomicLong reservedMemory = new AtomicLong();   // 实际占用
private static final AtomicLong totalCapacity = new AtomicLong();    // 用户视角容量
private static final AtomicLong count = new AtomicLong();            // DirectBuffer 个数

static void reserveMemory(long size, long cap) {
    if (!memoryLimitSet) {
        maxMemory = VM.maxDirectMemory();   // 读 -XX:MaxDirectMemorySize
        memoryLimitSet = true;
    }
    if (tryReserveMemory(size, cap)) return;

    // 第一次失败:触发 Reference Handler 处理积压的 PhantomReference
    final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
    while (jlra.tryHandlePendingReference()) {
        if (tryReserveMemory(size, cap)) return;
    }

    // 第二次失败:兜底 System.gc()
    System.gc();    // ★ 关键
    ...
    throw new OutOfMemoryError("Direct buffer memory");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 5.3 MaxDirectMemorySize限额

回到第 1 章疑问 ②:没设 -XX:MaxDirectMemorySize 时上限是多少?

// jdk.internal.misc.VM.maxDirectMemory()
private static long directMemory = 0L;
public static long maxDirectMemory() { return directMemory; }

// 初始化时(VM.initializeFromArchive 里读取)
String s = (String) props.get("sun.nio.MaxDirectMemorySize");
if (s == null || s.isEmpty() || s.equals("-1")) {
    directMemory = Runtime.getRuntime().maxMemory();   // 等于 -Xmx
} else {
    long l = Long.parseLong(s);
    if (l > -1) directMemory = l;
}
1
2
3
4
5
6
7
8
9
10
11
12

结论:未显式设置时,堆外上限默认等于 -Xmx。案例里 -Xmx2g ⇒ 堆外也允许 2GB,加上堆内 2GB,已经逼近容器 4GB 上限——这是事故的第二只手。

# 5.4 触发SystemGC回退

reserveMemory 失败时调 System.gc(),这是 NIO 的兜底机制:让 Full GC 顺便清理 PhantomReference 队列里的 DirectBuffer。但有两个陷阱:

  1. -XX:+DisableExplicitGC 会让 System.gc() 变空操作——堆外回收彻底失灵
  2. -XX:+ExplicitGCInvokesConcurrent 也救不了:CMS/G1 的并发 GC 不会立即处理 PhantomRef

很多调优文章建议关掉 System.gc(),对纯堆内服务可以,对 NIO 服务就是埋雷。

# 6. 堆外回收链路

# 6.1 Cleaner幻引用机制

DirectByteBuffer 不能用 finalize(性能差且不可靠),用的是 PhantomReference 子类 Cleaner:

// jdk.internal.ref.Cleaner
public class Cleaner extends PhantomReference<Object> {
    private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue<>();
    private final Runnable thunk;

    public static Cleaner create(Object referent, Runnable thunk) {
        if (thunk == null) return null;
        return add(new Cleaner(referent, thunk));
    }

    public void clean() {
        if (!remove(this)) return;
        try {
            thunk.run();    // ★ 真正释放堆外
        } catch (final Throwable x) { ... }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

幻引用的特点:referent 不可达时不会被自动 enqueue 到 ReferenceQueue 让用户感知,而是由 Reference Handler 线程在处理时调用其 clean() 方法(JDK 内部对 Cleaner 类型做了特殊处理)。

# 6.2 Deallocator回收任务

thunk 是这个:

// java.nio.DirectByteBuffer.Deallocator
private static class Deallocator implements Runnable {
    private long address;
    private long size;
    private int capacity;

    public void run() {
        if (address == 0) return;
        unsafe.freeMemory(address);     // ① 真正 free
        address = 0;
        Bits.unreserveMemory(size, capacity);  // ② 销账
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

完整链路:

flowchart LR
    A[DirectByteBuffer<br/>对象不可达] --> B[GC 发现<br/>PhantomRef referent 死亡]
    B --> C[Reference Handler<br/>线程处理 pending]
    C --> D[Cleaner.clean<br/>调用 thunk]
    D --> E[Deallocator.run]
    E --> F[Unsafe.freeMemory<br/>+ Bits.unreserveMemory]
1
2
3
4
5
6

# 6.3 JDK9前后的Cleaner

版本 包路径 反射访问
JDK 8 sun.misc.Cleaner 反射可达,Netty 直接调 cleaner().clean()
JDK 9+ jdk.internal.ref.Cleaner 模块化封装,需要 --add-opens

JDK 9 后官方推荐 java.lang.ref.Cleaner(公共 API),但 DirectByteBuffer 内部仍然用 jdk.internal.ref.Cleaner 以兼容旧代码。

Netty 兼容代码(PlatformDependent0):

// 简化逻辑
Object cleaner = directBufferConstructor.invoke(buffer);
Method cleanMethod = cleaner.getClass().getMethod("clean");
cleanMethod.invoke(cleaner);  // 主动释放,不等 GC
1
2
3
4

# 6.4 引用处理器线程

$ jstack <pid> | grep -i "Reference Handler"
"Reference Handler" #2 daemon prio=10 os_prio=31 cpu=0.85ms tid=0x...
   java.lang.Thread.State: RUNNABLE
   ...
1
2
3
4

这是一个 JVM 内置守护线程,优先级 10(最高)。但它的工作量是 O(n) 的:积压的 PhantomReference 越多,单次处理越久。如果业务每秒分配 1 万个 DirectBuffer,Reference Handler 处理速度跟不上,就会积压——这是泄漏的第三只手。

# 7. 堆外泄漏排查

# 7.1 四大泄漏现场

现场 触发条件 排查工具
ThreadLocal 临时池囤积 大量短命线程 jstack 看线程数 + jmap 看 ThreadLocalMap
业务层 allocateDirect 漏 close 没用 try-with-resources NMT detail diff
Cleaner 慢于分配 高并发 IO jstack 看 ReferenceHandler 是否 RUNNABLE 长时间
-XX:+DisableExplicitGC 配置错误 jinfo -flag 检查

# 7.2 NMT原生跟踪

# 启动加参数
-XX:NativeMemoryTracking=detail

# 运行时打基线
$ jcmd <pid> VM.native_memory baseline

# 一段时间后比较
$ jcmd <pid> VM.native_memory summary.diff
Total: reserved=4096MB +600MB, committed=3800MB +600MB
- Java Heap (reserved=2048MB)
- Internal (reserved=1200MB +600MB committed)   ← 关注 Internal
- Class (reserved=200MB)
...
1
2
3
4
5
6
7
8
9
10
11
12
13

Internal 区主要包含 DirectByteBuffer。+600MB 就是泄漏的增量。

# 7.3 BufferPoolMXBean读数

代码层面快速观察:

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

ScheduledExecutorService s = Executors.newSingleThreadScheduledExecutor();
s.scheduleAtFixedRate(() -> {
    ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class).forEach(p ->
        System.out.printf("[%s] count=%d used=%dMB%n",
            p.getName(), p.getCount(), p.getMemoryUsed() / 1024 / 1024));
}, 0, 10, TimeUnit.SECONDS);
1
2
3
4
5
6
7
8
9

输出:

[direct] count=2418663 used=2659MB     ← 不正常
[mapped] count=12 used=8MB
1
2

# 7.4 jcmd与pmap实战

# 看 Java 视角
$ jcmd <pid> VM.system_properties | grep -i direct
$ jcmd <pid> GC.run                        # 主动触发,验证回收能力

# 看 OS 视角
$ pmap -x <pid> | sort -k3 -n -r | head -20
# 找出最大的几个 anon 段,对比 NMT detail 里的地址区间
1
2
3
4
5
6
7

# 7.5 容器场景的坑

容器(Docker/K8s)下 Runtime.maxMemory() 在老 JDK 里可能读宿主机内存。JDK 10+ 的 +UseContainerSupport 修了这个 bug。事故的最终修复方案:

# 修复参数(叠加四件套)
-Xmx2g
-XX:MaxDirectMemorySize=512m              # ★ 显式上限
-XX:NativeMemoryTracking=summary
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintNMTStatistics
# 删除:-XX:+DisableExplicitGC(如果有的话)

# 业务层:限制临时线程数,改用固定线程池
ExecutorService pool = Executors.newFixedThreadPool(64);  // 替代 cachedThreadPool
1
2
3
4
5
6
7
8
9
10

# 8. 零拷贝家族

# 8.1 mmap内存映射

FileChannel fc = FileChannel.open(path);
MappedByteBuffer mbb = fc.map(MapMode.READ_ONLY, 0, fc.size());
// 后续读 mbb 就像读普通内存,OS 自动按需 page in
1
2
3

原理:mmap 把文件映射到进程虚拟地址空间,省掉一次"内核 page cache → 用户缓冲区"的拷贝。

适用:大文件随机读、文件作为共享内存。

# 8.2 sendfile文件传输

FileChannel src = ...;
SocketChannel dst = ...;
src.transferTo(0, src.size(), dst);   // ← 底层 sendfile
1
2
3

原理:sendfile 让数据完全不经过用户态,从磁盘 page cache 直接搬到网卡缓冲区。

传统 4 次拷贝:磁盘 → 内核缓冲 → 用户缓冲 → socket 缓冲 → 网卡
sendfile:    磁盘 → 内核缓冲 ──────────────→ socket 缓冲 → 网卡
1
2

适用:静态资源服务(Nginx 默认开启,Kafka 消费拉日志)。

# 8.3 splice管道拷贝

splice 比 sendfile 更通用:任意两个 fd 间通过管道做零拷贝。Java 没直接 API,但 Linux 下 transferTo 在某些 fd 组合上会自动选用 splice。

# 8.4 三者对应的Java API

系统调用 Java API 典型场景
mmap FileChannel.map() 随机读大文件
sendfile FileChannel.transferTo() 文件 → Socket
splice (间接,由 JVM 决定) Socket ↔ Socket

# 9. Netty为何自造ByteBuf

# 9.1 ByteBuffer五大短板

回到第 1 章疑问 ⑥:

短板 痛点
单指针 + flip 强制切换 写读不能交叉,每次切换易错
不支持池化 高并发下分配/释放压力大
不支持引用计数 共享时谁释放说不清
不支持容量自动扩展 写满即抛 BufferOverflowException
拼接需要拷贝 多 Buffer 合并必须 copy

# 9.2 双指针读写分离

ByteBuf 内存视图:
 ┌──────────┬──────────────┬───────────┐
 │ 已废弃区  │  可读区       │  可写区    │
 └──────────┴──────────────┴───────────┘
 0    readerIndex    writerIndex   capacity
1
2
3
4
5

读写指针独立,无需 flip。

# 9.3 引用计数与池化

ByteBuf buf = ByteBufAllocator.DEFAULT.directBuffer(1024);
buf.retain();           // 引用计数 +1
try {
    process(buf);
} finally {
    buf.release();      // 引用计数 -1,归 0 时归还到池或 free
}
1
2
3
4
5
6
7

PooledByteBufAllocator 仿 jemalloc 设计:PoolArena → PoolChunk(Buddy) → PoolSubpage(Slab),详见 43 篇。

# 9.4 CompositeByteBuf零拷贝

合并两个 ByteBuf 不需要拷贝:

CompositeByteBuf composite = ByteBufAllocator.DEFAULT.compositeBuffer();
composite.addComponents(true, header, body);
// composite 内部记录 [header, body] 两个组件的偏移量
// 读取时透明跨组件
1
2
3
4

Header + Body 拼装、HTTP 分块响应,全靠它把"逻辑连续 + 物理分散"做合一。

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章 6 个疑问,逐条作答:

# 疑问 答案
① HeapByteBuffer 写 Channel 为何扯出 DirectBuffer? IOUtil.write 必须把堆内数据拷到临时 DirectBuffer,因为内核需要稳定地址(§4.4)
② 没设 MaxDirectMemorySize 上限是多少? 默认等于 -Xmx,案例里 2GB——加上堆内 2GB 已逼近容器 4GB(§5.3)
③ DirectBuffer 内存什么时候释放? DirectBuffer 对象不可达 → Reference Handler 触发 Cleaner.clean → Deallocator.run → Unsafe.freeMemory(§6.2)
④ 240 万个 DirectBuffer 为什么没被回收? Util.bufferCache 是 ThreadLocal,cachedThreadPool 创建的临时线程死亡前 DirectBuffer 被强引用囤积(§4.5);Reference Handler 处理速度跟不上分配(§6.4)
⑤ 各工具长什么样? NMT Internal 增长、BufferPoolMXBean.direct 计数飙、pmap 看到 RSS 远超堆(§7)
⑥ Netty ByteBuf 改了什么? 双指针、引用计数、池化、自动扩展、CompositeByteBuf 零拷贝(§9)

# 10.2 一个Buffer的一生

跟着 ByteBuffer.allocate(1024).put(data).flip() 然后 channel.write(buf) 走一遍:

flowchart TD
    A[ByteBuffer.allocate 1024] --> B[new HeapByteBuffer<br/>byte[1024] 在 Eden 分配]
    B --> C[put 写数据<br/>position 推进]
    C --> D[flip<br/>limit=pos, pos=0]
    D --> E[channel.write 调用]
    E --> F{src 是 Direct?}
    F -->|否| G[IOUtil 找 ThreadLocal<br/>临时 DirectBuffer]
    G --> H{有缓存?}
    H -->|否| I[new DirectByteBuffer<br/>Bits.reserveMemory<br/>Unsafe.allocateMemory<br/>Cleaner.create]
    H -->|是| J[复用]
    I --> K[bb.put src 拷贝]
    J --> K
    K --> L[写入 fd<br/>系统调用]
    L --> M[归还 DirectBuffer<br/>到 ThreadLocal]
    M --> N[线程死亡或<br/>GC 才会真释放]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

整条链路上,HeapByteBuffer 的生命由 GC 管,DirectByteBuffer 的生命由 Cleaner 管,但 Cleaner 触发时机依赖 GC——这是堆外内存"不在堆里却仍受 GC 影响"的本质。

# 10.3 设计哲学回扣

  1. 内存即合约:四指针不变式 0 ≤ mark ≤ pos ≤ limit ≤ cap 是用户与 Buffer 之间的契约,违反就抛异常——比"程序员自觉"可靠 10 倍
  2. 堆内堆外是机制不是好坏:堆内 GC 友好,堆外内核友好,IOUtil 的临时拷贝是它们之间的桥,不是 bug 是 feature
  3. 回收链路要有兜底:Cleaner 是主路径,System.gc() 是兜底——任何"关掉 SystemGC"的优化都要先验证不影响堆外
  4. 观测先于优化:堆外泄漏没有"堆 dump"这么直观的工具,必须 NMT + BufferPoolMXBean + pmap 三方对账,少一个角度就可能找错地方

# 10.4 速查表

维度 速查
默认堆外上限 等于 -Xmx(未设 -XX:MaxDirectMemorySize 时)
回收触发 DirectBuffer 不可达 + GC + Reference Handler
立即释放 ((DirectBuffer)buf).cleaner().clean()(需反射)
排查工具 NMT Internal、BufferPoolMXBean、pmap、jcmd
危险参数 -XX:+DisableExplicitGC 会让堆外 OOM
flip vs clear flip 切读,clear 重置回写(不擦数据)
compact 时机 TCP 拆包后留残料
Netty 改进 双指针、引用计数、池化、自动扩、Composite

下一篇我们顺着"堆外内存 + 池化 + 引用计数"这条线,进入 43 篇《Netty 核心架构剖析》——看 Netty 如何把这些原料组合成工业级网络框架。

上次更新: 2026/06/10, 11:13:41
IO模型演进BIO到AIO
序列化原理与替代方案

← IO模型演进BIO到AIO 序列化原理与替代方案→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式