1. IO和NIO
分类 |
阻塞 |
选择器 |
处理方式 |
读取方向 |
java.io |
是 |
否 |
面向字节流、字符流 |
单向移动 |
java.nio |
否 |
是 |
面向缓冲 |
可在缓冲区前后双向移动 |
1.1 阻塞 vs 非阻塞
Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
IO的各种流是阻塞的,就是当一个线程调用读写方法时,该线程会被阻塞,直到读写完,在这期间该线程不能干其他事,CPU转而去处理其他线程,假如一个线程监听一个端口,一天只会有几次请求进来,但是CPU却不得不为该线程不断的做上下文切换,并且大部分切换以阻塞告终。
NIO通讯是将整个任务切换成许多小任务,由一个线程负责处理所有IO事件,并负责分发。它是利用事件驱动机制,而不是监听机制,事件到的时候再触发。NIO线程之间通过wait,notify等方式通讯。保证了每次上下文切换都有意义,减少无谓的进程切换。
1.2 面向流 vs 面向缓冲
Java IO和NIO之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区的。 Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。 Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
2. 知识准备
2.1 缓冲区操作
- (1)发起**read()**方法,执行系统调用将控制权移交给内核空间
- (2)内核空间收到请求先在内核空间的RAM内存中查找数据,若存在则拷贝传输到用户空间的缓冲区,若不存在执行步骤(3),挂起请求进程,即发生阻塞等待
- (3)从磁盘介质中读取数据放入RAM内存,拷贝传输到用户空间的缓冲区
为何不直接将磁盘介质拷贝传输到用户空间的缓冲区?
- 操作系统不允许用户空间直接访问硬件
- 内核空间作为中介者和桥梁,处理用户空间与底层硬件交互,对I/O数据进行分解、组合等
2.2 内核空间与用户空间
2.2.1 设计初衷
- 对于以前的 DOS 操作系统来说,是没有内核空间、用户空间以及内核态、用户态这些概念的。可以认为所有的代码都是运行在内核态的,因而用户编写的应用程序代码可以很容易的让操作系统崩溃掉。
- 对于 Linux 来说,通过区分内核空间和用户空间的设计,隔离了操作系统代码(操作系统的代码要比应用程序的代码健壮很多)与应用程序代码。即便是单个应用程序出现错误也不会影响到操作系统的稳定性,这样其它的程序还可以正常的运行。
- 现代的操作系统大都通过内核空间和用户空间的设计来保护操作系统自身的安全性和稳定性
2.2.2 空间态切换
当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态
发生从用户态向内核态切换,有以下三种情况:
情况 |
发起 |
描述 |
发生系统调用时 |
主动 |
这是处于用户态的进程主动请求切换到内核态的一种方式。用户态的进程通过系统调用申请使用操作系统提供的系统调用服务例程来处理任务。而系统调用的机制,其核心仍是使用了操作系统为用户特别开发的一个中断机制来实现的,即软中断。 |
产生异常时 |
被动 |
当CPU执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行的进程切换到处理此异常的内核相关的程序中,也就是转到了内核态,如缺页异常。 |
外设产生中断时 |
被动 |
当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作的完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。 |
2.3 虚拟内存
所有现代操作系统都使用虚拟内存。虚拟内存意为使用虚假(或虚拟)地址取代物理(硬件
RAM)内存地址。这样做好处颇多,总结起来可分为两大类:
- 一个以上的虚拟地址可指向同一个物理内存地址。
- 虚拟内存空间可大于实际可用的硬件内存。
2.4 分页技术
参考《Java IO》介绍,深入了解可以翻翻计算机组成原理相关书籍关于内存介绍
为了支持虚拟内存的第二个特性(寻址空间大于物理内存),就必须进行虚拟内存分页(经常称为交换,虽然真正的交换是在进程层面完成,而非页层面)。依照该方案,虚拟内存空间的页面能够继续存在于外部磁盘存储,这样就为物理内存中的其他虚拟页面腾出了空间。从本质上说,物理内存充当了分页区的高速缓存;而所谓分页区,即从物理内存置换出来,转而存储于磁盘上的内存页面。
把内存页大小设定为磁盘块大小的倍数,这样内核就可直接向磁盘控制硬件发布命令,把内存页写入磁盘,在需要时再重新装入。结果是,所有磁盘 I/O 都在页层面完成。对于采用分页技术的现代操作系统而言,这也是数据在磁盘与物理内存之间往来的唯一方式。
2.5 内存映射文件
内存映射文件是OS提供的一种无需用户态、内核态之间数据拷贝的对文件进行映射操作的机制,极大的提高了IO处理效率,可以参考我的另一篇文章整理 mmap内存映射原理
3. java.nio组成
java.nio主要通过Buffer(缓冲)、Channel(通道)、Selector(选择器) 实现组成
3.1 Buffer
参考我的另一篇文章整理,ByteBuffer总结
3.2 Channel
Channel 用于在字节缓冲区和位于通道另一侧的实体(通常是一个文件或套接字)之间有效地传输数据,可以把通道理解为是访问IO服务的导管或者工具。
一般我们会把IO广义的分为 File IO 和 Stream IO,对应的是文件流(File)和套接字流(Socket) ,因此通道也可以根据两种IO进行分配,一般分为
- FileChannel 处理文件流的通道
- ServerSocketChannel、SocketChannel、DatagramChannel 处理Socket套接字流的通道
java.nio.channels.spi 提供了可扩展的方式,允许新通道实现以一种受控且模块化的方式被植入到Java 虚拟机上专为某种操作系统、文件系统或应用程序而优化的通道来使性能最大化。
打开通道
- 文件流只能通过RandomAccessFile、FileInputStream 或 FileOutputStream
对象上调用getChannel( ) 方法来获取
RandomAccessFile raf = new RandomAccessFile ("somefile", "r");
FileChannel fc = raf.getChannel( );
- 套接字流可以调用静态方法open() 来获取
SocketChannel sc = SocketChannel.open( );
sc.connect (new InetSocketAddress ("somehost", someport));
ServerSocketChannel ssc = ServerSocketChannel.open( );
ssc.socket( ).bind (new InetSocketAddress (somelocalport));
DatagramChannel dc = DatagramChannel.open( );
双向通道
ReadableByteChannel实现接口则为可读、WritableByteChannel实现接口则为可写,当只实现一个接口时,channel为单向的,ByteChannel实现了以上两个接口,因此实现ByteChannel接口的类是支持可读可写的,是双向通道。
SelectableChannel实现接口则可以支持配合Selector(选择器) 使用
阻塞模式
可以设置阻塞(blocking) 、非阻塞(non-blocking) 模式
将非阻塞I/O 和选择器组合起来可以使您的程序利用多路复用 I/O(multiplexed I/O)
关闭通道
调用通道的close( )方法时,可能会导致在通道关闭底层I/O服务的过程中线程暂时阻塞,哪怕该通道处于非阻塞模式。通道关闭时的阻塞行为(如果有的话)是高度取决于操作系统或者文件系统的。在一个通道上多次调用close( )方法是没有坏处的,但是如果第一个线程在close( )方法中阻塞,那么在它完成关闭通道之前,任何其他调用close( )方法都会阻塞。后续在该已关闭的通道上调用close( )不会产生任何操作,只会立即返回。
文件通道(FileChannel)
- 文件通道总是阻塞式,因此不能被置于非阻塞模式。现代操作系统都有复杂的缓存和预取机
制,使得本地磁盘 I/O 操作延迟很少 - 对于文件 I/O,最强大之处在于异步 I/O(asynchronous I/O) ,它允许一个进程可以
从操作系统请求一个或多个 I/O 操作而不必等待这些操作的完成
套接字通道(SocketChannel、ServerSocketChannel)
Socket通道可以选择阻塞、非阻塞模式
SocketChannel 模拟连接导向的流协议(如 TCP/IP)
DatagramChannel 模拟包导向的无连接协议(如 UDP/IP)
流Socket与数据报Socket对比:
- UDP面向数据包,而TCP面向连接
- UDP传输不可靠且不能保证有序,会产生数据丢失或无序的数据;TCP传输可靠有序,有重试机制保证
- UDP“发射后不管”(fire and forget)而不需要知道您发送的包是否已接收
- UDP数据吞吐量更高;TCP需要多次交互和机制保证相比会占用很多网络资源
- UDP可以发送数据给多个接受者(多播或者广播)
关于Socket的IO介绍和使用可以参考我的另一篇文章 Socket通信原理及模型实现
管道(Pipe)
管道,即一对循环的通道。Pipe 类定义了两个嵌套的通道类来实现管路。
- Pipe.SourceChannel(管道负责读的一端)source 通道类似java.io.PipedInputStream提供的功能
- Pipe.SinkChannel(管道负责写的一端) sink 通道类似java.io.PipedOutputStream提供的功能
3.3 Selector
选择器提供选择执行已经就绪的任务的能力,这使得多元 I/O 成为可能。就像在第一章中描述的那样,就绪选择和多元执行使得单线程能够有效率地同时管理多个 I/O 通道(Channel)。
如果将一个Channel通道和事件Event注册到一个Selector上,那么Channel和Event在Selector上的映射关系组成了SelectonKey,也可以如上图将Channel注册到多个Selector上。
Selector
Selector 是Java nio能够支持高并发数据处理一个关键,其核心理念就是IO多路复用的原理,简单的说就是当多个客户端(Channel)连接服务器时,可以通过Selector同时对这些客户端请求进行监听,当客户端发送数据到服务器之后由Selector对这些Channel进行分发处理
SelectionKey
SelectionKey维护的是一个实现SelectableChannel的通道与Selector的映射关系,也就是多路复用器要监听哪些通道事件。
监听事件定义了如下四个:
- OP_READ 读取
- OP_WRITE 写入
- OP_CONNECT 连接
- OP_ACCEPT 接受
Selector代码示例
/**
* 单线程处理 多路复用
* created by guanjian on 2021/1/12 9:09
*/
public class SingleThreadNIOSocketChannelSelectorServer {
public static void main(String[] args) throws IOException, InterruptedException {
// 创建ServerSocketChannel通道,绑定监听端口为8080
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
// 设置为非阻塞模式
serverSocketChannel.configureBlocking(false);
// 注册选择器,设置选择器选择的操作类型
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("Server starting ...");
while (true) {
System.out.println("Server receive request ...");
// 等待请求,每次等待阻塞3s,超过时间则向下执行,若传入0或不传值,则在接收到请求前一直阻塞
if (selector.select(1000) > 0) {
System.out.println("Server receive event ...");
// 获取待处理的选择键集合
Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
while (keyIterator.hasNext()) {
SelectionKey selectionKey = keyIterator.next();
// 如果是连接请求,调用处理器的连接处理方法
if (selectionKey.isAcceptable()) {
System.out.println("Server receive connect ...");
handleAccept(selectionKey);
}
// 如果是读请求,调用对应的读方法
if (selectionKey.isReadable()) {
System.out.println("Server receive read ...");
handleRead(selectionKey);
}
// 处理完毕从待处理集合移除该选择键
keyIterator.remove();
}
}
//为了打印日志,故意设置时间间隔
Thread.sleep(2000);
}
}
public static void handleAccept(SelectionKey selectionKey) throws IOException {
SocketChannel socketChannel = ((ServerSocketChannel) selectionKey.channel()).accept();
socketChannel.configureBlocking(false);
socketChannel.register(selectionKey.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(1024));
}
public static void handleRead(SelectionKey selectionKey) throws IOException {
// 获取套接字通道
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
// 获取缓冲器并进行重置,selectionKey.attachment()为获取选择器键的附加对象
ByteBuffer byteBuffer = (ByteBuffer) selectionKey.attachment();
byteBuffer.clear();
// 没有内容则关闭通道
if (socketChannel.read(byteBuffer) == -1) {
socketChannel.close();
} else {
// 将缓冲器转换为读状态
byteBuffer.flip();
// 将缓冲器中接收到的值按localCharset格式编码保存
String receivedRequestData = Charset.forName("UTF-8").newDecoder().decode(byteBuffer).toString();
System.out.format("Server receive data:" + receivedRequestData);
// 关闭通道
//socketChannel.close();
}
}
}
参考
https://zhuanlan.zhihu.com/p/56876443
https://www.cnblogs.com/aspirant/p/8630283.html
https://www.cnblogs.com/sparkdev/p/8410350.html
https://www.cnblogs.com/vampirem/p/3157612.html
https://www.cnblogs.com/zhya/p/9640016.html
《Java NIO》