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的全过程可总结为以下几步:
- ·确定请求的数据分布在文件系统的哪些页(磁盘扇区组)。磁盘上的文件内容和元数据可能跨越多个文件系统页,而且这些页可能也不连续。
- 在内核空间分配足够数量的内存页,以容纳得到确定的文件系统页。
- 在内存页与磁盘上的文件系统页之间建立映射。
- 为每一个内存页产生页错误。
- 虚拟内存系统俘获页错误,安排页面调入,从磁盘上读取页内容,使页有效。
- 一旦页面调入操作完成,文件系统即对原始数据进行解析,取得所需文件内容或属性信息。
需要注意的是,这些文件系统数据也会同其他内存页一样得到高速缓存。对于随后发生的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 DatagramChannel