java nio 基础(一)

来源:互联网 发布:网络金融最初发展阶段 编辑:程序博客网 时间:2024/06/08 01:50

如今JVM在执行效率方面有了很大的进步,I/O操作逐渐成为限制java效率的主要因素。其实现代操作系统在传送数据方面也有着较高的效率,这通常是在DMA的协助下完成的。但是JVM的I/O类往往一次只处理少量数据。结果,操作系统送来整缓冲区的数据,java.io 的流数据类再花大量时间把它们拆成小块,往往拷贝一个小块就要往返于几层对象。操作系统喜欢整卡车地运来数据,java.io 类则喜欢一铲子一铲子地加工数据。有了 NIO,就可以轻松地把一卡车数据备份到您能直接使用的地方(ByteBuffer 对象)这并不是说使用传统的 I/O 模型无法移动大量数据——当然可以(现在依然可以)。具体地说,RandomAccessFile 类在这方面的效率就不低,只要坚持使用基于数组的 read( )和 write( )方法这些方法与底层操作系统调用相当接近,尽管必须保留至少一份缓冲区拷贝。

当进程请求 I/O 操作的时候,它执行一个系统调用(有时称为陷阱)将控制权移交给内核。当内核找到进程所需数据,并把数据传送到用户空间内的指定缓冲区。内核试图对数据进行高速缓存或预读取,因此进程所需数据可能已经在内核空间里了。如果是这样,该数据只需简单地拷贝出来即可。如果数据不在内核空间,则进程被挂起,内核着手把数据读进内存。

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

为了在内核空间的文件系统页与用户空间的内存区之间移动数据,一次以上的拷贝操作几乎总是免不了的。这是因为,在文件系统页与用户缓冲区之间往往没有

一一对应关系。内存映射 I/O解决了这一个问题,它完全摒弃缓冲区拷贝。内存映射 I/O 使用文件系统建立从用户空间直接到可用文件系统页的虚拟内存映射。
这样做有几个好处:

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

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

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

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

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

在处理大量数据时,尤其要记得这一点。如果数据缓冲区是按页对齐的,且大小是内建页大小的倍数,那么,对大多数操作系统而言,其处理效率会大幅提升(如上面蓝色部分介绍的,省去了数据的处理工作,可以不需要缓冲区拷贝)。

Channel的ByteBuffer也是经过了内核空间的缓冲区的拷贝的,二者的实现是类似的(我目前是这么理解的)。采用BufferedInputStream和Channel两种方式读取500M的文件(Channel与BufferedInputStream都使用1M的缓冲区),前者用了10s,后者用了8s(具体原因细节还不太清楚)。而内存映射文件是真正没有经过内核缓冲区拷贝的。但这不表明内存映射文件的效率就一定比前二者高。复制一个文件发现使用Channel到Channel方式的拷贝大大高于内存映射文件的方式。使用BufferedInputStream和Channel的方式复制一个文件效率在合理选择缓冲区大小的情况下也可以高于内存映射文件(考虑缺页中断的开销与内核缓冲区到用户缓冲区拷贝开销的一个平衡点),具体原因从上面的介绍中可以找到。

上面的是自己在读书的过程中遇到的一些知识点和一些思考,下面介绍java NIO的基础知识。

java NIO 的核心内容:缓冲区、通道和选择器。

一、缓冲区

Buffer的基本用法

使用Buffer读写数据一般遵循以下四个步骤:

  1. 写入数据到Buffer
  2. 调用flip()方法
  3. 从Buffer中读取数据
  4. 调用clear()方法或者compact()方法

当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。

Buffer的capacity,position和limit

缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。

为了理解Buffer的工作原理,需要熟悉它的三个属性:

  • capacity
  • position
  • limit

position和limit的含义取决于Buffer处在读模式还是写模式。不管Buffer处在什么模式,capacity的含义总是一样的。

capacity

作为一个内存块,Buffer有一个固定的大小值,也叫“capacity”.你只能往里写capacity个byte、long,char等类型。一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。

position

当你写数据到Buffer中时,position表示当前的位置。初始的position值为0.当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1.

当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0. 当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。

limit

在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。

当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,你能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)

Buffer的类型

Java NIO 有以下Buffer类型

ByteBuffer MappedByteBufferCharBuffer DoubleBufferFloatBuffer IntBufferLongBuffer ShortBuffer

这些Buffer类型代表了不同的数据类型。换句话说,就是可以通过char,short,int,long,float 或 double类型来操作缓冲区中的字节。

Buffer的分配

要想获得一个Buffer对象首先要进行分配。 每一个Buffer类都有一个allocate方法。下面是一个分配48字节capacity的ByteBuffer的例子。

ByteBuffer buff = ByteBuffer.allocate(48);

