【Netty 从成神到升仙系列 四】让我们一起探索 Netty 中的零拷贝

本文涉及的产品
数据传输服务 DTS,数据迁移 small 3个月
推荐场景:
MySQL数据库上云
数据传输服务 DTS,数据同步 small 3个月
推荐场景:
数据库上云
数据传输服务 DTS,数据同步 1个月
简介: 【Netty 从成神到升仙系列 四】让我们一起探索 Netty 中的零拷贝

一、编解码器

解码器:将字节解码成消息

编码器:将消息编码为字节

1. 一次解码器

解决半包、粘包问题的常用三种解码器

将客户端发送过来的字节数组转为可使用的用户数据

io.netty.buffer.ByteBuf(原始数据流)> io.netty.buffer.ByteBuf(用户数据)

2. 二次解码器

需要和项目中所使用的对象做转化的编解码器

  • Java 序列化
  • Marshaling
  • XML
  • JSON
  • MessagePack
  • Protobuf
  • 其他

二次解码器的速度对比:

链接:https://www.howtoautomate.in.th/protobuf-101/2017-05-06-10_30_22-serialization-performance-comparisonxmlbinaryjsonp/

86c17071d09d45e68bbb4297bf3065ee.png

MessagePack

优点:压缩之后占用字节较少

缺点:压缩后的内容晦涩难懂

3. 解码器为什么要分次?

一次和二次的功能不同

一次主要是针对客户端传输过来的数据转变为用户数据,这个解码主要避免粘包、半包问题

而二次解码的作用在于将用户数据转为可用的对象数据。

一次和二次的不同解码结合,能创建更多的选择

如果合二为一,一定程度上浪费了其扩展性

4. 解码器源码分析

4.1 String 解码器

@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
    out.add(msg.toString(charset));
}

4.2 NETTY的Java序列化编解码器

io.netty.handler.codec.serialization.ObjectEncoder 这个类中,存在 encode 方法,这个方法调用了 CompactObjectOutputStream

@Override
protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) throws Exception {
    int startIdx = out.writerIndex();
    ByteBufOutputStream bout = new ByteBufOutputStream(out);
    ObjectOutputStream oout = null;
    try {
        bout.write(LENGTH_PLACEHOLDER);
        // 重点主要在这
        oout = new CompactObjectOutputStream(bout);
        oout.writeObject(msg);
        oout.flush();
    }
    int endIdx = out.writerIndex();
    out.setInt(startIdx, endIdx - startIdx - 4);
}

实现的方法都是在 CompactObjectOutputStream(bout) 中,于是,我们一起看一下这个方法里面做了什么

@Override
protected void writeClassDescriptor(ObjectStreamClass desc) throws IOException {
    Class<?> clazz = desc.forClass();
    if (clazz.isPrimitive() || clazz.isArray() || clazz.isInterface() ||
        desc.getSerialVersionUID() == 0) {
        write(TYPE_FAT_DESCRIPTOR);
        super.writeClassDescriptor(desc);
    } else {
        //比较JDK的,少很多信息:元信息
        write(TYPE_THIN_DESCRIPTOR);
        //但是也写了类的名字,这点在反序列化(用反射)时就会用到,很重要
        writeUTF(desc.getName());
    }
    /**下面是JDK代码:JDK的序列化多写了下面一些信息
    out.writeShort(fields.length);
    for (int i = 0; i < fields.length; i++) {
        ObjectStreamField f = fields[i];
        out.writeByte(f.getTypeCode());
        out.writeUTF(f.getName());
        if (!f.isPrimitive()) {
            out.writeTypeString(f.getTypeString());
        }
    }
    */
}

通过上述对比,我们可以发现:

Netty 对于 Java 的序列化又封装了一层,较高的提高了序列化反序列化的性能

  • Netty:直接塞一个描述符 TYPE_THIN_DESCRIPTOR,将类名字塞进去,后续通过反射来获取
  • Java:塞类名称+类的变量

4.3 Protobuf编解码器

这里可以看出来,protobuf采用的是不定长,报文头不是定长的,站在性能角度来说,是可以不浪费空间的。

这样看也大致大知道protobuf非常高效

二、零拷贝

零拷贝(Zero-copy):减少没有必要拷贝的这类技术

传统的网络传输:4次拷贝

1. 为什么要有 DMA 技术?

我们来看一下,没有DMA技术的时候,我们的I/O流程是什么样子的?


  • 用户进程执行 read() 方法,由用户态变为内核态,由 CPU 向磁盘控制器发送 I/O 指令;
  • 磁盘控制器收到指令后,将磁盘中的数据读取放到磁盘缓存区,向 CPU 发送 中断 信号
  • CPU 收到中断信号后,将磁盘缓存区的数据一个字节一个字节的读进寄存器当中,再由寄存器读取到内存中
  • 在 CPU 搬运数据的过程中,CPU 是无法做其他的事情的,这就导致了如果我们传输大量的数据,势必会影响当前 CPU 的性能。

