JAVA NIO的学习笔记

关于缓冲和阻塞

JavaNIO的概念理解中对于缓冲和阻塞两个相关的概念理解需要透彻一点才能在以后的学习中不懵。

内核缓冲区与进程缓冲区

用户程序进行IO的读写,基本上会用到read&write两大系统调用。read系统调用,并不是把数据直接从物理设备,读数据到内存。write系统调用,也不是直接把数据,写入到物理设备。

read系统调用,是把数据从内核缓冲区复制到进程缓冲区;而write系统调用,是把数据从进程缓冲区复制到内核缓冲区。等待缓冲区达到一定数量的时候,再进行IO的调用,提升性能。至于什么时候读取和存储则由内核来决定,用户程序不需要关心。这个两个系统调用,都不负责数据在内核缓冲区和磁盘之间的交换。底层的读写交换,是由操作系统kernel内核完成的。

在linux系统中,系统内核也有个缓冲区叫做内核缓冲区。每个进程有自己独立的缓冲区,叫做进程缓冲区。所以,用户程序的IO读写程序,大多数情况下,并没有进行实际的IO操作,而是在读写自己的进程缓冲区。
JAVA NIO的学习笔记

一个典型Java 服务端处理网络请求的典型过程:

(1) 客户端请求

  • Linux通过网卡,读取客户端的请求数据,将数据读取到内核缓冲区。

(2) 获取请求数据

服务器从内核缓冲区读取数据到Java进程缓冲区。

(3) 服务器端业务处理

  • Java服务端在自己的用户空间中,处理客户端的请求。

(4) 服务器端返回数据

  • Java服务端已构建好的响应,从用户缓冲区写入系统缓冲区。

(5) 发送给客户端

  • Linux内核通过网络 I/O ,将内核缓冲区中的数据,写入网卡,网卡通过底层的通讯协议,会将数据发送给目标客户端。

阻塞与非阻塞

阻塞IO,指的是需要内核IO操作彻底完成后,才返回到用户空间,执行用户的操作。阻塞指的是用户空间程序的执行状态,用户空间程序需等到IO操作彻底完成。传统的IO模型都是同步阻塞IO。在java中,默认创建的socket都是阻塞的。

非阻塞IO,指的是用户程序不需要等待内核IO操作完成后,内核立即返回给用户一个状态值,用户空间无需等到内核的IO操作彻底完成,可以立即返回用户空间,执行用户的操作,处于非阻塞的状态。

简单的说:阻塞是指用户空间(调用线程)一直在等待,当前线程会被挂起,调用线程只有在得到结果之后才会返回,而且别的事情什么都不做;非阻塞是指用户空间(调用线程)拿到状态就返回,IO操作可以干就干,不可以干,就去干的事情。

  • 举个例子:

你打电话问书店老板有没有《分布式系统》这本书,你如果是阻塞式调用,你会一直把自己“挂起”,直到得到这本书有没有的结果,如果是非阻塞式调用,你不管老板有没有告诉你,你自己先一边去玩了, 当然你也要偶尔过几分钟check一下老板有没有返回结果。在这里阻塞与非阻塞与是否同步异步无关。跟老板通过什么方式回答你结果无关。

关于NIO

本章中并没有对Java NIO的使用做过多的描述,对Java NIO的使用方法会单独进行整理。

Java NIO概念的理解

NIO主要有三大核心部分:Channel(通道),Buffer(缓冲区), Selector。传统IO基于字节流和字符流进行操作,而NIO基于Channel和Buffer(缓冲区)进行操作。

NIO和传统IO之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区的。面向流意味着每次需要读取所有的字节,它们没有被缓存在任何地方,它不能前后移动流中的数据。

NIO的缓冲导向方法是将数据读取到一个缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性。但是,需要检查该缓冲区中包含的数据是否正确和完整(TCP粘包和拆包)。数据不完整需更多的数据读入缓冲区时,注意不要覆盖缓冲区里尚未处理的数据。

IO的各种流是阻塞的。当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被完全读取,或数据完全写入。该线程在此期间不能再干任何事情了。

NIO的非阻塞读,使一个线程从某通道读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。调用方法之后会立即返回,而不是保持线程阻塞。非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。

IO多路复用技术

NIO中的多路复用技术对应UNIX网络编程中的I/O复用模型,对应的是epoll系统调用。

I/O复用模型: Linux提供select/poll, 进程通过将-一个或多个fd传递给seleet或poll系统调用,阻塞在select操作.上,这样select/poll可以帮我们侦测多个fd是否处于就绪状态。select/poll 是顺序扫描fd是否就绪,而且支持的fd数量有限,因此它的使用受到了一些制约。Linux 还提供了一个epoll系统调用,epoll 使用基于事件驱动方式代替顺序扫描,因此性能更高。当有fd就绪时,立即回调函数rollback。