提供您自己的数组用做缓冲区的备份存储器,请调用wrap()函数

char [] myArray = new char [100]; 

CharBuffer charbuffer = CharBuffer.wrap (myArray);
CharBuffer charbuffer  =CharBuffer.wrap (myArray, 12, 42);//position=12,limit=54

分配一个1024字节的直接缓冲区

ByteBuffer buff = ByteBuffer.allocateDirect(1024);
I/O操作的目标内存区域必须是连续的字节序列。在JVM中,字节数组可能不会在内存中连续存储,或者无用存储单元收集可能随时对其进行移动。在Java中,数组是对象,而数据存储在对象中的方式在不同的JVM实现中都各有不同。出于这一原因,引入了直接缓冲区的概念。直接缓冲区时I/O的最佳选择,但可能比创建非直接缓冲区要花费更高的成本。直接缓冲区使用的内存是通过调用本地操作系统方面的代码分配的,绕过了标准JVM堆栈。建立和销毁直接缓冲区会明显比具有堆栈的缓冲区更加破费,这取决于主操作系统以及JVM实现。直接缓冲区的内存区域不受无用存储单元收集支配,因为它们位于标准JVM堆栈之外。通过分配堆栈外的内存,您可以使您的应用程序依赖于JVM未涉及的其它力量

向Buffer中写数据

写数据到Buffer有两种方式:

  • 从Channel写到Buffer。
  • 通过Buffer的put()方法写到Buffer里。

从Channel写到Buffer的例子


int bytesRead = inChannel.read(buf); //read into buffer.

通过put方法写Buffer的例子:


buf.put(127);

put方法有很多版本,允许你以不同的方式把数据写入到Buffer中。例如, 写到一个指定的位置,或者把一个字节数组写入到Buffer。 更多Buffer实现的细节参考JavaDoc。

flip()方法

flip方法将Buffer从写模式切换到读模式。调用flip()方法会将position设回0,并将limit设置成之前position的值。

rewind()方法

Buffer.rewind()将position设回0,所以你可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素(byte、char等)。

从Buffer中读取数据

从Buffer中读取数据有两种方式:

  1. 从Buffer读取数据到Channel。
  2. 使用get()方法从Buffer中读取数据。

从Buffer读取数据到Channel的例子:

1//read from buffer into channel.
2int bytesWritten = inChannel.write(buf);

使用get()方法从Buffer中读取数据的例子

1byte aByte = buf.get();

get方法有很多版本,允许你以不同的方式从Buffer中读取数据。例如,从指定position读取,或者从Buffer中读取数据到字节数组。更多Buffer实现的细节参考JavaDoc。

clear()与compact()方法

一旦读完Buffer中的数据,需要让Buffer准备好再次被写入。可以通过clear()或compact()方法来完成。

如果调用的是clear()方法,position将被设回0,limit被设置成 capacity的值。换句话说,Buffer 被清空了。Buffer中的数据并未清除,只是这些标记告诉我们可以从哪里开始往Buffer里写数据。

如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先先写些数据,那么使用compact()方法。

compact()方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。这一缓冲区工具在复制数据时要比您使用get()和put()函数高效得多。所以当您需要时,请使用compact()。

mark()与reset()方法

通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position。例如:

1buffer.mark();
2
3//call buffer.get() a couple of times, e.g. during parsing.
4
5buffer.reset();  //set position back to mark.

equals()与compareTo()方法

可以使用equals()和compareTo()方法两个Buffer。

equals()

当满足下列条件时,表示两个Buffer相等:

  1. 有相同的类型(byte、char、int等)。
  2. Buffer中剩余的byte、char等的个数相等。
  3. Buffer中所有剩余的byte、char等都相同。

如你所见,equals只是比较Buffer的一部分,不是每一个在它里面的元素都比较。实际上,它只比较Buffer中的剩余元素(position 到limit之间的)。

compareTo()方法

compareTo()方法比较两个Buffer的剩余元素(byte、char等), 如果满足下列条件,则认为一个Buffer“小于”另一个Buffer:

  1. 第一个不相等的元素小于另一个Buffer中对应的元素 。
  2. 所有元素都相等,但第一个Buffer比另一个先耗尽(第一个Buffer的元素个数比另一个少)。

批量移动

缓冲区的涉及目的就是为了能够高效传输数据。一次移动一个数据元素并不高效。buffer API提供了向缓冲区内外批量移动数据元素的函数。

ByteBufferget(byte[] dst)ByteBufferget(byte[] dst, int offset, int length)ByteBufferput(byte[] src)ByteBufferput(byte[] src, int offset, int length)这些批量移动的合成效果与循环的方式是相同的,但是这些方法可能高效得多,因为这种缓冲区实现能够利用本地代码或其他的优化来移动数据

