概览
IO是Java中的最重要的一个部分。 其中, java.io是所有编程者都应该掌握的IO方式。 在Java 1.4中,NIO被引入, 它引进了一种新的相对于流模型的新的IO模型, 以为非阻塞IO提供支持。 在Java 7中, NIO2又在NIO的基础上, 引入了对异步IO的支持。 在这篇文章中, 我将对这几种IO方式,进行一个比较系统的说明及总结, 同时,分析每一种IO模型的适用范围。
流IO
流IO是一种最为简洁的IO方式, 几乎所有的编程语言,在其标准库中都提供了对流IO的支持, 比如C的FILE, C++的iostream。 同样,在Java中, 流IO是最为基础,也是最为广泛使用的IO方式,一般来说,大家对这种方式都比较熟悉了, 总的来说,Java提供Byte输入流/Byte输出流/Char输入流/Char输出流, 同时,又提供了一系列的Decorator类在基础流的功能之上, 添加新的功能,如Buffering。
用下面的四张图,可以很好地概括整个流IO的相关类
NIO
一般来说, 流IO表现得很好, 对于大部分的IO场景,它都能适应。 但是, 由于它的阻塞性, 每一个流的读写都需要占用一个线程。这意味着, 流IO的可伸缩性很差。因此,引入非阻塞IO就再正常不过了。实际上, NIO就是IO Multiplexing在Java中的实现。 IO Multiplexing在系统级语言如C/C++中应用了很长时间。使用IO Multiplexing,IO的伸缩性大大提高,使用单个线程,就可以处理大量的IO对象。
在介绍NIO的非阻塞IO之前, 先大致了解一下NIO提供的IO模型。
NIO的概念概念有三个,Buffers/Channels/Selectors。
其中,Channels是输入/输出的管道,所有的读写操作都需要通过它来完成。Channel读写的粒度是Block, 而不是像流IO一样, 提供一个字节流或者字符流的抽象,这个Block的抽象即Buffer。 所有的读操作会由Channel将数据读入Buffer, 然后用户来处理Buffer,所有的写操作需要先将数据填到Buffer中, 再由Channel来消费Buffer中的数据。
NIO的第三个核心概念是Selector,它是一个事件监控器, 在通道上可以注册我们所感兴趣的IO事件, 并且对其进行Polling看, 来确定事件是否发生,发生则做相应的IO操作。其中,Selector所监控的对象是Channel, 我们在Selector上声明我们关心哪一个Channel的什么事件, Selector会监控这些Channels,并在事件发生时通知我们。
现在, 考虑三个问题:
-
为什么要引入Channel, 直接扩展已有的Stream类不行吗?
: 流的抽象已经很完备了, 添加更多的特性与概念只会将流的概念进一步复杂化, API更加难以使用, 这是一种很不好的API设计方式. 因此, NIO引入了一套新的抽象. Do one thing, and do it well. -
为什么引入Buffer? 直接用byte数组可以吗?
: 实际上肯定是可以的, 但Buffer类提供了更加方便的操作. 同时, Buffer提供了很多性能上的优化. -
为什么引入Buffer? 直接读写byte不行吗?
: 如果直接操作byte, 性能会很低, 实际上还是需要buffering来提供性能, 与其加一层buffering抽象, 不如直接给用户提供Buffer. 最重要的是, 基于Buffer的IO操作, 某些情况下可以直接映射成系统调用, 性能极高!
NIO支持阻塞与非阻塞两种模式。 阻塞模式下, 实际上与流IO差不多;非阻塞模式下, Channels与Selector配合, 才是它最大的威力所在。
我们可以大体将Channel分成两类:
1、是支持SelectableChannel(除了FileChannel以外都是, 一般是网络相关的操作.)
2、与non-SelectableChannel(即FileChannel). 前者可以与Selector一起使用, 提供强伸缩性的IO.
下面来看看:
java NIO 和阻塞I/O的区别
-
阻塞I/O通信模型
假如现在你对阻塞I/O已有了一定了解,我们知道阻塞I/O在调用InputStream.read()方法时是阻塞的,它会一直等到数据到来时(或超时)才会返回;同样,在调用ServerSocket.accept()方法时,也会一直阻塞到有客户端连接才会返回,每个客户端连接过来后,服务端都会启动一个线程去处理该客户端的请求。阻塞I/O的通信模型示意图如下:
如果你细细分析,一定会发现阻塞I/O存在一些缺点。
根据阻塞I/O通信模型,我总结了它的两点缺点:
1. 当客户端多时,会创建大量的处理线程。且每个线程都要占用栈空间和一些CPU时间。
2. 阻塞可能带来频繁的上下文切换,且大部分上下文切换可能是无意义的。
在这种情况下非阻塞式I/O就有了它的应用前景。
-
java NIO原理及通信模型
Java NIO是在jdk1.4开始使用的,它既可以说成“新I/O”,也可以说成非阻塞式I/O。下面是java NIO的工作原理:
1. 由一个专门的线程来处理所有的 IO 事件,并负责分发。
2. 事件驱动机制:事件到的时候触发,而不是同步的去监视事件。
3. 线程通讯:线程之间通过 wait,notify 等方式通讯。保证每次上下文切换都是有意义的。减少无谓的线程切换。
阅读过一些资料之后,下面贴出我理解的java NIO的工作原理图:
(注:每个线程的处理流程大概都是读取数据、解码、计算处理、编码、发送响应。)
Java NIO的服务端只需启动一个专门的线程来处理所有的 IO 事件,这种通信模型是怎么实现的呢?呵呵,我们一起来探究它的奥秘吧。java NIO采用了双向通道(channel)进行数据传输,而不是单向的流(stream),在通道上可以注册我们感兴趣的事件。一共有以下四种事件:
事件名 | 对应值 |
服务端接收客户端连接事件 | SelectionKey.OP_ACCEPT(16) |
客户端连接服务端事件 | SelectionKey.OP_CONNECT(8) |
读事件 | SelectionKey.OP_READ(1) |
写事件 | SelectionKey.OP_WRITE(4) |
服务端和客户端各自维护一个管理通道的对象,我们称之为selector,该对象能检测一个或多个通道 (channel) 上的事件。
我们以服务端为例,如果服务端的selector上注册了读事件,某时刻客户端给服务端发送了一些数据,阻塞I/O这时会调用read()方法阻塞地读取数据,而NIO的服务端会在selector中添加一个读事件。服务端的处理线程会轮询地访问selector,如果访问selector时发现有感兴趣的事件到达,则处理这些事件,如果没有感兴趣的事件到达,则处理线程会一直阻塞直到感兴趣的事件到达为止。
下面是我理解的java NIO的通信模型示意图:
java NIO服务端和客户端代码实现
为了更好地理解java NIO,下面贴出服务端和客户端的简单代码实现。
NIO服务端
package cn.nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
/**
* NIO服务端
*/
public class NIOServer {
//通道管理器
private Selector selector;
/**
* 获得一个ServerSocket通道,并对该通道做一些初始化的工作
* @param port 绑定的端口号
* @throws IOException
*/
public void initServer(int port) throws IOException {
// 获得一个ServerSocket通道
ServerSocketChannel serverChannel = ServerSocketChannel.open();
// 设置通道为非阻塞
serverChannel.configureBlocking(false);
// 将该通道对应的ServerSocket绑定到port端口
serverChannel.socket().bind(new InetSocketAddress(port));
// 获得一个通道管理器
this.selector = Selector.open();
//将通道管理器和该通道绑定,并为该通道注册SelectionKey.OP_ACCEPT事件,注册该事件后,
//当该事件到达时,selector.select()会返回,如果该事件没到达selector.select()会一直阻塞。
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
}
/**
* 采用轮询的方式监听selector上是否有需要处理的事件,如果有,则进行处理
* @throws IOException
*/
@SuppressWarnings("unchecked")
public void listen() throws IOException {
System.out.println("服务端启动成功!");
// 轮询访问selector
while (true) {
//当注册的事件到达时,方法返回;否则,该方法会一直阻塞
selector.select();
// 获得selector中选中的项的迭代器,选中的项为注册的事件
Iterator ite = this.selector.selectedKeys().iterator();
while (ite.hasNext()) {
SelectionKey key = (SelectionKey) ite.next();
// 删除已选的key,以防重复处理
ite.remove();
// 客户端请求连接事件
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel) key
.channel();
// 获得和客户端连接的通道
SocketChannel channel = server.accept();
// 设置成非阻塞
channel.configureBlocking(false);
//在这里可以给客户端发送信息哦
channel.write(ByteBuffer.wrap(new String("向客户端发送了一条信息").getBytes()));
//在和客户端连接成功之后,为了可以接收到客户端的信息,需要给通道设置读的权限。
channel.register(this.selector, SelectionKey.OP_READ);
// 获得了可读的事件
} else if (key.isReadable()) {
read(key);
}
}
}
}
/**
* 处理读取客户端发来的信息 的事件
* @param key
* @throws IOException
*/
public void read(SelectionKey key) throws IOException{
// 服务器可读取消息:得到事件发生的Socket通道
SocketChannel channel = (SocketChannel) key.channel();
// 创建读取的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(10);
channel.read(buffer);
byte[] data = buffer.array();
String msg = new String(data).trim();
System.out.println("服务端收到信息:"+msg);
ByteBuffer outBuffer = ByteBuffer.wrap(msg.getBytes());
channel.write(outBuffer);// 将消息回送给客户端
}
/**
* 启动服务端测试
* @throws IOException
*/
public static void main(String[] args) throws IOException {
NIOServer server = new NIOServer();
server.initServer(8000);
server.listen();
}
}
NIO客户端
package cn.nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
/**
* NIO客户端
*/
public class NIOClient {
//通道管理器
private Selector selector;
/**
* 获得一个Socket通道,并对该通道做一些初始化的工作
* @param ip 连接的服务器的ip
* @param port 连接的服务器的端口号
* @throws IOException
*/
public void initClient(String ip,int port) throws IOException {
// 获得一个Socket通道
SocketChannel channel = SocketChannel.open();
// 设置通道为非阻塞
channel.configureBlocking(false);
// 获得一个通道管理器
this.selector = Selector.open();
// 客户端连接服务器,其实方法执行并没有实现连接,需要在listen()方法中调
//用channel.finishConnect();才能完成连接
channel.connect(new InetSocketAddress(ip,port));
//将通道管理器和该通道绑定,并为该通道注册SelectionKey.OP_CONNECT事件。
channel.register(selector, SelectionKey.OP_CONNECT);
}
/**
* 采用轮询的方式监听selector上是否有需要处理的事件,如果有,则进行处理
* @throws IOException
*/
@SuppressWarnings("unchecked")
public void listen() throws IOException {
// 轮询访问selector
while (true) {
selector.select();
// 获得selector中选中的项的迭代器
Iterator ite = this.selector.selectedKeys().iterator();
while (ite.hasNext()) {
SelectionKey key = (SelectionKey) ite.next();
// 删除已选的key,以防重复处理
ite.remove();
// 连接事件发生
if (key.isConnectable()) {
SocketChannel channel = (SocketChannel) key
.channel();
// 如果正在连接,则完成连接
if(channel.isConnectionPending()){
channel.finishConnect();
}
// 设置成非阻塞
channel.configureBlocking(false);
//在这里可以给服务端发送信息哦
channel.write(ByteBuffer.wrap(new String("向服务端发送了一条信息").getBytes()));
//在和服务端连接成功之后,为了可以接收到服务端的信息,需要给通道设置读的权限。
channel.register(this.selector, SelectionKey.OP_READ);
// 获得了可读的事件
} else if (key.isReadable()) {
read(key);
}
}
}
}
/**
* 处理读取服务端发来的信息 的事件
* @param key
* @throws IOException
*/
public void read(SelectionKey key) throws IOException{
//和服务端的read方法一样
}
/**
* 启动客户端测试
* @throws IOException
*/
public static void main(String[] args) throws IOException {
NIOClient client = new NIOClient();
client.initClient("localhost",8000);
client.listen();
}
}
IO VS NIO
考虑IO与NIO的区别, 除了在概念模型的差别, IO与NIO在性能上也会有很大差异。
我们从三个方面来考虑性能问题:
-
可伸缩性: 流IO的在IO对象数较少及大规模IO的情况下, 表现得很好, 但是当需要处理成百上千的IO对象时, 它的性能会Drop得很快. 相反, NIO在非阻塞模式下(阻塞模式下应该与流IO具有相同的特点, 这是阻塞IO的共性), 即使用Selector, 它可以处理大量的非活跃连接, 是实现C10K的关键技术.
-
GC:许多号称高性能的服务器实现, 都以Zero Allocation作为一个重要的功能点. 理想情况上, 如果没有GC的开销, 服务器可以将所有时间花在有效地工作上, 并且保持一个可靠的延迟. 然后GC是不可避免的, Zero Allocation也只能是尽力而为. 而相比较而言, NIO只需要申请一个Buffer, 可以反复使用, 而字符流在这方便表现的就比较差了, 如readLine()这类接口, 需要分配大量临时的String对象.
-
API抽象层次: 相对而言, 基于Buffer的NIO抽象层次比流IO在低一些。特别是,系统调用级别的IO, 都是基于Buffer的. 当使用DirectBuffer时, 某些平台下, OS可以直接将数据复杂到DirectBuffer中, 避免了流IO中, OS将数据复制到OS Buffer后, 又需要向JVM Heap复制地过程. Zero Copy与Zero Allocation都是高性能服务器的重点技术. 特别是, 在使用Channel时, 需要使用DirectBuffer, 因为Channel内部使用的是DirectBuffer. 如果使用HeapBuffer, 则读写时, Channel会申请一个临时的DirectBuffer, 造成性能开销.
Memory Mapping
前面提到, FileChannel不支持非阻塞模式. 那么, 它是不是用处不大呢? 毕竟, NIO与IO相比最大的优势是非阻塞.
NIO中, FileChannel都一些属于自己的特性. 即, Memory Mapping. Memory Mapping是一个比较常见, 在此不加多说. 无论是在顺序读写, 还是随机读写中, Memory Mapping都能够提供不弱于BufferedInputStream或者RandomAccessFile的性能.
特别强调的是, Memory Mapping中Map的容量仅与虚拟内存大小有关, 与物理内存大小及JVM堆大小都没有关系. 因此, 在64位平台下, Memory Mapping可以工作得非常好.
NIO2
聊过非阻塞IO后, 再来看看异步IO. IO方面的概念很多, 阻塞性与异步性是其关键概念. 简单而言, 凡是需要由应用程序将数据读写到应用程序内存中的IO, 都是同步IO, 比如上面的流IO与NIO. 相对的, 凡是由OS来完成读写的, 就是异步IO. 这个说法有些迷惑. 举例而言, 在NIO中, 当应用程序检测到某个Channel有可读数据时, 必须显示发起一个read请求. 而在异步IO中, 应用程序仅仅需要告诉OS, 我需要什么数据, 并提供给OS一个Buffer和一个回调. OS会自己检测Channel的可读性, 但其发起其可读, 会自动将数据复制到Buffer中, 并通知应用程序任务完成. 异步IO的典型实现是NodeJS及Boost.ASIO. 显然, 由于将任务进一步下发到了OS, 应用程序的可伸缩性及性能会大大增强. 并且, 比起非阻塞的NIO, 异步IO编程更加容易一些, 性能也基本上总是优于它的.
NIO2最大的改进是引入了四个异步Channel, 用于支持异步读写. 同时, 它还增加了对文件系统和文件属性的支持, 提供了WatchService/FileVisitor这些高级功能.