第2篇 Netty初体验
第3章 Netty与NIO之前世今生
3.1 Java NIO三件套
在NIO中有三个核心对象需要掌握:缓冲区(Buffer)、选择器(Selector)和通道(Channel)。
3.1.1 缓冲区
1.Buffer操作基本API
缓冲区实际上是一个容器对象,更直接地说,其实就是一个数组,在NIO库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的;在写入数据时,它也是写入缓冲区的;任何时候访问NIO中的数据,都是将它放到缓冲区中。而在面向流I/O系统中,所有数据都是直接写入或者直接将数据读取到Stream对象中。
在NIO中,所有的缓冲区类型都继承于抽象类Buffer,最常用的就是ByteBuffer,对于Java中的基本类型,基本都有一个具体Buffer类型与之相对应,它们之间的继承关系如下图所示。
下面是一个简单的使用IntBuffer的例子。
运行后可以看到如下图所示的结果。
2.Buffer的基本原理
在谈到缓冲区时,我们说缓冲区对象本质上是一个数组,但它其实是一个特殊的数组,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况,如果我们使用get()方法从缓冲区获取数据或者使用put()方法把数据写入缓冲区,都会引起缓冲区状态的变化。
在缓冲区中,最重要的属性有下面三个,它们一起合作完成对缓冲区内部状态的变化跟踪。
● position:指定下一个将要被写入或者读取的元素索引,它的值由get()/put()方法自动更新,在新创建一个Buffer对象时,position被初始化为0。
● limit:指定还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时)。
● capacity:指定了可以存储在缓冲区中的最大数据容量,实际上,它指定了底层数组的大小,或者至少是指定了准许我们使用的底层数组的容量。
以上三个属性值之间有一些相对大小的关系:0<=position<=limit<=capacity。如果我们创建一个新的容量大小为10的ByteBuffer对象,在初始化的时候,position设置为0,limit和capacity设置为10,在以后使用ByteBuffer对象过程中,capacity的值不会再发生变化,而其他两个将会随着使用而变化。
准备一个txt文档,存放在E盘,输入以下内容。
我们用一段代码来验证position、limit和capacity这三个值的变化过程,代码如下。
完成后的输出结果如下图所示。
我们已经看到运行结果,下面对以上结果进行图解,三个属性值分别如下图所示。
我们可以从通道中读取一些数据到缓冲区中,注意从通道读取数据,相当于往缓冲区写入数据。如果读取4个自己的数据,则此时position的值为4,即下一个将要被写入的字节索引为4,而limit仍然是10,如下图所示。
下一步把读取的数据写入输出通道,相当于从缓冲区中读取数据,在此之前,必须调用flip()方法。该方法将会完成以下两件事情:一是把limit设置为当前的position值。二是把position设置为0。
由于position被设置为0,所以可以保证在下一步输出时读取的是缓冲区的第一个字节,而limit被设置为当前的position,可以保证读取的数据正好是之前写入缓冲区的数据,如下图所示。
现在调用get()方法从缓冲区中读取数据写入输出通道,这会导致position的增加而limit保持不变,但position不会超过limit的值,所以在读取之前写入缓冲区的4字节之后,position和limit的值都为4,如下图所示。
在从缓冲区中读取数据完毕后,limit的值仍然保持在调用flip()方法时的值,调用clear()方法能够把所有的状态变化设置为初始化时的值,如下图所示。
3.缓冲区的分配
在前面的几个例子中,我们已经看到,在创建一个缓冲区对象时,会调用静态方法allocate()来指定缓冲区的容量,其实调用allocate()方法相当于创建了一个指定大小的数组,并把它包装为缓冲区对象。或者我们也可以直接将一个现有的数组包装为缓冲区对象,示例代码如下。
4.缓冲区分片
在NIO中,除了可以分配或者包装一个缓冲区对象,还可以根据现有的缓冲区对象创建一个子缓冲区,即在现有缓冲区上切出一片作为一个新的缓冲区,但现有的缓冲区与创建的子缓冲区在底层数组层面上是数据共享的,也就是说,子缓冲区相当于现有缓冲区的一个视图窗口。调用slice()方法可以创建一个子缓冲区,下面我们通过例子来看一下。
在该示例中,分配了一个容量大小为10的缓冲区,并在其中放入了数据0~9,而在该缓冲区基础上又创建了一个子缓冲区,并改变子缓冲区中的内容,从最后输出的结果来看,只有子缓冲区“可见的”那部分数据发生了变化,并且说明子缓冲区与原缓冲区是数据共享的,输出结果如下图所示。
5.只读缓冲区
只读缓冲区非常简单,可以读取它们,但是不能向它们写入数据。可以通过调用缓冲区的asReadOnlyBuffer()方法,将任何常规缓冲区转换为只读缓冲区,这个方法返回一个与原缓冲区完全相同的缓冲区,并与原缓冲区共享数据,只不过它是只读的。如果原缓冲区的内容发生了变化,只读缓冲区的内容也随之发生变化。具体代码如下。
如果尝试修改只读缓冲区的内容,则会报ReadOnlyBufferException异常。只读缓冲区对于保护数据很有用。在将缓冲区传递给某个对象的方法时,无法知道这个方法是否会修改缓冲区中的数据。创建一个只读缓冲区可以保证该缓冲区不会被修改。只可以把常规缓冲区转换为只读缓冲区,而不能将只读缓冲区转换为可写的缓冲区。
6.直接缓冲区
直接缓冲区是为加快I/O速度,使用一种特殊方式为其分配内存的缓冲区,JDK文档中的描述为:给定一个直接字节缓冲区,Java虚拟机将尽最大努力直接对它执行本机I/O操作。也就是说,它会在每一次调用底层操作系统的本机I/O操作之前(或之后),尝试避免将缓冲区的内容拷贝到一个中间缓冲区或者从一个中间缓冲区拷贝数据。要分配直接缓冲区,需要调用allocateDirect()方法,而不是allocate()方法,使用方式与普通缓冲区并无区别,如下面的文件所示。
7.内存映射
内存映射是一种读和写文件数据的方法,可以比常规的基于流或者基于通道的I/O快得多。内存映射文件I/O通过使文件中的数据表现为内存数组的内容来完成,这初听起来似乎不过就是将整个文件读到内存中,但事实上并不是这样的。一般来说,只有文件中实际读取或写入的部分才会映射到内存中。来看下面的示例代码。
3.1.2 选择器
传统的Client/Server模式会基于TPR(Thread per Request),服务器会为每个客户端请求建立一个线程,由该线程单独负责处理一个客户请求。这种模式带来的一个问题就是线程数量的剧增,大量的线程会增大服务器的开销。大多数的实现为了避免这个问题,都采用了线程池模型,并设置线程池中线程的最大数量,这又带来了新的问题,如果线程池中有200个线程,而有200个用户都在进行大文件下载,会导致第201个用户的请求无法及时处理,即便第201个用户只想请求一个几KB大小的页面。传统的Client/Server模式如下图所示。
NIO中非阻塞I/O采用了基于Reactor模式的工作方式,I/O调用不会被阻塞,而是注册感兴趣的特定I/O事件,如可读数据到达、新的套接字连接等,在发生特定事件时,系统再通知我们。NIO中实现非阻塞I/O的核心对象是Selector,Selector是注册各种I/O事件的地方,而且当那些事件发生时,就是Seleetor告诉我们所发生的事件,如下图所示。
从图中可以看出,当有读或写等任何注册的事件发生时,可以从Selector中获得相应的SelectionKey,同时从SelectionKey中可以找到发生的事件和该事件所发生的具体的SelectableChannel,以获得客户端发送过来的数据。
使用NIO中非阻塞I/O编写服务器处理程序,大体上可以分为下面三个步骤。
(1)向Selector对象注册感兴趣的事件。
(2)从Selector中获取感兴趣的事件。
(3)根据不同的事件进行相应的处理。
下面我们用一个简单的示例来说明整个过程。首先是向Selector对象注册感兴趣的事件。
上述代码中先创建了ServerSocketChannel对象,并调用configureBlocking()方法,配置为非阻塞模式。接下来的三行代码把该通道绑定到指定端口,最后向Selector注册事件。此处指定的参数是OP_ACCEPT,即指定想要监听accept事件,也就是新的连接发生时所产生的事件。对于ServerSocketChannel通道来说,我们唯一可以指定的参数就是OP_ACCEPT。从Selector中获取感兴趣的事件,即开始监听,进入内部循环。
在非阻塞I/O中,内部循环模式基本都遵循这种方式。首先调用select()方法,该方法会阻塞,直到至少有一个事件发生,然后使用selectedKeys()方法获取发生事件的SelectionKey,再使用迭代器进行循环。
最后一步就是根据不同的事件,编写相应的处理代码。
此处判断是接受请求、读数据还是写事件,分别做不同的处理。在Java 1.4之前的I/O系统中,提供的都是面向流的I/O系统,系统一次一个字节地处理数据,一个输入流产生一个字节的数据,一个输出流消费一个字节的数据,面向流的I/O速度非常慢;而在Java 1.4中推出了NIO,这是一个面向块的I/O系统,系统以块的方式处理数据,每一个操作在一步中都产生或者消费一个数据库,按块处理数据要比按字节处理数据快得多。
3.1.3 通道
通道是一个对象,通过它可以读取和写入数据,当然所有数据都通过Buffer对象来处理。我们永远不会将字节直接写入通道,而是将数据写入包含一个或者多个字节的缓冲区。同样也不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。
NIO提供了多种通道对象,所有的通道对象都实现了Channel接口。它们之间的继承关系如下图所示。
1.使用NIO读取数据
前面我们说过,任何时候读取数据,都不是直接从通道读取的,而是从通道读取到缓冲区的。所以使用NIO读取数据可以分为下面三个步骤。
(1)从FileInputStream获取Channel。
(2)创建Buffer。
(3)将数据从Channel读取到Buffer中。
下面是一个简单的使用NIO从文件中读取数据的例子。
2.使用NIO写入数据
使用NIO写入数据与读取数据的过程类似,数据同样不是直接写入通道的,而是写入缓冲区,可以分为下面三个步骤。
(1)从FileInputStream获取Channel。
(2)创建Buffer。
(3)将数据从Channel写入Buffer。
下面是一个简单的使用NIO向文件中写入数据的例子。
3.多路复用I/O
我们试想一下这样的现实场景。
一个餐厅同时有100位客人到店,到店后要做的第一件事情就是点菜。但是问题来了,餐厅老板为了节约人力成本,目前只有一名大堂服务员拿着唯一的一本菜单给客人提供服务。
那么最笨(但是最简单)的方法(方法A)是,无论有多少客人等待点餐,服务员都把仅有的一份菜单递给其中一位客人,然后站在客人身旁等待这位客人完成点菜过程。在记录客人的点菜内容后,把点菜记录交给后堂厨师。然后是第二位客人、第三位客人……很明显,没有老板会这样设置服务流程。因为随后的80位客人,在等待超时后就会离店(还会给差评)。
于是还有一种办法(方法B),老板马上新雇佣99名服务员,同时印制99本新的菜单。每一名服务员手持一本菜单负责一位客人(关键不只在于服务员,还在于菜单。因为没有菜单,客人也无法点菜)。在客人点完菜后,服务员记录点菜内容交给后堂厨师(当然为了更高效,后堂厨师最好也有100名),如下图所示。这样每一位客人享受的都是VIP服务,当然客人也不会走,但是人力成本却很高(必亏无疑)。
另外一种办法(方法C),就是改进点菜的方式,当客人到店后,自己申请一本菜单。想好自己要点的菜后,就呼叫服务员。服务员站在自己身边记录客人的菜单内容。将菜单递给厨师的过程也要进行改进,并不是每一份菜单记录好以后,都要交给后堂厨师。服务员可以记录好多份菜单后,同时交给厨师就行了,如下图所示。那么这种方式,对于老板来说人力成本是最低的;对于客人来说,虽然不再享受VIP服务,并且要进行一段时间的等待,但是这些都是可以接受的;对于服务员来说,基本上她的时间都没有浪费,最大程度地提高了时间利用率。
如果你是老板,你会采用哪种方式呢?
到店情况:并发量。到店情况不理想时,一个服务员一本菜单,当然是足够的。所以不同的老板在不同的场合下,将会灵活选择服务员和菜单的配置。
客人:客户端请求。
点餐内容:客户端发送的实际数据。
老板:操作系统。
人力成本:系统资源。
菜单:文件描述符(FD)。操作系统对于一个进程能够同时持有的文件描述符的个数是有限制的,在Linux系统中用$ulimit-n查看这个限制值,当然也是可以(并且应该)进行内核参数调整的。
服务员:操作系统内核用于I/O操作的线程(内核线程)。
厨师:应用程序线程(当然厨房就是应用程序进程)。
方法A:同步I/O。
方法B:同步I/O。
方法C:多路复用I/O。
目前流行的多路复用I/O的实现主要包括四种:select、poll、epoll、kqueue。如下表所示是它们的一些重要特性的比较。
多路复用I/O技术最适用的是“高并发”场景,所谓“高并发”是指1ms内至少同时有上千个连接请求准备好。其他情况下多路复用I/O技术发挥不出它的优势。另外,使用Java NIO进行功能实现,相对于传统的套接字实现要复杂一些,所以实际应用中,需要根据自己的业务需求进行技术选择。
3.2 NIO源码初探
说到源码,先得从Selector的open方法开始看,我们看java.nio.channels.Selector类的源码。
看SelectorProvider.provider()的具体代码。
其中provider=sun.nio.ch.DefaultSelectorProvider.create()会根据操作系统来返回不同的实现类,Windows平台返回WindowsSelectorProvider;而if(provider!=null)return provider保证了整个Server程序中只有一个WindowsSelectorProvider对象;看WindowsSelectorProvider.openSelector()代码。
new WindowsSelectorImpl(SelectorProvider)的代码如下。
其中Pipe.open()是关键,这个方法的调用过程如下。
在SelectorProvider中,代码如下。
再看一下PipeImpl()的代码。
其中IOUtil.makePipe(true)是一个本地方法。
具体实现代码如下。
正如下面这段注释所描述的内容。
高位存放的是通道read端的文件描述符,低32位存放的是write端的文件描述符。所以取得makepipe()返回值后要做移位处理。
这行代码把返回的pipe的write端的FD放在pollWrapper中(后面会发现这么做是为了实现Selector的wakeup())。
ServerSocketChannel.open()的实现代码如下。
SelectorProvider的实现代码如下。
可见ServerSocketChannelImpl也有WindowsSelectorImpl的引用。
然后通过serverChannel1.register(selector,SelectionKey.OP_ACCEPT);把Selector和Channel绑定在一起,也就是把新建ServerSocketChannel时创建的FD与Selector绑定在一起。
到此,Server端已启动完成,主要创建了以下对象。
(1)WindowsSelectorProvider:为单例对象,实际上是调用操作系统的API。。
(2)WindowsSelectorImpl中包含如下内容。
● pollWrapper:保存Selector上注册的FD,包括pipe的write端FD和ServerSocketChannel所用的FD。
● wakeupPipe:通道(其实就是两个FD,一个是read端的,一个是write端的)。
下面来看Selector中的select()方法,selector.select()主要调用了WindowsSelectorImpl中的doSelect()方法。
其中subSelector.poll()是核心,也就是轮询pollWrapper中保存的FD;具体实现是调用native方法poll0()。
poll0.()会监听pollWrapper中的FD有没有数据进出,这会造成I/O阻塞,直到有数据读写事件发生。比如,由于pollWrapper中保存的也有ServerSocketChannel的FD,所以只要ClientSocket发一份数据到ServerSocket,那么poll0()就会返回;又由于pollWrapper中保存的也有pipe的write端的FD,所以只要pipe的write端向FD发一份数据,也会造成poll0()返回;如果这两种情况都没有发生,那么poll0()就会一直阻塞,也就是selector.select()会一直阻塞;如果有任何一种情况发生,那么selector.select()就会返回,所以在OperationServer的run()里要用while(true),这样可以保证在Selector接收数据并处理完后继续监听poll()。
再来看WindowsSelectorImpl.Wakeup()。
可见wakeup()是通过pipe的write端send(scoutFd,&byte,1,0)发送一个字节1来唤醒poll()的,所以在需要的时候就可以调用selector.wakeup()来唤醒Selector。
3.3 反应堆
现在我们已经对阻塞I/O有了一定了解,知道阻塞I/O在调用InputStream.read()方法时是阻塞的,它会一直等到数据到来(或超时)时才会返回;同样,在调用ServerSocket.accept()方法时,也会一直阻塞到有客户端连接才会返回,每个客户端连接成功后,服务端都会启动一个线程去处理该客户端的请求。阻塞I/O的通信模型示意如下图所示。
如果仔细分析,一定会发现阻塞I/O存在一些缺点。根据阻塞I/O通信模型,总结了它的两个缺点。
(1)当客户端多时,会创建大量的处理线程。且每个线程都要占用栈空间和一些CPU时间。
(2)阻塞可能带来频繁的上下文切换,且大部分上下文切换可能是无意义的。在这种情况下非阻塞I/O就有了它的应用前景。
Java NIO是从JDK 1.4开始使用的,它既可以说成是“新I/O”,也可以说成是“非阻塞I/O”。下面是Java NIO的工作原理。
(1)由一个专门的线程来处理所有的I/O事件,并负责分发。
(2)事件驱动机制:事件到的时候触发,而不是同步地去监视事件。
(3)线程通信:线程之间通过wait、notify等方式通信。保证每次上下文切换都是有意义的,减少无谓的线程切换。
下面是笔者理解的Java NIO反应堆的工作原理图。
(注:每个线程的处理流程大概都是读取数据、解码、计算处理、编码和发送响应。)
3.4 Netty与NIO
3.4.1 Netty支持的功能与特性
按照定义来说,Netty是一个异步的、事件驱动的、用来做高性能高可靠性的网络应用的框架。下面是其主要的优点。
(1)框架设计优雅,底层模型随意切换,适应不同的网络协议要求。
(2)提供了很多标准的协议、安全、编解码的支持。
(3)解决了很多NIO不易用的问题。
(4)社区更为活跃,在很多开源框架中使用,如Dubbo、RocketMQ、Spark等。
Netty支持的功能与特性如下图所示。
(1)底层核心有:Zero-Copy-Capable Buffer,非常易用的零拷贝Buffer(这个内容很有意思,稍后专门来讲);统一的API;标准可扩展的事件模型。
(2)传输方面的支持有:管道通信;HTTP隧道;TCP与UDP。
(3)协议方面的支持有:基于原始文本和二进制的协议;解压缩;大文件传输;流媒体传输;ProtoBuf编解码;安全认证;HTTP和WebSocket。
3.4.2 Netty采用NIO而非AIO的理由
(1)Netty不看重Windows上的使用,在Linux系统上,AIO的底层实现仍使用epoll,没有很好地实现AIO,因此在性能上没有明显的优势,而且被JDK封装了一层,不容易深度优化。
(2)Netty整体架构采用Reactor模型,而AIO采用Proactor模型,混在一起会非常混乱,把AIO也改造成Reactor模型,看起来是把Epoll绕个弯又绕回来。
(3)AIO还有个缺点是接收数据需要预先分配缓存,而NIO是需要接收时才分配缓存,所以对连接数量非常大但流量小的情况,AIO浪费很多内存。
(4)Linux上AIO不够成熟,处理回调结果的速度跟不上处理需求,比如外卖员太少,顾客太多,供不应求,造成处理速度有瓶颈。