这种肯定不是科学家们想要看到的,于是出现了 DMA(直接内存访问) 技术

DMA技术:在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务


简单来说,DMA技术代替了CPU将磁盘中的数据搬运到内核缓存区的这个过程。

2. 传统的文件传输的弊端

我们要想提供文件传输的能力,必须经过一下两步:

  • 从磁盘中读取出来:read(file, tmp_buf, len)

  • 从网卡中发送出去:write(socket, tmp_buf, len)

虽然只有两行代码,但发生的事情可不少


这个流程一共发生了4次用户态到内核态的切换以及4次数据拷贝(2次DMA拷贝、2次CPU拷贝)

要想优化我们的数据传输,必须要优化用户态到内核态的切换以及数据拷贝

3. 零拷贝

在文件传输的场景中,我们不会对文件进行再次加工,所以数据实际上不需要搬运到用户空间,因此用户区的缓存是完全没有必要的,也就是2次CPU拷贝也是没有必要的。

目前,零拷贝实现的方法有两种:

  • mmap + write
  • sendfile

3.1 mmap + write

原始的代码如下:

buf = mmap(file, len);
write(sockfd, buf, len);

这里的 mmap 就是我们将内核中的数据在用户数据做了一份映射,用户空间和内核空间都可以访问该处的数据。

这样,我们就不需要将数据拷贝到用户缓存区了。

但是,我们看图可以发现,这样的技术方案一共经历了:

  • 2次DMA拷贝、1次CPU拷贝
  • 2次系统调用(4次上下文切换)

3.2 sendfile

Linux 2.1 版本专门提供了一个发送文件的函数:sendfile()

#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
  • out_fd:输出端的描述符
  • in_fd:输入端的描述符
  • off_t:输入端的偏移量
  • count:复制数据的长度

使用一个方法代替了原有的 read() + write(),减少了一次系统调用,也就减少了2次上下文切换。


当然,如果你的网卡支持 SG-DMA 技术,我们可以直接把内核缓冲区的描述符直接发送到网卡。

这个时候,就是我们真正的零拷贝技术。

我们的CPU不参与任何的数据搬运,所有的数据都是通过 DMA 技术进行搬运的。

这种零拷贝技术相较于传统的数据传输,节省了:

  • 1次系统调用(2次上下文切换)
  • 2次CPU拷贝

总体来说,零拷贝相较于普通的数据传输,起码性能要提高一倍。

4. 零拷贝实战测试

当然,我们通过上述的描述可以看出:零拷贝确实比正常的数据传输性能要快一倍,但你怎么证明呢?

我们起一个简单的服务端:

public class Server {
    public static void main(String[] args) throws Exception {
        //创建serversocket 对象--8081服务
        ServerSocket serverSocket = new ServerSocket(8088);
        //循环监听连接
        while (true){
            Socket socket = serverSocket.accept();//客户端发起网络请求---连接
            //创建输⼊流对象
            DataInputStream dataInputStream = new
                    DataInputStream(socket.getInputStream());
            int byteCount=0;
            try{
                byte[] bytes = new byte[1024];        //创建缓冲区字节数组
                while(true){
                    int readCount = dataInputStream.read(bytes, 0,
                            bytes.length);
                    byteCount=byteCount+readCount;
                    if(readCount==-1){
                        System.out.println("服务端接受:"+byteCount+"字节");
                        break;
                    }
                }
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }
}

服务端的作用就负责接受数据

传统文件传输的客户端:

public class TranditionClient {
    public static void main(String[] args) throws Exception {
        Socket socket = new Socket("localhost",8088);
        String fileName = "C:\\Users\\Administrator\\Desktop\\零拷贝.png";
        //创建输⼊流对象
        InputStream inputStream = new FileInputStream(fileName);
        //创建输出流
        DataOutputStream dataOutputStream = new
                DataOutputStream(socket.getOutputStream());
        byte[] buffer = new byte[1024];
        long readCount = 0;
        long total=0;
        long startTime = System.currentTimeMillis();
        //TODO 这里要发生2次copy
        while ((readCount=inputStream.read(buffer))>=0){
            total+=readCount;
            //TODO 网络发送:这里要发生2次copy
            dataOutputStream.write(buffer);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("发送总字节数:"+total+",耗时:"+(endTime-startTime)+" ms");
        //释放资源
        dataOutputStream.close();
        socket.close();
        inputStream.close();
    }
}

零拷贝的客户端:

public class NewIOClient {
    public static void main(String[] args) throws Exception {
        //socket套接字
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("localhost",8088));
        socketChannel.configureBlocking(true);
        //文件
        String fileName = "C:\\Users\\Administrator\\Desktop\\零拷贝.png";
        //FileChannel 文件读写、映射和操作的通道
        FileChannel fileChannel = new FileInputStream(fileName).getChannel();
        long startTime = System.currentTimeMillis();
        //transferTo⽅法⽤到了零拷⻉,底层是sendfile,这里只需要发生2次copy和2次上下文切换
        long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);
        long endTime = System.currentTimeMillis();
        System.out.println("发送总字节数:"+transferCount+"耗时:"+(endTime-startTime)+" ms");
        //释放资源
        fileChannel.close();
        socketChannel.close();
    }
}

我们来看看效果对比:

