Java NIO 基础知识

前言

前言部分是科普,读者可自行选择是否阅读这部分内容。

为什么我们需要关心 NIO?我想很多业务猿都会有这个疑问。

我在工作的前两年对这个问题也很不解,因为那个时候我认为自己已经非常熟悉 IO 操作了,读写文件什么的都非常溜了,IO 包无非就是 File、RandomAccessFile、字节流、字符流这些,感觉没什么好纠结的。最混乱的当属 InputStream/OutputStream 一大堆的类不知道谁是谁,不过了解了装饰者模式以后,也都轻松破解了。

在 Java 领域,一般性的文件操作确实只需要和 java.io 包打交道就可以了,尤其对于写业务代码的程序员来说。不过,当你写了两三年代码后,你的业务代码可能已经写得很溜了,蒙着眼睛也能写增删改查了。这个时候,也许你会想要开始了解更多的底层内容,包括并发、JVM、分布式系统、各个开源框架源码实现等,处于这个阶段的程序员会开始认识到 NIO 的用处,因为系统间通讯无处不在。

可能很多人不知道 Netty 或 Mina 有什么用?和 Tomcat 有什么区别?为什么我用 HTTP 请求就可以解决应用间调用的问题却要使用 Netty?

当然,这些问题的答案很简单,就是为了提升性能。那意思是 Tomcat 性能不好?当然不是,它们的使用场景就不一样。当初我也不知道 Nginx 摆在 Tomcat 前面有什么用,也是经过实践慢慢领悟到了那么些意思。

Nginx 是 web 服务器,Tomcat/Jetty 是应用服务器,Netty 是通讯工具。

也许你现在还不知道 NIO 有什么用,但是一定不要放弃学习它。

缓冲区操作

缓冲区是 NIO 操作的核心,本质上 NIO 操作就是缓冲区操作。

写操作是将缓冲区的数据排干,如将数据从缓冲区持久化到磁盘中。

读操作是将数据填充到缓冲区中,以便应用程序后续使用数据。

当然,我们这里说的缓冲区是指用户空间的缓冲区。

Java NIO 基础知识

.

简单分析下上图。应用程序发出读操作后,内核向磁盘控制器发送命令,要求磁盘返回相应数据,磁盘控制器通过 DMA 直接将数据发送到内核缓冲区。一旦内核缓冲区满了,内核即把数据拷贝到请求数据的进程指定的缓冲区中。

DMA: Direct Memory Access

Wikipedia:直接内存访问是计算机科学中的一种内存访问技术。它允许某些电脑内部的硬件子系统(电脑外设),可以独立地直接读写系统内存,而不需中央处理器(CPU)介入处理 。在同等程度的处理器负担下,DMA 是一种快速的数据传送方式。很多硬件的系统会使用 DMA,包含硬盘控制器、绘图显卡、网卡和声卡。

也就是说,磁盘控制器可以在不用 CPU 的帮助下就将数据从磁盘写到内存中,毕竟让 CPU 等待 IO 操作完成是一种浪费

很容易看出来,数据先到内核,然后再从内核复制到用户空间缓冲区的做法并不高效,下面简单说说为什么需要这么设计。

  • 首先,用户空间运行的代码是不可以直接访问硬件的,需要由内核空间来负责和硬件通讯,内核空间由操作系统控制。
  • 其次,磁盘存储的是固定大小的数据块,磁盘按照扇区来组织数据,而用户进程请求的一般都是任意大小的数据块,所以需要由内核来负责协调,内核会负责组装、拆解数据。

内核空间会对数据进行缓存和预读取,所以,如果用户进程需要的数据刚好在内核空间中,直接拷贝过来就可以了。如果内核空间没有用户进程需要的数据的话,需要挂起用户进程,等待数据准备好。

虚拟内存

这个概念大家都懂,这里就继续啰嗦一下了,虚拟内存是计算机系统内存管理的一种技术。前面说的缓存区操作看似简单,但是具体到底层细节,还是蛮复杂的。

下面的描述,我尽量保证准确,但是不会展开得太具体,因为虚拟内存还是蛮复杂的,要完全介绍清楚,恐怕需要很大的篇幅,如果读者对这方面的内容感兴趣的话,建议读者寻找更加专业全面的介绍资料,如《深入理解计算机系统》。

物理内存被组织成一个很大的数组,每个单元是一个字节大小,然后每个字节都有一个唯一的物理地址,这应该很好理解。

虚拟内存是对物理内存的抽象,它使得应用程序认为它自己拥有连续可用的内存(一个连续完整的地址空间),而实际上,应用程序得到的全部内存其实是一个假象,它通常会被分隔成多个物理内存碎片(后面说的页),还有部分暂时存储在外部磁盘存储器上,在需要时进行换入换出。