如果您所要求的数量的数据不能被传送,那么不会有数据被传递,缓冲区的状态保持不变,同时抛出BufferUnderflowException异常。要将一个缓冲区释放到一个大数组中,要这样做:

char [] bigArray = new char [1000]; 

int length = buffer.remaining( ); // Get count of chars remaining in the buffer 

chars buffer.get (bigArrray, 0, length); // Buffer is known to contain < 1,000 

processData (bigArray, length);// Do something useful with the data 

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

复制缓冲区

当一个管理其他缓冲器所包含的数据元素的缓冲器被创建时,这个缓冲器被称为视图缓冲器。复制一个缓冲区会创建一个新的Buffer对象,但并不复制数据。原始缓冲区和副本都会操作同样的数据元素,但每个缓冲区拥有各自的位置,上界和标记属性对一个缓冲区内的数据元素所做的改变会反映在另外一个缓冲区上

CharBuffer buffer = CharBuffer.allocate (8); 

buffer.position (3).limit (6).mark( ).position (5); 

CharBuffer dupeBuffer = buffer.duplicate( ); 

buffer.clear( );
还可以使用asReadOnlyBuffer()函数来生成一个只读的缓冲区视图。这与duplicate()相同,除了这个新的缓冲区不允许使用put(),并且其isReadOnly()函数将会返回true。

如果一个只读的缓冲区与一个可写的缓冲区共享数据,或者有包装好的备份数组,那么对这个可写的缓冲区或直接对这个数组的改变将反映在所有关联的缓冲区上,包括只读缓冲区。

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

视图缓冲区

视图缓冲区通过已存在的缓冲区对象实例的工厂方法来创建。这种视图对象维护它自己的属性,容量,位置,上界和标记,但是和原来的缓冲区共享数据元素。但是ByteBuffer类允许创建视图来将byte型缓冲区字节数据映射为其它的原始数据类型。例如,asLongBuffer()函数创建一个将八个字节型数据当成一个long型数据来存取的视图缓冲区(这里还有个字节序的问题)。下面的代码创建了一个ByteBuffer缓冲区的CharBuffer视图:

ByteBuffer byteBuffer = ByteBuffer.allocate (7).order (ByteOrder.BIG_ENDIAN); 

CharBuffer charBuffer = byteBuffer.asCharBuffer( );

数据元素视图

ByteBuffer类提供了一个不太重要的机制来以多字节数据类型的形式存取byte数据组。ByteBuffer类为每一种原始数据类型提供了存取的和转化的方法。

根据这个缓冲区的当前的有效的字节顺序,这些字节数据会被排列或打乱成需要的原始数据类型。比如说,如果getInt()函数被调用,从当前的位置开始的四个字节会被包装成一个int类型的变量然后作为函数的返回值返回

二、通道Channel

通道是一种途径,借助该途径,可以用最小的总开销来访问操作系统本身的I/O服务。缓冲区则是通道内部用来发送和接收数据的端点。多数情况下,通道与操作系统的文件描述符(File Descriptor)和文件句柄(File Handle)有着一对一的关系。虽然通道比文件描述符更广义,但您将经常使用到的多数通道都是连接到开放的文件描述符的。Channel类提供维持平台独立性所需的抽象过程,不过仍然会模拟现代操作系统本身的I/O性能。
通道引入了一些与关闭和中断有关的新行为。如果一个通道实现InterruptibleChannel接口,它的行为以下述语义为准:如果一个线程在一个通道上被阻塞并且同时被中断(由调用该被阻塞线程的interrupt( )方法的另一个线程中断),那么该通道将被关闭,该被阻塞线程也会产生一个ClosedByInterruptException异常。当一个通道被关闭时,休眠在该通道上的所有线程都将被唤醒并接收到一个AsynchronousCloseException异常。

这些是Java NIO中最重要的通道的实现(有一个FileChannel类和三个socket通道类):

  • FileChannel//从文件中读写数据
  • DatagramChannel// 能通过UDP读写网络中的数据
  • SocketChannel//能通过TCP读写网络中的数据ServerSocketChannel//可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个
  • SocketChannel//可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel

1.FileChannel

FileChannel无法设置为非阻塞模式,它总是运行在阻塞模式下。

打开FileChannel

在使用FileChannel之前,必须先打开它。但是,我们无法直接打开一个FileChannel,需要通过使用一个InputStream、OutputStream或RandomAccessFile来获取一个FileChannel实例。下面是通过RandomAccessFile打开FileChannel的示例:

RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");FileChannel inChannel = aFile.getChannel();


从FileChannel读取数据

调用多个read()方法之一从FileChannel中读取数据。如:

ByteBuffer buf = ByteBuffer.allocate(48);int bytesRead = inChannel.read(buf);

首先,分配一个Buffer。从FileChannel中读取的数据将被读到Buffer中。

