1.JAVA NIO简介

Java NIO

第一章 简介

1.I/O与CPU时间的比较

 

2.CPU已不再是束缚

如今在运行时优化方面,JVM 已然前进了一大步。现在 JVM 运行字节码的速率已经接近本地 编译代码,借助动态运行时优化,其表现甚至还有所超越。这就意味着,多数 Java 应用程序已不 再受 CPU 的束缚(把大量时间用在执行代码上), 而更多时候是受 I/O 的束缚(等待数据传 输)。

然而,在大多数情况下,Java 应用程序并非真的受着 I/O 的束缚。操作系统并非不能快速传送 数据,让 Java 有事可做;相反,是 JVM 自身在 I/O 方面效率欠佳。操作系统与 Java 基于流的 I/O 模型有些不匹配。 操作系统要移动的是大块数据(缓冲区), 这往往是在硬件直接存储器存取 (DMA)的协助下完成的。而 JVM 的 I/O 类喜欢操作小块数据——单个字节、几行文本。结果, 操作系统送来整缓冲区的数据,java.io 的流数据类再花大量时间把它们拆成小块,往往拷贝一 个小块就要往返于几层对象。操作系统喜欢整卡车地运来数据,java.io 类则喜欢一铲子一铲子 地加工数据。有了 NIO,就可以轻松地把一卡车数据备份到您能直接使用的地方(ByteBuffer 对 象)。

这并不是说使用传统的 I/O 模型无法移动大量数据——当然可以(现在依然可以)。具体地 说,RandomAccessFile 类在这方面的效率就不低,只要坚持使用基于数组的 read( )和 write( )方法。 这些方法与底层操作系统调用相当接近,尽管必须保留至少一份缓冲区拷贝。

如表 1-1 所示,如果您的代码大部分时间都处于 I/O 等待状态,那么,该考虑一下提升 I/O 效 率的问题了,否则,您精心打造的代码多数时间都得闲着。

3.Java NIO

java.nio 软件包提供了新的抽象。具体地说,就是 Channel 和 Selector 类。它们提供了使用 I/O 服务的通用 API,JDK 1.4 以前的版本是无法使用这些服务的。天下还是 没有免费的午餐:您无法使用每一种操作系统的每一种特性,但是这些新类还是提供了强大的新框 架,涵盖了当今商业操作系统普遍提供的高效 I/O 特性。不仅如此,java.nio.channels.spi 还提供了新的服务提供接口(SPI),允许接入新型通道和选择器,同时又不违反规范的一致性。

4.IO概念

JDK 1.4 的 NIO 软件包引入了一套新的抽象用于 I/O 处理。与以往不同的是,新的抽象把重点放在了如何缩短抽象与现实之间的距离上 面。NIO 抽象与现实中存在的实体有着非常真实直接的交互关系。要想最大限度地满足 Java 应用 程序的密集 I/O 需求,理解这些新的抽象,以及与其发生交互作用的 I/O 服务(其重要性并不亚于 抽象),正是关键所在。

  • 理解以下概念是非常重要的:

    • 缓冲区操作

    • 内核空间与用户空间

    • 虚拟内存

    • 分页技术

    • 面向文件的 I/O 和流 I/O

    • 多工 I/O(就绪性选择)

4.1缓冲区操作

图 1-1 简单描述了数据从外部磁盘向运行中的进程的内存区域移动的过程。进程使用 read( )系 统调用,要求其缓冲区被填满。内核随即向磁盘控制硬件发出命令,要求其从磁盘读取数据。磁盘 控制器把数据直接写入内核内存缓冲区,这一步通过 DMA 完成,无需主 CPU 协助。一旦磁盘控 制器把缓冲区装满,内核即把数据从内核空间的临时缓冲区拷贝到进程执行 read( )调用时指定的缓 冲区。

注意图中用户空间和内核空间的概念。用户空间是常规进程所在区域。JVM 就是常规进程, 驻守于用户空间。用户空间是非特权区域:比如,在该区域执行的代码就不能直接访问硬件设备。 内核空间是操作系统所在区域。内核代码有特别的权力:它能与设备控制器通讯,控制着用户区域 进程的运行状态,等等。最重要的是,所有 I/O 都直接(如这里所述)或间接(见 1.4.2 小节)通 过内核空间。

