一、编解码器
解码器:将字节解码成消息
编码器:将消息编码为字节
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/
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
,对当前执行的对象进行加锁
请注意,因为这个方法已经被重构了,但是这个地方比较经典:展示需要找老版本
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)呢?
原因在于这样写可以节省内存空间,我们将 AtomicLong
与 totalPendingSize + 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(); } }
这里主要对比 LongAdder
和 AtomicLong
的性能
我们尽量用一张图来描述这两个技术的关联和区别
简单来说,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
来进行替代锁的功能