举个例子,在 32 位系统中,每个应用程序能访问到的内存是 4G(32 位系统的最大寻址空间 2^32),这里的 4G 就是虚拟内存,每个程序都以为自己拥有连续的 4G 空间的内存,即使我们的计算机只有 2G 的物理内存。也就是说,对于机器上同时运行的多个应用程序,每个程序都以为自己能得到连续的 4G 的内存。这中间就是使用了虚拟内存。

我们从概念上看,虚拟内存也被组织成一个很大的数组,每个单元也是一个字节大小,每个字节都有唯一的虚拟地址。它被存储于磁盘上,物理内存是它的缓存。

物理内存作为虚拟内存的缓存,当然不是以字节为单位进行组织的,那样效率太低了,它们之间是以页(page)进行缓存的。虚拟内存被分割为一个个虚拟页,物理内存也被分割为一个个物理页,这两个页的大小应该是一致的,通常是 4KB – 2MB。

举个例子,看下图:

Java NIO 基础知识

.

进程 1 现在有 8 个虚拟页,其中有 2 个虚拟页缓存在主存中,6 个还在磁盘上,需要的时候再读入主存中;进程 2 有 7 个虚拟页,其中 4 个缓存在主存中,3 个还在磁盘上。

在 CPU 读取内存数据的时候,给出的是虚拟地址,将一个虚拟地址转换为物理地址的任务我们称之为地址翻译。在主存中的查询表存放了虚拟地址到物理地址的映射关系,表的内容由操作系统维护。CPU 需要访问内存时,CPU 上有一个叫做内存管理单元的硬件会先去查询真实的物理地址,然后再到指定的物理地址读取数据。

上面说的那个查询表,我们称之为页表,虚拟内存系统通过页表来判断一个虚拟页是否已经缓存在了主存中。如果是,页表会负责到物理页的映射;如果不命中,也就是我们经常会见到的概念缺页,对应的英文是 page fault,系统首先判断这个虚拟页存放在磁盘的哪个位置,然后在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到内存中,替换这个牺牲页。

在磁盘和内存之间传送页的活动叫做交换(swapping)或者页面调度(paging)。

下面,简单介绍下虚拟内存带来的好处。

SRAM缓存:表示位于 CPU 和主存之间的 L1、L2 和 L3 高速缓存。

DRAM缓存:表示虚拟内存系统的缓存,缓存虚拟页到主存中。

物理内存访问速度比高速缓存要慢 10 倍左右,而磁盘要比物理内存慢大约 100000 倍。所以,DRAM 的缓存不命中比 SRAM 缓存不命中代价要大得多,因为 DRAM 缓存一旦不命中,就需要到磁盘加载虚拟页。而 SRAM 缓存不命中,通常由 DRAM 的主存来服务。而从磁盘的一个扇区读取第一个字节的时间开销比起读这个扇区中连续的字节要慢大约 100000 倍。

了解 Kafka 的读者应该知道,消息在磁盘中的顺序存储对于 Kafka 的性能至关重要。

结论就是,IO 的性能主要是由 DRAM 的缓存是否命中决定的。

内存映射文件

英文名是 Memory Mapped Files,相信大家也都听过这个概念,在许多对 IO 性能要求比较高的 java 应用中会使用到,它是操作系统提供的支持,后面我们在介绍 NIO Buffer 的时候会碰到的 MappedByteBuffer 就是用来支持这一特性的。

是什么:

我们可以认为内存映射文件是一类特殊的文件,我们的 Java 程序可以直接从内存中读取到文件的内容。它是通过将整个文件或文件的部分内容映射到内存页中实现的,操作系统会负责加载需要的页,所以它的速度是非常快的。

优势:

  • 一旦我们将数据写入到了内存映射文件,即使我们的 JVM 挂掉了,操作系统依然会帮助我们将这部分内存数据持久化到磁盘上。当然了,如果是断电的话,还是有可能会丢失数据的。
  • 另外,它比较适合于处理大文件,因为操作系统只会在我们需要的页不在内存中时才会去加载页数据,而用其处理大量的小文件反而可能会造成频繁的缺页。
  • 另一个重要的优势就是内存共享。我们可以在多个进程中同时使用同一个内存映射文件,也算是一种进程间协作的方式吧。想像下进程间的数据通讯平时我们一般采用 Socket 来请求,而内存共享至少可以带来 10 倍以上的性能提升。

我们还没有接触到 NIO 的 Buffer,下面就简单地示意一下:

import
 java.io.RandomAccessFile;import java.nio.MappedByteBuffer;import 
