前言
在Java技术栈中,Netty一直以来都作为网络编程的不二之选。Netty是基于Java Nio封装的网络编程框架,具有高并发、传输速度快、封装友好、扩展性强等特点。本文就着重分析下Netty传输速度快特点的技术方案:零拷贝
操作系统
- 传统数据拷贝
由上图可以看出,传统的数据拷贝方式分为以下4步。
1 将数据从磁盘读取到内核缓存
2 将数据从内核缓存读取到用户缓存
3 将数据从用户缓存写入到socket缓存
4 将数据从socket缓存写入到网卡设备
其中1、4步骤是有DMA(见文尾) COPY完成,2、3是有CPU COPY完成
其中2、3步骤是由CPU完成的,因为拷贝是将文件切分成多段,如果遇到较大文件,cpu的成本开销是巨大的,也会阻塞其他进程。所以,在一些场景下,例如不需要在用户缓存对数据进行校验或者修改等操作,是没有必要进行2、3步骤的,可以节省大量的cpu资源。
- linux2.1内核
去掉了传统模式的2、3步骤,不再需要将数据在内核缓存和用户缓存中来回拷贝,节省的cpu上下文切换的成本。
- linux2.4内核
优化了linux2.1之前的 将数据从ReadBuffer copy到Socketbuffer 的动作,变成 将数据的位置和长度的信息的描述符被追加到了SocketBuffer中,DMA引擎直接把数据从内核缓冲区传输到协议引擎 。真正实现了零拷贝。
Java NIO
Java NIO提供了transferTo() 方法依赖于linux操作系统的sendfile方法完成的,所以在不同的linux版本中,正如上面所述,实现是不同的。
Netty
代码分析基于netty4.1.25.final版本
- 操作系统层面
DefaultFileRegion类中transferTo()方法,就是调用的Java NIO的transferTo() 方法,完成了对操作系统层面数据零拷贝的支持。
@Override
public long transferTo(WritableByteChannel target, long position) throws IOException {
long count = this.count - position;
if (count < 0 || position < 0) {
throw new IllegalArgumentException(
"position out of range: " + position +
" (expected: 0 - " + (this.count - 1) + ')');
}
if (count == 0) {
return 0L;
}
if (refCnt() == 0) {
throw new IllegalReferenceCountException(0);
}
// Call open to make sure fc is initialized. This is a no-oop if we called it before.
open();
long written = file.transferTo(this.position + position, count, target);
if (written > 0) {
transferred += written;
}
return written;
}
-
用户态(Java层面)
Heap Buffer堆缓冲区是JVM堆中的空间,每次读写数据都要先将数据拷贝到直接缓冲区再进行传递,而Direct Buffer直接缓冲区是JVM实现通过本地调用来分配内存,在堆之外直接分配内存,直接缓冲区不会占用堆的容量,在通过套接字发送它之前,JVM将会在内部把你的缓冲区复制到一个直接缓冲区中,相对于Heap Buffer则节省了一次拷贝的过程。通过wrap操作, 我们可以将byte[]、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象, 进而避免拷贝操作。以byte[]零拷贝为例,普通拷贝方式是新创建一个byteBuf,默认容量256字节,再把byte数组拷贝到butyBuf中。而零拷贝则只是创建一个对数组的引用,没有开辟新的内存,优势明显。
@Test
public void byteToByteBuf(){
String a = "ccc";
byte[] bytesSrc = a.getBytes(CharsetUtil.UTF_8);
//普通拷贝
ByteBuf byteBuf = Unpooled.buffer();
byteBuf.writeBytes(bytesSrc);
// 零拷贝
ByteBuf byteBufZero = Unpooled.wrappedBuffer(bytesSrc);
}
Netty提供CompositeByteBuf复合缓冲区类, 可以将多个ByteBuf合并为一个逻辑上的ByteBuf, 避免了各个ByteBuf之间的拷贝。假设有一份协议数据,它由头部和消息体组成,而头部和消息体是分别存放在两个ByteBuf中的, 为了方便后续处理,要将两个ByteBuf进行合并
@Test
public void compositeByteBufTest(){
String header = "ccc";
String body = "dddd";
ByteBuf buf1 = Unpooled.wrappedBuffer(header.getBytes(CharsetUtil.UTF_8));
ByteBuf buf2 = Unpooled.wrappedBuffer(body.getBytes(CharsetUtil.UTF_8));
ByteBuf compositeByteBuf = Unpooled.wrappedBuffer(buf1,buf2);
int size = compositeByteBuf.readableBytes();
byte[] bytes = new byte[size];
compositeByteBuf.readBytes(bytes);
String value = new String(bytes,CharsetUtil.UTF_8);
System.out.println("composite buff result : " + value);
}
ByteBuf支持slice操作,可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf, 避免内存的拷贝。
@Test
public void sliceTest() {
String str = "cccdddd";
ByteBuf buf = Unpooled.wrappedBuffer(str.getBytes(CharsetUtil.UTF_8));
ByteBuf slice = buf.slice(0, 3);
int size = slice.readableBytes();
byte[] bytes = new byte[size];
slice.readBytes(bytes);
String header = new String(bytes, CharsetUtil.UTF_8);
System.out.println(header);
}
Kafka
我们都了解到,kafka因为通过磁盘的顺序读写,才实现了高吞吐量,其实还有一个重要原因就是使用了零拷贝,调用的也是Java NIO中的transferTo()方法。由于kafka对数据不需要校验和修改,简直是零拷贝最完美的应用场景。
扩展知识
- DMA(Direct Memory Access)
直接内存访问,是一种无需CPU的参与就可以让外设与系统内存之间进行双向数据传输的硬件机制。使用DMA可以使系统CPU从实际的I/O数据传输过程中摆脱出来,从而大大提高系统的吞吐率。DMA方式的数据传输由DMA控制器(DMAC)控制,在传输期间,CPU可以并发的执行其他任务。当DMA结束后,DMAC通过中断通知CPU数据传输已经结束,由CPU执行相应的中断服务程序进行后续处理。