Java NIO 教程

Posted sp42a

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java NIO 教程相关的知识,希望对你有一定的参考价值。

前言

计算机毫无用处,它只能给你答案。
——巴伯罗·毕加索

本书介绍了 Java 平台上的高级输入/输出,具体点说,就是使用 Java 2标准版(J2SE)软件开发包(SDK)1.4及以后版本进行的输入/输出。J2SE 1.4版代号 Merlin,包含可观的 I/O 新特性,对此我们将作详细论述。这些新的 I/O 特性主要包含在 java.nio软件包及其子包中,并被命名为 New I/O(NIO)。通过本书,您将学会如何使用这些令人兴奋的新特性来极大地提升Java应用程序的 I/O 效率。

Java真正的归宿还在企业应用(何谓“企业应用”,恐怕还没有一个确切的定义),但与本地编译语言相比,Java在I/O领域一直处于劣势,这种情况直到J2SE SDK发布了1.4版以后才有了改观。Java的劣势源于其最大的优势:一次编写,到处运行。Java需要运行于虚拟机(即JVM)之上,为了保证Java字节码在各种JVM部署平台上运行效果一致,作些妥协是必须的。既然需要通用于不同的操作系统平台,那么,某种程度上就必须选择各种平台都接受的处理方案。

最切实地感受到妥协带来的后果的,莫过于I/O领域。虽然Java有一套完备的I/O类,但迄今为止还只是针对通用特性,通常位于高端抽象层,横跨各种操作系统。这些I/O类主要面向流数据,经常为了处理个别字节或字符,就要执行好几个对象层的方法调用。

这种面向对象的处理方法,将不同的I/O对象组合到一起,提供了高度的灵活性,但需要处理大量数据时,却可能对执行效率造成致命伤害。I/O的终极目标是效率,而高效的I/O往往又无法与对象形成一一对应的关系。高效的I/O往往意味着您要选择从A到B的最短路径,而执行大量I/O操作时,复杂性毁了执行效率。

传统Java平台上的I/O抽象工作良好,适应用途广泛。但是当移动大量数据时,这些I/O类可伸缩性不强,也没有提供当今大多数操作系统普遍具备的常用I/O功能,如文件锁定、非块I/O、就绪性选择和内存映射。这些特性对实现可伸缩性是至关重要的,对保持与非Java应用程序的正常交互也可以说是必不可少的,尤其是在企业应用层面,而传统的Java I/O机制却没有模拟这些通用I/O服务。

具体的企业在具体的系统上配置具体的应用,这里无关乎抽象。在现实世界,效率是大事——头等大事。企业购置的用于部署大型应用的计算机系统,其I/O性能异常卓越(系统供应商往往投入巨资进行研发),而Java迄今为止一直无法充分利用这一点。当企业的需求是以最快的速度传送大量数据时,样貌朴实但迅捷的解决方案往往胜过漂亮却动作迟缓的。总之一句话,时间就是金钱。

