简介

JDK1.4,以后的版本提供了java.nio包,具体说是Channel和Selector类。

I/O概念

缓冲区操作

缓冲区操作 缓冲区是所有I/O的基础。输入/输出讲的无非就是把数据移进或移出缓冲区。进程进行IO归结起来就是向操作系统发出请求,让它要么把缓冲区里的数据排干(写),要么把数据缓冲区填满(读)。

进程使用read()系统调用时。内核向磁盘控制硬件发出命令,要求其从磁盘读取数据。磁盘控制器把数据直接写进内核内存缓存区,这一步通过DMA完成,无需CPU协助。一旦磁盘控制器把缓冲区装满,内核即把数据从内核空间的临时缓冲区拷贝到进程执行read()调用时指定的缓冲区。

发散/汇聚

进程只需要一个系统调用,就能把一连串缓冲区地址传递给操作系统。然后,内核就可以顺序填充或排干多个缓冲区,读的时候就把数据发散到多个用户空间缓冲区,写的时候再从多个缓冲区把数据汇聚起来。这样用户进程就不必执行多次系统调用。

虚拟内存

使用虚拟内存的好处:

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

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

前面讲过一的设备控制器不能通过DMA直接存储到用户空间,但是通过利用上面提到的第一项可以把内核空间地址与用户空间地址映射到同一物理地址。这样DMA就可以填充对内核与用户进程同时可见的缓冲区。

内存页面调度

为支持虚拟内存的第二个特性,必须进行虚拟内存分页。按这个方案,虚拟内存空间的页面能够继续存在于外部磁盘存储,这样就为物理内存中的其它虚拟页面腾出了空间。从本质上说,物理内存充当了分页区的调整缓存;而所谓分页区,即从物理内存置换出来,转而存储于磁盘上的内存页面。

文件I/O

文件I/O属于文件系统范畴,与磁盘迥然不同。它是更高层次的抽象,是安排、解释磁盘数据的一种独特方式。

内存映射文件

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

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

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

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

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

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

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

文件锁定

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

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

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

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

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

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

流I/O

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

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

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

缓冲区

一个Buffer对象是固定数量的数据的容器。其作用是一个存储器,或者分段运输区,在这里数据可以被存储并在之后用于检索。缓冲区如我们在前一章讨论的那样被写满和释放。对于每个非布尔原始数据类型都有一个缓冲区类。尽管缓冲区作用于它们存储的原始数据类型,但缓冲区十分人民币于处理字节。非字节缓冲区可以在后台执行从字节或到字节的转换,这取决缓冲区是如何创建的。

缓冲区的工作与通道紧密联系。通道是I/O传输发生时通过的入口,而缓冲区是这些数据传输的来源或目标。对于离开缓冲区的传输,你想传递出去的数据被置于一个缓冲区,被传送到通道。对于传回缓冲区的传输,一个通道将数据旋转在你所提供的缓冲区中。

缓冲区基础

缓冲区属性

  • 容量(Capacity):缓冲区能容纳的数据元素的最大数量。在创建时被设定,并且不能被改变。

  • 上界(Limit):缓冲区的第一个不能被读或写的元素。或者说,缓冲区中现存元素的计数。

  • 位置(Position):下一个要被读或写的元素的索引。位置会自动由相应的get()和put()函数更新。

  • 标记(Mark):备忘位置。调用mark()来设定mark=position。调用reset()设定position=mark。标记在设定前是未定义的(undefined)。

