高性能网络编程中的 ByteBuffer 分配与回收策略
—— 基于 t-io 的实践优化
背景
在 Java NIO 网络编程中,ByteBuffer 是最基础的 I/O 缓冲容器。 默认情况下,很多框架会在发送响应时直接调用:
ByteBuffer buf = ByteBuffer.allocate(...) // 堆内存
// 或
ByteBuffer buf = ByteBuffer.allocateDirect(...) // 堆外内存
这种“临时分配 → 写出 → 交由 GC 回收”的模式,在高并发场景下会遇到几个问题:
- GC 压力大:短生命周期的
ByteBuffer数量巨大,容易造成频繁 Minor GC。 - Cleaner 抖动:Direct ByteBuffer 依赖
Cleaner释放堆外内存,释放时机不可控,P99 延迟容易抖动。 - 内存碎片化:频繁分配大块 direct 内存,可能触发
-XX:MaxDirectMemorySize限制,甚至导致 OOM。
为了解决这些问题,我们在 t-io 的 HTTP 响应链路上,对 ByteBuffer 的分配和回收进行了重构。
改造思路
不再直接分配
HttpResponseEncoder中原先ByteBuffer.allocate(...)/allocateDirect(...)的地方,统一改成 从池借用:ByteBuffer buf = Buffers.DIRECT_POOL.borrow(size);文件传输(非 SSL)仍使用
FileChannel.transferTo零拷贝,不需要额外 buffer。
自动回收
在
SendPacketTask.sendByteBuffer()里,如果发现发送的 buffer 是 direct,就给它挂上一个 returnToPool 回调:Runnable returnToPool = () -> Buffers.DIRECT_POOL.giveBack(buf); WriteCompletionVo vo = new WriteCompletionVo(buf, packet, false, returnToPool);WriteCompletionHandler.completed()在写完并调用handle(...)后,会自动执行这个回调,把 buffer 归还池。
池化策略
- 池内部按照 2 的幂次分桶:1KB、2KB、4KB … 1MB。
- 每个线程有自己的本地缓存(避免锁竞争);全局还有一个共享队列作为兜底。
- 超过
maxBucketSize的大 buffer(例如 >1MB 的响应体),直接allocateDirect,写完交给 Cleaner 回收,不入池。
发送链路中的分配与回收流程
普通响应(非文件)
HttpResponseEncoder根据 header+body 计算所需容量,从池借一个 direct buffer。- 写入响应行、header、body → 返回给
SendPacketTask。 SendPacketTask调用sendByteBuffer(...),挂上归还回调。WriteCompletionHandler.completed()检测到写完,执行returnToPool.run()→ buffer 清空并放回池。
文件响应
非 SSL:
- Header 部分依然用池借 buffer,写完归还池。
- 文件体通过
FileChannel.transferTo()零拷贝直接进网卡 → 没有额外分配。
SSL:
- 无法用零拷贝,只能分块读文件。
- 每次循环从池借一个 64KB direct buffer → 读文件 → SSL 加密 → 写出 → 归还池。
分配策略设计
1. 为什么用 direct buffer
- 写入
SocketChannel时,direct buffer 避免了 JVM 堆 → native 堆的二次拷贝。 - 在高并发下,能显著降低 CPU 使用率。
2. 为什么要池化
- 避免频繁
allocateDirect带来的系统调用开销和 Cleaner 抖动。 - 对于小到中等尺寸(1KB–1MB)的响应,高复用率,池化收益极大。
3. 为什么分桶
不同响应大小差异大(JSON 小报文、静态文件 header、中等二进制数据)。
分桶能保证复用时减少内存浪费:
- 请求 2KB 时,借一个 2KB 桶;
- 请求 40KB 时,借一个 64KB 桶;
- 避免全都用 1MB 桶导致浪费。
4. 为什么大于 1MB 不池化
- 大 buffer 出现频率低,长期缓存会导致内存占用过高。
- 直接分配,用完交给 Cleaner 即可。
效果与收益
- 分配延迟下降:从毫秒级的系统调用,降到纳秒级的栈操作。
- GC 压力减轻:直接减少大量临时 direct buffer,Cleaner 调用次数大幅下降。
- 延迟更稳定:P99 抖动改善,避免偶发的 stop-the-world Cleaner 行为。
- 吞吐更高:Socket 写操作避免了堆→堆外的额外拷贝。
总结
通过在 t-io 的发送链路上引入 Direct ByteBuffer 池化,我们实现了:
- HttpResponseEncoder 从池借 buffer →
- SendPacketTask 在写之前挂上归还回调 →
- WriteCompletionHandler 写完自动归还池。
配合 零拷贝传输 和 SSL 分块池化,形成了一整套高效的 ByteBuffer 分配与回收策略。
这套机制的核心优势是:
- 高效(分配快)
- 稳定(减少 GC 抖动)
- 灵活(小块池化,大块直分配)
非常适合高并发场景下的 HTTP/WebSocket/自定义协议服务。
