第三章:内存管理与零拷贝

内存管理,说白了就是跟操作系统抢资源。做交易网关这些年,我最大的感悟就是:谁控制了内存,谁就控制了延迟。今天咱们聊聊堆外内存、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 流程是这样的:

  1. 磁盘 → 内核缓冲区(DMA)
  2. 内核缓冲区 → 用户缓冲区(CPU 拷贝)
  3. 用户缓冲区 → Socket 缓冲区(CPU 拷贝)
  4. Socket 缓冲区 → 网卡(DMA)

一共 4 次拷贝,2 次上下文切换。零拷贝可以做到:

  1. 磁盘 → 内核缓冲区(DMA)
  2. 内核缓冲区 → 网卡(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,那才是真正考验并发功底的地方。