然后,调用FileChannel.read()方法。该方法将数据从FileChannel读取到Buffer中。read()方法返回的int值表示了有多少字节被读到了Buffer中。如果返回-1,表示到了文件末尾。

向FileChannel写数据

使用FileChannel.write()方法向FileChannel写数据,该方法的参数是一个Buffer。如:

String newData = "New String to write to file..." + System.currentTimeMillis();ByteBuffer buf = ByteBuffer.allocate(48);buf.clear();buf.put(newData.getBytes());buf.flip();while(buf.hasRemaining()) {channel.write(buf);}

注意FileChannel.write()是在while循环中调用的。因为无法保证write()方法一次能向FileChannel写入多少字节,因此需要重复调用write()方法,直到Buffer中已经没有尚未写入通道的字节。

关闭FileChannel

用完FileChannel后必须将其关闭。如:

channel.close();

FileChannel的position方法

同底层的文件描述符一样,每个FileChannel都有一个叫“file position”的概念。这个position值决定文件中哪一处的数据接下来将被读或者写。有两种形式的position( )方法。第一种,不带参数的,返回当前文件的position值。返回值是一个长整型(long),表示文件中的当前字节位置。第二种形式的position( )方法带一个long(长整型)参数并将通道的position设置为指定值。如果position设置到超出文件尾,这样做会把position设置为指定值而不改变文件大小。假如在将position设置为超出当前文件大小时实现了一个read( )方法,那么会返回一个文件尾(end-of-file)条件;倘若此时实现的是一个write( )方法则会引起文件增长以容纳写入的字节,具体行为类似于实现一个绝对write( )并可能导致出现一个文件空洞(file hole)。
不同于缓冲区的是,如果实现write( )方法时position前进到超过文件大小的值,该文件会扩展以容纳新写入的字节。同样类似于缓冲区,也有带position参数的绝对形式的read( )和write( )方法。这种绝对形式的方法在返回值时不会改变当前的文件position。由于通道的状态无需更新,因此绝对的读和写可能会更加有效率,操作请求可以直接传到本地代码。更妙的是,多个线程可以并发访问同一个文件而不会相互产生干扰。这是因为每次调用都是原子性的(atomic),并不依靠调用之间系统所记住的状态。

FileChannel的size方法

FileChannel实例的size()方法将返回该实例所关联文件的大小。如:

long fileSize = channel.size();

FileChannel的truncate方法

可以使用FileChannel.truncate()方法截取一个文件。截取文件时,文件将中指定长度后面的部分将被删除。如:

channel.truncate(1024);

这个例子截取文件的前1024个字节。

当需要减少一个文件的size时,truncate( )方法会砍掉您所指定的新size值之外的所有数据。如果当前size大于新size,超出新size的所有字节都会被悄悄地丢弃。如果提供的新size值大于或等于当前的文件size值,该文件不会被修改。这两种情况下,truncate( )都会产生副作用:文件的position会被设置为所提供的新size值

FileChannel的force方法

FileChannel.force()方法将通道里尚未写入磁盘的数据强制写到磁盘上。出于性能方面的考虑,操作系统会将数据缓存在内存中,所以无法保证写入到FileChannel里的数据一定会即时写到磁盘上。要保证这一点,需要调用force()方法。对于关键操作如事务(transaction)处理来说,这一点是非常重要的,可以保证数据完整性和可靠的恢复。

force()方法有一个boolean类型的参数,指明是否同时将文件元数据(文件所有者、访问权限、最后一次修改时间等)写到磁盘上。大多数情形下,该信息对数据恢复而言是不重要的。一些大数量事务处理程序可能通过在每次调用force( )方法时不要求元数据更新来获取较高的性能提升,同时也不会牺牲数据完整性。

下面的例子同时将文件数据和元数据强制写到磁盘上:

channel.force(true);

文件锁定