目前支持I/O多路复用的系统调用有select、pselect、poll、epoll,在Linux网络编程过程中,很长一段时间都使用select做轮询和网络事件通知,然而select的一些固有缺陷导致了它的应用受到了很大的限制,最终Linux不得不在新的内核版本中寻找select的替代方案,最终选择了epoll。epoll的改进总结如下。

  1. 支持一个进程打开的socket描述符(FD)不受限制(仅受限于操作系统的最大文件句柄数)。

select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FD_ _SETSIZE设置,默认值是1024。 对于那些需要支持上万个TCP连接的大型服务器来说显然太少了。可以选择修改这个宏然后重新编译内核,不过这会带来网络效率的下降。我们也可以通过选择多进程的方案(传统的Apache方案)解决这个问题,不过虽然在Linux上创建进程的代价比较小,但仍旧是不可忽视的。另外,进程间的数据交换非常麻烦,对于Java来说,由于没有共享内存,需要通过Socket 通信或者其他方式进行数据同步,这带来了额外的性能损耗,增加了程序复杂度,所以也不是一种完美的解决方案。值得庆幸的是,epoll并没有这个限制,它所支持的FD上限是操作系统的最大文件句柄数,这个数字远远大于1024。例如,在1GB内存的机器上大约是10 万个句柄左右,具体的值可以通过cat/proc/sys/fs/file- max察看,通常情况下这个值跟系统的内存关系比较大。

  1. I/0 效率不会随着FD数目的增加而线性下降。

传统selectpoll的另一个致命弱点,就是当你拥有一个很大的socket集合时,由于网络延时或者链路空闲,任一时刻只有少部分的socket 是“活跃”的,但是selectpoll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。epoll 不存在这个问题,它只会对“活跃”的socket进行操作一这是因为在内核实现中,epoll是根据每个fd.上面的callback函数实现的。那么,只有“活跃”的socket才会去主动调用callback函数,其他idle状态的socket 则不会。在这点上,epoll 实现了一个伪AIO。针对epoll 和seleet 性能对比的benchmark测试表明:如果所有的socket都处于活跃态一例如一个 高速LAN环境,epoll并不比select/poll效率高太多;相反,如果过多使用epoll_ ctl,效率相比还有稍微地降低。但是一旦使用idle connections模拟WAN环境,epoll 的效率就远在select/poll之上了。

  1. 使用mmap加速内核与用户空间的消息传递。

无论是select. poll 还是epoll 都需要内核把FD消息通知给用户空间,如何避免不必要的内存复制就显得非常重要,epoll 是通过内核和用户空间mmap同一块内存来实现的。

  1. epoll 的API更加简单。

包括创建一个epoll 描述符、添加监听事件、阻塞等待所监听的事件发生、关闭epoll描述符等。

(fd的解释:file discriptor,文件描述符。在Linux里一切皆为文件,一个socket连接也有相应的描述符,称为socketfd,socket描述符。)

关于Reactor模式

Reactor模式是一个事件驱动的模型,当特定的事件发生时会使用该事件对应的处理器来处理该事件。

在Reactor模式由五种角色构成:

产生事件的角色称为Handle(句柄或描述符),在网络编程中对应socket描述符。Handle是事件产生的发源地,事件比如说客户端的连接请求,客户端将数据发送到服务器等;Handle本质上表示一种资源,由操作系统提供。

同步事件分离器(Synchronous Event Demultiplexer),它底层实现是一个系统调用,用于等待事件的发生。调用方在调用它的时候会被阻塞,一直阻塞到同步事件分离器上有事件产生为止。对于Linux来说,同步事件分离器指的就是常用的I/O多路复用机制,比如说select、poll、epoll等。在Java NIO领域中,同步事件分离器对应的组件就是Selector;对应的阻塞方法就是select方法。

事件处理器(Event Handler),具有多个回调方法构成,这些回调方法用于处理某个具体事件。用来处理分发来的事件,对事件进行处理。

具体事件处理器(Concrete Event Handler):是事件处理器的实现。它实现了事件处理器所提供的各种方法,从而执行特定的业务任务。就是我们所编写的各种处理器的实现。

初始分发器(Initiation Dispatcher),它本身是整个设计的核心所在,一旦事件发生,分发器首先会使用事件分离器分离出每一个事件,然后调用事件处理器,最后调用相关的回调方法来处理这些事件。

Reactor模式在Nio中的实现方式

参考资料:NIO.pdf

Reactor的单线程模式和使用工作者线程池的区别

JAVA NIO的学习笔记
JAVA NIO的学习笔记