主要内容
Nio和普通IO的区别
- 传统的IO操作是基于流的,例如字节流和字符流;而Nio是基于缓冲区的(Buffer对象)。
- Nio可以同时进行读写操作(双向操作),而传统的IO则是单向的,只能读或写。
- Nio可进行异步读写操作,而传统的IO则只能进行阻塞式操作。
Channel和Buffer(缓冲区)
在Nio中,数据总是在Channel对象和Buffer对象之间进行传输:把数据从Buffer写入Channel,或者从Channel读入数据到Buffer。Buffer是内存中的一个数据段,java提供了一些列的api来方便地操作这个数据对象。Channel是一个接口,我们通过和Channel连接的底层文件或网络套接字操作数据。Nio包中实现了几个比较重要的类:
- FileChannel
- DatagramChannel
- SocketChannel
- ServerSocketChannel
FileChannel用于进行文件的读取和写入操作。
DatagramChannel用于向网络中通过udp的形式读取和写入数据
SocketChannel同样是向网络中读取和写入数据,但是是以tcp的形式进行操作。
ServerSocketChannel可用于监听网络中的tcp连接,每个连接创建一个SocketChannel对象。
创建Channel对象
在早期的API中,FileChannel可通过FileInputStream、FileOutputStream、RandomAccessFile对象的getChannel方法创建一个连接到底层文件的Channel对象。
而DatagramChannel和SocketChannel则可通过它们的静态方法open来创建对象。
下面的例子创建了一个FileChannel对象并从文件读取数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
FileInputStream inputStream = new FileInputStream(new File("D:\\test.txt")); FileChannel fileChannle = inputStream.getChannel(); ByteBuffer buffer = ByteBuffer.allocate(64); int read = fileChannle.read(buffer); if (read != -1) { System.out.println("Read " + read); buffer.flip(); while (buffer.hasRemaining()) { System.out.print((char) buffer.get()); } buffer.clear(); read = fileChannle.read(buffer); } inputStream.close(); |
从java1.7 开始,可以使用新的apiFiles
创建Channel对象,例如:
1 2 3 4 5 6 7 |
Path filePath = Paths.get("D:\\test.txt"); try (WritableByteChannel channel = Files.newByteChannel(filePath, EnumSet.of(CREATE, WRITE))) { //执行buffer操作 } catch (IOException e) { e.printStackTrace(); } |
创建Buffer对象
Java NIO中有如下几种Buffer类型:
- ByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
其中最重要的类型是ByteBuffer,因为Channel中的读写方法只接受ByteBuffer类型的参数。其他的类型使用的时候都是通过ByteBuffer转换而来的。
在前面的代码中,我们通过ByteBuffer的静态方法allocate创建了一个容量为64的ByteBuffer对象:
1 2 3 4 5 |
//创建ByteBuffer对象 ByteBuffer buffer = ByteBuffer.allocate(64); //把数据装入到buffer对象 int read = fileChannle.read(buffer); |
缓冲区的容量(Capacity)
缓冲区的容量指的是该对象可容纳多少个指定类型的数据,例如前面的allocate(64),则该缓冲区可包含64个byte类型的元素,如果是DoubleBuffer的对象,例如:DoubleBuffer.allocate(32)
,则这个缓冲区可容纳32个Double类型的元素。缓冲区中的值从零开始索引,因此,在引用缓冲区中的值时,索引位置从0开始到capacity-1。
缓冲区的位置(position)和限制(limit)
缓冲区有两个模式,分别是读模式和写模式。刚初始化的缓冲区position在索引0处。在写模式中,position通常指的是已经写入的数据量,例如一个初始化容量为64的缓冲区,写入的数据量为32,则position为32。limit在写模式中指的是允许写入的数据量,默认情况下limit的值等于缓冲区capacity的值。
在读模式中,情况稍有不同。在向一个缓冲区写完数据后,再从这个缓冲区中读取数据,可以这样做:
1 2 3 4 5 |
//把position重置为0 buffer.flip(); //获取一个字符 char data = (char) buffer.get(); |
flip()
方法用于把缓冲区从写模式切换到读模式,额外地,调用flip()
方法会把缓冲区的position重置为0,并把limit的值设置为写模式时position的值(之前写入的数据量),也就是说,limit在读模式表示我们从缓冲区能够读取的最大数据。
rewind方法
rewind方法用于重置position的值,通常我们想切换缓冲区到读模式的时候才会这样做,以便读取所有的数据。rewind方法并不会设置limit的值,limit的值依然等于capacity,这样,就会读取到缓冲区中的空数据。如果要达到flip()
方法的效果,可以显式设置缓冲区的limit值:
1 |
buffer.limit(buffer.position()).rewind(0); |
clear方法和compact()方法
clear方法用于把position的值和limit的值还原到初始状态,即position=0,limit=capacity,但是,clear之后缓冲区中的数据依然存在。
compact方法用于压缩缓冲区。假如缓冲区中有部分数据还没有读取,但是这个时候你需要先向缓冲区中写数据,那么,调用compact方法会把未读取的数据移动到缓冲区开始的位置,然后把position设置到未读数据的后面,limit设置为capacity,这样,未读的数据就不会被覆盖。
标记(mark)和重置(reset)
对缓冲区使用标记是为了告诉缓冲区我们稍后使用reset想要返回的特定索引位置。例如:
1 |
buffer.limit(512).position(256).mark(); |
上面的代码把标记设置为256,在经过一系列对缓冲区(position)的修改之后,可以使用reset方法重新返回到索引256的位置。reset之前必须设置了标记,否则reset方法会抛出InvalidMarkException
异常。
传输数据到缓冲区
所有Buffer类都有两种put()
方法,用于将单个数据类型传输到缓冲区:
- put(byte b)
- put(int index, byte b)
单个参数的put方法把字节传输到缓冲区的当前位置(position)并且将位置增加1,注意position的位置不能超过limit的限制,否则抛出BufferOverflowException异常;两个参数的put方法把字节传输到指定index的索引位置,但不改变position的值,如果index值为负数,或者index大于或等于缓冲区的limit值,则抛出IndexOutOfBoundsException异常。
除了可以把单个数据类型传输到缓冲区外,所有的Buffer类都有三个put方法,用于传输大块的数据到缓冲区。下面是这些方法:
- put(byte[] array)
- put(byte[] array, int offset, int length)
- put(ByteBuffer src)
其他putXXX()方法
除了byte类型的数据,Buffer类还提供了支持其他数据类型操作的方法,例如:putChar()、putShort()、putInt()、putLong()、putDouble()、putFloat()。每个方法都传输对应类型的值,这些方法都只提供对单个值的传输,如果想传输一个数组,就必须使用循环。
从缓冲区提取数据
和put方法类似,ByteBuffer对象提供了一系列的get方法,用来提取缓冲区中的数据,如下:
- get() 提取并返回当前缓冲区位置所在的字节并且增加position的值
- get(int index) 返回索引位置index的字节
- get(byte[] bytes) 从缓冲区位置0开始,往后提取bytes.length个字节,position的值增加bytes.length
- get(byte[] bytes, int offset, int length) 从当前位置往后提取length个字节,储存到bytes数组中从offset开始的索引
分散读和集中写
分散读和集中写由两个Channel接口提供支持,它们分别是:ScatteringByteChannel
和GatheringByteChannel
分散读
分散读指的是把数据从Channel读入到多个Buffer对象。
ScatteringByteChannel的具体实现类如下:
下面的代码把一个文件的数据读入到多个Buffer对象中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
public void scatteringRead() throws IOException{ FileInputStream fis = new FileInputStream(new File("D:\\test.txt")); FileChannel fileChannel = fis.getChannel(); //创建Buffer ByteBuffer buf1 = ByteBuffer.allocate(128); ByteBuffer buf2 = ByteBuffer.allocate(102400); ByteBuffer[] bufferArray = {buf1, buf2}; //把数据传输到数组中的两个buffer中 long read = fileChannel.read(bufferArray); if(read>0){ buf1.flip(); //把buf1切换到读模式 buf2.flip(); //把buf2切换到读模式 while(buf1.hasRemaining()){ System.out.print((char)buf1.get()); } while(buf2.hasRemaining()){ System.out.print((char)buf2.get()); } } fis.close(); } |
在分散读的时候,当把第一个缓冲区填满之后,才会使用第二个缓冲区。
集中写
集中写的数据流向和分散读相反,集中写把数据从多个缓冲区取出,然后写入到一个Channel中。基本上实现了分散读的类同样实现了集中写。
下面的代码展示如何把多个缓冲区的数据写入和channel连接的底层文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public void gatheringWrite() throws IOException{ FileOutputStream fos = new FileOutputStream(new File("D:\\test.txt")); FileChannel fileChannel = fos.getChannel(); // 创建Buffer ByteBuffer buf1 = ByteBuffer.allocate(128); ByteBuffer buf2 = ByteBuffer.allocate(256); //填充内容 buf1.put("the first line\r\n".getBytes()); buf2.put("the second line".getBytes()); ByteBuffer[] bufferArray = {buf1, buf2}; buf1.flip(); //重置position和limit buf2.flip(); //重置position和limit //把缓冲区数据写入和channel连接的底层文件 fileChannel.write(bufferArray); fos.close(); } |
假如一个容量为128的ByteBuffer,里面只有20个数据,那么集中写只会向channel写这20个数据,空白的数据被忽略。
视图缓冲区
视图缓冲区是指除ByteBuffer类型之外的其他缓冲区类型,由于这些缓冲区类型底层还是封装了ByteBuffer类型,所以称它们为ByteBuffer的视图缓冲区。以CharBuffer为例子,查看ByteBuffer的asCharBuffer()
方法,就会看到类似下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public CharBuffer asCharBuffer() { int size = this.remaining() >> 1; int off = offset + position(); return (bigEndian ? (CharBuffer)(new ByteBufferAsCharBufferB(this, -1, 0, size, size, off)) : (CharBuffer)(new ByteBufferAsCharBufferL(this, -1, 0, size, size, off))); } |
可以看到,当创建一个CharBuffer缓冲区的时候,新的CharBuffer容量实际上是ByteBuffer的剩余空间除以2得来的。需要注意的是,剩余空间只能转换能被2整除的部分,不能被2整除的部分会被舍弃。例如下面的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@Test public void usingCharBuffer(){ ByteBuffer byteBuffer = ByteBuffer.allocate(32); //初始状态没有使用任何空间,可用空间为32 System.out.println(byteBuffer.remaining()); //byteBuffer填充了11个数据 byteBuffer.put("hello world".getBytes()); System.out.println(byteBuffer.remaining()); //创建一个char类型的缓冲视图 CharBuffer charBuffer = byteBuffer.asCharBuffer(); //输出结果为10 System.out.println(charBuffer.capacity()); } |
意料之中,上面的代码初始化了一个容量为32的ByteBuffer,然后使用11个数据进行填充,因此,可用空间剩下21个,除以2,获得charBuffer的容量为10,余数1被舍弃。
另外,从代码中可以得出另一个结论:视图缓冲区和ByteBuffer缓冲区使用的是同一个内存空间。
并不是说,转换为CharBuffer后的空间ByteBuffer就不能够使用,实际上,ByteBuffer还是和原来一样,它还能够操作CharBuffer的那一段空间。转换为CharBuffer的好处是使我们操作char类型数据的时候更加方便。另外,ByteBuffer和CharBuffer的position和limit并不一样,它们不会互相干扰。如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@Test public void usingCharBuffer() { //创建一个容量为12的buffer ByteBuffer byteBuffer = ByteBuffer.allocate(12); //填充4个字节的数据 byteBuffer.put("test".getBytes()); //剩下的8个字节转换为容量等于4的CharBuffer CharBuffer charBuffer = byteBuffer.asCharBuffer(); //向charBuffer写数据 charBuffer.put("haha"); charBuffer.flip(); //获取charBuffer第一个位置上的数据,结果是 h System.out.println(charBuffer.get()); } |
因为CharBuffer是以两个字节为单位来保存数据的,所以haha
刚好占用了CharBuffer的所有4个单位空间。
下图可以更清楚地描述两者之间的关系:
分析一下上图中的一些数据,第一次调用byteBuffer.put()的时候,byteBuffer的position是4,limit是12,因为没有执行flip()
方法。而charBuffer在创建之后填充数据之前,它的position和limit值分别是0和4;填充数据之后,执行flip方法之前,position分别是4和4。所以,charBuffer的位置和限制只维护属于自己管理的那一段数据。
我们之前说过,视图缓冲区和ByteBuffer缓冲区使用的是同一个内存空间,也就是说,如果ByteBuffer的position和limit值管理不好的话,是很容易覆盖掉CharBuffer中的数据的,例如下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
@Test public void usingCharBuffer() { //创建一个容量为12的buffer ByteBuffer byteBuffer = ByteBuffer.allocate(12); //填充4个字节的数据 byteBuffer.put("test".getBytes()); //剩下的8个字节转换为容量等于4的CharBuffer CharBuffer charBuffer = byteBuffer.asCharBuffer(); //向charBuffer写数据 charBuffer.put("haha"); charBuffer.flip(); //获取charBuffer第一个位置上的数据,结果是 h System.out.println(charBuffer.get()); //往第五个索引写入数据, //byteBuffer的第五个索引实际上是CharBuffer的第一个索引 byteBuffer.put(5, (byte)'b'); charBuffer.flip(); //这个时候,charBuffer获取到的是上面设置的数据b,原先的h已经被覆盖 System.out.println(charBuffer.get()); } |
代码中的注释已经说得很清楚,byteBuffer的第五个索引实际上是CharBuffer的第一个索引,因此原先CharBuffer中保存的h被数据b覆盖了。为了防止这样的事情发生,我们必须自己追踪ByteBuffer的位置和限制。
转载请注明:Pure nonsense » Java NIO 简介