直到JDK 1.4版本发布时Java编程人员才可以使用文件锁(file lock)。在集成许多其他非Java程序时,文件锁定显得尤其重要。此外,它在判优(判断多个访问请求的优先级别)一个大系统的多个Java组件发起的访问时也很有价值
锁可以是共享的(shared)或独占的(exclusive)。并非所有的操作系统和文件系统都支持共享文件锁。对于那些不支持的,对一个共享锁的请求会被自动提升为对独占锁的请求。这可以保证准确性却可能严重影响性能。另外,并非所有平台都以同一个方式来实现基本的文件锁定。在不同的操作系统上,甚至在同一个操作系统的不同文件系统上,文件锁定的语义都会有所差异。
有关FileChannel实现的文件锁定模型的一个重要注意项是:锁的对象是文件而不是通道或线程,这意味着文件锁不适用于判优同一台Java虚拟机上的多个线程发起的访问
锁定区域的范围不一定要限制在文件的size值以内,锁可以扩展从而超出文件尾。因此,我们可以提前把待写入数据的区域锁定,我们也可以锁定一个不包含任何文件内容的区域,比如文件最后一个字节以外的区域。如果之后文件增长到达那块区域,那么您的文件锁就可以保护该区域的文件内容了。相反地,如果您锁定了文件的某一块区域,然后文件增长超出了那块区域,那么新增加的文件内容将不会受到您的文件锁的保护。
下面是FileChannel类中的lock方法:
FileLocklock()
Acquires an exclusive lock on this channel's file.
abstract FileLocklock(long position, long size, boolean shared)
Acquires a lock on the given region of this channel's file.
FileLocktryLock()
Attempts to acquire an exclusive lock on this channel's file.
abstract FileLocktryLock(long position, long size, boolean shared)
Attempts to acquire a lock on the given region of this channel's file.
尽管一个FileLock对象是与某个特定的FileChannel实例关联的,它所代表的锁却是与一个底层文件关联的,而不是与通道关联。因此,如果您在使用完一个锁后而不释放它的话,可能会导致冲突或者死锁。请小心管理文件锁以避免出现此问题。一旦您成功地获取了一个文件锁,如果随后在通道上出现错误的话,请务必释放这个锁(lock.release(),放到finally块中)。

内存映射文件

新的FileChannel类提供了一个名为map( )的方法,该方法可以在一个打开的文件和一个特殊类型的ByteBuffer之间建立一个虚拟内存映射。在FileChannel上调用map( )方法会创建一个由磁盘文件支持的虚拟内存映射,并在那块虚拟内存空间外部封装一个MappedByteBuffer对象。

调用get( )方法会从磁盘文件中获取数据,此数据反映该文件的当前内容,即使在映射建立之后文件已经被一个外部进程做了修改。相似地,对映射的缓冲区实现一个put( )会更新磁盘上的那个文件(假设对该文件您有写的权限),并且您做的修改对于该文件的其他阅读者也是可见的

通过内存映射机制来访问一个文件会比使用常规方法读写高效得多,甚至比使用通道的效率都高(这个要看具体情况)。因为不需要做明确的系统调用,那会很消耗时间(其实缺页中断也会导致现场保存,并等待下一次调度选中它。内存映射节省了一次内存之间的拷贝)。更重要的是,操作系统的虚拟内存可以自动缓存内存页(memory page)。这些页是用系统内存来缓存的,所以不会消耗Java虚拟机内存堆(memory heap)。

那些包含索引以及其他需频繁引用或更新的内容的巨大而结构化文件能因内存映射机制受益非常多(内存映射文件对于顺序读取全部文件的情况优势并不是很明显,但是这里所说的情况下,的确会有很大优势)。如果同时结合文件锁定来保护关键区域和控制事务原子性,那您将能了解到内存映射缓冲区如何可以被很好地利用

public abstract MappedByteBuffer map (MapMode mode, long position,long size)

MapMode 是访问模式,包括READ_ONLY、READ_WRITE,这里只介绍第三种模式MapMode.PRIVATE。它表示您想要一个写时拷贝(copy-on-write)的映射(写时拷贝这一技术经常被操作系统使用,以在一个进程生成另一个进程时管理虚拟地址空间)。这意味着您通过put( )方法所做的任何修改都会导致产生一个私有的数据拷贝并且该拷贝中的数据只有MappedByteBuffer实例可以看到。该过程不会对底层文件做任何修改,而且一旦缓冲区被施以垃圾收集动作(garbage collected),那些修改都会丢失。尽管写时拷贝的映射可以防止底层文件被修改,您也必须以read/write权限来打开文件以建立MapMode.PRIVATE映射。只有这样,返回的MappedByteBuffer对象才能允许使用put( )方法。选择使用MapMode.PRIVATE模式并不会导致您的缓冲区看不到通过其他方式对文件所做的修改(注意写时拷贝是按页拷贝的,没有拷贝的页如果被其他操作修改,也是可以看到的)

没有unmap( )方法。也就是说,一个映射一旦建立之后将保持有效,直到MappedByteBuffer对象被施以垃圾收集动作为止。同锁不一样的是,映射缓冲区没有绑定到创建它们的通道上。关闭相关联的FileChannel不会破坏映射,只有丢弃缓冲区对象本身才会破坏该映射。

所有的MappedByteBuffer对象都是直接的,这意味着它们占用的内存空间位于Java虚拟机内存堆之外(并且可能不会算作Java虚拟机的内存占用,不过这取决于操作系统的虚拟内存模型)。