当进程请求 I/O 操作的时候,它执行一个系统调用(有时称为陷阱)将控制权移交给内核。 C/C++程序员所熟知的底层函数 open( )、read( )、write( )和 close( )要做的无非就是建立和执行适当的系统调用。当内核以这种方式被调用,它随即采取任何必要步骤,找到进程所需数据,并把数据 传送到用户空间内的指定缓冲区。内核试图对数据进行高速缓存或预读取,因此进程所需数据可能 已经在内核空间里了。如果是这样,该数据只需简单地拷贝出来即可。如果数据不在内核空间,则 进程被挂起,内核着手把数据读进内存。

看了图 1-1,您可能会觉得,把数据从内核空间拷贝到用户空间似乎有些多余。为什么不直接 让磁盘控制器把数据送到用户空间的缓冲区呢?这样做有几个问题。首先,硬件通常不能直接访问 用户空间 1 。其次,像磁盘这样基于块存储的硬件设备操作的是固定大小的数据块,而用户进程请 求的可能是任意大小的或非对齐的数据块。在数据往来于用户空间与存储设备的过程中,内核负责 数据的分解、再组合工作,因此充当着中间人的角色。

1)发散/汇聚

许多操作系统能把组装/分解过程进行得更加高效。根据发散/汇聚的概念,进程只需一个系 统调用,就能把一连串缓冲区地址传递给操作系统。然后,内核就可以顺序填充或排干多个缓冲 区,读的时候就把数据发散到多个用户空间缓冲区,写的时候再从多个缓冲区把数据汇聚起来(图 1-2)。

这样用户进程就不必多次执行系统调用(那样做可能代价不菲),内核也可以优化数据的处理 过程,因为它已掌握待传输数据的全部信息。如果系统配有多个 CPU,甚至可以同时填充或排干 多个缓冲区。

4.2虚拟内存

所有现代操作系统都使用虚拟内存。虚拟内存意为使用虚假(或虚拟)地址取代物理(硬件 RAM)内存地址。这样做好处颇多,总结起来可分为两大类:

  1. 一个以上的虚拟地址可指向同一个物理内存地址。

  2. 虚拟内存空间可大于实际可用的硬件内存。

 

前一节提到,设备控制器不能通过 DMA 直接存储到用户空间,但通过利用上面提到的第一 项,则可以达到相同效果。把内核空间地址与用户空间的虚拟地址映射到同一个物理地址,这样, DMA 硬件(只能访问物理内存地址)就可以填充对内核与用户空间进程同时可见的缓冲区(见图 1-3)。

这样真是太好了,省去了内核与用户空间的往来拷贝,但前提条件是,内核与用户缓冲区必须 使用相同的页对齐,缓冲区的大小还必须是磁盘控制器块大小(通常为 512 字节磁盘扇区)的倍 数。操作系统把内存地址空间划分为页,即固定大小的字节组。内存页的大小总是磁盘块大小的倍 数,通常为 2 次幂(这样可简化寻址操作)。典型的内存页为 1,024、2,048 和 4,096 字节。虚拟和 物理内存页的大小总是相同的。图 1-4 显示了来自多个虚拟地址的虚拟内存页是如何映射到物理内存的。

4.3内存页面调度

为了支持虚拟内存的第二个特性(寻址空间大于物理内存),就必须进行虚拟内存分页(经常 称为交换,虽然真正的交换是在进程层面完成,而非页层面)。依照该方案,虚拟内存空间的页面 能够继续存在于外部磁盘存储,这样就为物理内存中的其他虚拟页面腾出了空间。从本质上说,物 理内存充当了分页区的高速缓存;而所谓分页区,即从物理内存置换出来,转而存储于磁盘上的内 存页面。

图 1-5 显示了分属于四个进程的虚拟页面,其中每个进程都有属于自己的虚拟内存空间。进程 A 有五个页面,其中两个装入内存,其余存储于磁盘。

把内存页大小设定为磁盘块大小的倍数,这样内核就可直接向磁盘控制硬件发布命令,把内存 页写入磁盘,在需要时再重新装入。结果是,所有磁盘 I/O 都在页层面完成。对于采用分页技术的 现代操作系统而言,这也是数据在磁盘与物理内存之间往来的唯一方式。