  • 传统文件传输:
  • 零拷贝文件传输:

Netty的文件传输使用了 FileChannel 的 transferTo 方法,底层使用到sendfile函数来实现了零拷贝

// 类的全路径:io.netty.channel.DefaultFileRegion
@Override
public long transferTo(WritableByteChannel target, long position) throws IOException {
    long count = this.count - position;
    //这里使用的sendfile的拷贝技术
    long written = file.transferTo(this.position + position, count, target);
    if (written > 0) {
        transferred += written;
    } else if (written == 0) {
        validate(this, position);
    }
    return written;
}

三、锁优化

1. 减少锁的粒度

在版本 4.1.15 版本中,io.netty.bootstrap.ServerBootstrap#init,对当前执行的对象进行加锁

请注意,因为这个方法已经被重构了,但是这个地方比较经典:展示需要找老版本




67adc4746ed838640228b07c507f7f65.png

2. 减少锁对象的空间占用

io.netty.channel.ChannelOutboundBuffer 类中,Netty 使用了 private volatile long totalPendingSize; 统计待发送的字节数。

//使用原子操作类确保多线程安全
private static final AtomicLongFieldUpdater<ChannelOutboundBuffer> TOTAL_PENDING_SIZE_UPDATER =
        AtomicLongFieldUpdater.newUpdater(ChannelOutboundBuffer.class, "totalPendingSize");
//统计待发送的字节数(为什么不直接使用原子操作类:AtomicLong)
private volatile long totalPendingSize;
private void incrementPendingOutboundBytes(long size, boolean invokeLater) {
    if (size == 0) {
        return;
    }
    long newWriteBufferSize = TOTAL_PENDING_SIZE_UPDATER.addAndGet(this, size);
    //判断待发送的数据的size是否高于高水位线
    if (newWriteBufferSize > channel.config().getWriteBufferHighWaterMark()) {
        setUnwritable(invokeLater);
    }
}

使用 volatile 是为了保证线程安全,那么为什么不直接使用原子操作类(AtomicLong)呢?

原因在于这样写可以节省内存空间,我们将 AtomicLongtotalPendingSize + AtomicLongFieldUpdater 进行对比可发现

对于 AtomicLong 它始终是一个类,既然是类,那么必定有 对象头 + Long + 引用 这样,在 32 位的系统下,这个类最低也要占用 32 bytes,而我们的 totalPendingSize 只需要占用 8 bytes 即可。

我们一次计算就可以减少 24 bytes ,假设拿 Netty 做的网关,一天的调用量可达上亿次,这就体现出空间优化的好处了。

3. 提高锁的性能

public static LongCounter newLongCounter() {
    if (javaVersion() >= 8) {
        return new LongAdderCounter();
    } else {
        return new AtomicLongCounter();
    }
}
final class LongAdderCounter extends LongAdder implements LongCounter {
    @Override
    public long value() {
        return longValue();
    }
}

这里主要对比 LongAdderAtomicLong 的性能

我们尽量用一张图来描述这两个技术的关联和区别

简单来说,LongAdder 使用空间换时间的概念,性能比 AtomicLong 要高

测试数据如下:

条件>>>>>>线程数:10, 单线程操作10000
LongAdder--count100000,time:5
Atomic--count100000,time:5
==================
条件>>>>>>线程数:10, 单线程操作200000
LongAdder--count2000000,time:17
Atomic--count2000000,time:43
==================
条件>>>>>>线程数:100, 单线程操作200000
LongAdder--count20000000,time:29
Atomic--count20000000,time:377

随着线程数的越来越多,我们 LongAdder 的性能远远大于我们的 AtomicLong 的性能

但如果你当前的线程较少,则直接使用 AtomicLong 即可,不需要使用 LongAdder

4. 能不用锁则不用锁

io.netty.util.Recycler 中,使用 ThreadLocal 来进行替代锁的功能

相关实践学习
如何在云端创建MySQL数据库
开始实验后,系统会自动创建一台自建MySQL的 源数据库 ECS 实例和一台 目标数据库 RDS。
Sqoop 企业级大数据迁移方案实战
Sqoop是一个用于在Hadoop和关系数据库服务器之间传输数据的工具。它用于从关系数据库(如MySQL,Oracle)导入数据到Hadoop HDFS,并从Hadoop文件系统导出到关系数据库。 本课程主要讲解了Sqoop的设计思想及原理、部署安装及配置、详细具体的使用方法技巧与实操案例、企业级任务管理等。结合日常工作实践,培养解决实际问题的能力。本课程由黑马程序员提供。
相关文章
|
3月前
|
消息中间件 存储 Java
美团面试:说说Netty的零拷贝技术?
零拷贝技术(Zero-Copy)是一个大家耳熟能详的技术名词了,它主要用于提升 IO(Input & Output)的传输性能。 那么问题来了,为什么零拷贝技术能提升 IO 性能? ## 1.零拷贝技术和性能 在传统的 IO 操作中,当我们需要读取并传输数据时,我们需要在用户态(用户空间)和内核态(内核空间)中进行数据拷贝,它的执行流程如下: ![](https://cdn.nlark.com/yuque/0/2024/png/92791/1706491312473-52f5904a-2742-4e99-9b78-995e9a8b9696.png?x-oss-process=image%2F
41 0
|
存储
前中电技术总监带你了解,什么是零拷贝,Netty是如何实现的?
呢作为一个高性能的网络通信框架,被越来越多互联网公司关注和重视。最近,有小伙伴在面试过程中被问到Netty是如何实现零拷贝的问题?,今天,我给大家来聊一聊。另外,往期面试题解析中配套的文档我已经准备好,想获得的可以在我的煮叶简介中找到。
59 0
|
存储 消息中间件 缓存
Netty入门到超神系列-零拷贝技术
内存主要作用是在计算机运行时为操作系统和各种程序提供临时储存,操作系统的进程和进程之间是共享CPU和内存资源的。为了防止内存泄露需要一套完善且高效的内存管理机制。因此现代操作系提供了一种基于主内存抽象出来的概念:虚拟内存(Virtual Memory)。 虚拟内存 虚拟内存是计算机系统内存管理的一种技术,主要为每个进程提供私有的地址空间,让每个进程拥有一片连续完整的内存空间。而实际上,虚拟内存通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换,加载到物理内存中来 物理内存 物理内存指通过内存条而获得的内存空间,而虚拟内存则是指将硬盘的一块区域划分来
153 0
|
Java
Netty入门到超神系列-Java NIO零拷贝实战
这一章我们来操作一下NIO的零拷贝,这里我会先写代码样式一下传统IO数据拷贝场景下的耗时,然后再对比NIO场景下的考别耗时,通过耗时差异就能看到NIO零拷贝和传统IO拷贝的区别了。
113 0
|
存储 Java Linux
Netty ByteBuf 的零拷贝(Zero Copy)详解
Netty ByteBuf 的零拷贝(Zero Copy)详解
204 0
Netty - 探究零拷贝Zero Copy
Netty - 探究零拷贝Zero Copy
49 0
|
消息中间件 缓存 网络协议
【Netty 从成神到升仙系列 大结局】全网一图流死磕解析 Netty 源码
【Netty 从成神到升仙系列 大结局】全网一图流死磕解析 Netty 源码
【Netty 从成神到升仙系列 大结局】全网一图流死磕解析 Netty 源码
|
消息中间件 缓存 安全
【Netty 从成神到升仙系列 大结局】全网一图流死磕解析 Netty 源码
【Netty 从成神到升仙系列 大结局】全网一图流死磕解析 Netty 源码
【Netty 从成神到升仙系列 大结局】全网一图流死磕解析 Netty 源码
|
设计模式 安全 Java
【Netty 从成神到升仙系列 五】Netty 的责任链真有这么神奇吗?
【Netty 从成神到升仙系列 五】Netty 的责任链真有这么神奇吗?
【Netty 从成神到升仙系列 五】Netty 的责任链真有这么神奇吗?
|
存储 缓存 NoSQL
跟着源码学IM(十一):一套基于Netty的分布式高可用IM详细设计与实现(有源码)
本文将要分享的是如何从零实现一套基于Netty框架的分布式高可用IM系统,它将支持长连接网关管理、单聊、群聊、聊天记录查询、离线消息存储、消息推送、心跳、分布式唯一ID、红包、消息同步等功能,并且还支持集群部署。
13440 1