load( )方法会加载整个文件以使它常驻内存。然而,load( )方法返回并不能保证文件就会完全常驻内存,这是由于请求页面调入(demand paging)是动态的。我们可以通过调用isLoaded( )方法来判断一个被映射的文件是否完全常驻内存了。如果该方法返回true值,那么很大概率是映射缓冲区的访问延迟很少或者根本没有延迟。不过,这也是不能保证的。方法force( )同FileChannel类中的同名方法相似,该方法会强制将映射缓冲区上的更改应用到永久磁盘存储器上。当用MappedByteBuffer对象来更新一个文件,您应该总是使用MappedByteBuffer.force( )而非FileChannel.force( ),因为通道对象可能不清楚通过映射缓冲区做出的文件的全部更改。MappedByteBuffer没有不更新文件元数据的选项——元数据总是会同时被更新的

Channel-to-Channel传输

FileChannel类:
public abstract long transferTo (long position, long count, WritableByteChannel target)
public abstract long transferFrom (ReadableByteChannel src,long position, long count)
transferTo( )和transferFrom( )方法允许将一个通道交叉连接到另一个通道,而不需要通过一个中间缓冲区来传递数据(所以在拷贝文件时,效率非常高)。只有FileChannel类有这两个方法,因此channel-to-channel传输中通道之一必须是FileChannel
直接的通道传输不会更新与某个FileChannel关联的position值。假如传输的目的地是一个非阻塞模式的socket通道,那么当发送队列(send queue)满了之后传输就可能终止,并且如果输出队列(output queue)已满的话可能不会发送任何数据。类似地,对于transferFrom( )方法:如果来源src是另外一个FileChannel并且已经到达文件尾,那么传输将提早终止;如果来源src是一个非阻塞socket通道,只有当前处于队列中的数据才会被传输(可能没有数据)。由于网络数据传输的非确定性,阻塞模式的socket也可能会执行部分传输,这取决于操作系统。许多通道实现都是提供它们当前队列中已有的数据而不是等待您请求的全部数据都准备好。

Socket Channel

新的socket通道类可以运行非阻塞模式并且是可选择的。这两个性能可以激活大程序(如网络服务器和中间件组件)巨大的可伸缩性和灵活性。再也没有为每个socket连接使用一个线程的必要了,也避免了管理大量线程所需的上下文交换总开销。借助新的NIO类,一个或几个线程就可以管理成百上千的活动socket连接了并且只有很少甚至可能没有性能损失

全部socket通道类(SocketChannelDatagramChannel和ServerSocketChannel)都是由位于java.nio.channels.spi包中的AbstractSelectableChannel引申而来。这意味着我们可以用一个Selector对象来执行socket通道的有条件的选择(readiness selection)。

1.SocketChannel

Java NIO中的SocketChannel是一个连接到TCP网络套接字的通道。可以通过以下2种方式创建SocketChannel:

  1. 打开一个SocketChannel并连接到互联网上的某台服务器。
  2. 一个新连接到达ServerSocketChannel时,会创建一个SocketChannel。

打开 SocketChannel

下面是SocketChannel的打开方式:

SocketChannel socketChannel = SocketChannel.open();socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80));

关闭 SocketChannel

当用完SocketChannel之后调用SocketChannel.close()关闭SocketChannel:

socketChannel.close();

从 SocketChannel 读取数据

要从SocketChannel中读取数据,调用一个read()的方法之一。以下是例子:

ByteBuffer buf = ByteBuffer.allocate(48);int bytesRead = socketChannel.read(buf);

首先,分配一个Buffer。从SocketChannel读取到的数据将会放到这个Buffer中。

然后,调用SocketChannel.read()。该方法将数据从SocketChannel 读到Buffer中。read()方法返回的int值表示读了多少字节进Buffer里。如果返回的是-1,表示已经读到了流的末尾(连接关闭了)。

写入 SocketChannel

写数据到SocketChannel用的是SocketChannel.write()方法,该方法以一个Buffer作为参数。示例如下:

String newData = "New String to write to file..." + System.currentTimeMillis();ByteBuffer buf = ByteBuffer.allocate(48);buf.clear();buf.put(newData.getBytes());buf.flip();while(buf.hasRemaining()) {    channel.write(buf);}

注意SocketChannel.write()方法的调用是在一个while循环中的。Write()方法无法保证能写多少字节到SocketChannel。所以,我们重复调用write()直到Buffer没有要写的字节为止。

非阻塞模式

可以设置 SocketChannel 为非阻塞模式(non-blocking mode).设置之后,就可以在异步模式下调用connect(), read() 和write()了。

connect()

如果SocketChannel在非阻塞模式下,此时调用connect(),该方法可能在连接建立之前就返回了。为了确定连接是否建立,可以调用finishConnect()的方法。像这样:

