文章目录
1.NIO概述
NIO包括:Channel、Buffer、Selector
NIO是面向缓冲区的,传统IO是面向流的;传统IO基于字节流、字符流进行操作。NIO基于Channel和Buffer进行操作,数据总是从Channel读到Buffer,从Buffer写到Channel。传统IO是阻塞的,NIO是非阻塞的,实现了异步操作。
2.Channel
Stream是单向的,Channel是双向的。
NIO的Channel有:
- FileChannel
FileChannel无法设置为非阻塞模式,它总是运行在阻塞模式下。(可用于文件复制,速度比流处理快很多。)- DatagramChannel
- SocketChannel
- ServerSocketChannel
注:FileChannel为什么无法设置为非阻塞模式
源码层:源码中FileChannel类中没有configureBlocking()方法,所以不能设置我非阻塞模式,因此也就无法和Selector配合使用,无法注册Selector。
其它:Selector的设计理念是,如果打开了多个通道,并且每个通道的流量都很低的时候,使用Selector会很方便,比如QQ,阿里旺旺。而FileChannel一般适用于大文件的复制,流量很大,没有必要设置成非阻塞的。
3.Buffer
NIO中的关键Buffer实现有:ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer,分别对应基本数据类型: byte, char, double, float, int, long, short。当然NIO中还有MappedByteBuffer, HeapByteBuffer, DirectByteBuffer等。
Buffer实际上是一个容器,一个连续数组。
它的位置状态变量有:
- capacity:缓冲区数组的总长度
- position:下一个要操作的数据元素的位置
- limit:缓冲区数组中不可操作的下一个元素的位置:limit<=capacity
- mark:用于记录当前position的前一个位置或者默认是-1
初始状态时,position的位置为0,capacity和limit默认都是数组长度,写入一个字符就将position + 1。
调用ByteBuffer.flip()方法,position设为0,limit设成之前的position的值;这样就可将数据写入Channel。
这时底层操作系统就可以从缓冲区中正确读取这些数据并发送出去了,在下一次写数据之前我们再调用clear()方法,缓冲区的索引位置又回到了初始位置。
clear()方法:position将被设回0,limit设置成capacity,Buffer中的数据并未被清除,下次写的时候直接覆盖原位置上的数据。
compact()方法:如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先写些数据,那么使用compact()方法;compact()方法将所有未读的数据拷贝到Buffer起始处,然后将position设到最后一个未读元素正后面。limit设置成capacity,现在Buffer准备好写数据了,但是不会覆盖未读的数据。
Buffer.mark()方法:可以标记Buffer中的一个特定的position,之后可以通过调用Buffer.reset()方法恢复到这个position。
Buffer.rewind()方法将position设回0,所以可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素。
4.Selector
种通过把一个套接字通道(SocketChannel)注册到一个选择器(Selector)中,不时调用后者的选择(select)方法就能返回满足的选择键(SelectionKey),键中包含了SOCKET事件信息。这就是select模型。
要使用Selector, 得向Selector注册Channel,然后调用它的select()方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子有如新的连接进来、数据接收等。(与Selector一起使用时,Channel必须处于非阻塞模式下)
当向Selector注册Channel时,register()方法会返回一个SelectionKey对象,这个对象包含以下属性:
- interest集合
- ready集合
- Channel
- Selector
- 附加的对象(可选)
interest集合是你所选择的感兴趣的事件集合
ready 集合是通道已经准备就绪的操作的集合。在一次选择(Selection)之后,你会首先访问这个ready set。
5.内存映射文件
JAVA处理大文件,一般用BufferedReader,BufferedInputStream这类带缓冲的IO类,不过如果文件超大的话,更快的方式是采用MappedByteBuffer。
MappedByteBuffer 将文件直接映射到内存(虚拟内存)。
FileChannel提供了map方法来把文件影射为内存映像文件: MappedByteBuffer map(int mode,long position,long size); 可以把文件的从position开始的size大小的区域映射为内存映像文件,mode指出了 可访问该内存映像文件的方式:
- READ_ONLY,(只读): 试图修改得到的缓冲区将导致抛出 ReadOnlyBufferException.(MapMode.READ_ONLY)
- READ_WRITE(读/写): 对得到的缓冲区的更改最终将传播到文件;该更改对映射到同一文件的其他程序不一定是可见的。 (MapMode.READ_WRITE)
- PRIVATE(专用): 对得到的缓冲区的更改不会传播到文件,并且该更改对映射到同一文件的其他程序也不是可见的;相反,会创建缓冲区已修改部分的专用副本。 (MapMode.PRIVATE)
MappedByteBuffer是ByteBuffer的子类,其扩充了三个方法:
- force():缓冲区是READ_WRITE模式下,此方法对缓冲区内容的修改强行写入文件;
- load():将缓冲区的内容载入内存,并返回该缓冲区的引用;
- isLoaded():如果缓冲区的内容在物理内存中,则返回真,否则返回假;
6.Reactor模式
第一个阶段:服务器用一个while循环,不断监听端口是否有新的套接字连接。
缺点:无法并发,效率太低,请求可能长时间阻塞。
第二个阶段:使用多线程,connection per thread,即每一个连接用一个线程处理。
缺点:在一个线程里只能处理一个socket,如果连接数太高,系统无法承受,而且,线程的反复创建-销毁也需要代价。
第三阶段:使用线程池。
缺点:线程同步的粒度太大了,限制了吞吐量。
第四阶段:Reactor阶段。把一次连接的操作分为更细的粒度或者过程,这些更细的粒度是更小的线程。整个线程池的数目会翻倍,但是线程更简单,任务更加单一。
在Reactor中,这些被拆分的小线程或者子过程对应的是handler,每一种handler会出处理一种event。这里会有一个全局的管理者selector,我们需要把channel注册感兴趣的事件,那么这个selector就会不断在channel上检测是否有该类型的事件发生,如果没有,那么主线程就会被阻塞,否则就会调用相应的事件处理函数即handler来处理。
7.单线程下的Reactor
采用基于事件驱动的设计,当有事件触发时,才会调用处理器进行数据处理。
Reactor:负责响应IO事件,当检测到一个新的事件,将其发送给相应的Handler去处理。
Handler:负责处理非阻塞的行为,标识系统管理的资源;同时将handler与事件绑定。
Reactor为单个线程,需要处理accept连接,同时发送请求到处理器中。
由于只有单个线程,所以处理器中的业务需要能够快速处理完。
注:稍微大的业务如何处理?利用线程池,将各个handler放入线程池中进行处理。
Reactor为单个线程,最大可以维持多少连接?使用了Linux操作系统级别的epoll()来取代select,epoll由内核维护事件表,只需要处理有响应的描述符,不受限制,所以Reactor的连接数也是不受限制的。而select本身处理文件描述符受到限制,默认1024。
8.多线程下的Reactor
mainReactor负责监听连接,accept连接给subReactor处理。
为什么要单独分一个Reactor来处理监听呢?
因为像TCP这样需要经过3次握手才能建立连接,这个建立连接的过程也是要耗时间和资源的,单独分一个Reactor来处理,可以提高性能。
9.Reactor模式的优缺点
优点
1)响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的;
2)编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
3)可扩展性,可以方便的通过增加Reactor实例个数来充分利用CPU资源;
4)可复用性,reactor框架本身与具体事件处理逻辑无关,具有很高的复用性;
缺点
1)相比传统的简单模型,Reactor增加了一定的复杂性,因而有一定的门槛,并且不易于调试。
2)Reactor模式需要底层的Synchronous Event Demultiplexer支持,比如Java中的Selector支持,操作系统的select系统调用支持,如果要自己实现Synchronous Event Demultiplexer可能不会有那么高效。
3) Reactor模式在IO读写数据时还是在同一个线程中实现的,即使使用多个Reactor机制的情况下,那些共享一个Reactor的Channel如果出现一个长时间的数据读写,会影响这个Reactor中其他Channel的相应时间,比如在大文件传输时,IO操作就会影响其他Client的相应时间,因而对这种操作,使用传统的Thread-Per-Connection或许是一个更好的选择,或则此时使用Proactor模式。
10关于Netty
Java NIO采用的是Reactor模式,Netty和Redis也是采用Reactor模式的。
netty线程模型采用“服务端监听线程”和“IO线程”分离的方式,与多线程Reactor模型类似。Netty里对应mainReactor的角色叫做“Boss”,而对应subReactor的角色叫做”Worker”。Boss负责分配请求,Worker负责执行。
抽象出NioEventLoop来表示一个不断循环执行处理任务的线程,每个NioEventLoop有一个selector,用于监听绑定在其上的socket链路。
1、串行化设计避免线程竞争
netty采用串行化设计理念,从消息的读取->解码->处理->编码->发送,始终由IO线程NioEventLoop负责。整个流程不会进行线程上下文切换,数据无并发修改风险。
一个NioEventLoop聚合一个多路复用器selector,因此可以处理多个客户端连接。
netty只负责提供和管理“IO线程”,其他的业务线程模型由用户自己集成。
时间可控的简单业务建议直接在“IO线程”上处理,复杂和时间不可控的业务建议投递到后端业务线程池中处理。
2、定时任务与时间轮
NioEventLoop中的Thread线程按照时间轮中的步骤不断循环执行:
a)在时间片Tirck内执行selector.select()轮询监听IO事件;
b)处理监听到的就绪IO事件;
c)执行任务队列taskQueue/delayTaskQueue中的非IO任务。