directbuffer 分配开销大因需调用 unsafe.allocatememory 申请堆外内存,涉及 native 调用、内存对齐、tlb 刷新及 cleaner 创建;其回收依赖 gc 回收 directbytebuffer 对象后异步触发,易致堆外内存泄漏。

DirectBuffer 分配为什么开销大
DirectBuffer 的分配不是简单 new 一个对象,而是调用 Unsafe.allocateMemory 向操作系统申请堆外内存。每次分配都要走 native 调用、内存页对齐、TLB 刷新,还要额外创建一个 Cleaner 虚引用绑定回收逻辑。这个过程比堆内对象分配慢 10 倍以上。
更关键的是:它的回收不归 GC 主动管。只有当 DirectByteBuffer 对象本身被 GC 回收后,Cleaner 才异步触发 unsafe.freeMemory。而 DirectByteBuffer 实例极小(几十字节),很难触发 GC;一旦高并发高频创建,堆外内存就“只进不出”,RSS 持续上涨,很快报 OutOfMemoryError: Direct buffer memory。
Netty 的 PooledByteBufAllocator 是怎么缓解的
它没绕过 Cleaner,而是用三级复用结构把分配次数降到最低:
- Arena 层:按大小分级(Tiny/Small/Normal/Huge),每类由独立内存块管理,避免碎片
- ThreadLocalCache 层:每个线程缓存常用规格(如 8KB、16KB)的已释放 buffer,免锁复用
- PoolChunk 层:以 16MB 为单位切分,用位图管理子页,支持快速分配/合并
但默认参数不适合超高吞吐:比如 maxCachedBufferCapacity=32768,意味着所有 ≥32KB 的 buffer 都不进线程缓存,每次都要抢锁走 Arena 分配;normalCacheSize=32 在 QPS >5w 时容易击穿,引发毛刺。
手写一个轻量 BufferPool 用于变量重用
不需要照搬 Netty 全套,针对固定大小 buffer(如统一 4KB 报文缓冲区),可实现一个无锁、无虚引用、无 Cleaner 依赖的池子:
- 用 ArrayDeque
存已清空的 direct buffer,push/pop 是 O(1) - 初始化时预分配 N 个 ByteBuffer.allocateDirect(4096),全部放入队列
- get() 方法直接 poll,为空则 new 一个新的(加日志告警,说明池不够)
- put(ByteBuffer b) 先调 b.clear() 重置 position/limit,再 offer 回队列
- 避免使用 wrap 或 slice 视图——它们不持有底层内存所有权,归还后可能被误复用
这种池子完全规避了 Cleaner 延迟问题,buffer 生命周期由业务代码显式控制,适合协议解析、代理转发等 buffer 大小稳定、作用域明确的场景。
关键注意事项
自定义 BufferPool 不是万能的,踩坑点很集中:
- 必须确保 put 之前 clear,否则下次 get 出来的 buffer 可能残留旧数据或 position 错乱
- 不能把同一个 buffer 同时交给多个线程读写,池子不解决线程安全,只解决分配开销
- 如果业务中 buffer 大小波动大(从 1KB 到 128KB 不等),硬用单槽位池会导致大量 allocateDirect 调用,不如直接用 Netty 并调参
- 上线前一定要用 jcmd $PID VM.native_memory summary 对比开启池前后的 committed 内存变化










