Java NIO概念及示例
NIO概述:
NIO弥补了原来IO的不足,它在标准Java基础上提供了高速的,面向块(缓冲区)的IO操作。NIO的创建目的是为了让Java程序员可以实现高速IO而无需编写操作流中数据代码。NIO将最耗时的IO操作(即填充和提取缓冲区)转移回操作系统,因而可以极大地提高速度。原来的 I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。
面向流的 I/O 系统一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据,面向流的 I/O 通常相当慢。一个面向块的 I/O 系统以块的形式处理数据。每一个操作都在一步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。
在 JDK 1.4 中把原来的 I/O 包和 NIO 已经很好地集成了。 java.io.* 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。java.io.* 包中的一些类包含以块的形式读写数据的方法,这使得即使在更面向流的系统中,处理速度也会更快。
NIO中的核心概念:
通道和缓冲区是NIO中的核心对象,几乎在每一个IO操作中都要使用它们。通道类似于原IO包中流。到任何目的地(或者来自任何地方)的所有数据都必须通过一个Channel对象。一个Buffer(缓冲区)实质上是一个容器对象。发送给一个通道的所有对象都必须首先放到缓冲区中;同样的,从通道中读取的任何数据都要读到缓冲区中。
通道:
通道类结构:
Channel
Channel表现了一个可以进行IO操作的通道,该interface定义了以下方法:
boolean isOpen()
该Channel是否是打开的。
void close()
关闭这个Channel,相关的资源会被释放。
ReadableByteChannel
定义了一个可从中读取byte数据的channel interface。
int read(ByteBuffer dst)
从channel中读取byte数据并写到ByteBuffer中。返回读取的byte数。
WritableByteChannel
定义了一个可向其写byte数据的channel interface。
int write(ByteBuffer src)
从ByteBuffer中读取byte数据并写到channel中。返回写出的byte数。
ByteChannel
ByteChannel并没有定义新的方法,它的作用只是把ReadableByteChannel和WritableByteChannel合并在一起。
ScatteringByteChannel
继承了ReadableByteChannel并提供了同时往几个ByteBuffer中写数据的能力。
GatheringByteChannel
继承了WritableByteChannel并提供了同时从几个ByteBuffer中读数据的能力。
Channel 是一个对象,可以通过它读取和写入数据。拿 NIO 与原来的 I/O 做个比较,通道就像是流。正如前面提到的,所有数据都通过 Buffer 对象来处理。你永远不能将字节直接写入通道中,相反,是将数据写入包含一个或者多个字节的缓冲区。同样,不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。通道与流的不同之处在于通道是双向的。而流只是在一个方向上移动(一个流必须是 InputStream 或者OutputStream 的子类), 而通道可以用于读、写或者同时用于读写。因为它们是双向的,所以通道可以比流更好地反映底层操作系统的真实情况。特别是在 UNIX 模型中,底层操作系统通道是双向的。
下面为一个使用NIO进行文件copy的示例,可以看到通道的使用:
1 public static void copyFile(File dest,File src)throws Exception{ 2 if(!src.exists()){ 3 System.out.println("the source file isn't exist!!!"); 4 return; 5 } 6 //获取文件输入通道 7 FileInputStream fin = new FileInputStream(src); 8 FileChannel finc = fin.getChannel(); 9 //获取文件输出通道 10 FileOutputStream fout = new FileOutputStream(dest); 11 FileChannel foutc = fout.getChannel(); 12 //构建Buffer 13 ByteBuffer bb = ByteBuffer.allocate(8); 14 15 //循环写入通道,如果等于-1表示读取完了 16 int len; 17 while((len=finc.read(bb))!=-1) 18 { 19 //重置position和limit,position指到数组头,limit指到数组中有数组元素的尾部,防止从buffer数组中读出空元素 20 bb.flip(); 21 foutc.write(bb); 22 //重置position和limit相当于清空缓冲数组,position指到数组头,limit指导capacity位置 23 bb.clear(); 24 //buffer.clear()方法重设缓冲区,使它可以接受读入的数据,flip()方法让缓冲区可以将新读入的数据写入另一个通道 25 } 26 finc.close(); 27 fin.close(); 28 foutc.close(); 29 fout.close(); 30 31 }
缓冲区:
类图结构
缓冲区通过Buffer来实现。 在 NIO 中加入 Buffer 对象,体现了新库与原 I/O 的一个重要区别。在面向流的 I/O 中,将数据直接写入或者将数据直接读到 Stream 对象中。在 NIO 库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的。在写入数据时,它是写入到缓冲区中的。任何时候访问 NIO 中的数据,都是将它放到缓冲区中。缓冲区实质上是一个数组。通常它是一个字节数组,但是也可以使用其他种类的数组。但是一个缓冲区不仅仅是一个数组。它还提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。
缓冲区类型包括:ByteBuffer,CharBuffer,ShortBuffer,IntBuffer,LongBuffer,FloatBuffer,DoubleBuffer.每一个Buffer类都是Buffer接口的一个实例,除了ByteBuffer,每一个Buffer类都有完全一样的操作,只是它们所处理的数据类型不一样而已。因为大多数标准IO操作都使用ByteBuffer,所以它具有所有共享的操作以及一些特有的操作。
1.缓冲区的重要组件:状态变量和访问方法(accessor)
状态变量(每个Buffer都有以下属性):
在缓冲区中用三个值可以指定缓冲区在任意时刻状态:position,limit,capacity。这三个变量一起可以跟踪缓冲区的状态和它包含的数据。
Position: 缓冲区实际上就是美化了的数组。在从通道读取时,将所读取的数据放到底层的数组中。position变量跟踪已经写了多少数据。指定了下一个字节将放到数组的哪一个元素中。如果从通道读取三个字节到缓冲区中,那么缓冲区的position将会设置为3,指向数组中的第四个元素。同理,在写入通道时,是从缓冲区中获取数据,position值跟踪从缓冲区中获取了多少数据。它指定下一个字节来自数组的哪一个元素。因此如果从缓冲区写了5个字节到通道中,那么缓冲区的postion将设置为5,指向数组的第6个元素。它就是数组中的游标。
limit: limit变量表明还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时)position总是小于或者等于limit.
Capacity: 缓冲区的capacity表明可以存储在缓冲区中的最大数据容量。实际上,它指定了底层数组的大小,或者至少是指定了准许我们使用的底层数组的容量。limit决不能大于capacity
mark: 一个临时存放的位置下标。调用mark()会将mark设为当前的position的值,以后调用reset()会将position属性设置为mark的值。mark的值总是小于等于position的值,如果将position的值设的比mark小,当前的mark值会被抛弃掉。
这些属性总是满足以下条件:
0 <= mark <= position <= limit <= capacity
Buffer clear()
把position设为0,把limit设为capacity,一般在把数据写入Buffer前调用。
Buffer flip()
把limit设为当前position,把position设为0,一般在从Buffer读出数据前调用。
Buffer rewind()
把position设为0,limit不变,一般在把数据重写入Buffer前调用。
一个新的buffer区,position总指向0,limit和capacity指向尾位置 当读入数据或写出数据时position就会变化,在我们要将数据写到通道中,我们必须调用flip()方法,这个方法做两件事:1.它将limit设置为当前的position处。2.它将position设置为0. 当我们往缓冲区中读入数据时要调用clear()方法,这个方法是重设缓冲区便接收更多的字节,clear做两件非常重要的事:1.它将limit设置与capacity相同。2.它设置position为0
访问方法:
get()方法 ByteBuffer类中有四个get()方法 1.byte get() 2.ByteBuffer get(byte[] dst) 3.ByteBuffer get(byte[] dst,int offset,int length); 4.byte get(int index) 第一个方法获取单个字节。第二和第三个方法将一组字节读到一个数组中。第四个方法从缓冲区中的特定位置获取字节。此外,我们认为前三个 get() 方法是相对的,而最后一个方法是绝对的。 相对意味着 get() 操作服从 limit 和 position 值 ― 更明确地说,字节是从当前 position 读取的,而 position 在 get 之后会增加。另一方面,绝对方法会忽略 limit 和 position 值,也不会影响它们。事实上,它完全绕过了缓冲区的统计方法。上面列出的方法对应于 ByteBuffer 类。其他类有等价的 get() 方法,这些方法除了不是处理字节外,其它方面是 是完全一样的,它们处理的是与该缓冲区类相适应的类型。
put()方法: ByteBuffer 类中有五个 put() 方法: 1. ByteBuffer put( byte b ); 2. ByteBuffer put( byte src[] ); 3. ByteBuffer put( byte src[], int offset, int length ); 4. ByteBuffer put( ByteBuffer src ); 5. ByteBuffer put( int index, byte b ); 第一个方法 写入(put) 单个字节。第二和第三个方法写入来自一个数组的一组字节。第四个方法将数据从一个给定的源 ByteBuffer 写入这个 ByteBuffer。第五个方法将字节写入缓冲区中特定的位置 。那些返回 ByteBuffer 的 方法只是返回调用它们的缓冲区的 this 值。 与 get() 方法一样,我们将把 put() 方法划分为相对或者绝对的。前四个方法是相对的,而第五个方法是绝对的。 上面显示的方法对应于 ByteBuffer 类,其他类有等价的 put() 方法,这些方法除了不是处理字节之外,其它方面 是完全一样的。它们处理的是与该缓冲区类相适应的类型。
类型化的get()和put()方法:除了前些小节中描述的 get() 和 put() 方法, ByteBuffer 还有用于读写不同类型的值的其他方法,如下所示: . getByte() . getChar() . getShort() . getInt() . getLong() . getFloat() . getDouble() . putByte() . putChar() . putShort() . putInt() . putLong() . putFloat() . putDouble() 事实上,这其中的每个方法都有两种类型: 一种是相对的,另一种是绝对的。它们对于读取格式化的二进制数据很有用。
2.缓冲区分配和包装
要创建缓冲区,必须分配它。我们使用静态方法 allocate() 来分配缓冲区:ByteBuffer buffer = ByteBuffer.allocate( 1024 ); allocate() 方法分配一个具有指定大小的底层数组,并将它包装到一个缓冲区对象中 .还可以将一个现有的数组转换为缓冲区,如下所示:
byte array[] = new byte[1024];
ByteBuffer buffer = ByteBuffer.wrap( array );
本例使用了 wrap() 方法将一个数组包装为缓冲区。必须非常小心地进行这类操作。一旦完成包装,底层数据就可以通过缓冲区或者直接访问。
3.缓冲区分片和数据共享
slice() 方法根据现有的缓冲区创建一种子缓冲区。也就是说,它创建一个新的缓冲区,新缓冲区与原来的缓冲区的一部分共享数据。 我们首先创建一个长度为 10 的 ByteBuffer:ByteBuffer buffer = ByteBuffer.allocate( 10 ); 然后使用数据来填充这个缓冲区: for (int i=0; i<buffer.capacity(); ++i) { buffer.put( (byte)i ); } 现在我们对这个缓冲区分片,以创建一个包含槽 3 到槽 6 的子缓冲区。在某种意义上,子缓冲区就像原来的缓冲区 中的一个窗口。 窗口的起始和结束位置通过设置 position 和 limit 值来指定,然后调用 Buffer 的 slice() 方法:buffer.position( 3 ); buffer.limit( 7 ); ByteBuffer slice = buffer.slice(); 片是缓冲区的 子缓冲区 。不过, 片段 和 缓冲区共享同一个底层数据数组。
我们遍历子缓冲区,将每一个元素乘以 5 来改变它。 for (int i=0; i<slice.capacity(); ++i) { byte b = slice.get( i ); b *= 5; slice.put( i, b ); } 最后,再看一下原缓冲区中的内容,结果表明只有在子缓冲区窗口中的元素被改变了 。缓冲区片对于促进抽象非常有帮助。可以编写自己的函数处理整个缓冲区,而且如果想要将这个过程应用于子缓冲区上,只需取主缓冲区的一个片,并将它传递给函数。这比编写自己的函数来取额外的参数以指定要对缓冲区的哪一部 分进行操作更容易。
4.只读缓冲区
通过调用缓冲区的 asReadOnlyBuffer() 方法,任何常规缓冲区转换为只读缓冲区,这个方法返回一个与原缓冲区完全相同的缓冲区(并与其共享数据),只不过它是只读的。
只读缓冲区对于保护数据很有用。
5.直接和间接缓冲区
另一种有用的 ByteBuffer 是直接缓冲区。 直接缓冲区是为加快 I/O 速度,而以一种特殊的方式分配其内存的缓冲区。实际上,直接缓冲区的准确定义是与实现相关的。Sun 的文档是这样描述直接缓冲区的:给定一个直接字节缓冲区,Java 虚拟机将尽最大努力直接对它执行本机 I/O 操作。也就是说,它会在每一次调用底层操作系统的本机 I/O 操作之前(或之后),尝试避免将缓冲区的内容拷贝到一个中间缓冲区中(或者从一个中间缓冲区中拷贝数据)。创建直接缓冲区:ByteBuffer buffer = ByteBuffer.allocateDirect( 1024 )
6.内存映射文件直接缓冲区
内存映射文件 I/O 是一种读和写文件数据的方法,它可以比常规的基于流或者基于通道的 I/O 快得多。 内存映射文件 I/O 是通过使文件中的数据出现为内存数组的内容来完成的。这听起来似乎不过就是将整个文件读到内存中,但是事实上并不是这样。一般来说,只有文件中实际读取或者写入的部分才会送入(或者 映射)到内存中。 现代操作系统一般根据需要将文件的部分映射为内存的部分,从而实现文件系统。Java 内存映射机制不过是在底层操作系统中可以采用这种机制时,提供了对该机制的访问。 尽管创建内存映射文件相当简单,但是向它写入可能是危险的。仅只是改变数组的单个元素,就可能会直接修改磁盘上的文件。
在下面的例子中,我们要将一个 FileChannel (它的全部或者部分)映射到内存 中。为此我们将使用 FileChannel.map() 方法。下面代码行将文件的前 1024 个字节映射到内存中: MappedByteBuffer mbb = fc.map( FileChannel.MapMode.READ_WRITE, 0, 1024 ); map() 方法返回一个 MappedByteBuffer,它是 ByteBuffer 的子类。此时,可以像使用其他任何 ByteBuffer 一样使用新映射的缓冲区,操作系统会在需要时负责执行映射。
7.分散和聚焦
分散/聚集 I/O 是使用多个而不是单个缓冲区来保存数据的读写方法。 一个分散的读取就像一个常规通道读取,只不过它是将数据读到一个缓冲区数组中而不是读到单个缓冲区中。同样地,一个聚集写入是向缓冲区数组而不是向单个缓冲区写入数据。分散/聚集 I/O 对于将数据流划分为单独的部分很有用,这有助于实现复杂的数据格式。分散、聚焦,IO 通道可以有选择地实现两个新的接口: ScatteringByteChannel 和 GatheringByteChannel。一个 ScatteringByteChannel 是一个具有两个附加读方法的通道:long read( ByteBuffer[] dsts ); long read( ByteBuffer[] dsts, int offset, int length ); 这些 long read() 方法很像标准的 read 方法,只不过它们不是取单个缓冲区而是取一个缓冲区数组。在分散读取中,通道依次填充每个缓冲区。填满一个缓冲区后,它就开始填充下一个。在某种意义上,缓冲区数组就 像一个大缓冲区。
分散/聚集 I/O 对于将数据划分为几个部分很有用。例如,可能在编写一个使用消息对象的网络应用程序,每一个消息被划分为固定长度的头部和固定长度的正文。可以创建一个刚好可以容纳头部的缓冲区和另一个刚好可以容难正文的缓冲区。当将它们放入一个数组中并使用分散读取来向它们读入消息时,头部和正文将整齐地划分到这两个缓冲区中。我们从缓冲区所得到的方便性对于缓冲区数组同样有效。因为每一个缓冲区都跟踪自己还可以接受多少数据,所以分散读取会自动找到有空间接受数据的第一个缓冲区。在这个缓冲区填满后,它就会移动到下一个缓冲区。
聚集写入类似于分散读取,只不过是用来写入。它也有接受缓冲区数组的方法: long write( ByteBuffer[] srcs ); .long write( ByteBuffer[] srcs, int offset, int length ); 聚集写对于把一组单独的缓冲区组成单个数据流很有用。为了与上面的消息例子保持一致,可以使用聚集写入来自动将网络消息的各个部分组装为单个数据流,以便跨越网络传输消息。
8.文件锁
文件锁就像常规的 Java 对象锁 ― 它们是 劝告式的(advisory) 锁。它们不阻止任何形式的数据访问,相反,它们通过锁的共享和获取允许系统的不同部分相互协调。你可以锁定整个文件或者文件的一部分。如果获取一个排它锁,那么其他人就不能获得同一个文件或者文件的一部分上的锁。如果获得一个共享锁,那么其他人可以获得同一个文件或者文件一部分上的共享锁,但是不能获得排它锁。文件锁定并不总是出于保护数据的目的。例如,你可能临时锁定一个文件以保证特定的写操作成为原子的,而不会有其他程序的干扰。大多数操作系统提供了文件系统锁,但是它们并不都是采用同样的方式。有些实现提供了共享锁,而另一些仅提供了排它锁。事实上,有些实现使得文件的锁定部分不可访问,尽管大多数实现不是这样的。
实例化FileLock对象的方法有:
public final FileLock lock()throws IOException-------------获得此通道的文件的独占锁
public abstract FileLock lock(long position,long size,boolean shared)throws IOException 获得此通道文件给定区域的锁定,并指定锁定位置,锁定大小,是共享锁(true) 或是独占锁(false)
public final FileLock tryLock()throws IOException-------------试图获得此通道的独占锁
public abstract FileLock tryLock(long position,long size,boolean shared)throws IOException 试图获得此通道给定区域的锁定,并指定锁定位置,锁定大小,是共享锁(true) 或是独占锁(false)
锁定文件:
要获取文件的一部分上的锁,要调用一个打开的 FileChannel 上的 lock() 方法。注意,如果要获取一个排它锁,必须以写方式打开文件。
RandomAccessFile raf = new RandomAccessFile( "usefilelocks.txt", "rw" );
FileChannel fc = raf.getChannel();
FileLock lock = fc.lock( start, end, false );
在拥有锁之后,您可以执行需要的任何敏感操作,然后再释放锁:
lock.release();
在释放锁后,尝试获得锁的其他任何程序都有机会获得它。
文件锁定和可移植性:文件锁定可能是一个复杂的操作,特别是考虑到不同的操作系统是以不同的方式实现锁这一事实。下面的指导原则将帮助您尽可能保持代码的可移植性:
. 只使用排它锁。
. 将所有的锁视为劝告式的(advisory)。
9.NIO中字符集,编码,解码
要读和写文本,我们要分别使用 CharsetDecoder 和 CharsetEncoder。将它们分别称为解码器和编码器。在此一个字符不再表示一个特定的位模式,而是表示字符系统中的一个实体。因此,由某个实际的位模式表示的字符必须以某种特定的编码来表示。CharsetDecoder 用于将逐位表示的一串字符转换为具体的 char 值。同样,一个 CharsetEncoder 用于将字符转换回位。
下面为一个示例,将文件解码,之后再编码输出的方法
1 public static void coderFile(File inputFile,File outputFile)throws Exception{ 2 RandomAccessFile inf = new RandomAccessFile( inputFile, "r" ); 3 RandomAccessFile outf = new RandomAccessFile( outputFile, "rw" ); 4 5 long inputLength = inputFile.length(); 6 7 FileChannel inc = inf.getChannel(); 8 FileChannel outc = outf.getChannel(); 9 //直接映射文件到直接缓冲区 10 MappedByteBuffer inputData = 11 inc.map( FileChannel.MapMode.READ_ONLY, 0, inputLength ); 12 13 //建立某字符编码的字符集和相应的编码器和解码器 14 Charset latin1 = Charset.forName( "ISO-8859-1" ); 15 CharsetDecoder decoder = latin1.newDecoder(); 16 CharsetEncoder encoder = latin1.newEncoder(); 17 //给缓冲区解码 解码返回的是CharBuffer 18 CharBuffer cb = decoder.decode( inputData ); 19 System.out.println(cb); 20 // Process char data here 21 //再给缓冲区编码 注解编码返回的是ByteBuffer 22 ByteBuffer outputData = encoder.encode( cb ); 23 24 outc.write( outputData ); 25 26 inf.close(); 27 outf.close(); 28 }