socketChannel.configureBlocking(false);socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80));while(! socketChannel.finishConnect() ){    //wait, or do something else...}

write()

非阻塞模式下,write()方法在尚未写出任何内容时可能就返回了。所以需要在循环中调用write()。。

read()

非阻塞模式下,read()方法在尚未读取到任何数据时可能就返回了。所以需要关注它的int返回值,它会告诉你读取了多少字节

2、ServerSocketChannel

打开 ServerSocketChannel

通过调用 ServerSocketChannel.open() 方法来打开ServerSocketChannel.如:

1ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

关闭 ServerSocketChannel

通过调用ServerSocketChannel.close() 方法来关闭ServerSocketChannel. 如:

1serverSocketChannel.close();

监听新进来的连接

通过 ServerSocketChannel.accept() 方法监听新进来的连接。当 accept()方法返回的时候,它返回一个包含新进来的连接的 SocketChannel。因此, accept()方法会一直阻塞到有新连接到达。

通常不会仅仅只监听一个连接,在while循环中调用 accept()方法. 如下面的例子:

1while(true){
2    SocketChannel socketChannel =
3            serverSocketChannel.accept();
4
5    //do something with socketChannel...
6}

当然,也可以在while循环中使用除了true以外的其它退出准则。

非阻塞模式

ServerSocketChannel可以设置成非阻塞模式。在非阻塞模式下,accept() 方法会立刻返回,如果还没有新进来的连接,返回的将是null。 因此,需要检查返回的SocketChannel是否是null.如:

01ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
02
03serverSocketChannel.socket().bind(new InetSocketAddress(9999));
04serverSocketChannel.configureBlocking(false);
05
06while(true){
07    SocketChannel socketChannel =
08            serverSocketChannel.accept();
09
10    if(socketChannel != null){
11        //do something with socketChannel...
12    }
13}

3.DatagramChannel

正如SocketChannel对应Socket,ServerSocketChannel对应ServerSocket,每一个DatagramChannel对象也有一个关联的DatagramSocket对象。正如SocketChannel模拟连接导向的流协议(如TCP/IP),DatagramChannel则模拟包导向的无连接协议(如UDP/IP)。
DatagramChannel channel = DatagramChannel.open( ); 
DatagramSocket socket = channel.socket( ); 
socket.bind (new InetSocketAddress (portNumber));

创建DatagramChannel的模式和创建其他socket通道是一样的:调用静态的open( )方法来创建一个新实例。新DatagramChannel会有一个可以通过调用socket( )方法获取的对等DatagramSocket对象。DatagramChannel对象既可以充当服务器(监听者)也可以充当客户端(发送者)。如果您希望新创建的通道负责监听,那么通道必须首先被绑定到一个端口或地址/端口组合上。

DatagramChannel是无连接的。每个数据报(datagram)都是一个自包含的实体,拥有它自己的目的地址及不依赖其他数据报的数据净荷。与面向流的的socket不同,DatagramChannel可以发送单独的数据报给不同的目的地址。同样,DatagramChannel对象也可以接收来自任意地址的数据包。每个到达的数据报都含有关于它来自何处的信息(源地址)。
数据的实际发送或接收是通过send( )和receive( )方法来实现的:
public abstract SocketAddress receive (ByteBuffer dst) throws IOException; 
public abstract int send (ByteBuffer src, SocketAddress target)
receive( )方法将下次将传入的数据报的数据净荷复制到预备好的ByteBuffer中并返回一个SocketAddress对象以指出数据来源。如果通道处于阻塞模式,receive( )可能无限期地休眠直到有包到达。如果是非阻塞模式,当没有可接收的包时则会返回null。如果包内的数据超出缓冲区能承受的范围,多出的数据都会被悄悄地丢弃。
调用send( )会发送给定ByteBuffer对象的内容到给定SocketAddress对象所描述的目的地址和端口,内容范围为从当前position开始到末尾处结束。如果DatagramChannel对象处于阻塞模式,调用线程可能会休眠直到数据报被加入传输队列。如果通道是非阻塞的,返回值要么是字节缓冲区的字节数,要么是“0”。
DatagramChannel有一个connect( )方法:
public abstract DatagramChannel connect (SocketAddress remote) throws IOException;
有时候,将数据报对话限制为两方是很可取的。将DatagramChannel置于已连接的状态可以使除了它所“连接”到的地址之外的任何其他源地址的数据报被忽略。这是很有帮助的,因为不想要的包都已经被网络层丢弃了,从而避免了使用代码来接收、检查然后丢弃包的麻烦。当DatagramChannel已连接时,使用同样的令牌,您不可以发送包到除了指定给connect( )方法的目的地址以外的任何其他地址
已连接通道会发挥作用的使用场景之一是一个客户端/服务器模式、使用UDP通讯协议的实时游戏。每个客户端都只和同一台服务器进行会话而希望忽视任何其他来源地数据包。将客户端的DatagramChannel实例置于已连接状态可以减少按包计算的总开销(因为不需要对每个包进行安全检查)和剔除来自欺骗玩家的假包。服务器可能也想要这样做,不过需要每个客户端都有一个DatagramChannel对象
我们可以使用isConnected( )方法来测试一个数据报通道的连接状态。不同于SocketChannel(必须连接了才有用并且只能连接一次),DatagramChannel对象可以任意次数地进行连接或断开连接。每次连接都可以到一个不同的远程地址。调用disconnect( )方法可以配置通道,以便它能再次接收来自安全管理器(如果已安装)所允许的任意远程地址的数据或发送数据到这些地址上
当一个DatagramChannel处于已连接状态时,发送数据将不用提供目的地址而且接收时的源地址也是已知的。这意味着DatagramChannel已连接时可以使用常规的read( )和write( )方法:
public abstract int read (ByteBuffer dst) throws IOException; 
public abstract long read (ByteBuffer [] dsts) throws IOException;
  public abstract long read (ByteBuffer [] dsts, int offset, int length) throws IOException; 
public abstract int write (ByteBuffer src) throws IOException; 
public abstract long write(ByteBuffer[] srcs) throws IOException; 
public abstract long write(ByteBuffer[] srcs, int offset, int length) throws IOException;
read( )方法返回读取字节的数量,如果通道处于非阻塞模式的话这个返回值可能是“0”。write( )方法的返回值同send( )方法一致:要么返回您的缓冲区中的字节数量,要么返回“0”(如果由于通道处于非阻塞模式而导致数据报不能被发送)。当通道不是已连接状态时调用read( )或write( )方法,都将产生NotYetConnectedException异常。
数据报的吞吐量要比流协议高很多,并且数据报可以做很多流无法完成的事情。下面列出了一些选择数据报socket而非流socket的理由:
  1. 您的程序可以承受数据丢失或无序的数据。
  2. 您希望“发射后不管”(fire and forget)而不需要知道您发送的包是否已接收。
  3. 数据吞吐量比可靠性更重要。
  4. 您需要同时发送数据给多个接受者(多播或者广播)
  5. 包隐喻比流隐喻更适合手边的任务。

Pipe

Java NIO 管道是2个线程之间的单向数据连接。Pipe有一个source通道和一个sink通道。数据会被写到sink通道,从source通道读取。Pipe的source通道和sink通道提供类似java.io.PipedInputStream和java.io.PipedOutputStream所提供的功能,不过它们可以执行全部的通道语义。请注意,SinkChannel和SourceChannel都由AbstractSelectableChannel引申而来(所以也是从SelectableChannel引申而来),这意味着pipe通道可以同选择器一起使用。

管道可以被用来仅在同一个Java虚拟机内部传输数据。虽然有更加有效率的方式来在线程之间传输数据,但是使用管道的好处在于封装性。根据给定的通道类型,相同的代码可以被用来写数据到一个文件、socket或管道。选择器可以被用来检查管道上的数据可用性,如同在socket通道上使用那样地简单。这样就可以允许单个用户线程使用一个Selector来从多个通道有效地收集数据,并可任意结合网络连接或本地工作线程使用。因此,这些对于可伸缩性、冗余度以及可复用性来说无疑都是意义重大的。

Pipes的另一个有用之处是可以用来辅助测试。一个单元测试框架可以将某个待测试的类连接到管道的“写”端并检查管道的“读”端出来的数据。它也可以将被测试的类置于通道的“读”端并将受控的测试数据写进其中。两种场景对于回归测试都是很有帮助的。

这里是Pipe原理的图示:

创建管道

通过Pipe.open()方法打开管道。例如:

Pipe pipe = Pipe.open();

向管道写数据

要向管道写数据,需要访问sink通道。像这样:

Pipe.SinkChannel sinkChannel = pipe.sink();

通过调用SinkChannel的write()方法,将数据写入SinkChannel,像这样:

String newData = "New String to write to file..." + System.currentTimeMillis();ByteBuffer buf = ByteBuffer.allocate(48);buf.clear();buf.put(newData.getBytes());buf.flip();while(buf.hasRemaining()) {    sinkChannel.write(buf);}

从管道读取数据

从读取管道的数据,需要访问source通道,像这样:

Pipe.SourceChannel sourceChannel = pipe.source();

调用source通道的read()方法来读取数据,像这样:

ByteBuffer buf = ByteBuffer.allocate(48);int bytesRead = sourceChannel.read(buf);

read()方法返回的int值会告诉我们多少字节被读进了缓冲区。



参考:
  1. 《java nio》
  2. http://ifeve.com/file-channel/
  3. http://ifeve.com/socket-channel/
  4. http://ifeve.com/datagram-channel/
  5. http://ifeve.com/pipe/



1 0