第三章:内存管理与零拷贝
内存管理,说白了就是跟操作系统抢资源。做交易网关这些年,我最大的感悟就是:谁控制了内存,谁就控制了延迟。今天咱们聊聊堆外内存、DirectBuffer、mmap 和零拷贝,还有我踩过无数坑的内存池设计。
3.1 为什么堆内存不够用?
先问个问题:你用过 Java 的 new byte[1024] 吗?这玩意儿分配在堆上,GC 一触发,你的交易延迟就炸了。我见过一个极端案例——某券商网关因为 Full GC,订单直接超时 500ms。
堆内存的问题在于:
- GC 不可控:你不知道它什么时候来,来了就停你几毫秒
- 内存拷贝多:从堆到内核,再到网卡,绕一大圈
- 对象开销大:每个 byte[] 都有对象头,浪费
所以,低延迟系统必须用堆外内存。说白了,就是绕过 JVM 堆,直接跟操作系统申请内存。
3.2 堆外内存与 DirectBuffer
Java 里最常用的堆外内存就是 ByteBuffer.allocateDirect()。这玩意儿分配在 native heap,不受 GC 管理。
核心区别:
| 特性 | 堆内存 | 堆外内存 |
|---|---|---|
| 分配速度 | 快(几纳秒) | 慢(几十微秒) |
| GC 影响 | 有 | 无 |
| IO 效率 | 低(需拷贝) | 高(零拷贝) |
| 内存上限 | 受 -Xmx 限制 | 受物理内存限制 |
我个人习惯是:高频交易数据全部用 DirectBuffer。比如行情快照、订单簿数据,直接映射到堆外。
// 分配 1MB 堆外内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
// 写入数据
buffer.putLong(System.nanoTime());
buffer.putInt(orderId);
// 直接发送到 SocketChannel
socketChannel.write(buffer); // 零拷贝!
注意:allocateDirect 分配很慢,我建议在启动时一次性分配好,不要频繁创建销毁。
避坑指南:我曾经在线上遇到过 DirectBuffer 内存泄漏。原因是忘记调用 sun.misc.Cleaner 释放内存。后来我改用 Netty 的 PooledByteBufAllocator,它自带内存池和自动回收。
3.3 mmap:内存映射文件
mmap 是个好东西。它把文件直接映射到进程地址空间,读写文件就像读写内存一样。
为什么适合交易网关?因为行情数据通常是文件形式存储的。用 mmap 可以:
- 省去 read/write 系统调用:少两次上下文切换
- 避免内存拷贝:内核态到用户态的直接映射
- 支持大文件:几百 GB 的行情数据也能轻松处理
// 使用 mmap 读取行情文件
FileChannel channel = new RandomAccessFile("market.dat", "r").getChannel();
MappedByteBuffer mbb = channel.map(
FileChannel.MapMode.READ_ONLY, 0, channel.size()
);
// 直接读取,像操作数组一样
long timestamp = mbb.getLong();
double price = mbb.getDouble();
嗯,这里要注意:MappedByteBuffer 是堆外内存,不受 GC 管理。但它的释放依赖 GC,这是个坑。我一般用 sun.misc.Cleaner 手动释放。
我的经验:mmap 适合读多写少的场景。比如行情回放、历史数据分析。如果是高频写入(比如订单日志),建议用 DirectBuffer + 异步刷盘。
3.4 零拷贝技术
零拷贝,说白了就是减少数据在内存间的搬运次数。传统 IO 流程是这样的:
- 磁盘 → 内核缓冲区(DMA)
- 内核缓冲区 → 用户缓冲区(CPU 拷贝)
- 用户缓冲区 → Socket 缓冲区(CPU 拷贝)
- Socket 缓冲区 → 网卡(DMA)
一共 4 次拷贝,2 次上下文切换。零拷贝可以做到:
- 磁盘 → 内核缓冲区(DMA)
- 内核缓冲区 → 网卡(DMA,通过 SG-DMA)
只有 2 次拷贝,0 次 CPU 参与。你想想看,这延迟能差多少?
Java 里实现零拷贝有两种方式:
- FileChannel.transferTo():文件到 Socket 的零拷贝
- DirectBuffer + SocketChannel.write():堆外内存到 Socket 的零拷贝
// 零拷贝发送文件
FileChannel fileChannel = new RandomAccessFile("data.bin", "r").getChannel();
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress(host, port));
// 直接从文件到网络,不经过用户空间
fileChannel.transferTo(0, fileChannel.size(), socketChannel);
性能对比(我实测的数据):
| 方式 | 延迟(1KB 数据) | CPU 占用 |
|---|---|---|
| 传统 IO | ~5μs | 高 |
| DirectBuffer | ~1μs | 低 |
| transferTo | ~0.5μs | 极低 |
3.5 内存池设计
前面说了,堆外内存分配慢。那怎么办?池化。提前分配好一大块,用的时候从池子里拿,用完还回去。
我设计的内存池核心思路:
- 预分配:启动时分配 100MB 堆外内存
- 分片管理:按 64B、256B、1KB、4KB 等大小分片
- 无锁设计:每个线程有自己的本地缓存(ThreadLocal)
- 回收策略:用完归还,不触发 GC
public class MemoryPool {
// 每个线程独立的内存块
private static final ThreadLocal<Stack<ByteBuffer>> localCache =
ThreadLocal.withInitial(() -> new Stack<>());
// 全局共享池
private final ConcurrentLinkedQueue<ByteBuffer> globalPool;
public ByteBuffer acquire(int size) {
Stack<ByteBuffer> local = localCache.get();
if (!local.isEmpty()) {
return local.pop();
}
// 从全局池取
ByteBuffer buf = globalPool.poll();
if (buf == null) {
buf = ByteBuffer.allocateDirect(size);
}
return buf;
}
public void release(ByteBuffer buffer) {
buffer.clear();
localCache.get().push(buffer);
}
}
避坑指南:我曾经犯过一个错误——内存池分片大小不合理。64B 的片太多,浪费;4KB 的片太少,频繁分配。后来我根据实际报文大小做了统计,发现 90% 的报文在 256B 以内,于是重点优化了小片内存的分配。
3.6 实战:交易网关的内存布局
最后,分享一个我实际项目中用的内存布局方案:
| 区域 | 大小 | 用途 | 技术 |
|---|---|---|---|
| 行情缓存 | 500MB | 存储最新行情快照 | DirectBuffer + 内存池 |
| 订单缓冲区 | 200MB | 待发送的订单数据 | RingBuffer + 堆外内存 |
| 日志缓冲区 | 100MB | 交易日志 | mmap + 异步刷盘 |
| 共享内存 | 50MB | 进程间通信 | mmap + 信号量 |
这个方案在线上跑了两年,GC 暂停时间控制在 1ms 以内。说白了,就是让 GC 无数据可回收——所有高频数据都在堆外。
我的建议:刚开始做内存优化时,别追求一步到位。先用 -XX:+PrintGCDetails 看看 GC 频率,再逐步把热点数据移到堆外。我见过有人一上来就全部用 DirectBuffer,结果内存泄漏搞崩了系统。
好了,这一章就到这里。下一章咱们聊聊无锁队列和 RingBuffer,那才是真正考验并发功底的地方。