介绍
Java NIO(New IO)也有人称之为 java non-blocking IO,也有人称为是new io,是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java IO API。
NIO与原来的IO有同样的作用和目的,但是使用的方式完全不同,NIO支持面向缓冲区的、基于通道的IO操作。NIO可以理解为非阻塞IO,传统的IO的read和write只能阻塞执行,线程在读写IO期间不能干其他事情,比如调用socket.read()时,如果服务器一直没有数据传输过来,线程就一直阻塞,而NIO中可以配置socket为非阻塞模式。
流与块
I/O 与 NIO 最重要的区别是数据打包和传输的方式,I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。
面向流的I/O 一次处理一个字节数据:
一个输入流产生一个字节数据,一个输出流消费一个字节数据。为流式数据创建过滤器非常容易,链接几个过滤器,以便每个过滤器只负责复杂处理机制的一部分。不利的一面是,面向流的 I/O 通常相当慢。
面向块的I/O 一次处理一个数据块:
按块处理数据比按流处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。
I/O 包和 NIO 已经很好地集成了,有一些工具类可以块的形式读写数据的方法,这使得即使在面向流的系统中,处理速度也会更快。
核心部分
NIO 有三大核心部分:Channel( 通道) ,Buffer( 缓冲区), Selector( 选择器)。
在 Java NIO 中的通道(Channel)就相当于操作系统的内核空间(kernel space)的缓冲区,而缓冲区(Buffer)对应的相当于操作系统的用户空间(user space)中的用户缓冲区(user buffer)。
Channel( 通道)
通道,被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操作系统)。一个通道会有一个专属的文件状态描述符。那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据。
通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。且通道是双向的,可以用于读、写或者同时用于读写。
通道包括以下类型:
- FileChannel: 从文件中读写数据;
- DatagramChannel: 通过 UDP 读写网络中数据;
- SocketChannel: 通过 TCP 读写网络中数据;
- ServerSocketChannel: 可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel。
Buffer( 缓冲区)
读写都需要经过缓冲区,不会直接对通道进行读写数据,只能通过缓冲区对通道进行读写数据。缓冲区实质上是一个数组,但它不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。
缓冲区包括以下类型:
- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
缓冲区状态变量
- capacity: 最大容量;
- position: 当前已经读写的字节数;
- limit: 还可以读写的字节数。
Selector( 选择器)
NIO 常常被叫做非阻塞 IO。NIO 实现了 IO 多路复用中的 Reactor 事件驱动模型,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件。
通过配置监听的通道 Channel 为非阻塞,那么当 Channel 上的 IO 事件还未到达时,就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。
注意:
只有套接字 Channel 才能配置为非阻塞,而 FileChannel 不能,为 FileChannel 配置非阻塞也没有意义。
FileChannel
FileChannel 是一个用于文件读写、映射和操作的通道,同时它在并发环境下是线程安全的,基于 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方法可以创建并打开一个文件通道。FileChannel 定义了 transferFrom() 和 transferTo() 两个抽象方法,它通过在通道和通道之间建立连接实现数据传输的。
NIO实现过程
1)创建选择器
Selector selector = Selector.open();
2)将通道注册到选择器上
ServerSocketChannel ssChannel =ServerSocketChannel.open(); ssChannel.configureBlocking(false); ssChannel.register(selector,SelectionKey.OP_ACCEPT);
通道必须配置为非阻塞模式,否则使用选择器就没有任何意义了。在将通道注册到选择器上时,还需要指定要注册的具体事件。
3)监听事件
使用 select() 来监听到达的事件,它会一直阻塞直到有至少一个事件到达。
int num = selector.select();
4)获取到达的事件
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// ...
} else if (key.isReadable()) {
// ...
}
keyIterator.remove();
}
5)事件循环
因为一次 select() 调用不能处理完所有的事件,并且服务器端有可能需要一直监听事件,因此服务器端处理事件的代码一般会放在一个死循环内。
while (true) {
int num = selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// ...
} else if (key.isReadable()) {
// ...
}
keyIterator.remove();
}
}
套接字NIO 实例
public class NIOServer {
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.configureBlocking(false);
ssChannel.register(selector, SelectionKey.OP_ACCEPT);
ServerSocket serverSocket = ssChannel.socket();
InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8888);
serverSocket.bind(address);
while (true) {
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
ServerSocketChannel ssChannel1 = (ServerSocketChannel) key.channel();
// 服务器会为每个新连接创建一个 SocketChannel
SocketChannel sChannel = ssChannel1.accept();
sChannel.configureBlocking(false);
// 这个新连接主要用于从客户端读取数据
sChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel sChannel = (SocketChannel) key.channel();
System.out.println(readDataFromSocketChannel(sChannel));
sChannel.close();
}
keyIterator.remove();
}
}
}
private static String readDataFromSocketChannel(SocketChannel sChannel) throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1024);
StringBuilder data = new StringBuilder();
while (true) {
buffer.clear();
int n = sChannel.read(buffer);
if (n == -1) {
break;
}
buffer.flip();
int limit = buffer.limit();
char[] dst = new char[limit];
for (int i = 0; i < limit; i++) {
dst[i] = (char) buffer.get(i);
}
data.append(dst);
buffer.clear();
}
return data.toString();
}
}
public class NIOClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("127.0.0.1", 8888);
OutputStream out = socket.getOutputStream();
String s = "hello world";
out.write(s.getBytes());
out.close();
}
}
对比
- NIO 是非阻塞的
- NIO 面向块,I/O 面向流
典型的多路复用IO实现
目前流程的多路复用IO实现主要包括四种: select
、poll
、epoll
、kqueue
。下表是他们的一些重要特性的比较:
IO模型 | 相对性能 | 关键思路 | 操作系统 | JAVA支持情况 |
---|---|---|---|---|
select | 较高 | Reactor | windows/Linux | 支持,Reactor模式(反应器设计模式)。Linux操作系统的 kernels 2.4内核版本之前,默认使用select;而目前windows下对同步IO的支持,都是select模型 |
poll | 较高 | Reactor | Linux | Linux下的JAVA NIO框架,Linux kernels 2.6内核版本之前使用poll进行支持。也是使用的Reactor模式 |
epoll | 高 | Reactor/Proactor | Linux | Linux kernels 2.6内核版本及以后使用epoll进行支持;Linux kernels 2.6内核版本之前使用poll进行支持;另外一定注意,由于Linux下没有Windows下的IOCP技术提供真正的 异步IO 支持,所以Linux下使用epoll模拟异步IO |
kqueue | 高 | Proactor | Linux | 目前JAVA的版本不支持 |
**多路复用IO技术最适用的是“高并发”场景,所谓高并发是指1毫秒内至少同时有上千个连接请求准备好。**另一方面,使用JAVA NIO进行功能实现,相对于传统的Socket套接字实现要复杂一些,所以实际应用中,需要根据自己的业务需求进行技术选择。
Reactor事件驱动模型
**Reactor模型是以事件驱动来处理网络事件的。在Reactor模型中,主要有四个角色:客户端连接,Reactor,Acceptor和Handler。这里Acceptor会不断地接收客户端的连接,然后将接收到的连接交由Reactor进行分发,最后有具体的Handler进行处理。**改进后的Reactor模型相对于传统的IO模型主要有如下优点:
- 从模型上来讲,Reactor模型是以事件进行驱动的,其能够将接收客户端连接+ 网络读和网络写,以及业务计算进行拆分,从而极大的提升处理效率;
- Reactor模型是异步非阻塞模型,工作线程在没有网络事件时可以处理其他的任务,而不用像传统IO那样必须阻塞等待。
Reactor模型----业务处理与IO分离以及并发读写
由于网络读写和业务操作都在同一个线程中,在高并发情况下,会存在一定的系统瓶颈,可以通过使用线程池的方式处理业务操作。
瓶颈主要在两方面:
- 高频率的网络读写事件处理;
- 大量的业务操作处理;
在多线程进行业务操作的模型下,该模式主要具有如下特点:
- 使用一个线程进行客户端连接的接收以及网络读写事件的处理;
- 在接收到客户端连接之后,将该连接交由线程池进行数据的编解码以及业务计算。
其他扩展
Netty零拷贝
零拷贝是指避免在用户态(User-space) 与内核态(Kernel-space) 之间来回拷贝数据的技术。
**JDK1.4加入了NIO机制和直接内存,目的是防止Java堆和Native堆之间数据复制带来的性能损耗,此后NIO可以使用Native的方式直接在 Native堆分配内存,也就是说直接使用堆外内存。**但是堆外内存溢出很难排查,需要手工清理。也可以通过安装插件来监控,避免内存溢出。
NIO提供MappedByteBuffer文件内存映射方案,其使用的也是直接内存。但是不受GC管控,后续Java提供了DirectByteBuffer类,它实现了DirectBuffer接口,维护一个Cleaner对象来完成内存回收。
1)FileChannel
文件传输FileChannel是使用transferTo方法将数据从FileChannel对象传送到可写的字节通道(如Socket Channel等)。在transferTo方法内部实现中,由native方法transferTo0来实现,它依赖底层操作系统的支持。
在UNIX和Linux系统中,调用transferTo这个方法会引起sendfile()系统调用,实现了数据直接从内核的读缓冲区传输到套接字缓冲区,避免了用户态(User-space) 与内核态(Kernel-space) 之间的数据拷贝。
**FileChannel提供了map方法来把文件映射为内存对象。**另外Java 中设计MappedByteBuffer
就是为大文件准备的。
如果其后续要进行修改,还是通过SocketChannel发送,还是需要CPU进行数据的拷贝。
RocketMQ和Kafka对比
RocketMQ 选择了 mmap + write 这种零拷贝方式,适用于业务级消息这种小块文件的数据持久化和传输;
Kafka 采用的是 sendfile 这种零拷贝方式,适用于系统日志消息这种高吞吐量的大块文件的数据持久化和传输。Kafka 的索引文件使用的是 mmap + write 方式,数据文件使用的是 sendfile 方式。