再次剖析NIO

netty为什么快呢?这是因为netty底层使用了JAVA的NIO技术,并在其基础上进行了性能的优化,虽然netty不是单纯的JAVA nio,但是netty的底层还是基于的是nio技术。

nio是JDK1.4中引入的,用于区别于传统的IO,所以nio也可以称之为new io。

nio的三大核心是Selector,channel和Buffer,本文我们将会深入探究NIO和netty之间的关系。

Java NIO则是非阻塞的,每一次数据读写调用都会立即返回,并将目前可读(或可写)的内容写入缓冲区或者从缓冲区中输出,即使当前没有可用数据,调用仍然会立即返回并且不对缓冲区做任何操作

NIO框架存在的问题

但是之前我们在使用NIO框架的时候,还是发现了一些问题,我们先来盘点一下。

客户端关闭导致服务端空轮询

当我们的客户端主动与服务端断开连接时,会导致READ事件一直被触发,也就是说selector.select()会直接通过,并且是可读的状态,但是我们发现实际上读到是数据是一个空的(上面的图中在空轮询两次后抛出异常了,也有可能是无限的循环下去)所以这里我们得稍微处理一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
} else if(key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(128);
//这里我们需要判断一下,如果read操作得到的结果是-1,那么说明服务端已经断开连接了
if(channel.read(buffer) < 0) {
System.out.println("客户端已经断开连接了:"+channel.getRemoteAddress());
channel.close(); //直接关闭此通道
continue; //继续进行选择
}
buffer.flip();
System.out.println("接收到客户端数据:"+new String(buffer.array(), 0, buffer.remaining()));
channel.write(ByteBuffer.wrap("已收到!".getBytes()));
}

当然,除了这种情况可能会导致空轮询之外,实际上还有一种可能,这种情况是NIO框架本身的BUG:

1
2
3
4
5
while (true) {
int count = selector.select(); //由于底层epoll机制的问题,导致select方法可能会一直返回0,造成无限循环的情况。
System.out.println("监听到 "+count+" 个事件");
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
1
This is an issue with poll (and epoll) on Linux. If a file descriptor for a connected socket is polled with a request event mask of 0, and if the connection is abruptly terminated (RST) then the poll wakes up with the POLLHUP (and maybe POLLERR) bit set in the returned event set. The implication of this behaviour is that Selector will wakeup and as the interest set for the SocketChannel is 0 it means there aren't any selected events and the select method returns 0.

这个问题本质是与操作系统有关的,所以JDK一直都认为是操作系统的问题,不应该由自己来处理,所以这个问题在当时的好几个JDK版本都是存在的,这是一个很严重的空转问题,无限制地进行空转操作会导致CPU资源被疯狂消耗。

不过,这个问题,却被Netty框架巧妙解决了,我们后面再说。

粘包/拆包问题

除了上面的问题之外,我们接着来看下一个问题。

我们在计算机网络这门课程中学习过,操作系统通过TCP协议发送数据的时候,也会先将数据存放在缓冲区中,而至于什么时候真正地发出这些数据,是由TCP协议来决定的,这是我们无法控制的事情。

也就是说,比如现在我们要发送两个数据包(P1/P2),理想情况下,这两个包应该是依次到达服务端,并由服务端正确读取两次数据出来,但是由于上面的机制,可能会出现下面的情况:

  1. 可能P1和P2被合在一起发送给了服务端(粘包现象)
  2. 可能P1和P2的前半部分合在一起发送给了服务端(拆包现象)
  3. 可能P1的前半部分就被单独作为一个部分发给了服务端,后面的和P2一起发给服务端(也是拆包现象)

当然,对于这种问题,也有一些比较常见的解决方案:

  1. 消息定长,发送方和接收方规定固定大小的消息长度,例如每个数据包大小固定为200字节,如果不够,空位补空格,只有接收了200个字节之后,作为一个完整的数据包进行处理。
  2. 在每个包的末尾使用固定的分隔符,比如每个数据包末尾都是\r\n,这样就一定需要读取到这样的分隔符才能将前面所有的数据作为一个完整的数据包进行处理。
  3. 将消息分为头部和本体,在头部中保存有当前整个数据包的长度,只有在读到足够长度之后才算是读到了一个完整的数据包。

Netty的ByteBuf

  • 写操作完成后无需进行flip()翻转。
  • 具有比ByteBuffer更快的响应速度。
  • 动态扩容。

两个指针不需要flip

1
2
3
4
5
6
7
public abstract class AbstractByteBuf extends ByteBuf {
...
int readerIndex; //index被分为了读和写,是两个指针在同时工作
int writerIndex;
private int markedReaderIndex; //mark操作也分两种
private int markedWriterIndex;
private int maxCapacity; //最大容量,没错,这玩意能动态扩容

可以看到,读操作和写操作分别由两个指针在进行维护,每写入一次,writerIndex向后移动一位,每读取一次,也是readerIndex向后移动一位,当然readerIndex不能大于writerIndex,这样就不会像NIO中的ByteBuffer那样还需要进行翻转了。

我们继续来看看它的另一个特性,动态扩容,比如我们申请一个容量为10的缓冲区:

通过结果我们发现,在写入一个超出当前容量的数据时,会进行动态扩容,扩容会从64开始,之后每次触发扩容都会x2,当然如果我们不希望它扩容,可以指定最大容量

我们接着来看一下缓冲区的三种实现模式:堆缓冲区模式、直接缓冲区模式、复合缓冲区模式。

堆缓冲区(数组实现)和直接缓冲区(堆外内存实现)不用多说,复合缓冲区模式

1
2
3
4
5
6
7
8
//创建一个复合缓冲区
CompositeByteBuf buf = Unpooled.compositeBuffer();
buf.addComponent(Unpooled.copiedBuffer("abc".getBytes()));
buf.addComponent(Unpooled.copiedBuffer("def".getBytes()));

for (int i = 0; i < buf.capacity(); i++) {
System.out.println((char) buf.getByte(i));
}

零拷贝简介

零拷贝是一种I/O操作优化技术,可以快速高效地将数据从文件系统移动到网络接口,而不需要将其从内核空间复制到用户空间,首先第一个问题,什么是内核空间,什么又是用户空间呢?

其实早期操作系统是不区分内核空间和用户空间的,但是应用程序能访问任意内存空间,程序很容易不稳定,常常把系统搞崩溃,比如清除操作系统的内存数据。实际上让应用程序随便访问内存真的太危险了,于是就按照CPU 指令的重要程度对指令进行了分级,指令分为四个级别:Ring0 ~ Ring3,Linux 下只使用了 Ring0 和 Ring3 两个运行级别,进程运行在 Ring3 级别时运行在用户态,指令只访问用户空间,而运行在 Ring0 级别时被称为运行在内核态,可以访问任意内存空间。

实现零拷贝我们这里演示三种方案:

  1. 使用虚拟内存

    现在的操作系统基本都是支持虚拟内存的,我们可以让内核空间和用户空间的虚拟地址指向同一个物理地址,这样就相当于是直接共用了这一块区域,也就谈不上拷贝操作了:

  1. 使用mmap/write内存映射

实际上这种方式就是将内核空间中的缓存直接映射到用户空间缓存,比如我们之前在学习NIO中使用的MappedByteBuffer,就是直接作为映射存在,当我们需要将数据发送到Socket缓冲区时,直接在内核空间中进行操作就行了

  1. 使用sendfile方式

在Linux2.1开始,引入了sendfile方式来简化操作,我们可以直接告诉内核要把哪个文件数据拷贝拷贝到Socket上,直接在内核空间中一步到位: