Java NIO 详解---Buffer与Channel

一.NIO 和BIO的比较
NIO是jdk1.4开始提供的一种新的IO方式。原来的 I/O 库(在 java.io.*中) 与 NIO 最重要的区别是数据打包和传输的方式。原来的 I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。面向流 的 I/O 系统一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。为流式数据创建过滤器非常容易。链接几个过滤器,以便每个过滤器只负责单个复杂处理机制的一部分,这样也是相对简单的。不利的一面是,面向流的 I/O 通常相当慢。
一个 面向块 的 I/O 系统以块的形式处理数据。每一个操作都在一步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。这里需要说明为什么面向块的IO要比面向流的IO要快,举例来说应用程序从磁盘读取数据的时候其实并不是直接从磁盘取数据的而是先由操作系统把磁盘上的数据读到系统内存中,这一步是操作系统的IO,不由应用程序控制;然后应用程序再把数据从系统内存读取到应用内存中去,后一步也就是我们程序中的IO操作。操作系统一般一次将一块的数据由磁盘移动到系统内存上去,基于块的IO和基于流IO的不同之处在于基于流的IO需要一个个字符的把系统内存上的数据移动到应用内存上去,而基于块的IO会一次一块的将数据移动到应用内存,效率自然是基于块的IO更好。但在jDK1.4之后,BIO的底层也使用NIO方式进行了部分重写,所以就文件读写效率方面来说,两者差别已经不大,最重要的差别还是NIO提供了异步非阻塞的网络编程模型,这是BIO所不能实现的。

二.NIO的核心概念—通道和缓冲区
通道对应NIO中的Channel类,通道类似于基础IO中的流(InputStream/OutputStream),数据的读取和写入都需要经过通道; 缓冲区对应NIO中的Buffer类,可以认为它是一个容器,所有需要写入到通道中去或需要从通道中读取的数据都需要放置在buffer中。
1)Channel
Channel和流很多方面都是类似的,最大的不同在于流是单向的而Channel是双向的。可以按不同的数据源类型将Channel进行分类FileChannel,DatagramChannel,SocketChannel,
ServerSocketChannel,分别对应了文件IO和网络IO。另外,Channel对象一般都是通过对应类的静态工厂方法或对应流的getChannel()方法得到的,不能通过new关键字创建一个Channel对象。下面演示通过Channel来将一个文件中的内容复制到另外一个文件中去,可以看到操作其实和BIO的读取写入很类似,不同的地方是数据的读取和写入都使用了到了ByteBuffer对象,而不是常用的byte[]。

long ctime = System.currentTimeMillis();
FileInputStream fileInputStream = new FileInputStream("/test/poiList.txt");
FileOutputStream fileOutputStream = new FileOutputStream("/test/poiList2.txt");
FileChannel in = fileInputStream.getChannel();
FileChannel out = fileOutputStream.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (in.read(buffer) > 0) {
    buffer.flip();
    out.write(buffer);
    buffer.clear();
}
in.close();
out.close();
System.out.println("NIO: "+(System.currentTimeMillis()-ctime));

Channel还支持文件锁功能,具体的来说,如果当前的Channel具备文件的写权限就可以对其加锁,如下面的代码所示:

FileOutputStream outputStream = new FileOutputStream("/test/test.txt");
FileChannel fileChannel = outputStream.getChannel();
FileLock fileLock = fileChannel.lock();
//下面可以执行写入数据而不会受其它程序干扰
fileLock.release();

具体来说,文件锁分为共享锁和排它锁,文件上的共享锁可以同时被多个用户同时获得而同一个时刻只能有一个用户获得排它锁;但文件锁依赖于底层的操作系统,不同的操作系统可能具有不同的实现,所以并不是非常可靠;如果要考虑可移植性应该尽量只使用排它锁,并且不能认为它是百分百可靠的。

2)Buffer
Buffer是NIO中设计的比较精细的一个数据结构;如果从底层数据结构来看,buffer只是一个数组而已,实际上确实可以看成一个美化了的数组。具体来说,除了一个装载数据的数组之外,buffer中还包括了两个重要组件:状态变量和访问方法,下面以ByteBuffer为例来进行介绍。
1.状态变量及状态改变方法
对于ByteBuffer来说,有可以向其中写入数据的写状态,也有可以从中读取数据的读状态,并且在两种状态中还需要记录存放数据的位置,数据读写的位置,这些信息都是通过position、 limit、capacity三个变量来标识的。
capacity的含义比较简单,表示整个Buffer的容量即可以装载多少数据,表明了这个Buffer对应的底层数组的容量—或者至少是指定了准许我们使用的底层数组的容量。
limit的含义取决于Buffer处于什么状态,在写状态的时候limit表明可以写入的数据量,此时limit一般等于capacity;在读取数据的时候,limit表示Buffer中可以读取的数据量。
position的含义同样取决于ByteBuffer处于什么状态,在写状态的时候position表示下一个可以写入数据的位置,最开始为0,写入n个数据以后为n,最大不会超过limit;在读取状态position表示下一个可以读取数据的位置,最开始为0,读取n个数据以后为n,同样最大不会超过limit。
flip()和clear()这两个方法负责改变Buffer的状态,Buffer中是没有一个状态变量表示当前是处于读状态还是写状态的,这两个函数也是通过操作上面的状态变量来改变Buffer的状态。
调用clear()会让Buffer进入写状态,这时候应用程序可以向Buffer中写入数据;从状态变量来说,这时候limit会被置为capacity一样,position会被置成0。
调用flip()会让Buffer进入读状态,这时候应用程序可以从Buffer中读取数据;从状态变量来说,这时候limit会被置成前面的position,而position会被置成0。
值得注意的是无论是flip()还是clear()都不会改变底层数组中的数据,所以并不是说clear()操作以后,底层数组中的数据就会全部清空了,实际上这时候还是可以访问到这些数据的。
上面的文字描述有点抽象,下面通过一组图示来详细的描绘这个过程:

- 观察变量
我们首先观察一个新创建的缓冲区。出于本例子的需要,我们假设这个缓冲区的 总容量 为8个字节。 Buffer 的状态如下所示:

回想一下 ,limit 决不能大于 capacity,此例中这两个值都被设置为 8。我们通过将它们指向数组的尾部之后(如果有第8个槽,则是第8个槽所在的位置)来说明这点。

position 设置为0。如果我们读一些数据到缓冲区中那么下一个读取的数据就进入 slot 0 。如果我们从缓冲区写一些数据,从缓冲区读取的下一个字节就来自 slot 0 。 position 设置如下所示:

由于 capacity 不会改变,所以我们在下面的讨论中可以忽略它。
- 第一次读取
现在我们可以开始在新创建的缓冲区上进行读/写操作。首先从输入通道中读一些数据到缓冲区中。第一次读取得到三个字节。它们被放到数组中从 position 开始的位置,这时 position 被设置为 0。读完之后,position 就增加到 3,如下所示:

- 第二次读取
在第二次读取时,我们从输入通道读取另外两个字节到缓冲区中。这两个字节储存在由 position 所指定的位置上, position 因而增加 2:

以上过程向ByteBuffer写入数据的过程limit都没有改变。
- flip
现在我们要将数据写到输出通道中。在这之前,我们必须调用 flip() 方法。这个方法做两件非常重要的事:
它将 limit 设置为当前 position。
它将 position 设置为 0。
前一小节中的图显示了在 flip 之前缓冲区的情况。下面是在 flip 之后的缓冲区:

我们现在可以将数据从缓冲区写入通道了。 position 被设置为 0,这意味着我们得到的下一个字节是第一个字节。 limit 已被设置为原来的 position,这意味着它包括以前读到的所有字节,并且一个字节也不多。
- 第一次写入
在第一次写入时,我们从缓冲区中取四个字节并将它们写入输出通道。这使得 position 增加到 4,而 limit 不变,如下所示:

- 第二次写入
我们只剩下一个字节可写了。 limit在我们调用 flip() 时被设置为 5,并且 position 不能超过 limit。所以最后一次写入操作从缓冲区取出一个字节并将它写入输出通道。这使得 position 增加到 5,并保持 limit 不变,如下所示:

- clear
最后一步是调用缓冲区的 clear() 方法。这个方法重设缓冲区以便接收更多的字节。 Clear 做两种非常重要的事情:
它将 limit 设置为与 capacity 相同。
它设置 position 为 0。
下图显示了在调用 clear() 后缓冲区的状态:

缓冲区现在可以接收新的数据了。

2.访问方法
如果只是通过Buffer将数据在通道之间转移,就不用关系如何将数据写入到Buffer和如何将数据从Buffer中读出。但是通常的情况下我们还是要向其中填充数据或读取数据的;这时候我们可以通过get()和put()方法来直接访问缓冲区中的数据。
get()方法可以从缓冲区中提取数据,它有四种形式(以ByteBuffer为例):

byte get();
ByteBuffer get( byte dst[] );
ByteBuffer get( byte dst[], int offset, int length );
byte get( int index );

第一个返回单个byte值;第二个、第三个将一组字节装到传入的数组当中去;第四个从缓冲区对应的底层数组的指定位置取得一个byte。上面返回ByteBuffer的方法,只是将缓冲区自身的this返回了。
可以认为前三个方法都是相对的,而第四个方法是绝对的;这里相对的意思是服从缓冲区position、limit等状态变量的控制,更准确的说,是从position位置开始读取,读取以后position的位置会增加并不会超过limit。而第四个方法不会受到这两个状态变量的约束,你可以指定从底层数组的任何位置读取数据。
put()方法将数据加入到缓冲区,它有以下五种形式(以ByteBuffer为例):

ByteBuffer put( byte b );
ByteBuffer put( byte src[] );
ByteBuffer put( byte src[], int offset, int length );
ByteBuffer put( ByteBuffer src );
ByteBuffer put( int index, byte b );

第一个方法写入单个字节;第二个方法写入一组字节;第三个方法从指定位置写入一组字节;第四个方法将另一个ByteBuffer中的数据作为源数据写入;最后一个方法是对缓冲区底层数组的指定位置写入了一个字节。这些方法返回的ByteBuffer只是返回调用它们的缓冲区的this。
3.缓冲区的分配
我们并不能通过new关键字来构造一个ByteBuffer对象,而只能通过ByteBuffer的静态方法来获得一个缓冲区,这样的方法如下所示:

// 通过allocate(int capacity)方法分配一个指定大小的缓冲区,这个方法会在底层创建一个capacity大小的byte[]
ByteBuffer byteBuffer= ByteBuffer.allocate(1024);

//通过wrap(byte[] array)方法将一个byte数组包装成一个ByteBuffer,要注意此时是可以通过改变ByteBuffer中的值来改变原先bytes数组中值的。
byte[] bytes = new byte[1024];
ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);

上面的例子都是关于ByteBuffer的,实际上缓冲区有很丰富的类型,包括:

ByteBuffer
CharBuffer
ShortBuffer
IntBuffer
LongBuffer
FloatBuffer
DoubleBuffer

实际上对于Java中的每一种基本类型都有一种对应的缓冲区,它们的基本操作都是类似于ByteBuffer的,这样的好处是便于我们操作基本类型的数据而不用总是将它们转成byte数组。

你可能感兴趣的:(Java,NIO)