缓冲区API

  • 存取:get()和put()函数。可以是绝对位置也可以是相对位置。相对方案是不带索引参数的函数。当相对函数被调用时,position在返回时增加1.如果position前进过多,相对操作就会抛出异常。对于put(),如果位置超出上界,就会抛出BufferOverflowException异常。对于get(),如果位置不小于limit,就会抛出BufferUnderflowException异常。绝对存取不影响缓冲区的position属性,但是如果提供的索引超出范围(负数或不小于limit),也将抛出IndexOutOfBoundsException异常。

  • 填充:put()函数。

  • 翻转:flip()函数。将缓冲区从填充状态转换到释放状态。当我们写满缓冲区时,必须准备将其清空。我们想把这个缓冲区传递给一个通道,以使内容能被全部写出。但是如果通道现在在缓冲区上执行get(),那么它将从我们刚刚插入的有用数据之外取出未定义数据。如果我们将位置重新设置为0,通道就会从正确位置开始获取,直到limit位置。limit位置指明了缓冲区有效内容的末端。因此我们需要limit设置为当前位置,然后将position重置为0。flip()操作实现的功能类似于buffer.limit(buffer.position()).position(0);

  • 释放:如果接收到一个在别处被填满的缓冲区,你可能需要在检索内容之前将其翻转。布尔函数hasRemaining()会在释放缓冲区时告诉你是否已经达到缓冲区的limit。remainig()函数将告诉你从当前位置到limit还剩余的元素数量。后一种方法通常会更高效,因为不需要总是检查limit。一旦缓冲区对象完成填充并释放,它就可以被重新使用。clear()函数将缓冲区重置为空状态。它并不改变缓冲区中的任何数据元素,而是仅仅将limit设为容量值,并把position设置回0。这使得缓冲区可以重新填入。

  • 压缩:从缓冲区释放一部分数据,而不是全部,然后重新填充。为实现一点,未读的数据元素需要下移以使第一个元素索引为0.尽管这样重复会效率低下,但这有时非常必要。API中提供了compact函数。这一缓冲区工具在复制数据时会比直接使用get()和put()函数高效得多。compact()操作完成后,position指向复制的数据的末端,position后面可能仍然存在复制之前的数据,但它们将会在下次put()时被覆盖。如果在compact()操作后要读取数据,则也需要先调用flip()。

  • 标记:mark属性,在mak()函数被调用前是未定义的,调用后被设置为当前position的值。reset()函数调用后将position设置为mark值。如果mark值未定义,调用reset()将导致InvalidMarkException异常。一些缓冲区函数会抛弃已经设定的标记(rewind(),clear()及flip()总是抛弃标记)。如果新设定的值比当前的mark小,调用limit()或position()带有索引参数的版本会抛弃标记。

  • 比较:所有缓冲区都提供了一个常规的equals函数以测试两个缓冲区是否相等,以及一个compareTo函数用以比较缓冲区,缓冲区也支持用compareTo()函数以词典顺序进行比较。缓冲区被认为相等的条件是:

  • 两个对象类型相等。包含不同数据类型的buffer永远不会相等,而且buffer绝不会等于非buffer对象。

  • 两个对象都剩余相同数量的元素。Buffer的容量不需要相同,而且缓冲区中剩余数据的索引也不必相同。但每个缓冲区中剩余元素的数目(从position到limit)必须相同。

  • 在每个缓冲区中应被get()函数返回的剩余数据元素序列必须一致。

  • 批量移动:有两种形式的get()可以从缓冲区到数组进行数据复制。第一种形式只将一个数组作为参数,将一个缓冲区释放到的数组。第二种形式使用offset和length参数来指定目标数组的子区间。如果你的数量的数据不能被传送,那么不会有数据被传递,缓冲区状态保持不变,同时抛出BufferUnderflowException异常。因此在get()之前必须查询缓冲区中的元素数量(remaining())。

1
2
3
4
5
6
7
8
char [] smallArray = new char [10];  
  
while (buffer.hasRemaining(  )) {  
        int length = Math.min (buffer.remaining(  ), smallArray.length);  
  
        buffer.get (smallArray, 0, length);  
        processData (smallArray, length);  
} 

put()的批量版本工作方式相似,但以相反的方向移动数据,从数据移动到缓冲区。如果缓冲区有足够的空间接受数组中的数据(buffer.remaining()>myArray.length),数据将会被复制到从当前位置开始的缓冲区,并且缓冲区position会被增加所增加数据元素的数量。如果缓冲区中没有足够的空间,那么不会有数据被传递,同时抛出一个BufferOverflowException异常。

创建缓冲区

每种Java语言中的非布尔类型的原始数据类型都对应有缓冲区类。MappedByteBuffer是ByteBuffer专门用于内存映射文件的一种特例。这些类没有一种能直接实例化。都是抽象类,但都包含静态工厂方法用来创建相应类的实例,通常是allocate静态方法。缓冲区类也提供了wrap()函数可以用您自己提供的数组当作缓冲区的备份存储器。

复制缓冲区

duplicate()函数创建一个与原始缓冲区类似的新缓冲区。两个缓冲区共享数据元素,拥有同样的容量,但每个缓冲区拥有各自的position、limit和mark属性。对一个缓冲区内数据元素所做的改变会反映在另外一个缓冲区上。这一副本缓冲区具有与原始缓冲区同样的数据视图。如果原始的缓冲区为吟诗,或者为直接缓冲区,新的缓冲区将继承这些属性。

分割缓冲区与复制相似,slice()创建一个从原始缓冲区的当前postion开始的新缓冲区,并且其容量是原始缓冲区剩余元素数量(limit-position)。这个新缓冲区与原始缓冲区共享一段数据元素子序列。分割出来的缓冲区也会继承只读和直接属性。

字节缓冲区

字节缓冲区有自己独特之处。字节是操作系统及其I/O设备使用的基本数据类型。当JVM和操作系统间传递数据时,将其他的数据类型拆分成构成它们的字节是十分必要的。