NIO是JDK 1.4中开始引入的新的IO库。它提供了高速的、面向块的I/O。NIO和IO的不同之处在于:
1)通道和缓冲区(Channels and Buffers)
标准的IO基于字节流和字符流进行操作,而NIO基于通道(Channel)和缓冲区(Buffer)。NIO的数据总是从通道读取到缓冲区,或者从缓冲区写入到通道。
2)非阻塞IO(Non-blocking IO)
NIO可以让你以非阻塞的方式使用IO。比如,线程从通道读取数据到缓冲区时,线程还可以干其他事。当数据被写入到缓冲区时,线程可以继续处理它。从缓冲区写入通道也类似。
3)选择器(Selectors)
NIO引入了选择器的概念,它用于监听多个通道的事件(如:连接打开,数据到达)。因此,单个线程可以监听多个数据通道。
在java编程中,在NIO引入之前,使用流(Stream)的方式完成IO。所有I/O均被视为单个字节的移动。通过一个称为流的对象一次移动一个字节。流IO用于将对象转换为字节,然后再转换为对象。
缓冲区(Buffer)
buffer是一个对象,它包含一些要写入或刚读出的数据。在面向流的IO中,数据直接写入或读到stream对象中。而在NIO中,所有数据都用缓冲区处理。在读取数据时,它是直接读到缓冲区的。在写入数据时,它是写入到缓冲区的。缓冲区的实质是一个数组,通常是一个字节数组,但也有其他类型的数组。但缓冲区也不仅是数组,它提供了对数组的结构化访问,而且还可以跟踪系统的读/写进程。
最常用的缓冲区是ByteBuffer。一个ByteBuffer可以在其底层字节数组上进行get/set操作。常用的缓冲区类型有:
这些buffer覆盖了你能通过IO发送的基本数据类型。
通道(Channel)
通道是对原IO包中流的模拟。所有数据可以从通道读到buffer,也可以从buffer写到通道。所有数据都是通过buffer对象来处理,永远不会将字节直接写入通道中。通道与流的不同在于它是双向的。而流只是在一个方向上移动(一个流必须是InputStream或OutputStream的子类)。而通道可以用于读、写或同时读写。java NIO中最重要的通道实现如下:
FileChannel从文件中读写数据。DatagramChannel通过UDP读写网络中的数据。SocketChannel通过TCP读写网络中的数据。ServerSocketChannel可以监听新进来的TCP连接,像web服务器一样,对每个新进来的连接都创建一个SocketChannel。
选择器(Selector)
选择器允许单线程处理多个通道。如果应用打开了多个连接(通道),但每个连接流量都很低,使用选择器就会很方便。一个单线程使用一个选择器处理三个通道的例子如下:
要使用选择器,需要向选择器注册通道,然后调用它的select()方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件(如有新连接,数据接收等)。
NIO中的读和写
读和写是IO的基本过程。从通道中读取数据很简单,只需要创建一个缓冲区,然后从通道中将数据读取到缓冲区。写入则恰好相反,将缓冲区的数据写入通道中。
读取文件的三步如下:
1)从FileInputStream获取channel;
FileInputStream fin = new FileInputStream( "test.txt" ); FileChannel fc = fin.getChannel();
2)创建Buffer;
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
3)将数据从Channel读到Buffer中。
fc.read( buffer );
写入文件的三步如下:
1)从FileOutputStream获取channel;
FileOutputStream fout = new FileOutputStream( "test.txt" ); FileChannel fc = fout.getChannel();2)创建一个缓冲区,并放入一些数据。
ByteBuffer buffer = ByteBuffer.allocate( 1024 ); for (int i=0; i<message.length; ++i) { buffer.put( message[i] ); } buffer.flip();3)将缓冲区的内容写入通道中。
fc.write( buffer );
读写混合
以一个CopyFile.java的程序作为例子,它用来实现将一个文件的内容拷贝到另一个文件中。该程序执行三个基本操作:1)创建一个buffer;2)将源文件的数据读入这个缓冲区中;3)重复读,直到源文件结束。
public class CopyFile { public static void main(String[] args) { try { FileInputStream fileInputStream = new FileInputStream("/export/src.txt"); FileOutputStream fileOutputStream = new FileOutputStream("/export/des.txt"); FileChannel fInChannel = fileInputStream.getChannel(); FileChannel fOutChannel =fileOutputStream.getChannel(); ByteBuffer byteBuffer = ByteBuffer.allocate(1024); while (true) { byteBuffer.clear(); int result = fInChannel.read(byteBuffer); if (result == -1) { break; } byteBuffer.flip(); fOutChannel.write(byteBuffer); } fileInputStream.close(); fileOutputStream.close(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } }clear()方法重设缓冲区,使它可以接收读入的数据。flip()方法让缓冲区可以将新读入的数据写入另一个通道。
缓冲区本质上是一块可写入数据,然后可从中读数据的内存。这块数据被包装成NIO Buffer对象,并且提供了一组方法,用来方便访问这块内存。
理解了buffer的下面三个属性,就可以很好的理解它的工作原理了。
position和limit的含义取决于buffer处于读模式还是写模式。不过,不论buffer处于什么模式,capacity的含义是一样的。
capacity
buffer有固定的大小值,称作capacity。只能往buffer中写capacity个byte、long、char等类型。一旦Buffer满了,需要将其清空(读数据或清除数据)才能继续往buffer中写数据。
position
当写数据到buffer中时,position表示当前的位置。初始position值为0。当一个byte、char等数据写入buffer后,position会向前移动到下一个可插入数据的buffer单元。position最大可为capacity-1。
当读数据时,也是从某个特定位置读。当buffer从写模式切换到读模式,position会被重置为0。当从buffer的position处读取数据时,position向前移动到下一个可读的位置。
position总是小于或等于limit。
limit
写模式下,buffer的limit表示最多能往buffer里写入多少数据。写模式下,limit等于buffer的capacity。
当切换到读模式时,limit表示最多能读到多少数据。因此,当切换buffer到读模式时,limit会被设置成写模式下的position值。也就是说,你只能读到之前写入的所有数据(limit被设置成已写入的数据量,也就是写模式下的position)。
limit决不能大于capacity。
接下来我们以一个实例来更详细的了解一下这些buffer的内部细节。
创建一个大小为8个字节的buffer。有8个槽,分别为(slot-0,...,slot-7)
limit不能大于capacity。所以初始的时候,它们的位置如下:
position的初始值为0。如果读一些数据到缓冲区中,那么下一个读取的数据被放入slot-0。
第一次读取数据。假设第一次获取3个字节。读完之后,position就会增加到3。如下:
因为capacity一直不会改变,所以省略了capacity标记。第二次再读取两个字节数据后,position增加到5。如下:
limit一直都没有改变。现在我们需要将数据写入到通道中。我们必须调用flip()方法。它会做两件很重要的事情:
1.将limit设置为当前position;
2.将position设置为0。
flip()方法调用之后,如下:
现在就可以将数据从缓冲区写入通道了。position被设置为0。也意味着我们得到的下一个字节是第一个字节。limit被设置成了原来的position。意味着可以读到原来的所有字节。
第一次写入时,我们从缓冲区取4个字节将它们写入通道。这使得position增加到4,而limit不变。如下:
第二次就只剩下一个字节可写,limit在我们调用flip()之后被设置为5,且position不能超过limit,所以最后一次写入操作从缓冲区取出一个字节并将它写入输出通道。使得position增加到5,而limit保持不变。如下:
最后一步是调用缓冲区的clear()方法。该方法重设缓冲区,以便接收更多字节。clear做两件非常重要的事情:
1.将limit设置为与capacity相同;
2.设置position为0。
调用clear()方法后的状态如下:
缓冲区此时就可以接收新的数据了。
缓冲区分配和包装
读写缓冲区之前,必须先创建一个缓冲区,常用的创建方法是用静态方法allocate()来分配:
ByteBuffer buffer = ByteBuffer.allocate( 1024 );该方法分配一个指定大小的底层数组,并将其包装到一个缓冲区对象中。也可以将一个现有数组转换成缓冲区:
byte array[] = new byte[1024]; ByteBuffer buffer = ByteBuffer.wrap( array );缓冲区分片
使用slice()方法可以根据现有缓冲区创建一个子缓冲区。新缓冲区与原缓冲区共享一部分数据。
创建一个缓冲区,并在第n个槽放入数字n,如下:
ByteBuffer buffer = ByteBuffer.allocate( 10 ); for (int i=0; i<buffer.capacity(); ++i) { buffer.put( (byte)i ); }现在我们该缓冲区分片,以创建一个包含槽3到槽6的子缓冲区。
buffer.position( 3 ); buffer.limit( 7 ); ByteBuffer slice = buffer.slice();缓冲区数据共享
我们遍历子缓冲区,将每个元素乘以10来改变它。例如,5会变成50。
for (int i=0; i<slice.capacity(); ++i) { byte b = slice.get( i ); b *= 10; slice.put( i, b ); }最后再查看原缓冲区的内容:
buffer.position( 0 ); buffer.limit( buffer.capacity() ); while (buffer.remaining()>0) { System.out.println( buffer.get() ); }结果如下:
0 1 2 30 40 50 60 7 8 9