了解java的NIO,需要先了解同步异步以及阻塞非阻塞的概念,同步/异步,阻塞/非阻塞
NIO就是采用的同步非阻塞这种组合方式。或简单一点,采用的是IO复用的策略,可以使用一个线程管理多个IO连接。
BIO
常见使用方式
传统的BIO是同步阻塞的方式,因此,在服务器中常见的使用方式是:
- 来一个请求创建一个线程,阻塞的等待网络IO的数据。
- 使用一个线程池,来一个请求就从线程池里取出来一个线程,阻塞的等待网络IO的数据。
两种方式的图例:
BIO面临的问题
上面的方案可能会出现的问题是
- 针对第一种方式,如果短时间内qps过高,可能会导致线程数过多,拖垮服务器。
- 针对第二种方式,现在一般用的http1.1支持长连接,若系统中有大量的长连接没有释放,依然在阻塞的等待网络IO,就会导致线程池资源慢慢被消耗调,最终可能导致线程池满无法提供服务。
总结一下,上述两点问题的原因,其共同点是可能会有很多的空闲线程阻塞的等待IO,导致服务器以各种表现形式没有办法继续对外提供服务。
NIO
NIO的IO多路复用
因此,如果是同步非阻塞的方式,可以只需要一个线程,管理多个IO连接。一旦有连接可以读/写,才开启一个线程进行读/写、执行相应的操作。如下:
Java中的NIO
原理接说到这里,下面看一下jdk中NIO的实现和用法。jdk中的NIO的实现,主要几个部分是Channel(通道),Buffer(缓冲区),Selector(选择器)。
- Channel提供从文件、网络读取数据的通道,但是读取或写入的数据都必须经由Buffer。通道是双向的,通过一个Channel既可以进行读,也可以进行写
- Buffer,故名思意,缓冲区,实际上是一个容器,是一个连续数组。服务端接收数据必须通过Channel将数据读入到Buffer中,然后再从Buffer中取出数据来处理。
- Selector类是NIO的核心类,Selector能够检测多个注册的通道上是否有事件发生,如果有事件发生,便获取事件然后针对每个事件进行相应的响应处理。
在一个基于NIO的IO多路复用的具体应用场景中,它们之间的关系可能是这样的:
其中,一个线程管理一个Selector,而一个Selector管理多个Channel,被管理的Channel需要在该Selector上注册自己感兴趣的事件,如Accept,Read,Write等。
每个Channel对应一个缓冲区Buffer,每次Channel中有数据可以读写的时候,就读写到缓冲区中。然后程序再对缓冲区进行操作。
这样一来,只是用一个单线程就可以管理多个通道,也就是管理多个连接。这样使得只有在连接真正有读写事件发生时,才会调用相应的方法或者线程来进行读写、操作,大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程,并且避免了多个空闲连接的多线程之间的上下文切换导致的开销。
Selector
既然NIO是非阻塞,其实就是把阻塞的位置从系统的CPU层面提到了程序层面,那么当Channel中注册的感兴趣的事件就绪时,Selector需要通过某种策略得知Channel数据已经就绪,可以采用轮询、事件驱动等方式。这里就封装成了Selector的select方法,返回值是已经就绪的通道的数量。
当Selector得知有通道对其感兴趣的事件就绪时,就取出所有已经就绪的通道,进行读写或者其它操作。Selector的 selectedKeys()方法就封装了取出所有就绪的通道的事件,返回值是一个SelectionKey的集合。SelectionKey中封装了一个Channel与selector的对应关系、Channel感兴趣的事件、Channel哪种事件已经就绪的判断(isReadable、isWritable等)。
Selector的工作方式
看一下Selector的工作方式:
public int select() throws IOException { return this.select(0L); } public int select(long timeout) throws IOException { if (timeout < 0L) { throw new IllegalArgumentException("Negative timeout"); } else { return this.lockAndDoSelect(timeout == 0L ? -1L : timeout); } } public int selectNow() throws IOException { return this.lockAndDoSelect(0L); }
看得出来,select阻塞获取操作系统就绪通道的关键的实现在于lockAndDoSelect方法中:
private int lockAndDoSelect(long var1) throws IOException { synchronized(this) { if (!this.isOpen()) { throw new ClosedSelectorException(); } else { Set var4 = this.publicKeys; int var10000; synchronized(this.publicKeys) { Set var5 = this.publicSelectedKeys; synchronized(this.publicSelectedKeys) { var10000 = this.doSelect(var1); } } return var10000; } } }
加了两个锁,然后会调用一个doSelect方法。doSelect方法由子类实现,有PollSelectorImpl、EPollSelectorImpl。他们实现doSelect时分别调用了本地方法poll0、epollWait,分别对应操作系统的poll、epoll策略。
在调用Selector的open方时,就已经根据操作系统、内核版本决定了采用哪种IO复用策略,简单看一下sun.nio.ch.DefaultSelectorProvider#create里Selector的创建:
public static SelectorProvider create() { String var0 = (String)AccessController.doPrivileged(new GetPropertyAction("os.name")); if (var0.equals("SunOS")) { return createProvider("sun.nio.ch.DevPollSelectorProvider"); } else { return (SelectorProvider)(var0.equals("Linux") ? createProvider("sun.nio.ch.EPollSelectorProvider") : new PollSelectorProvider()); } }
如果是Linux系统的话,使用的是操作系统的epoll 的策略
对于操作系统来说:
epoll:如果有IO已经就绪,会给用户线程返回所有就绪的事件,可以对这个就绪的IO通道进行读写。
poll:得到有就绪的IO时,需要遍历去查询哪些IO是已就绪的,然后返回给用户线程去读写。
参考文章: