高性能的Java通信,绝对离不开Java NIO组件,目前主流的技术框架或中间件服务器,都使用了Java NIO组件,譬如Tomcat、Jetty、Netty。
NIO的起源
NIO技术是怎么来的?为啥需要这个技术呢?先给出一份在Java NIO出来之前,服务器端同步阻塞I/O处理(也就是BIO,Blocking I/O)的参考代码:
package com.information;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ConnectionPerThreadWithPool implements Runnable {
public void run() {
//线程池
//注意,生产环境不能这么用,具体请参考《java 高并发核心编程卷 2》
ExecutorService executor = Executors.newFixedThreadPool(100);
try {
//服务器监听 socket
ServerSocket serverSocket =
new ServerSocket(8081);
//主线程死循环, 等待新连接到来
while (!Thread.interrupted()) {
Socket socket = serverSocket.accept();
//接收一个连接后,为 socket 连接,新建一个专属的处理器对象
Handler handler = new Handler(socket);
//创建新线程来 handle
//或者,使用线程池来处理
new Thread(handler).start();
}
} catch (IOException ex) { /* 处理异常 */ }
}
static class Handler implements Runnable {
final Socket socket;
Handler(Socket s) {
socket = s;
}
public void run() {
//死循环处理读写事件
boolean ioCompleted=false;
while (!ioCompleted) {
try {
byte[] input = new byte[1024];
/* 读取数据 */
socket.getInputStream().read(input);
// 如果读取到结束标志
// ioCompleted= true
// socket.close();
/* 处理业务逻辑,获取处理结果 */
byte[] output = null;
/* 写入结果 */
socket.getOutputStream().write(output);
} catch (IOException ex) { /*处理异常*/ }
}
}
}
public static void main(String[] args) {
ConnectionPerThreadWithPool pool = new ConnectionPerThreadWithPool();
pool.run();
}
}
以上示例代码中,对于每一个新的网络连接,都通过线程池分配给一个专门线程去负责 IO处理。每个线程都独自处理自己负责的socket连接的输入和输出。当然,服务器的监听线程也是独立的,任何的socket连接的输入和输出处理,不会阻塞到后面新socket连接的监听和建立,这样,服务器的吞吐量就得到了提升。早期版本的Tomcat服务器,就是这样实现的。
这是一个经典的每连接每线程的模型——Connection Per Thread模式。这种模型,在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的I/O并且编程模型简单,也不用过多思考系统的过载、限流等问题。此模型往往会结合线程池使用,线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。
不过,这个模型最本质的问题在于,严重依赖于线程。但线程是很”贵”的资源,主要表目前:
- 线程的创建和销毁成本很高,线程的创建和销毁都需要通过重量级的系统调用去完成。
- 线程本身占用较大内存,像Java的线程的栈内存,一般至少分配512K~1M的空间,如果系统中的线程数过千,整个JVM的内存将被耗用1G。
- 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。过多的线程频繁切换带来的后果是,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统CPU sy值特别高(超过20%以上)的情况,导致系统几乎陷入不可用的状态。

这里关注的是输出信息的第三行,其中:0.3%us表明用户进程所占的百分比;0.3%sy表明内核线程处理所占的百分比;0.0%ni表明被nice命令改变优先级的任务所占的百分比;
99.3%id表明CPU空闲时间所占的百分比;0.0%wa表明等待IO所占的百分比;0.0%hi表明硬件中断所占的百分比,0.0%si表明为软件中断所占的百分比。
当 CPU sy 值高时,表明系统调用耗费了较多的 CPU,对于 Java 应用程序而言,造成这种现象的主要缘由是启动的线程比较多,并且这些线程多数都处于不断的等待(例如锁等待状态)和执行状态的变化过程中,这就导致了操作系统要不断的调度这些线程,切换执行。
- 容易造成锯齿状的系统负载。由于系统负载(System Load)是用活动线程数和等待线程数来综合计算的,一旦线程数量高但外部网络环境不是很稳定,就很容易造成大量请求同时到来,从而激活大量阻塞线程从而使系统负载压力过大。
但是,高并发的需求却越来越普通,随着移动端应用的兴起和各种网络游戏的盛行,百万级长连接日趋普遍,此时,必然需要一种更高效的I/O处理组件——这就是Java 的NIO编程组件。
Java NIO简介
在1.4版本之前,Java IO类库是阻塞式IO;从1.4版本开始,引进了新的异步IO库,被称为Java New IO类库,简称为Java NIO。Java NIO类库的目标,就是要让Java支持非阻塞IO,基于这个缘由,更多的人喜爱称JavaNIO为非阻塞IO(Non-Block IO),称“老的”阻塞式Java IO为OIO(Old IO)。总体上说,NIO弥补了原来面向流的OIO同步阻塞的不足,它为标准Java代码提供了高速的、面向缓冲区的IO。
Java NIO类库包含以下三个核心组件:
- Channel(通道)
- Buffer(缓冲区)
- Selector(选择器)
Java NIO,属于第三种模型—— IO 多路复用模型。只不过,Java NIO组件提供了统一的应用开发API,为大家屏蔽了底层的操作系统的差异。后面的章节,我们会对以上的三个Java NIO的核心组件,展开详细介绍。先来看看Java的NIO和OIO的简单对比。
NIO和OIO的对比
在Java中,NIO和OIO的区别,主要体目前三个方面:
(1)OIO是面向流(Stream Oriented)的,NIO是面向缓冲区(Buffer Oriented)的。问题是:什么是面向流,什么是面向缓冲区呢?
在面向流的OIO操作中,IO的 read() 操作总是以流式的方式顺序地从一个流(Stream)中读取一个或多个字节,因此,我们不能随意地改变读取指针的位置,也不能前后移动流中的数据。
而NIO中引入了Channel(通道)和Buffer(缓冲区)的概念。面向缓冲区的读取和写入,都是与Buffer进行交互。用户程序只需要从通道中读取数据到缓冲区中,或将数据从缓冲区中写入到通道中。NIO不像OIO那样是顺序操作,可以随意地读取Buffer中任意位置的数据,可以随意修改Buffer中任意位置的数据。
(2)OIO的操作是阻塞的,而NIO的操作是非阻塞的。
OIO的操作是阻塞的,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。例如,我们调用一个read方法读取一个文件的内容,那么调用read的线程会被阻塞住,直到read操作完成。
NIO如何做到非阻塞的呢?当我们调用read方法时,系统底层已经把数据准备好了,应用程序只需要从通道把数据复制到Buffer(缓冲区)就行;如果没有数据,当前线程可以去干别的事情,不需要进行阻塞等待。NIO的非阻塞是如何做到的呢?实则在上一章,答案已经揭晓了,根本缘由是:NIO使用了通道和通道的IO多路复用技术。
(3)OIO没有选择器(Selector)概念,而NIO有选择器的概念。
NIO技术的实现,是基于底层的IO多路复用技术实现的,列如在Windows中需要select多路复用组件的支持,在Linux系统中需要select/poll/epoll多路复用组件的支持。所以NIO的需要底层操作系统提供支持。而OIO不需要用到选择器。
通道(Channel)
Channel的角色和OIO中的Stream(流)是差不多的。在OIO中,同一个网络连接会关联到两个流:一个输入流(Input Stream),另一个输出流(Output Stream),Java应用程序通过这两个流,不断地进行输入和输出的操作。
在NIO中,一个网络连接使用一个Channel(通道)表明,所有的NIO的IO操作都是通过连接通道完成的。一个通道类似于OIO中的两个流的结合体,既可以从通道读取数据,也可以向通道写入数据。

