一、NIO介绍
1.1 什么是NIO?
NIO 库是在 JDK 1.4 中引入的。NIO 弥补了原来的 I/O 的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O。NIO翻译成 no-blocking io 或者 new io都说得通。
1.2 NIO和BIO的区别
面向流与面向缓冲
Java NIO和IO之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区的。 Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。 Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
阻塞与非阻塞IO
Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。
Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
选择器(Selectors)
Java NIO的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器,然后使用一个单独的线程来“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。而BIO中是一个线程一个连接,在高并发情况下,可能会导致线程被链接耗光而进入阻塞的情况。
1.3 适用场景
NIO适用场景
服务器需要支持超大量的长时间连接。 并且每个客户端并不会频繁地发送太多数据 。Jetty、Mina、Netty、ZooKeeper,dubbo等都是基于NIO方式实现。
BIO适用场景
适用于连接数目比较小,并且一次发送大量数据的场景 ,这种方式对服务器资源要求比较高,并发局限于应用中。
因此,不一定是NIO一定性能就高。选择合适的场景才是最重要的。如果使用方式不对,可能不仅不会增加服务吞吐,反而使单个接口响应时间变长。
二、NIO的核心组成
NIO主要有三个核心部分组成:
buffer缓冲区、Channel管道、Selector选择器,他们的关系图如下
2.1 Selector
Selector的英文含义是“选择器”,也可以称为为“轮询代理器”、“事件订阅器”、“channel容器管理机”都行。
应用程序将向Selector对象注册需要它关注的Channel,以及具体的某一个Channel会对哪些IO事件感兴趣。Selector中也会维护一个“已经注册的Channel”的容器。
2.2 Channels
通道,被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操作系统)。那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据,而且可以同时进行读写。
- 所有被Selector(选择器)注册的通道,只能是继承了SelectableChannel类的子类。
- ServerSocketChannel:应用服务器程序的监听通道。只有通过这个通道,应用程序才能向操作系统注册支持“多路复用IO”的端口监听。同时支持UDP协议和TCP协议。
- ScoketChannel:TCP Socket套接字的监听通道,一个Socket套接字对应了一个客户端IP:端口 到 服务器IP:端口的通信连接。
通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。
2.3 buffer缓冲区
Buffer用于和NIO通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的。以写为例,应用程序都是将数据写入缓冲,再通过通道把缓冲的数据发送出去,读也是一样,数据总是先从通道读到缓冲,应用程序再读缓冲的数据。
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存( 其实就是数组)。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
Buffer用于和NIO通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的。以写为例,应用程序都是将数据写入缓冲,再通过通道把缓冲的数据发送出去,读也是一样,数据总是先从通道读到缓冲,应用程序再读缓冲的数据。
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存( 其实就是数组)。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
2.3.1 buffer重要属性
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
为了理解Buffer的工作原理,需要熟悉它的三个属性:
- capacity
- position
- limit
position和limit的含义取决于Buffer处在读模式还是写模式。不管Buffer处在什么模式,capacity的含义总是一样的。
这里有一个关于capacity,position和limit在读写模式中的说明,详细的解释在插图后面。
capacity
作为一个内存块,Buffer有一个固定的大小值,也叫“capacity”.你只能往里写capacity个byte、long,char等类型。一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。
position
当你写数据到Buffer中时,position表示当前的位置。初始的position值为0.当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1.
当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0. 当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。
limit
在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。
当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,你能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)
2.3.2 Buffer的分配
堆内内存
要想获得一个Buffer对象首先要进行分配。 每一个Buffer类都有allocate方法(可以在堆上分配,也可以在直接内存上分配)。
分配48字节capacity的ByteBuffer的例子:ByteBuffer buf = ByteBuffer.allocate(48);
分配一个可存储1024个字符的CharBuffer:CharBuffer buf = CharBuffer.allocate(1024);
wrap方法:把一个byte数组或byte数组的一部分包装成ByteBuffer:
ByteBuffer wrap(byte [] array)
ByteBuffer wrap(byte [] array, int offset, int length)
直接内存(堆外内存)
HeapByteBuffer与DirectByteBuffer,在原理上,前者可以看出分配的buffer是在heap区域的,其实真正flush到远程的时候会先拷贝到直接内存,再做下一步操作;在NIO的框架下,很多框架会采用DirectByteBuffer来操作,这样分配的内存不再是在java heap上,而是在操作系统的C heap上,经过性能测试,可以得到非常快速的网络交互,在大量的网络交互下,一般速度会比HeapByteBuffer要快速好几倍。
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError 异常出现。
NIO可以使用Native 函数库直接分配堆外内存,然后通过一个存储在Java 堆里面的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java 堆和Native 堆中来回复制数据。
堆内内存和对外内存分配code:
/**
* @author DarkKing
* 类说明:Buffer的分配
*/
public class AllocateBuffer {
public static void main(String[] args) {
System.out.println("----------Test allocate--------");
System.out.println("before alocate:"
+ Runtime.getRuntime().freeMemory());
//堆上分配
ByteBuffer buffer = ByteBuffer.allocate();
System.out.println("buffer = " + buffer);
System.out.println("after alocate:"
+ Runtime.getRuntime().freeMemory());
// 直接内存分配
ByteBuffer directBuffer = ByteBuffer.allocateDirect();
System.out.println("directBuffer = " + directBuffer);
System.out.println("after direct alocate:"
+ Runtime.getRuntime().freeMemory());
System.out.println("----------Test wrap--------");
byte[] bytes = new byte[];
buffer = ByteBuffer.wrap(bytes);
System.out.println(buffer);
buffer = ByteBuffer.wrap(bytes,, 10);
System.out.println(buffer);
}
}
堆外内存的优点和缺点
堆外内存,其实就是不受JVM控制的内存。相比于堆内内存有几个优势:
1 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作(可能使用多线程或者时间片的方式,根本感觉不到)
2 加快了复制的速度。因为堆内在flush到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了这个工作。(零拷贝原理)
而福之祸所依,自然也有不好的一面:
1 堆外内存难以控制,如果内存泄漏,那么很难排查
2 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合。
直接内存(堆外内存)与堆内存比较
直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显
性能测试
/**
* @author DarkKing
* 类说明:
*/
public class ByteBufferCompare {
public static void main(String[] args) {
allocateCompare(); //分配比较
operateCompare(); //读写比较
}
/**
* 直接内存 和 堆内存的 分配空间比较
* 结论: 在数据量提升时,直接内存相比非直接内的申请,有很严重的性能问题
*/
public static void allocateCompare() {
int time =; //操作次数
long st = System.currentTimeMillis();
for (int i =; i < time; i++) {
//ByteBuffer.allocate(int capacity) 分配一个新的字节缓冲区。
ByteBuffer buffer = ByteBuffer.allocate(); //非直接内存分配申请
}
long et = System.currentTimeMillis();
System.out.println("在进行" + time + "次分配操作时,堆内存 分配耗时:" + (et - st) + "ms");
long st_heap = System.currentTimeMillis();
for (int i =; i < time; i++) {
//ByteBuffer.allocateDirect(int capacity) 分配新的直接字节缓冲区。
ByteBuffer buffer = ByteBuffer.allocateDirect(); //直接内存分配申请
}
long et_direct = System.currentTimeMillis();
System.out.println("在进行" + time + "次分配操作时,直接内存 分配耗时:" + (et_direct - st_heap) + "ms");
}
/**
* 直接内存 和 堆内存的 读写性能比较
* 结论:直接内存在直接的IO 操作上,在频繁的读写时 会有显著的性能提升
*/
public static void operateCompare() {
int time =;
ByteBuffer buffer = ByteBuffer.allocate( * time);
long st = System.currentTimeMillis();
for (int i =; i < time; i++) {
// putChar(char value) 用来写入 char 值的相对 put 方法
buffer.putChar('a');
}
buffer.flip();
for (int i =; i < time; i++) {
buffer.getChar();
}
long et = System.currentTimeMillis();
System.out.println("在进行" + time + "次读写操作时,非直接内存读写耗时:" + (et - st) + "ms");
ByteBuffer buffer_d = ByteBuffer.allocateDirect( * time);
long st_direct = System.currentTimeMillis();
for (int i =; i < time; i++) {
// putChar(char value) 用来写入 char 值的相对 put 方法
buffer_d.putChar('a');
}
buffer_d.flip();
for (int i =; i < time; i++) {
buffer_d.getChar();
}
long et_direct = System.currentTimeMillis();
System.out.println("在进行" + time + "次读写操作时,直接内存读写耗时:" + (et_direct - st_direct) + "ms");
}
}
堆外内存的优点和缺点
堆外内存,其实就是不受JVM控制的内存。相比于堆内内存有几个优势:
1 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作(可能使用多线程或者时间片的方式,根本感觉不到)
2 加快了复制的速度。因为堆内在flush到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了这个工作。(零拷贝原理)
而福之祸所依,自然也有不好的一面:
1 堆外内存难以控制,如果内存泄漏,那么很难排查
2 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合。
直接内存(堆外内存)与堆内存比较
直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显
性能测试
/**
* @author DarkKing
* 类说明:
*/
public class ByteBufferCompare {
public static void main(String[] args) {
allocateCompare(); //分配比较
operateCompare(); //读写比较
}
/**
* 直接内存 和 堆内存的 分配空间比较
* 结论: 在数据量提升时,直接内存相比非直接内的申请,有很严重的性能问题
*/
public static void allocateCompare() {
int time =; //操作次数
long st = System.currentTimeMillis();
for (int i =; i < time; i++) {
//ByteBuffer.allocate(int capacity) 分配一个新的字节缓冲区。
ByteBuffer buffer = ByteBuffer.allocate(); //非直接内存分配申请
}
long et = System.currentTimeMillis();
System.out.println("在进行" + time + "次分配操作时,堆内存 分配耗时:" + (et - st) + "ms");
long st_heap = System.currentTimeMillis();
for (int i =; i < time; i++) {
//ByteBuffer.allocateDirect(int capacity) 分配新的直接字节缓冲区。
ByteBuffer buffer = ByteBuffer.allocateDirect(); //直接内存分配申请
}
long et_direct = System.currentTimeMillis();
System.out.println("在进行" + time + "次分配操作时,直接内存 分配耗时:" + (et_direct - st_heap) + "ms");
}
/**
* 直接内存 和 堆内存的 读写性能比较
* 结论:直接内存在直接的IO 操作上,在频繁的读写时 会有显著的性能提升
*/
public static void operateCompare() {
int time =;
ByteBuffer buffer = ByteBuffer.allocate( * time);
long st = System.currentTimeMillis();
for (int i =; i < time; i++) {
// putChar(char value) 用来写入 char 值的相对 put 方法
buffer.putChar('a');
}
buffer.flip();
for (int i =; i < time; i++) {
buffer.getChar();
}
long et = System.currentTimeMillis();
System.out.println("在进行" + time + "次读写操作时,非直接内存读写耗时:" + (et - st) + "ms");
ByteBuffer buffer_d = ByteBuffer.allocateDirect( * time);
long st_direct = System.currentTimeMillis();
for (int i =; i < time; i++) {
// putChar(char value) 用来写入 char 值的相对 put 方法
buffer_d.putChar('a');
}
buffer_d.flip();
for (int i =; i < time; i++) {
buffer_d.getChar();
}
long et_direct = System.currentTimeMillis();
System.out.println("在进行" + time + "次读写操作时,直接内存读写耗时:" + (et_direct - st_direct) + "ms");
}
}
执行程序后
可以看到,
1、内存分配方面,在数据量提升时,直接内存相比非直接内的申请,有很严重的性能问题。
2、IO读写方面,直接内存在直接的IO 操作上,在频繁的读写时 会有显著的性能提升
2.3.3 Buffer的读写
从Buffer中写数据
写数据到Buffer有两种方式:
1.读取Channel写到Buffer。
2.通过Buffer的put()方法写到Buffer里。
从Channel写到Buffer的例子 int bytesRead = inChannel.read(buf); //read into buffer.
通过put方法写Buffer的例子:buf.put(127);
put方法有很多版本,允许你以不同的方式把数据写入到Buffer中。例如, 写到一个指定的位置,或者把一个字节数组写入到Buffer。 更多Buffer实现的细节参考JavaDoc。
flip()方法
flip方法将Buffer从写模式切换到读模式。调用flip()方法会将position设回0,并将limit设置成之前position的值。
换句话说,position现在用于标记读的位置,limit表示之前写进了多少个byte、char等 —— 现在能读取多少个byte、char等。
从Buffer中读取数据
从Buffer中读取数据有两种方式:
1.从Buffer读取数据写入到Channel。
2.使用get()方法从Buffer中读取数据。
从Buffer读取数据到Channel的例子:int bytesWritten = inChannel.write(buf);
使用get()方法从Buffer中读取数据的例子:byte aByte = buf.get();
get方法有很多版本,允许你以不同的方式从Buffer中读取数据。例如,从指定position读取,或者从Buffer中读取数据到字节数组。更多Buffer实现的细节参考JavaDoc。
使用Buffer读写数据常见步骤:
1.写入数据到Buffer
2.调用flip()方法
3.从Buffer中读取数据
4.调用clear()方法或者compact()方法
当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。
一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。
2.3.4 buffer其他常用方法
rewind()方法
Buffer.rewind()将position设回0,所以你可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素(byte、char等)。
clear()与compact()方法
一旦读完Buffer中的数据,需要让Buffer准备好再次被写入。可以通过clear()或compact()方法来完成。
如果调用的是clear()方法,position将被设回0,limit被设置成 capacity的值。换句话说,Buffer 被清空了。Buffer中的数据并未清除,只是这些标记告诉我们可以从哪里开始往Buffer里写数据。
如果Buffer中有一些未读的数据,调用clear()方法,数据将“被遗忘”,意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。
如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先先写些数据,那么使用compact()方法。
compact()方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。
mark()与reset()方法
通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position。例如:
buffer.mark();//call buffer.get() a couple of times, e.g. during parsing.
buffer.reset(); //set position back to mark.
equals()与compareTo()方法
可以使用equals()和compareTo()方法两个Buffer。
equals()
当满足下列条件时,表示两个Buffer相等:
- 有相同的类型(byte、char、int等)。
- Buffer中剩余的byte、char等的个数相等。
- Buffer中所有剩余的byte、char等都相同。
如你所见,equals只是比较Buffer的一部分,不是每一个在它里面的元素都比较。实际上,它只比较Buffer中的剩余元素。
buffer方法演示
/**
* @author DarkKing
* 类说明:Buffer方法演示
*/
public class BufferMethod {
public static void main(String[] args) {
System.out.println("------Test get-------------");
ByteBuffer buffer = ByteBuffer.allocate();
buffer.put((byte) 'a')//
.put((byte) 'b')//
.put((byte) 'c')//
.put((byte) 'd')//
.put((byte) 'e')//
.put((byte) 'f');//
System.out.println("before flip()" + buffer);
/* 转换为读取模式*/
buffer.flip();
System.out.println("before get():" + buffer);
System.out.println((char) buffer.get());
System.out.println("after get():" + buffer);
/* get(index)不影响position的值*/
System.out.println((char) buffer.get());
System.out.println("after get(index):" + buffer);
byte[] dst = new byte[];
/* position移动两位*/
buffer.get(dst,, 2);
/*这里的buffer是 abcdef[pos= lim=6 cap=32]*/
System.out.println("after get(dst,, 2):" + buffer);
System.out.println("dst:" + new String(dst));
System.out.println("--------Test put-------");
ByteBuffer bb = ByteBuffer.allocate();
System.out.println("before put(byte):" + bb);
System.out.println("after put(byte):" + bb.put((byte) 'z'));
// put(,(byte) 'c')不改变position的位置
bb.put(, (byte) 'c');
System.out.println("after put(,(byte) 'c'):" + bb);
System.out.println(new String(bb.array()));
// 这里的buffer是 abcdef[pos= lim=6 cap=32]
bb.put(buffer);
System.out.println("after put(buffer):" + bb);
System.out.println(new String(bb.array()));
System.out.println("--------Test reset----------");
buffer = ByteBuffer.allocate();
System.out.println("buffer = " + buffer);
buffer.clear();
buffer.position();//移动position到5
buffer.mark();//记录当前position的位置
buffer.position();//移动position到10
System.out.println("before reset:" + buffer);
buffer.reset();//复位position到记录的地址
System.out.println("after reset:" + buffer);
System.out.println("--------Test rewind--------");
buffer.clear();
buffer.position();//移动position到10
buffer.limit();//限定最大可写入的位置为15
System.out.println("before rewind:" + buffer);
buffer.rewind();//将position设回
System.out.println("before rewind:" + buffer);
System.out.println("--------Test compact--------");
buffer.clear();
//放入个字节,position移动到下个可写入的位置,也就是4
buffer.put("abcd".getBytes());
System.out.println("before compact:" + buffer);
System.out.println(new String(buffer.array()));
buffer.flip();//将position设回,并将limit设置成之前position的值
System.out.println("after flip:" + buffer);
//从Buffer中读取数据的例子,每读一次,position移动一次
System.out.println((char) buffer.get());
System.out.println((char) buffer.get());
System.out.println((char) buffer.get());
System.out.println("after three gets:" + buffer);
System.out.println(new String(buffer.array()));
//compact()方法将所有未读的数据拷贝到Buffer起始处。
// 然后将position设到最后一个未读元素正后面。
buffer.compact();
System.out.println("after compact:" + buffer);
System.out.println(new String(buffer.array()));
}
}
三、NIO之Reactor模式
“反应”器名字中”反应“的由来:
“反应”即“倒置”,“控制逆转”,具体事件处理程序不调用反应器,而向反应器注册一个事件处理器,表示自己对某些事件感兴趣,有时间来了,具体事件处理程序通过事件处理器对某个指定的事件发生做出反应;这种控制逆转又称为“好莱坞法则”(不要调用我,让我来调用你)
NIO为实现Reactor模式提供了基础,上面的NIO图示其实就是Reactor模式的雏形,只是Reactor以OO的方式抽象出了几个概念,使得职责划分更加明确。
- Reactor:Reactor是IO事件的派发者,对应NIO的Selector;
- Acceptor:Acceptor接受client连接,建立对应client的Handler,并向Reactor注册此Handler,对应NIO中注册Channel和事件触发时的判断分支(上述NIO服务端示例代码的38-46行);
- Handler:IO处理类,对应NIO中Channel[使用socket]操作Buffer的过程。
3.1 单线程Reactor模式流程
- 服务器端的Reactor是一个线程对象,该线程会启动事件循环,并使用Selector(选择器)来实现IO的多路复用。注册一个Acceptor事件处理器到Reactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样Reactor会监听客户端向服务器端发起的连接请求事件(ACCEPT事件)。
2. 客户端向服务器端发起一个连接请求,Reactor监听到了该ACCEPT事件的发生并将该ACCEPT事件派发给相应的Acceptor处理器来进行处理。Acceptor处理器通过accept()方法得到与这个客户端对应的连接(SocketChannel),然后将该连接所关注的READ事件以及对应的READ事件处理器注册到Reactor中,这样一来Reactor就会监听该连接的READ事件了。
3. 当Reactor监听到有读或者写事件发生时,将相关的事件派发给对应的处理器进行处理。比如,读处理器会通过SocketChannel的read()方法读取数据,此时read()操作可以直接读取到数据,而不会堵塞与等待可读的数据到来。
4.每当处理完所有就绪的感兴趣的I/O事件后,Reactor线程会再次执行select()阻塞等待新的事件就绪并将其分派给对应处理器进行处理。
注意,Reactor的单线程模式的单线程主要是针对于I/O操作而言,也就是所有的I/O的accept()、read()、write()以及connect()操作都在一个线程上完成的。
但在目前的单线程Reactor模式中,不仅I/O操作在该Reactor线程上,连非I/O的业务操作也在该线程上进行处理了,这可能会大大延迟I/O请求的响应。所以我们应该将非I/O的业务逻辑操作从Reactor线程上卸载,以此来加速Reactor线程对I/O请求的响应。
3.2 单线程Reactor,工作者线程池
与单线程Reactor模式不同的是,添加了一个工作者线程池,并将非I/O操作从Reactor线程中移出转交给工作者线程池来执行。这样能够提高Reactor线程的I/O响应,不至于因为一些耗时的业务逻辑而延迟对后面I/O请求的处理。
使用线程池的优势:
1.通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程产生的巨大开销。
2.另一个额外的好处是,当请求到达时,工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性。
3.通过适当调整线程池的大小,可以创建足够多的线程以便使处理器保持忙碌状态。同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败。
改进的版本中,所以的I/O操作依旧由一个Reactor来完成,包括I/O的accept()、read()、write()以及connect()操作。
对于一些小容量应用场景,可以使用单线程模型。但是对于高负载、大并发或大数据量的应用场景却不合适,主要原因如下:
1.一个NIO线程同时处理成百上千的链路,性能上无法支撑,即便NIO线程的CPU负荷达到100%,也无法满足海量消息的读取和发送;
2.当NIO线程负载过重之后,处理速度将变慢,这会导致大量客户端连接超时,超时之后往往会进行重发,这更加重了NIO线程的负载,最终会导致大量消息积压和处理超时,成为系统的性能瓶颈;
3.3 多Reactor线程模式
Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的事件循环逻辑。
mainReactor可以只有一个,但subReactor一般会有多个。mainReactor线程主要负责接收客户端的连接请求,然后将接收到的SocketChannel传递给subReactor,由subReactor来完成和客户端的通信。
流程:
1.注册一个Acceptor事件处理器到mainReactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样mainReactor会监听客户端向服务器端发起的连接请求事件(ACCEPT事件)。启动mainReactor的事件循环。
2.客户端向服务器端发起一个连接请求,mainReactor监听到了该ACCEPT事件并将该ACCEPT事件派发给Acceptor处理器来进行处理。Acceptor处理器通过accept()方法得到与这个客户端对应的连接(SocketChannel),然后将这个SocketChannel传递给subReactor线程池。
3.subReactor线程池分配一个subReactor线程给这个SocketChannel,即,将SocketChannel关注的READ事件以及对应的READ事件处理器注册到subReactor线程中。当然你也注册WRITE事件以及WRITE事件处理器到subReactor线程中以完成I/O写操作。Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的循环逻辑。
4.当有I/O事件就绪时,相关的subReactor就将事件派发给响应的处理器处理。注意,这里subReactor线程只负责完成I/O的read()操作,在读取到数据后将业务逻辑的处理放入到线程池中完成,若完成业务逻辑后需要返回数据给客户端,则相关的I/O的write操作还是会被提交回subReactor线程来完成。
注意,所以的I/O操作(包括,I/O的accept()、read()、write()以及connect()操作)依旧还是在Reactor线程(mainReactor线程 或 subReactor线程)中完成的。Thread Pool(线程池)仅用来处理非I/O操作的逻辑。
多Reactor线程模式将“接受客户端的连接请求”和“与该客户端的通信”分在了两个Reactor线程来完成。mainReactor完成接收客户端连接请求的操作,它不负责与客户端的通信,而是将建立好的连接转交给subReactor线程来完成与客户端的通信,这样一来就不会因为read()数据量太大而导致后面的客户端连接请求得不到即时处理的情况。并且多Reactor线程模式在海量的客户端并发请求的情况下,还可以通过实现subReactor线程池来将海量的连接分发给多个subReactor线程,在多核的操作系统中这能大大提升应用的负载和吞吐量。
3.4 和观察者模式的区别
观察者模式:
也可以称为为 发布-订阅 模式,主要适用于多个对象依赖某一个对象的状态并,当某对象状态发生改变时,要通知其他依赖对象做出更新。是一种一对多的关系。当然,如果依赖的对象只有一个时,也是一种特殊的一对一关系。通常,观察者模式适用于消息事件处理,监听者监听到事件时通知事件处理者对事件进行处理(这一点上面有点像是回调,容易与反应器模式和前摄器模式的回调搞混淆)。
Reactor模式:
reactor模式,即反应器模式,是一种高效的异步IO模式,特征是回调,当IO完成时,回调对应的函数进行处理。这种模式并非是真正的异步,而是运用了异步的思想,当IO事件触发时,通知应用程序作出IO处理。模式本身并不调用系统的异步IO函数。
reactor模式与观察者模式有点像。不过,观察者模式与单个事件源关联,而反应器模式则与多个事件源关联 。当一个主体发生改变时,所有依属体都得到通知。
四、NIO使用举例
4.1、NioServerHandle
Nio通信服务端处理器
/**
* @author DarkKing
* 类说明:nio通信服务端处理器
*/
public class NioServerHandle implements Runnable {
private Selector selector;
private ServerSocketChannel serverChannel;
private volatile boolean started;
/**
* 构造方法
*
* @param port 指定要监听的端口号
*/
public NioServerHandle(int port) {
try {
//创建选择器
selector = Selector.open();
//打开监听通道
serverChannel = ServerSocketChannel.open();
//如果为 true,则此通道将被置于阻塞模式;
// 如果为 false,则此通道将被置于非阻塞模式
serverChannel.configureBlocking(false);//开启非阻塞模式
serverChannel.socket().bind(new InetSocketAddress(port));
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
//标记服务器已开启
started = true;
System.out.println("服务器已启动,端口号:" + port);
} catch (IOException e) {
e.printStackTrace();
System.exit();
}
}
public void stop() {
started = false;
}
@Override
public void run() {
//循环遍历selector
while (started) {
try {
//阻塞,只有当至少一个注册的事件发生的时候才会继续.
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> it = keys.iterator();
SelectionKey key = null;
while (it.hasNext()) {
key = it.next();
it.remove();
try {
handleInput(key);
} catch (Exception e) {
if (key != null) {
key.cancel();
if (key.channel() != null) {
key.channel().close();
}
}
}
}
} catch (Throwable t) {
t.printStackTrace();
}
}
//selector关闭后会自动释放里面管理的资源
if (selector != null)
try {
selector.close();
} catch (Exception e) {
e.printStackTrace();
}
}
private void handleInput(SelectionKey key) throws IOException {
if (key.isValid()) {
//处理新接入的请求消息
if (key.isAcceptable()) {
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel sc = ssc.accept();
System.out.println("=======建立连接===");
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
}
//读消息
if (key.isReadable()) {
System.out.println("======socket channel 数据准备完成," +
"可以去读==读取=======");
SocketChannel sc = (SocketChannel) key.channel();
//创建ByteBuffer,并开辟一个M的缓冲区
ByteBuffer buffer = ByteBuffer.allocate();
//读取请求码流,返回读取到的字节数
int readBytes = sc.read(buffer);
//读取到字节,对字节进行编解码
if (readBytes >) {
//将缓冲区当前的limit设置为position,position=,
// 用于后续对缓冲区的读取操作
buffer.flip();
//根据缓冲区可读字节数创建字节数组
byte[] bytes = new byte[buffer.remaining()];
//将缓冲区可读字节数组复制到新建的数组中
buffer.get(bytes);
String message = new String(bytes, "UTF-");
System.out.println("服务器收到消息:" + message);
//处理数据
String result = Const.response(message);
//发送应答消息
doWrite(sc, result);
}
//链路已经关闭,释放资源
else if (readBytes <) {
key.cancel();
sc.close();
}
}
}
}
//发送应答消息
private void doWrite(SocketChannel channel, String response)
throws IOException {
//将消息编码为字节数组
byte[] bytes = response.getBytes();
//根据数组容量创建ByteBuffer
ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
//将字节数组复制到缓冲区
writeBuffer.put(bytes);
//flip操作
writeBuffer.flip();
//发送缓冲区的字节数组
channel.write(writeBuffer);
}
}
4.2、NioServer
nio通信服务端
/**
* @author DarkKing
* 类说明:nio通信服务端
*/
public class NioServer {
private static NioServerHandle nioServerHandle;
public static void start() {
if (nioServerHandle != null)
nioServerHandle.stop();
nioServerHandle = new NioServerHandle(Const.DEFAULT_PORT);
new Thread(nioServerHandle, "Server").start();
}
public static void main(String[] args) {
start();
}
}
4.3NioClientHandlenio
通信客户端处理器
/**
* @author DarkKing
* 类说明:nio通信客户端处理器
*/
public class NioClientHandle implements Runnable {
private String host;
private int port;
private volatile boolean started;
private Selector selector;
private SocketChannel socketChannel;
public NioClientHandle(String ip, int port) {
this.host = ip;
this.port = port;
try {
/*创建选择器*/
this.selector = Selector.open();
/*打开监听通道*/
socketChannel = SocketChannel.open();
/*如果为 true,则此通道将被置于阻塞模式;
* 如果为 false,则此通道将被置于非阻塞模式
* 缺省为true*/
socketChannel.configureBlocking(false);
started = true;
} catch (IOException e) {
e.printStackTrace();
System.exit(-);
}
}
public void stop() {
started = false;
}
@Override
public void run() {
//连接服务器
try {
doConnect();
} catch (IOException e) {
e.printStackTrace();
System.exit(-);
}
/*循环遍历selector*/
while (started) {
try {
/*阻塞方法,当至少一个注册的事件发生的时候就会继续*/
selector.select();
/*获取当前有哪些事件可以使用*/
Set<SelectionKey> keys = selector.selectedKeys();
/*转换为迭代器*/
Iterator<SelectionKey> it = keys.iterator();
SelectionKey key = null;
while (it.hasNext()) {
key = it.next();
/*我们必须首先将处理过的 SelectionKey 从选定的键集合中删除。
如果我们没有删除处理过的键,那么它仍然会在事件集合中以一个激活
的键出现,这会导致我们尝试再次处理它。*/
it.remove();
try {
handleInput(key);
} catch (Exception e) {
if (key != null) {
key.cancel();
if (key.channel() != null) {
key.channel().close();
}
}
}
}
} catch (IOException e) {
e.printStackTrace();
System.exit(-);
}
}
if (selector != null) {
try {
selector.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
/*具体的事件处理方法*/
private void handleInput(SelectionKey key) throws IOException {
if (key.isValid()) {
/*获得关心当前事件的channel*/
SocketChannel sc = (SocketChannel) key.channel();
/*处理连接就绪事件
* 但是三次握手未必就成功了,所以需要等待握手完成和判断握手是否成功*/
if (key.isConnectable()) {
/*finishConnect的主要作用就是确认通道连接已建立,
方便后续IO操作(读写)不会因连接没建立而
导致NotYetConnectedException异常。*/
if (sc.finishConnect()) {
/*连接既然已经建立,当然就需要注册读事件,
写事件一般是不需要注册的。*/
socketChannel.register(selector, SelectionKey.OP_READ);
} else System.exit(-);
}
/*处理读事件,也就是当前有数据可读*/
if (key.isReadable()) {
/*创建ByteBuffer,并开辟一个k的缓冲区*/
ByteBuffer buffer = ByteBuffer.allocate();
/*将通道的数据读取到缓冲区,read方法返回读取到的字节数*/
int readBytes = sc.read(buffer);
if (readBytes >) {
buffer.flip();
byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes);
String result = new String(bytes, "UTF-");
System.out.println("客户端收到消息:" + result);
}
/*链路已经关闭,释放资源*/
else if (readBytes <) {
key.cancel();
sc.close();
}
}
}
}
/*进行连接*/
private void doConnect() throws IOException {
/*如果此通道处于非阻塞模式,则调用此方法将启动非阻塞连接操作。
如果连接马上建立成功,则此方法返回true。
否则,此方法返回false,
因此我们必须关注连接就绪事件,
并通过调用finishConnect方法完成连接操作。*/
if (socketChannel.connect(new InetSocketAddress(host, port))) {
/*连接成功,关注读事件*/
socketChannel.register(selector, SelectionKey.OP_READ);
} else {
socketChannel.register(selector, SelectionKey.OP_CONNECT);
}
}
/*写数据对外暴露的API*/
public void sendMsg(String msg) throws IOException {
doWrite(socketChannel, msg);
}
private void doWrite(SocketChannel sc, String request) throws IOException {
byte[] bytes = request.getBytes();
ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
writeBuffer.put(bytes);
writeBuffer.flip();
sc.write(writeBuffer);
}
}
4.4、NioClient
nio通信客户端
/**
* @author DarkKing
* 类说明:nio通信客户端
*/
public class NioClient {
private static NioClientHandle nioClientHandle;
public static void start() {
if (nioClientHandle != null)
nioClientHandle.stop();
nioClientHandle = new NioClientHandle(Const.DEFAULT_SERVER_IP, Const.DEFAULT_PORT);
new Thread(nioClientHandle, "Server").start();
}
//向服务器发送消息
public static boolean sendMsg(String msg) throws Exception {
nioClientHandle.sendMsg(msg);
return true;
}
public static void main(String[] args) throws Exception {
start();
Scanner scanner = new Scanner(System.in);
while (NioClient.sendMsg(scanner.next())) ;
}
}
4.5 效果演示
执行Nioserver服务
执行Nioclient,启动客户端,服务端打印
客户端输入
服务端接收客户端数据,并返回消息
至此,JAVA IO相关的已经介绍的差不多了。