4.2 双栈对照:Go 编解码与 Netty ByteBuf 零拷贝
4.1 的编解码器每读一个字段就往后挪一次游标,本身不拷贝。但真实网关还有更贵的一步:把一整帧拆成「消息头 + 身体」时,很多实现会顺手 copy 一份身体出来。一条 100 字节的移动通知,一进一出就多两次 100 字节的拷贝;网关每秒几百万条消息时,这些拷贝会实打实地吃满一个 CPU 核。
零拷贝(zero-copy)指的就是省掉这些拷贝:让「身体」不是一个新数组,而是原始帧上的一个视图(view)。Go 用切片表达视图,Java 用 ByteBuffer 表达视图,Netty 的 ByteBuf 是同一思路的工程化版本。本节把两条栈的视图机制都跑一遍:Go 侧是 L1,go test 直接跑;Java 侧是 L2J,经 Docker 里的 Temurin 21 真跑。最后回答一个可判定的问题——什么时候切片视图够用,什么时候非上池化不可。
零拷贝到底在省什么
先厘清「零拷贝」不是一种技术,而是三个层层递进的境界:
- 视图(view):不复制数据,只换一个
(起始偏移, 长度)的描述头。Go 的b[a:c]、Java 的ByteBuffer.slice()都是视图。省掉的是「数据拷贝」,代价是视图的生命周期被绑死在原始缓冲上。 - 引用计数共享(ref-counted view):视图自带一个计数器,视图还在用,原始缓冲就不能被回收或复用。Netty 的
retainedSlice()走这条路。省掉的是「手动保证原始缓冲别提前释放」的心智负担。 - 池化(pooling):连「分配缓冲区」这件事都省掉,改成从池里借、用完还。Netty 的
PooledByteBufAllocator走这条路。省掉的是「分配 + GC」的成本。
用一条时间线看一帧的旅程:
[内核缓冲区] --recv--> [应用缓冲 buf] --视图--> [payload 视图] --decode--> [结构体]
^^^^^^^^^^^^ ^^^^^^^^^^^
真正分配的地方 零拷贝:与 buf 共享底层数组
绝大多数场景,第一层「视图」就够省了——因为真正的分配只有 buf 一次,payload 只是它的切片。只有当你发现「buf 的分配」本身成了 GC 热点,才需要往上走到池化。下面两条栈分别演示第一层。
Go 侧:切片视图就是零拷贝
Go 里最便宜的零拷贝就是一次切片操作。下面这段是 L1,go test ./internal/codec/ 可以直接跑:
// file: internal/codec/zerocopy.go
// tier: L1
// verify: go test ./internal/codec/
package codec
// 本文件演示「切片视图」式的零拷贝:从一帧原始字节里切出 payload,
// 不 new、不 copy,payload 与原始帧共享同一块底层数组。
//
// 这是 Go 里最便宜的零拷贝手段:一次切片头赋值(3 个机器字),
// 代价是 payload 的生命周期被绑定到原始帧——原始帧不释放,payload 就一直在。
// SplitFrame 从一帧「uint32 长度前缀 + payload」里切出 payload 视图。
//
// 返回的 payload 是 raw 的切片,与 raw 共享底层数组。调用方必须保证
// 在使用 payload 期间,raw 不被复用或写坏。
func SplitFrame(raw []byte) (payload []byte, err error) {
r := NewReader(raw)
n, err := r.GetUint32()
if err != nil {
return nil, err
}
if uint64(n) > uint64(r.Remaining()) {
return nil, ErrShortBuffer
}
start := r.Pos()
// 直接切片:不 new、不 copy。&payload[0] == &raw[start]。
return raw[start : start+int(n)], nil
}
// SliceBytes 与 Reader.GetBytes 相对:同样读「uint32 长度前缀 + 内容」,
// 但返回的是 raw 的切片视图而非拷贝。适合「读完立刻转发」的网关场景。
func (r *Reader) SliceBytes() ([]byte, error) {
n, err := r.GetUint32()
if err != nil {
return nil, err
}
if uint64(n) > uint64(r.Remaining()) {
return nil, ErrShortBuffer
}
start := r.pos
r.pos += int(n)
return r.buf[start:r.pos], nil
}
// SharesBackingArray 判断两个字节切片是否共享同一底层数组。
// 仅用于测试与断言——生产代码不该依赖这个。
func SharesBackingArray(a, b []byte) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
return &a[0] == &b[0]
}
怎么证明「没发生拷贝」?光断言 &a[0] == &b[0] 可能被编译器优化,最稳的判据是改一个,看另一个变不变。下面是测试里的核心断言:
// snippet: internal/codec/zerocopy_test.go(摘录)
func TestSplitFrameIsZeroCopy(t *testing.T) {
// 一帧:uint32 长度前缀 3 + 3 字节 payload。
raw := []byte{0, 0, 0, 3, 0xAA, 0xBB, 0xCC}
payload, err := SplitFrame(raw)
if err != nil {
t.Fatalf("SplitFrame: %v", err)
}
if !SharesBackingArray(payload, raw[4:]) {
t.Fatal("payload 未与 raw 共享底层数组——说明发生了拷贝")
}
// 改原始帧的一个字节,payload 视图应同步变化。
raw[4] = 0x99
if payload[0] != 0x99 {
t.Fatalf("改 raw[4] 后 payload[0] = %#x,未共享底层数组", payload[0])
}
// 反过来改 payload,raw 也应变化。
payload[2] = 0x77
if raw[6] != 0x77 {
t.Fatalf("改 payload[2] 后 raw[6] = %#x,未共享底层数组", raw[6])
}
}
这正是零拷贝的「契约」:payload 与 raw 是同一块内存的两个名字。省了拷贝,代价也在这里——只要你还在用 payload,raw 就不能被复用。网关若把 raw 放回缓冲池循环使用,而 payload 视图被异步转发出去,就会读到被改写的脏数据。这是零拷贝最常见的一类 bug。
Java 侧:ByteBuffer 的三把视图
Java 的对应物是 java.nio.ByteBuffer。它把「读写指针」拆成 position(下一个读写位置)与 limit(可读写的边界),再配三个零拷贝视图方法:slice() 切出一段独立窗口、duplicate() 复制一份独立的 position/limit、asReadOnlyBuffer() 给出只读视图。下面这段是 L2J,经 Docker 里的 Temurin 21 真跑(本机 JDK 只有 8,不作数):
// file: $HOME/.aegis-java/ch04/ByteBufDemo.java
// tier: L2J
// verify: docker run --rm -v "$HOME/.aegis-java/ch04:/w" -w /w eclipse-temurin:21-jdk sh -c "javac ByteBufDemo.java && java ByteBufDemo"
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ReadOnlyBufferException;
// 用 JDK 自带的 java.nio.ByteBuffer 演示「视图式零拷贝」:
// slice() / duplicate() / asReadOnlyBuffer() 都返回共享同一底层数组的视图,
// 不复制数据。Netty 的 ByteBuf 是同一思路的工程化版本(读写指针分离 + 引用计数 + 池化)。
public class ByteBufDemo {
public static void main(String[] args) {
// 一帧:uint32 长度前缀 3 + 3 字节 payload。
ByteBuffer frame = ByteBuffer.allocate(32).order(ByteOrder.BIG_ENDIAN);
frame.putInt(3);
frame.put((byte) 0xAA);
frame.put((byte) 0xBB);
frame.put((byte) 0xCC);
frame.flip();
// 读长度前缀,然后把 limit 卡到 payload 末尾(等价于「拆包」)。
int len = frame.getInt();
frame.limit(frame.position() + len);
// 关键:slice() 是零拷贝视图——共享同一底层数组,只换独立的 position/limit。
ByteBuffer payload = frame.slice();
System.out.println("len=" + len);
System.out.println("payload.position=" + payload.position()
+ " limit=" + payload.limit() + " remaining=" + payload.remaining());
System.out.println("frame.array() == payload.array() ? " + (frame.array() == payload.array()));
// 通过 payload 改一个字节,原始 frame 的底层数组同步变化 → 证明共享。
payload.put(0, (byte) 0x99);
System.out.printf("frame.array()[4]=0x%02X payload.get(0)=0x%02X%n",
frame.array()[4], payload.get(0));
// duplicate():独立读写指针,仍共享数据。
ByteBuffer dup = payload.duplicate();
dup.position(1);
System.out.printf("dup.get()=0x%02X%n", dup.get() & 0xFF);
// asReadOnlyBuffer():只读视图,写入抛异常。
ByteBuffer ro = payload.asReadOnlyBuffer();
try {
ro.put(0, (byte) 0x00);
System.out.println("readonly write: 未抛异常(不该发生)");
} catch (ReadOnlyBufferException e) {
System.out.println("readonly write: ReadOnlyBufferException(符合预期)");
}
// 对照:真正复制会得到另一块底层数组。
byte[] copied = new byte[len];
payload.duplicate().get(copied);
System.out.println("copied 与视图共享底层数组 ? " + (copied == payload.array()));
// 池化视角:直接缓冲区(堆外)不能拿 array(),只能靠视图共享。
ByteBuffer direct = ByteBuffer.allocateDirect(32).order(ByteOrder.BIG_ENDIAN);
direct.putInt(2).put((byte) 0x11).put((byte) 0x22).flip();
int dlen = direct.getInt();
direct.limit(direct.position() + dlen);
ByteBuffer dview = direct.slice();
dview.put(0, (byte) 0x33);
System.out.printf("direct view 写回后 direct.get(4)=0x%02X hasArray=%b%n",
direct.get(4) & 0xFF, direct.hasArray());
}
}
本机 Docker 实跑输出(Temurin 21.0.12.1):
len=3
payload.position=0 limit=3 remaining=3
frame.array() == payload.array() ? true
frame.array()[4]=0x99 payload.get(0)=0x99
dup.get()=0xBB
readonly write: ReadOnlyBufferException(符合预期)
copied 与视图共享底层数组 ? false
direct view 写回后 direct.get(4)=0x33 hasArray=false
逐行读:frame.array() == payload.array() 是 true,说明 slice() 与原始 frame 共享同一底层数组;通过 payload 写入 0x99 后 frame.array()[4] 同步变成 0x99,与 Go 侧「改一个看另一个」是同一个证明。duplicate() 得到独立 position 的视图但数据共享,dup.position(1) 后读出 0xBB。只读视图写入抛 ReadOnlyBufferException——这正是防止「视图被下游意外改写」的第一道闸。最后一行 hasArray=false 揭示一个关键区别:堆外(direct)缓冲没有可访问的 Java 数组,array() 直接抛 UnsupportedOperationException,所以堆外场景下视图是唯一不拷贝的手段。
Netty ByteBuf:同一思路的工程化版本
Netty 的 ByteBuf 没有引入新魔法,它只是把上面这套视图机制做成了生产级组件。三点增强最值得对照:
| 能力 | Go 切片 | JDK ByteBuffer | Netty ByteBuf |
|---|---|---|---|
| 零拷贝视图 | b[a:c] | slice() / duplicate() | slice() / duplicate() / retainedSlice() |
| 读写指针 | 无(自己维护下标) | position / limit(共用) | readerIndex / writerIndex(分离) |
| 只读视图 | []byte 无只读语义 | asReadOnlyBuffer() | asReadOnly() |
| 生命周期管理 | 靠 GC,无引用计数 | 靠 GC | 引用计数 retain() / release() |
| 缓冲区池化 | 无(sync.Pool 需自己接) | 无 | PooledByteBufAllocator |
| 扩容 | append 自动扩容 | 固定容量,越界抛异常 | capacity() 可动态扩展 |
最本质的差别是读写指针分离。JDK 的 ByteBuffer 只有一组 position/limit,flip() 就是「把写模式切成读模式」——一旦开始读,就不能再往后写。Netty 拆成 readerIndex 与 writerIndex 两个独立游标,可以边读边写,这对「同一个缓冲里既放已解码消息、又追加新收到的字节」的网关非常关键。Go 没有内建这种结构,但 4.1 的 Reader(持 pos)配上 Writer(持 buf)就是它的手工版。
选型建议:切片够用,还是必须池化
这是本节要给的可判定结论。按消息的生命周期来分:
- 读完即用、不跨协程:切片视图够用,零分配。解码在同一个 goroutine 里同步完成,
payload用完就丢,原始帧继续复用。这是 90% 业务消息的情形。 - 视图要异步转发、原始帧会被复用:必须上引用计数。要么用 Netty 的
retainedSlice(),要么自己给缓冲加一个refCnt:视图持有期间计数不归零,池不回收。否则就是前面说的「读到脏数据」。 - 分配本身成了 GC 热点:必须池化。判据很具体——用
pprof看到make([]byte, n)在 alloc_objects 里排前列,或 Go 侧go test -benchmem显示 allocs/op 高且稳定。此时把NewWriter的make换成sync.Pool借还,配合Writer.Reset()(4.1 里留的那个方法)就能把每次消息的分配降到零。
反过来,不要为了「零拷贝」三个字过早池化。池化引入借还配对、泄漏排查、跨池污染三类新 bug,收益只在高频分配路径上才成立。先用切片视图把「数据拷贝」消掉,测出分配仍是瓶颈,再上池化——这个顺序能省掉大量返工。
本节小结
Go 与 Java 的零拷贝视图机制本质相同:都是「共享底层数组 + 独立游标」,都用「改一个看另一个」来证明不复制。差别在工程化程度:Netty 额外给了读写指针分离、引用计数与池化三层,正好对应「切片够用 / 要异步转发 / 分配成热点」三个递进场景。本节两段代码都真跑过——Go 侧 go test 绿,Java 侧在 Temurin 21 容器里输出如上。
阅读导航:上一节:4.1 二进制编解码从零写 · 下一节:4.3 基准:JSON、MessagePack、Protobuf 与扁平化协议 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。