过往写过的一些小case,读取文件的字节还在使用while循环,后来发现Java 7提供的新特性能很轻松的调用相关方法实现,遂感慨:这个版本的更新还是有些东西的。于是以此为契机,从Java NIO说开去,本篇主讲Java NIO基本的底层基础。
缓存区、DMA
缓存区的理解可以阅读《Linux/UNIX系统编程手册》,虽然感觉写得不是很深入,但是作为Java程序员来讲似乎够用。
通常来说read()和write()不会直接发起磁盘访问,而是从用户空间缓冲区到内核缓冲区高速缓存(kernel buffer cache)之间复制数据。
缓冲区设计目的:
- 使read()和write()调用操作更快;
- 减少内核的磁盘传输次数;
受限因素:
- 可用的物理内存总量;
- 出于其他目的对物理内存的需求;
可见缓冲区也是物理内存的一部分,而不是单独设定的区域。如果没有缓冲区,对磁盘的读写将进行多次的系统调用,虽然这比磁盘操作快,但是并不能提高I/O的性能。设定一定大小的缓冲区进行I/O,可以拥有更少的系统调用和更高的I/O性能。
DMA:直接内存读取。通过DMA磁盘把数据直接写入内核缓冲区,不需要CPU协作。
此时就会产生一个疑问:为什么要经过内核缓冲区绕一圈进行读写呢?
两个原因:
- 硬件通常不能直接访问用户空间;
- 磁盘操作的是固定大小的数据块(这个涉及到物理构造),而用户请求的通常是任意大小的数据或非对齐的数据块;
内核缓冲区就像中央政府,它把数据汇聚在一起,然后发散到多个用户空间缓冲区,“聚是一团火,散是满天星”。
虚拟内存
MMU:负责虚拟内存的“总管”,它来负责虚拟地址映射的操作,没有它CPU直接访问内存芯片的地址。
我们知道无论是内核空间地址还是用户空间地址,他们都不是真正的物理地址,如果用户空间的地址和内核地址映射到同一物理地址,那么就可以免去内核和用户空间的拷贝(这里涉及到零拷贝,后续将深入)。所以虚拟内存的一个好处就是:一个以上的虚拟地址可以指向同一个物理内存地址
。
内存映射文件 & 零拷贝
这是一波大节奏,因为相关的底层实现并没有想象中的那么简单,但是作为Java程序员如果只是了解了基本的抽象,似乎也不会完全阻碍实际代码的编写,本小结就来领略这些抽象。
普通磁盘I/O
首先看一张图:
可以看出一次IO的过程通常是:hard drive -> kernel buffer -> user buffer -> socket buffer -> protocol engine。会经历两次DMA copy,两次CPU copy。
mmap
mmap如何工作的呢?引用一段知乎上某答案的解释(具体答案见文末链接):
mmap的工作原理,当你发起这个调用的时候,它只是在你的虚拟空间中分配了一段空间,连真实的物理地址都不会分配的,当你访问这段空间,CPU陷入OS内核执行异常处理,然后异常处理会在这个时间分配物理内存,并用文件的内容填充这片内存,然后才返回你进程的上下文,这时你的程序才会感知到这片内存里有数据…
这正是前面内存映射文件的具体实现。如此一来,IO的过程变成如下新图:
通过这种方法,减少了一次CPU copy,也就是磁盘到用户缓冲区的拷贝。
sendfile01
snedfile的原理见下图:
它连shared的部分都不存在了,也是减少了内核态到用户态上下文的切换,进一步优化了mmap。
sendfile02
sendfile02比起sendfile01更进一步减少了CPU copy,继续看下图:
免去了一次kernel buffer到socket buffer的CPU拷贝,Zero Copy I: User-Mode Perspective中这样描述这一步骤:
No data is copied into the socket buffer. Instead, only descriptors with information about the whereabouts and length of the data are appended to the socket buffer. The DMA engine passes data directly from the kernel buffer to the protocol engine, thus eliminating the remaining final copy.
小节sendfile01和小节sendfile02介绍的方法在api调用层面是相同的:
|
|
所以,具体采用哪一种需要知道操作系统的内核版本。如果是2.4以前版本则使用的是sendfile01。
绕过缓冲区高速缓存:直接I/O
前面介绍的方法,很多人认为就是零拷贝,但是也有很多人认为不是。差别就在于是否认可将数据拷贝到内核缓冲区。在《Linux/UNIX系统编程手册》的十三章介绍了绕过缓冲区的方法和原理。Linux在进行直接I/O的过程中,也要遵守一些限制:
- 缓冲区内存边界必须对齐为块的整数倍;
- 传输起点也应该是块大小的整数倍;
- 传输的数据长度也应该是块大小的整数倍;
这里的缓冲区指的是用户空间缓冲区,块大小是设备的物理块大小,通常为512字节。
关于直接I/O还可以看看IBM Developer:Linux 中直接 I/O 机制的介绍这篇文字的相关段落:
凡是通过直接 I/O 方式进行数据传输,数据均直接在用户地址空间的缓冲区和磁盘之间直接进行传输,完全不需要页缓存的支持。操作系统层提供的缓存往往会使应用程序在读写数据的时候获得更好的性能,但是对于某些特殊的应用程序,比如说数据库管理系统这类应用,他们更倾向于选择他们自己的缓存机制,因为数据库管理系统往往比操作系统更了解数据库中存放的数据,数据库管理系统可以提供一种更加有效的缓存机制来提高数据库中数据的存取性能。
Channel理解
《Java NIO》作者: Ron Hitchens将Channel比做银行出纳使用的气动导管,载体就是缓冲区,支票是data,将data填充缓冲区,接着再通过Channel传递到另一侧。
所以本人认为Channel抽象了端到端的信息传送。不同的Channel,通过前面的底层实现,进行data的不同方式的传递。
小结
本篇主要介绍了NIO.2实现的底层基础,这和操作系统的实现细节密切相关,通过本篇有助于进一层理解Java NIO.2,而非局限于api的调用。
参考
Zero Copy I: User-Mode Perspective