现代 CPU 包含一个称为内存管理单元(MMU)的子系统,逻辑上位于 CPU 与物理内存之 间。该设备包含虚拟地址向物理内存地址转换时所需映射信息。当 CPU 引用某内存地址时,MMU 负责确定该地址所在页(往往通过对地址值进行移位或屏蔽位操作实现),并将虚拟页号转换为物 理页号(这一步由硬件完成,速度极快)。如果当前不存在与该虚拟页形成有效映射的物理内存 页,MMU 会向 CPU 提交一个页错误。

页错误随即产生一个陷阱(类似于系统调用),把控制权移交给内核,附带导致错误的虚拟地 址信息,然后内核采取步骤验证页的有效性。内核会安排页面调入操作,把缺失的页内容读回物理 内存。这往往导致别的页被移出物理内存,好给新来的页让地方。在这种情况下,如果待移出的页已经被碰过了(自创建或上次页面调入以来,内容已发生改变),还必须首先执行页面调出,把页 内容拷贝到磁盘上的分页区。

如果所要求的地址不是有效的虚拟内存地址(不属于正在执行的进程的任何一个内存段),则 该页不能通过验证,段错误随即产生。于是,控制权转交给内核的另一部分,通常导致的结果就是 进程被强令关闭。

一旦出错的页通过了验证,MMU 随即更新,建立新的虚拟到物理的映射(如有必要,中断被 移出页的映射),用户进程得以继续。造成页错误的用户进程对此不会有丝毫察觉,一切都在不知 不觉中进行。

4.文件IO

文件 I/O 属文件系统范畴,文件系统与磁盘迥然不同。磁盘把数据存在扇区上,通常一个扇区 512 字节。磁盘属硬件设备,对何谓文件一无所知,它只是提供了一系列数据存取窗口。在这点 上,磁盘扇区与内存页颇有相似之处:都是统一大小,都可作为大的数组被访问。

文件系统是更高层次的抽象,是安排、解释磁盘(或其他随机存取块设备)数据的一种独特方 式。您所写代码几乎无一例外地要与文件系统打交道,而不是直接与磁盘打交道。是文件系统定义 了文件名、路径、文件、文件属性等抽象概念。

文件系统把一连串大小一致的数据块组织到一起。有些块存储元信息,如空闲块、目录、索引 等的映射,有些包含文件数据。单个文件的元信息描述了哪些块包含文件数据、数据在哪里结束、 最后一次更新是什么时候,等等。

当用户进程请求读取文件数据时,文件系统需要确定数据具体在磁盘什么位置,然后着手把相 关磁盘扇区读进内存。 老式的操作系统往往直接向磁盘驱动器发布命令, 要求其读取所需磁盘扇 区。而采用分页技术的现代操作系统则利用请求页面调度取得所需数据。

操作系统还有个页的概念,其大小或者与基本内存页一致,或者是其倍数。典型的操作系统页 从 2,048 到 8,192 字节不等,且始终是基本内存页大小的倍数。

 

  • 采用分页技术的操作系统执行 I/O 的全过程可总结为以下几步:

    • 确定请求的数据分布在文件系统的哪些页(磁盘扇区组)。磁盘上的文件内容和元数 据可能跨越多个文件系统页,而且这些页可能也不连续。

    • 在内核空间分配足够数量的内存页,以容纳得到确定的文件系统页。

    • 在内存页与磁盘上的文件系统页之间建立映射。

    • 为每一个内存页产生页错误。

    • 虚拟内存系统俘获页错误,安排页面调入,从磁盘上读取页内容,使页有效。

    • 一旦页面调入操作完成,文件系统即对原始数据进行解析,取得所需文件内容或属性 信息。

 

需要注意的是,这些文件系统数据也会同其他内存页一样得到高速缓存。对于随后发生的 I/O 请求,文件数据的部分或全部可能仍旧位于物理内存当中,无需再从磁盘读取即可重复使用。

大多数操作系统假设进程会继续读取文件剩余部分,因而会预读额外的文件系统页。如果内存 争用情况不严重,这些文件系统页可能在相当长的时间内继续有效。这样的话,当稍后该文件又被 相同或不同的进程再次打开,可能根本无需访问磁盘。这种情况您可能也碰到过:当重复执行类似 的操作,如在几个文件中进行字符串检索,第二遍运行得似乎快多了。