JDK 1.4是由Java社区进程(Java Community Process)主导发行的首个主要发行版。Java产品的用户和供应商可就Java平台需要引入什么新特性提出要求和建议,JCP(http://jcp.org/)为此提供了手段。本书的主题——Java New I/O(NIO)——就是这样一项提议的产物。Java规范请求#51(JSR 51, http://jcp.org/jsr/detail/51.jsp)包含了对高速、可伸缩I/O特性的详尽描述,借助这一特性,底层操作系统的I/O性能可以得到更好发挥。JSR 51的实现,其结果就是新增类组合到一起,构成了 java.nio 及其子包,以及java.util.regex软件包,同时现存软件包也相应作了几处修改。JCP网站详细介绍了JSR的运作流程,以及NIO从最初的提议到最终实现并发布的演进历程。

随着 Merlin的发布,操作系统强大的I/O特性终于可以借助Java提供的工具得到充分发挥。论及I/O性能,Java再也不逊于任何一款编程语言。

组织形式

本书分六章,每章针对NIO的一个大的方面。第一章讨论一般I/O概念,为以后各章相关论述作了铺垫。第二至第四章论及NIO的核心内容:缓冲区、通道和选择器。接下来介绍新引入的正则表达式API。正则表达式的处理与I/O紧密贴合,亦被纳入JSR 51特征集之内。最后,我们看一下新的可插拔字符集映射系统,这也是NIO和JSR 51的组成部分。

以下概要,是专门为那些迫不及待往前赶的人准备的。

缓冲区(Buffers)

新的Buffer类是常规Java类和通道之间的纽带。原始数据元素组成的固定长度数组,封装在包含状态信息的对象中,存入缓冲区。缓冲区提供了一个会合点:通道既可提取放在缓冲区中的数据(写),也可向缓冲区存入数据供读取(读)。此外,还有一种特殊类型的缓冲区,用于内存映射文件。

第二章将详细讨论缓冲区对象。

通道(Channels)

NIO新引入的最重要的抽象是通道的概念。Channel 对象模拟了通信连接,管道既可以是单向的(进或出),也可以是双向的(进和出)。可以把通道想象成连接缓冲区和I/O服务的捷径。

某些情况下,软件包中的旧类也可以使用通道。为了能够向与文件或套接字关联的通道进行存取,适当的地方都增加了新方法。

多数通道可工作在非阻塞模式下,这意味着更好的可伸缩性,尤其是与选择器一同使用的时候。

通道会在第三章作详细介绍。

文件锁定和内存映射文件(File locking and memory-mapped files)

新的FileChannel对象包含在java.nio.channels软件包内,提供许多面向文件的新特性,其中最有趣的两个是文件锁定和内存映射文件。

在多个进程协同工作的情况下,要协调各个进程对共享数据的访问,文件锁定是必不可少的工具。

将文件映射到内存,这样在您看来,磁盘上的文件数据就像是在内存中一样。这利用了操作系统的虚拟内存功能,无需在内存中实际保留一份文件的拷贝,就可实现文件内容的动态高速缓存。

文件锁定和内存映射文件也在第三章讨论。

套接字(Sockets)

套接字通道类为使用网络套接字实现交互提供了新方法。套接字通道可工作于非阻塞模式,并可与选择器一同使用。因此,多个套接字可实现多路传输,管理效率也比 java.net 提供的传统套接字更高。

三个新套接字通道,即 ServerSocketChannel、SocketChannel 和 DatagramChannel,将在第三章讲到。

选择器(Selectors)

选择器可实现就绪性选择。Selector 类提供了确定一或多个通道当前状态的机制。使用选择器,借助单一线程,就可对数量庞大的活动I/O通道实施监控和维护。

选择器会在第四章作详细介绍。

正则表达式(Regular expressions)

新增的 java.util.regex 软件包将类似Perl语言的正则表达式处理机制引入Java。这一人们期盼已久的特性有着广泛用途。

新的正则表达式API之所以被看成是NIO的组成部分,是因 JSR 51把它与其他NIO特性放在一起作了详细说明。虽然它在许多方面与NIO的其他组成部分缺乏平行关系,但它在文件处理等众多领域都是极其有用的。

第五章讨论JDK 1.4正则表达式API。

字符集(Character sets)

java.nio.charsets 提供了新类用于处理字符与字节流之间的映射关系。您可以对字符转换映射方式进行选择,也可以自己创建映射。

字符代码转换的相关问题在第六章讨论。

目标读者

本书为中高级Java程序员所写:他们熟练掌握这门语言,在完成大规模、复杂的数据处理任务时,有充分利用Java NIO所提供的新特性的愿望和需求。在写作的过程中,我假定您对JDK标准类软件包、面向对象的设计技巧、继承等等都有充分了解。我还假定您了解I/O在操作系统层面的基本工作原理,知道什么是文件,什么是套接字,什么是虚拟内存,诸如此类。第一章就这些概念作了高屋建瓴的回顾,但没有深度剖析。

如果您对Java平台上的I/O软件包尚处于摸索阶段,也许您应该先读一读Elliote Rusty Harold所著《Java I/O》(O’Reilly)(http://www.oreilly.com/catalog/javaio/)。那是一本关于java.io软件包的很好的入门教材。虽然可以把本书看作那本书的后续读物,但两者并无承接关系。本书重点关注的是如何利用新的java.nio软件包实现I/O性能的最大化。另外,本书还引入了一些新的I/O概念,这也超出了java.io软件包的范畴。

我们还探讨了字符集编码和正则表达式,这也是与NIO绑定在一起的新特征集的一部分。为软件国际化或专业应用使用字符集的程序员会对java.nio.charsets软件包感兴趣,第六章会有相关讨论。

如果您已转向Java,但时不时地需要回到Perl处理正则表达式,告诉您,以后再也不用了。新推出的java.util.regex软件包在标准JDK中包含了Perl 5所有的正则表达式功能,个别极其晦涩难懂的除外,此外还有几项创新。

软件及版本

本书描述了Java的I/O性能,尤其是java.nio和java.util.regex两个软件包。这两个软件包首次出现于J2SE 1.4版,所以,您必须拥有Java SDK 1.4(或以后)的可用版本,才能使用本书提供的素材。您可通过访问Sun公司网站http://java.sun.com/j2se/1.4/获得Java SDK。我还会在正文中使用J2SE SDK指代Java开发包(JDK),在本书范围内,二者含义相同。

本书基于2002年2月发布的SDK最终版本1.4.0。早前几个月,1.4 beta版已广为流传。在最终版本即将发布之前,NIO API 作了重要修正。因此,您会发现本书所讲某些细节,与您在最终版本发布之前所知论述存在出入。本书根据所有已知的最后修正作了更新,应该不存在与最终版本1.4.0不一致之处。J2SE的后续版本可能引入与本书相冲突的内容,如果存在疑问,请参考与软件一同发布的技术文档。

简介一

先把事实搞清楚,歪曲是以后的事。
                ——马克·吐温

我们谈谈I/O吧。别走哇,回来!I/O其实没那么枯燥。输入/输出问题(I/O)虽谈不上多吸引人,却很重要。程序员多半把I/O等同于疏通下水管道:无疑很重要,没有不行,但要是直接跟它打交道,就没那么惬意了,搞不好弄得浑身臭哄哄的。本书要讲的可不是管道疏通,但是阅读了随后章节,您就会知道如何让您的数据流动得稍微顺畅一些。

面向对象的程序设计讲的无非就是封装。封装是个好东西:它分解任务,隐藏实施细节,提高对象的重复利用率。这样的分解、整合既适用于程序,也适用于程序员。您没准是一位技艺高超的Java程序员,创建极其复杂的对象,完成惊世骇俗的任务,而对支撑Java平台的基本I/O概念却几乎一无所知。本章,我们暂且把封装问题抛在一边,先来看看某些底层I/O实施细节,希望有助于您更好地组织协调各个零部件的I/O操作。

I/O与CPU时间的比较

程序员多半当自个儿是软件大师,设计出精巧的例程,这儿压缩几个字节,那儿解开一个循环,要不就在别处作些调整,让对象更加牢固。这些事情当然很重要,乐趣也不少,但是代码优化所带来的回报,可能轻易就被低效的I/O所抵销。I/O操作比在内存中进行数据处理任务所需时间更长,差别要以数量级计。许多程序员一门心思扑在他们的对象如何加工数据上,对影响数据取得和存储的环境问题却不屑一顾。

表1-1所示为对数据单元进行磁盘读写所需时间的假设值。第一列为处理一个数据单元所需平均时间,第二列为对该数据单元进行磁盘读写所需时间,第三列为每秒所能处理的数据单元数,第四列为改变第一第二列的值所能产生的数据吞吐率的提升值。

表1-1. 处理时间与I/O时间对吞吐率的影响比较

前三行显示了处理阶段的效率提升会如何影响吞吐率。把单位处理时间减半,仅能提高吞吐率2.2%。而另一方面,仅仅缩短I/O延迟10%,就可使吞吐率增加9.7%;把I/O时间减半,吞吐率几乎翻番。当您了解到I/O花在一个数据单元上的时间是处理时间的20倍,这样的结果就不足为奇了。

表中所列并非真实数据,目的只在说明相对时间度量,现实情况绝非如此简单。正如您所看到的,影响应用程序执行效率的限定性因素,往往并非处理速率,而是I/O。程序员热衷于调试代码,I/O性能的调试往往被摆在第二位,甚至完全忽略。殊不知,在I/O性能上的小小投入就可换来可观的回报,想来实在令人惋惜。

CPU已不再是束缚

Java程序员把全部精力用在优化处理效率上,而对I/O关注不足,在某种程度上讲这并非他们的错。在Java的早期,JVM在解释字节码时往往很少或没有运行时优化。这就意味着,Java程序往往拖得很长,其运行速率大大低于本地编译代码,因而对操作系统I/O子系统的要求并不太高。

如今在运行时优化方面,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效率的问题了,否则,您精心打造的代码多数时间都得闲着。

进入正题

操作系统研发人员将大量精力投入到提升I/O性能上。众多高手日以继夜地工作,只为完善数据传输技术。操作系统开发商为了取得竞争优势,投入大量时间、金钱,以便在测试数据上胜过竞争对手。

当今的操作系统是现代软件工程的奇迹(没错,有的比奇迹还奇迹),可是Java程序员如何能够既利用操作系统的强大功能,又保持平台独立性?唉,天下没有免费的午餐,此为一例。

JVM是把双刃剑。它提供了统一的操作环境,让Java程序员不用再为操作系统环境的区别而烦恼。与特定平台相关的细枝末节大都被隐藏了起来,因而代码写得又快又容易。但是隐藏操作系统的技术细节也意味着某些个性鲜明、功能强大的特性被挡在了门外。

怎么办呢?如果您是程序员,可以使用Java本地接口(JNI)编写本地代码,直接使用操作系统特性。这样的话,您就被绑定在该操作系统上(也许还是其特定版本上)。如果您的本地代码不是100%无漏洞,您还可能把JVM置于频繁出错乃至崩溃的境地。如果您是操作系统开发商,则可以在您的JVM实现中包含本地代码,以Java API的形式提供这些特性。但这样做可能违反您所签署相关许可协议,根据协议,您只能提供符合一致性要求的JVM。Sun曾就此问题将Microsoft告上法庭,因为很明显,JDirect软件包只能在微软的系统上运行。如果以上方法都行不通,那么您只好转向其他语言,以实现对性能要求极为苛刻的应用。

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

随着NIO的面世,Java已经为严肃的商业、娱乐、科研和学术应用做好了准备。在这些领域,高性能I/O是必不可少的。

除了NIO,JDK 1.4还包含许多其他重要改进。从1.4版开始,Java平台已进入高度成熟期,它仍无法涉足的应用领域已所剩无几。David Flanagan所著《Java技术手册》(第四版)(Java in a Nutshell, Fourth Edition [O’Reilly])是全面了解JDK 1.4各方面特性的绝佳向导。

简介二

I/O 概念

Java平台提供了一整套I/O隐喻,其抽象程度各有不同。然而,离冰冷的现实越远,要想搞清楚来龙去脉就越难——不管使用哪一种抽象,情况都是如此。JDK 1.4的NIO软件包引入了一套新的抽象用于I/O处理。与以往不同的是,新的抽象把重点放在了如何缩短抽象与现实之间的距离上面。NIO抽象与现实中存在的实体有着非常真实直接的交互关系。要想最大限度地满足Java应用程序的密集I/O需求,理解这些新的抽象,以及与其发生交互作用的I/O服务(其重要性并不亚于抽象),正是关键所在。

本书假定您熟知基本的I/O概念,因此,本节将快速回顾一些基本概念,为下一步论述新的NIO类如何运作奠定基础。NIO类模拟I/O函数,因此,必须掌握操作系统层面的处理细节,才能理解新的I/O模型。

在阅读本书的过程中,理解以下概念是非常重要的:

  • 缓冲区操作
  • 内核空间与用户空间
  • 虚拟内存
  • 页技术
  • 面向文件的I/O和流I/O
  • 多工I/O(就绪性选择)

缓冲区操作

缓冲区,以及缓冲区如何工作,是所有I/O的基础。所谓“输入/输出”讲的无非就是把数据移进或移出缓冲区。

进程执行I/O操作,归结起来,也就是向操作系统发出请求,让它要么把缓冲区里的数据排干(写),要么用数据把缓冲区填满(读)。进程使用这一机制处理所有数据进出操作。操作系统内部处理这一任务的机制,其复杂程度可能超乎想像,但就概念而言,却非常直白易懂。

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

图1-1. I/O缓冲区操作简图

图中明显忽略了很多细节,仅显示了涉及到的基本步骤。

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

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

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

发散/汇聚

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

图1-2. 三个缓冲区的发散读操作

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

虚拟内存

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

  • 一个以上的虚拟地址可指向同一个物理内存地址。
  • 虚拟内存空间可大于实际可用的硬件内存。

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

图1-3. 内存空间多重映射

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

图1-4. 内存页

内存页面调度

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

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


图1-5. 用于分页区高速缓存的物理内存

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

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

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

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

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

文件I/O

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

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

前一节讲到,所有I/O都是通过请求页面调度完成的。您应该还记得,页面调度是非常底层的操作,仅发生于磁盘扇区与内存页之间的直接传输。而文件I/O则可以任意大小、任意定位。那么,底层的页面调度是如何转换为文件I/O的?

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

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

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

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

  1. ·确定请求的数据分布在文件系统的哪些页(磁盘扇区组)。磁盘上的文件内容和元数据可能跨越多个文件系统页,而且这些页可能也不连续。
  2. 在内核空间分配足够数量的内存页,以容纳得到确定的文件系统页。
  3. 在内存页与磁盘上的文件系统页之间建立映射。
  4. 为每一个内存页产生页错误。
  5. 虚拟内存系统俘获页错误,安排页面调入,从磁盘上读取页内容,使页有效。
  6. 一旦页面调入操作完成,文件系统即对原始数据进行解析,取得所需文件内容或属性信息。

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

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

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

内存映射文件

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

图1-6. 用户内存到文件系统页的映射

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

  • 用户进程把文件数据当作内存,所以无需发布read( )或write( )系统调用。
  • 当用户进程碰触到映射内存空间,页错误会自动产生,从而将文件数据从磁盘读进内存。如果用户修改了映射内存空间,相关页会自动标记为脏,随后刷新到磁盘,文件得到更新。
  • 操作系统的虚拟内存子系统会对页进行智能高速缓存,自动根据系统负载进行内存管理。
  • 数据总是按页对齐的,无需执行缓冲区拷贝。
  • 大型文件使用映射,无需耗费大量内存,即可进行数据拷贝。

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

文件锁定

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

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

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

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

图1-7. 共享锁阻断独占锁请求

图1-8. 独占锁阻断共享锁请求

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

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

流I/O

并非所有I/O都像前几节讲的是面向块的,也有流I/O,其原理模仿了通道。I/O字节流必须顺序存取,常见的例子有TTY(控制台)设备、打印机端口和网络连接。

流的传输一般(也不必然如此)比块设备慢,经常用于间歇性输入。多数操作系统允许把流置于非阻塞模式,这样,进程可以查看流上是否有输入,即便当时没有也不影响它干别的。这样一种能力使得进程可以在有输入的时候进行处理,输入流闲置的时候执行其他功能。

比非阻塞模式再进一步,就是就绪性选择。就绪性选择与非阻塞模式类似(常常就是建立在非阻塞模式之上),但是把查看流是否就绪的任务交给了操作系统。操作系统受命查看一系列流,并提醒进程哪些流已经就绪。这样,仅仅凭借操作系统返回的就绪信息,进程就可以使用相同代码和单一线程,实现多活动流的多路传输。这一技术广泛用于网络服务器领域,用来处理数量庞大的网络连接。就绪性选择在大容量缩放方面是必不可少的。

小结

本章概述了系统层面的I/O,只是一带而过,肯定很不全面。如果需要更加详尽地了解相关内容,可以找本好的参考书,有不少的。我过去的老板Avi Silberschatz所著《操作系统概念》(第六版)(Operating System Concepts, Sixth Edition [John Wiley & Sons])是本权威的操作系统教科书,从这本书出发是个不错的选择。

通过前面的讲述,您应当对后面各章所涵盖内容有了大致的了解。带上您的知识储备,随我前往核心主题:Java New I/O(NIO)。学习了新的NIO抽象,也别忘了脑子里的具体概念。理解了这些基本概念,才能更容易地识别新类所模拟之I/O性能。

NIO学习之旅即将启程。发动机在轰鸣,一切都已就绪。快上来吧,找个位子,舒舒服服坐好,让我们立刻开拔。

以上是关于Java NIO 教程的主要内容,如果未能解决你的问题,请参考以下文章

Java NIO系列教程 Java NIO 概述

Java NIO系列教程 Java NIO 概述

Java NIO系列教程 Java NIO与IO

Java NIO系列教程 Java NIO DatagramChannel

Java NIO系列教程 Java NIO DatagramChannel

Java NIO系列教程 Java NIO 概述