Channel和Stream的一个显著的不同是:Stream是单向的,譬如InputStream是单向的只读流,OutputStream是单向的只写流;而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作。
NIO中的Channel的主要实现有:
- FileChannel 用于文件IO操作
- DatagramChannel 用于UDP的IO操作
- SocketChannel 用于TCP的传输操作
- ServerSocketChannel 用于TCP连接监听操作
选择器(Selector)
第一,回顾一个前面介绍的基础知识,什么是IO多路复用模型?
IO多路复用指的是一个进程/线程可以同时监视多个文件描述符(含socket连接),一旦其中的一个或者多个文件描述符可读或者可写,该监听进程/线程能够进行IO事件的查询。
在Java应用层面,如何实现对多个文件描述符的监视呢?
需要用到一个超级重大的Java NIO组件——Selector 选择器。Selector 选择器可以理解为一个IO事件的监听与查询器。通过选择器,一个线程可以查询多个通道的IO事件的就绪状态。
在介绍Selector选择器之前,第一介绍一下这个前置的概念:IO事件。
什么是IO事件呢?表明通道某种IO操作已经就绪、或者说已经做好了准备。例如,如果一个新Channel链接建立成功了,就会在Server Socket Channel上发生一个IO事件,代表一个新连接一个准备好,这个IO事件叫做“接收就绪”事件。再例如,一个Channel通道如果有数据可读,就会发生一个IO事件,代表该连接数据已经准备好,这个IO事件叫做 “读就绪”事件。
Java NIO将NIO事件进行了简化,只定义了四个事件,这四种事件用SelectionKey的四个常量来表明:
- SelectionKey.OP_CONNECT
- SelectionKey.OP_ACCEPT
- SelectionKey.OP_READ
- SelectionKey.OP_WRITE
在大家了解了IO事件之后,再回头来看Selector选择器。Selector的本质,就是去查询这
些IO就绪事件,所以,它的名称就叫做 Selector查询者。
从编程实现维度来说,IO多路复用编程的第一步,是把通道注册到选择器中,第二步则是通过选择器所提供的事件查询(select)方法,这些注册的通道是否有已经就绪的IO事件(例如可读、可写、网络连接完成等)。
由于一个选择器只需要一个线程进行监控,所以,我们可以很简单地使用一个线程,通过选择器去管理多个连接通道。

与OIO相比,NIO使用选择器的最大优势:系统开销小,系统不必为每一个网络连接(文件描述符)创建进程/线程,从而大大减小了系统的开销。
总之,通过Java NIO可以达到一个线程负责多个连接通道的IO处理,这是超级高效的。这种高效,恰恰就来自于Java的选择器组件Selector以及其底层的操作系统IO多路复用技术的支持。
缓冲区(Buffer)
应用程序与通道(Channel)主要的交互,主要是进行数据的read读取和write写入。为了完成NIO的非阻塞读写操作,NIO为大家准备了第三个重大的组件——NIO Buffer(NIO 缓冲区)。
Buffer顾名思义:缓冲区,实际上是一个容器,一个连续数组。Channel提供从文件、网络读取数据的渠道,但是读写的数据都必须经过Buffer。

所谓通道的读取,就是将数据从通道读取到缓冲区中;所谓通道的写入,就是将数据从缓冲区中写入到通道中。缓冲区的使用,是面向流进行读写操作的OIO所没有的,也是NIO非阻塞的重大前提和基础一。



收藏了,感谢分享