类似的步骤在写文件数据时也会采用。这时,文件内容的改变(通过 write( ))将导致文件系 统页变脏,随后通过页面调出,与磁盘上的文件内容保持同步。文件的创建方式是,先把文件映射 到空闲文件系统页,在随后的写操作中,再将文件系统页刷新到磁盘。

1)内存映射文件

传统的文件 I/O 是通过用户进程发布 read( )和 write( )系统调用来传输数据的。为了在内核空间 的文件系统页与用户空间的内存区之间移动数据,一次以上的拷贝操作几乎总是免不了的。这是因 为,在文件系统页与用户缓冲区之间往往没有一一对应关系。但是,还有一种大多数操作系统都支 持的特殊类型的 I/O 操作,允许用户进程最大限度地利用面向页的系统 I/O 特性,并完全摒弃缓冲 区拷贝。这就是内存映射 I/O,如图 1-6 所示。

  • 内存映射 I/O 使用文件系统建立从用户空间直到可用文件系统页的虚拟内存映射。这样做有几 个好处:

    • 用户进程把文件数据当作内存,所以无需发布 read( )或 write( )系统调用。

    • 当用户进程碰触到映射内存空间,页错误会自动产生,从而将文件数据从磁盘读进内存。如果用户修改了映射内存空间,相关页会自动标记为脏,随后刷新到磁盘,文件 得到更新。

    • 操作系统的虚拟内存子系统会对页进行智能高速缓存,自动根据系统负载进行内存管 理。

    • 数据总是按页对齐的,无需执行缓冲区拷贝。

    • 大型文件使用映射,无需耗费大量内存,即可进行数据拷贝。

 

虚拟内存和磁盘 I/O 是紧密关联的,从很多方面看来,它们只是同一件事物的两面。在处理大 量数据时,尤其要记得这一点。如果数据缓冲区是按页对齐的,且大小是内建页大小的倍数,那 么,对大多数操作系统而言,其处理效率会大幅提升。

2)文件锁定

文件锁定机制允许一个进程阻止其他进程存取某文件,或限制其存取方式。通常的用途是控制 共享信息的更新方式,或用于事务隔离。在控制多个实体并行访问共同资源方面,文件锁定是必不 可少的。数据库等复杂应用严重信赖于文件锁定。

“文件锁定”从字面上看有锁定整个文件的意思(通常的确是那样),但锁定往往可以发生在更 为细微的层面,锁定区域往往可以细致到单个字节。锁定与特定文件相关,开始于文件的某个特定 字节地址,包含特定数量的连续字节。这对于协调多个进程互不影响地访问文件不同区域,是至关 重要的。

文件锁定有两种方式:共享的和独占的。多个共享锁可同时对同一文件区域发生作用;独占锁 则不同,它要求相关区域不能有其他锁定在起作用。

共享锁和独占锁的经典应用,是控制最初用于读取的共享文件的更新。某个进程要读取文件, 会先取得该文件或该文件部分区域的共享锁。第二个希望读取相同文件区域的进程也会请求共享 锁。两个进程可以并行读取,互不影响。但是, 假如有第三个进程要更新该文件,它会请求独占 锁。该进程会处于阻滞状态,直到既有锁定(共享的、独占的)全部解除。一旦给予独占锁,其他 共享锁的读取进程会处于阻滞状态,直到独占锁解除。这样,更新进程可以更改文件,而其他读取 进程不会因为文件的更改得到前后不一致的结果。图 1-7 和图 1-8 描述了这一过程。

 

 

文件锁有建议使用和强制使用之分。建议型文件锁会向提出请求的进程提供当前锁定信息,但 操作系统并不要求一定这样做,而是由相关进程进行协调并关注锁定信息。多数 Unix 和类 Unix 操 作系统使用建议型锁,有些也使用强制型锁或兼而有之。

强制型锁由操作系统或文件系统强行实施,不管进程对锁的存在知道与否,都会阻止其对文件 锁定区域的访问。微软的操作系统往往使用的是强制型锁。假定所有文件锁均为建议型,并在访问 共同资源的各个应用程序间使用一致的文件锁定,是明智之举,也是唯一可行的跨平台策略。依赖 于强制文件锁定的应用程序,从根子上讲就是不可移植的。

摘自JAVA NIO(中文版)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章