java.nio.channels.FileChannel;public class MemoryMappedFileInJava {  
private static int count = 10485760; //10 MB public static void 
main(String[] args) throws Exception { RandomAccessFile memoryMappedFile
 = new RandomAccessFile("largeFile.txt", "rw"); // 将文件映射到内存中,map 方法 
MappedByteBuffer out = 
memoryMappedFile.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, 
count); // 这一步的写操作其实是写到内存中,并不直接操作文件 for (int i = 0; i < count; i++) {
 out.put((byte) 'A'); } System.out.println("Writing to Memory Mapped 
File is completed"); // 这一步的读操作读的是内存 for (int i = 0; i < 10 ; i++) { 
System.out.print((char) out.get(i)); } System.out.println("Reading from 
Memory Mapped File is completed"); }}

我们需要注意的一点就是,用于加载内存映射文件的内存是堆外内存。

参考资料:Why use Memory Mapped File or MapppedByteBuffer in Java

分散/聚集 IO

scatter/gather IO,个人认为这个看上去很酷炫,实践中比较难使用到。

分散/聚集 IO(另一种说法是 vectored I/O 也就是向量 IO)是一种可以在单次操作中对多个缓冲区进行输入输出的方法,可以把多个缓冲区的数据写到单个数据流,也可以把单个数据流读到多个缓冲区中。

Java NIO 基础知识

.

Java NIO 基础知识

.

这个功能是操作系统提供的支持,Java NIO 包中已经给我们提供了操作接口 。这种操作可以提高一定的性能,因为一次操作相当于多次的线性操作,同时这也带来了原子性的支持,因为如果用多线程来操作的话,可能存在对同一文件的操作竞争。

非阻塞 IO

相信读者在很多地方都看到过说 NIO 其实不是代表 New IO,而是 Non-Blocking IO,我们这里不纠结这个。我想之所以会有这个说法,是因为在 Java 1.4 第一次推出 NIO 的时候,提供了 Non-Blocking IO 的支持。

在理解非阻塞 IO 前,我们首先要明白,它的对立面 阻塞模式为什么不好。

比如说 InputStream.read 这个方法,一旦某个线程调用这个方法,那么就将一直阻塞在这里,直到数据传输完毕,返回 -1,或者由于其他错误抛出了异常。

我们再拿 web 服务器来说,阻塞模式的话,每个网络连接进来,我们都需要开启一个线程来读取请求数据,然后到后端进行处理,处理结束后将数据写回网络连接,这整个流程需要一个独立的线程来做这件事。那就意味着,一旦请求数量多了以后,需要创建大量的线程,大量的线程必然带来创建线程、切换线程的开销,更重要的是,要给每个线程都分配一部分内存,会使得内存迅速被消耗殆尽。我们说多线程是性能利器,但是这就是过多的线程导致系统完全消化不了了。

通常,我们可以将 IO 分为两类:面向数据块(block-oriented)的 IO 和面向流(stream-oriented)的 IO。比如文件的读写就是面向数据块的,读取键盘输入或往网络中写入数据就是面向流的。

注意,这节混着用了流和通道这两个词,提出来这点是希望不会对读者产生困扰。

面向流的 IO 往往是比较慢的,如网络速度比较慢、需要一直等待用户新的输入等。

这个时候,我们可以用一个线程来处理多个流,让这个线程负责一直轮询这些流的状态,当有的流有数据到来后,进行相应处理,也可以将数据交给其他子线程来处理,这个线程继续轮询。

问题来了,不断地轮询也会带来资源浪费呀,尤其是当一个线程需要轮询很多的数据流的时候。

现代操作系统提供了一个叫做 readiness selection 的功能,我们让操作系统来监控一个集合中的所有的通道,当有的通道数据准备好了以后,就可以直接到这个通道获取数据。当然,操作系统不会通知我们,但是我们去问操作系统的时候,它会知道告诉我们通道 N 已经准备好了,而不需要自己去轮询(后面我们会看到,还要自己轮询的 select 和 poll)。

后面我们在介绍 Java NIO 的时候会说到 Selector,对应类 java.nio.channels.Selector,这个就是 java 对 readiness selection 的支持。这样一来,我们的一个线程就可以更加高效地管理多个通道了。

Java NIO 基础知识

.

上面这张图我想大家也都可能看过,就是用一个 Selector 来管理多个 Channel,实现了一个线程管理多个连接。说到底,其实就是解决了我们前面说的阻塞模式下线程创建过多的问题。

在 Java 中,继承自 SelectableChannel 的子类就是实现了非阻塞 IO 的,我们可以看到主要有 socket IO 中的 DatagramChannel 和 SocketChannel,而 FileChannel 并没有继承它。所以,文件 IO 是不支持非阻塞模式的。

在系统实现上,POSIX 提供了 select 和 poll 两种方式。它们两个最大的区别在于持有句柄的数量上,select 最多只支持到 FD_SETSIZE(一般常见的是 1024),显然很多场景都会超过这个数量。而 poll 我们想创建多少就创建多少。它们都有一个共同的缺点,那就是当有任务完成后,我们只能知道有几个任务完成了,而不知道具体是哪几个句柄,所以还需要进行一次扫描。

正是由于 select 和 poll 的不足,所以催生了以下几个实现。BSD& OS X 中的 kqueue,Solaris 中的 /dev/poll,还有 Linux 中的 epoll。

Windows 没有提供额外的实现,只能使用 select。

在不同的操作系统上,JDK 分别选择相应的系统支持的非阻塞实现方式。

异步 IO

我们知道 Java 1.4 引入了 New IO,从 Java 7 开始,就不再是 New IO 了,而是 More New IO 来临了,我们也称之为 NIO2。

Java7 在 NIO 上带来的最大的变化应该就属引入了 Asynchronous IO(异步 IO)。本来吧,异步 IO 早就提上日程了,可是大佬们没有时间完成,所以才一直拖到了 java 7 的。废话不多说,简单来看看异步 IO 是什么。

要说异步 IO 是什么,当然还得从 Non-Blocking IO 没有解决的问题入手。非阻塞 IO 很好用,它解决了阻塞式 IO 的等待问题,但是它的缺点是需要我们去轮询才能得到结果。

而异步 IO 可以解决这个问题,线程只需要初始化一下,提供一个回调方法,然后就可以干其他的事情了。当数据准备好以后,系统会负责调用回调方法。

异步 IO 最主要的特点就是回调,其实回调在我们日常的代码中也是非常常见的。

最简单的方法就是设计一个线程池,池中的线程负责完成一个个阻塞式的操作,一旦一个操作完成,那么就调用回调方法。比如 web 服务器中,我们前面已经说过不能每来一个请求就新开一个线程,我们可以设计一个线程池,在线程池外用一个线程来接收请求,然后将要完成的任务交给线程池中的线程并提供一个回调方法,这样这个线程就可以去干其他的事情了,如继续处理其他的请求。等任务完成后,池中的线程就可以调用回调方法进行通知了。

另外一种方式就是自己不设计线程池,让操作系统帮我们实现。流程也是基本一样的,提供给操作系统回调方法,然后就可以干其他事情了,等操作完成后,操作系统会负责回调。这种方式的缺点就是依赖于操作系统的具体实现,不过也有它的一些优势。

首先,我们自己设计处理任务的线程池的话,我们需要掌握好线程池的大小,不能太大,也不能太小,这往往需要凭我们的经验;其次,让操作系统来做这件事情的话,操作系统可以在一些场景中帮助我们优化性能,如文件 IO 过程中帮助更快找到需要的数据。

操作系统对异步 IO 的实现也有很多种方式,主要有以下 3 中:

  1. Linux AIO:由 Linux 内核提供支持
  2. POSIX AIO:Linux,Mac OS X(现在该叫 Mac OS 了),BSD,solaris 等都支持,在 Linux 中是通过 glibc 来提供支持的。
  3. Windows:提供了一个叫做 completion ports 的机制。

这篇文章 asynchronous disk I/O 的作者表示,在类 unix 的几个系统实现中,限制太多,实现的质量太差,还不如自己用线程池进行管理异步操作。

而 Windows 系统下提供的异步 IO 的实现方式有点不一样。它首先让线程池中的线程去自旋调用 GetQueuedCompletionStatus.aspx) 方法,判断是否就绪。然后,让任务跑起来,但是需要提供特定的参数来告诉执行任务的线程,让线程执行完成后将结果通知到线程池中。一旦任务完成,操作系统会将线程池中阻塞在 GetQueuedCompletionStatus 方法的线程唤醒,让其进行后续的结果处理。

Windows 智能地唤醒那些执行 GetQueuedCompletionStatus 方法的线程,以让线程池中活跃的线程数始终保持在合理的水平。这样就不至于创建太多的线程,降低线程切换的开销。

Java 7 在异步 IO 的实现上,如果是 Linux 或者其他类 Unix 系统上,是采用自建线程池实现的,如果是 Windows 系统上,是采用系统提供的 completion ports 来实现的。

所以,在非阻塞 IO 和异步 IO 之间,我们应该怎么选择呢?

如果是文件 IO,我们没得选,只能选择异步 IO。

如果是 Socket IO,在类 unix 系统下我们应该选择使用非阻塞 IO,Netty 是基于非阻塞模式的;在 Windows 中我们应该使用异步 IO。

当然了,Java 的存在就是为了实现平台无关化,所以,其实不需要我们选择,了解这些权当让自己涨点知识吧。

总结

和其他几篇文章一样,也没什么好总结的,要说的都在文中了,希望读者能学到点东西吧。

如果哪里说得不对了,我想也是正常的,我这些年写的都是 Java,对于底层了解得愈发的少了,所以如果读者发现有什么不合理的内容,非常希望读者可以提出来。

发表评论

电子邮件地址不会被公开。 必填项已用*标注