NIO 一 缓冲区

一 缓冲区

1.1 什么是缓冲区

  缓冲区就是一块指定大小的内存空间,缓冲区存在的意义其一是减少实际物理读写次数,将IO设备和CPU进行隔离,使得CPU快速读写缓冲区后可执行其他任务,IO设备面向缓冲区而无需长时间占用CPU;其二在于减少内存碎片,系统运行时即分配固定大小的缓冲区,并且读写复用,减少了内存空间不连续触发的动态分配和内存回收次数。

1.2 直接缓冲区

  直接缓冲区是一个和JVM相关的概念,它是对缓冲区的一个分类,分类的维度在于数据交互模式不同。

  如果缓冲区进行数据读写时,JVM创建一个中间缓冲区暂存数据,然后再传递给缓冲区,这类缓冲区非直接缓冲区。相对的,如果数据交互直接在内核空间中处理,而不经过中间缓冲区,这类缓冲区就是直接缓冲区。缓冲区是否为直接缓冲区,和创建缓冲区的API有关。

  提前介绍这一点非常重要,后文中对部分API的介绍会反复提及此概念。

1.3 NIO缓冲区实现

  Java NIO中将缓冲区抽象为Buffer类型,Buffer本身是一个抽象类。从Buffer派生出了7个抽象子类:

  1. ByteBuffer
  2. CharBuffer
  3. DoubleBuffer
  4. FloatBuffer
  5. IntBuffer
  6. LongBuffer
  7. ShortBuffer。

  需要注意的是没有StringBuffer,因为StringBuffer是lang包下的,上述7个抽象子类是nio包下的。另一个需要注意的是,NIO中没有boolean类型的Buffer,因为boolean作为缓冲数据毫无意义。

  之所以将子类设计为抽象类,是因为创建Buffer时的具体类型依调用的API决定。如ByteBuufer.wrap方法会返回一个HeapByteBuffer类型的对象,而通过ByteBuffer.allocateDirect方法创建的缓冲区是DirectByteBuffer直接缓冲区类型。

  派生出诸多类型的子类的原因在于传统的和IO相关的API,都是依赖byte[]和char[]实现的,而Java中对数组的进行操作的API非常少,基本上只有length属性和通过索引下标进行访问和设置的方法,为了方便开发者使用基于数组实现的过程复杂的数据读写操作,这才引入了Buffer设计。

  严格意义上讲缓冲区就是一块内存区域,区别于其他内存区域如堆、栈等,主要在于其应用场景不同:大小确定,暂存数据,读写复用。满足这三个应用需求时,才考虑使用Buffer。

二 核心参数

2.1 参数介绍

  NIO中的缓冲区提供的所有API都针对数据读写,存储的数据则通过四个核心参数控制:

参数名 含义 介绍
capacity 容量 缓冲区尺寸,不能为负,无法修改,创建缓冲区时确定
limit 限制 缓冲区可读写的数据长度,不能为负,不能大于capacity
position 位置 当前可读写位置,不能大于limit
mark 标记 用于复位position位置,配合reset方法使用,不能大于position

2.2 参数限定条件

  上述参数的数值必须满足如下限定条件:

  0<=mark<=position<=limit<=capacity

  如果核心参数值不满足限定条件,程序运行中会抛出对应的异常,如设置和访问超过limit限制的数据时抛出java.lang.IndexOutOfBoundsExceptio:

public class CharBufferTest {
    public static void main(String[] args) throws Exception {
        char[] chars = new char[]{'a', 'b', 'c'};
        CharBuffer charBuffer = CharBuffer.wrap(chars);
        charBuffer.limit(3);
		// charBuffer.set(3);
        charBuffer.get(3);
    }
}

  运行结果如下:

Exception in thread "main" java.lang.IndexOutOfBoundsException
	at java.nio.Buffer.checkIndex(Buffer.java:540)
	at java.nio.HeapCharBuffer.get(HeapCharBuffer.java:139)
	at com.eframesoft.nio.CharBufferTest.main(CharBufferTest.java:12)

2.3 参数终值

  需要注意的是,设置参数时会依照既定的规则确定参数最终的值,这一点非常重要,很多时候如果读者发现进行缓冲区参数设置时,莫名的出现了一些难以解释的问题,那么需要根据下述场景进行分析(仅列出我已知的部分,更多场景后续补充,或者请读者自行查阅DOC或源码):

场景 规则
设置limit值时,如果pisition > limit position被设置为limit值
设置limit/position值时,如果mark > limit/position 丢弃mark,mark=-1
limit = position 写入数据异常,读取数据为空

2.4 参数操作API

  Buffer作为缓冲区抽象基类,针对上述4个核心参数提供了访问/设置(部分参数允许设置)和相关计算的API,可以说整个缓冲区API设计都是围绕核心参数来实现的,基类Buffer已经提供了非常详细的API实现,派生类可视情况重写,下面针对参数值的设置和访问API进行介绍。

  因篇幅受限,本章节仅列出围绕核心参数操作的常用API,其他API说明请参考DOC或查阅源码。另请读者注意,很多参数设置方法会返回此Buffer对象引用,这样设计仅仅是为了可以级联调用,无需对返回值太过关注。

2.4.1 获取缓冲区尺寸

  int capacity(),返回缓冲区的实际大小,示例如下:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
System.out.println(charBuffer.capacity());

输出结果;
3

2.4.2 获取缓冲区可读写尺寸限制

  int limit(),返回缓冲区的限制大小,示例如下:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
System.out.println(charBuffer.limit());

输出结果:
3

2.4.3 设置缓冲区可读写尺寸限制

   Buffer limit(int newLimit), 设置缓冲区的限制大小,严格意义上讲,limit表示第一个不允许读写的数据的索引值,示例如下:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
System.out.println(charBuffer.limit(2));
System.out.println(charBuffer.limit());

输出结果:
ab
2

2.4.4 获取缓冲区可读写数据索引

   int position(),返回此缓冲区下一个可读写的数据的索引值,示例如下:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
System.out.println(charBuffer.position());

输出结果:
0

2.4.5 设置缓冲区可读写位置索引

  Buffer position(int newPosition),设置此缓冲区下一个可读写的数据的索引值,示例如下:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
System.out.println(charBuffer.position(1));
System.out.println(charBuffer.position());

输出结果:
bc
1

  需要注意的是:

  1. 如果position等于limit值,那么后续读数据的时候是读不到的,但是不会报错;
  2. 如果position大于limit值,那么会抛出IllegalArgumentException异常。

  示例如下:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
for (int i = 0; i < charBuffer.capacity(); i++) {
	System.out.println("设置下一个可读写数据索引值:" + charBuffer.position(i) + " 下一个可读写数据的索引值:" + charBuffer.position());
}

charBuffer.position(3);
System.out.println(charBuffer.position());

charBuffer.position(4);
System.out.println(charBuffer.position());

输出结果:
设置下一个可读写数据索引值:abc 下一个可读写数据的索引值:0
设置下一个可读写数据索引值:bc 下一个可读写数据的索引值:1
设置下一个可读写数据索引值:c 下一个可读写数据的索引值:2
3
Exception in thread "main" java.lang.IllegalArgumentException
	at java.nio.Buffer.position(Buffer.java:244)
	at com.eframesoft.nio.CharBufferTest.main(CharBufferTest.java:15)

2.4.6 获取可用缓冲区大小

  int remaining(),返回position和limit参数差值,这部分数据长度就是此缓冲区尚可使用的内存空间大小,示例如下:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
System.out.println(charBuffer.remaining());

输出结果:
3

2.4.7 标记重置位置

  Buffer mark(),这个API比较难理解,是因为它单独存在毫无意义,配合使用的是另一个API reset(),mark方法可以在小于position值的位置上打上一个标记,当调用reset方法的时候,将position重置为mark标记值。

  mark值的约束较多,且这并非是一个必要的属性,但如果使用不当会出现很多难以理解的问题,这部分请参考前文参数终值部分。

  需要注意的是设置limit或者position时,limit/position的值小于当前mark值,那么mark会被强制设置为-1,此时如果调用reset方法重置position,会抛出InvalidMarkException异常,示例如下:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
// 在position2处标记
charBuffer.position(2);
charBuffer.mark();
System.out.println("当前位置:" + charBuffer.position());
// 用get访问下一个数据,position自增1
charBuffer.get();
System.out.println("当前位置:" + charBuffer.position());
// 重置position
charBuffer.reset();
System.out.println("当前位置:" + charBuffer.position());
// 重设限制值,使mark丢弃,值为-1
charBuffer.limit(1);
charBuffer.reset();

输出结果:
当前位置:2
当前位置:3
当前位置:2
Exception in thread "main" java.nio.InvalidMarkException
	at java.nio.Buffer.reset(Buffer.java:306)
	at com.eframesoft.nio.CharBufferTest.main(CharBufferTest.java:20)

2.4.8 重置核心参数

  final Buffer clear(),将缓冲区的核心参数还原为初始状态(因缓冲区大小capacity无法更改,所以不作调整,limit设置为capacity,position设置为0,mark设置为-1),这是Buffer中非常核心的方法,常用于复用此缓冲区进行数据写入前,为了保证核心参数的语义一致,所以此方法被声明为final,不允许派生类重写:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
charBuffer.limit(2);
charBuffer.position(1);
System.out.println("capacity:" + charBuffer.capacity() + " limit:" + charBuffer.limit() + " position:" + charBuffer.position());
charBuffer.clear();
System.out.println("capacity:" + charBuffer.capacity() + " limit:" + charBuffer.limit() + " position:" + charBuffer.position());

输出结果:
capacity:3 limit:2 position:1
capacity:3 limit:3 position:0

  另外读者必须要注意,clear方法仅仅将核心参数重置,缓冲区中的数据是不会丢失的,只不过是通过写入新的数据来进行数据内容的覆盖而已,这也是缓冲区支持复用的基础,如果读写数据时核心参数设置不正确,那么是极有可能出现读写错误数据的!!!与之类似的还有下面小节中介绍的方法。

2.4.9 写读模式转换

  final Buffer flip(),按API文档中描述此方法用作反转此缓冲区,然而我还是喜欢将其称作写读模式转换,原因无他,此方法常用于向缓冲区中写入数据后,读写入数据前。

  这个方法的声明依然是final的,其内部实现的逻辑就是将limit设置为position,再将position设置为0,mark直接丢弃。在写入数据后调用此方法,即可保证从position=0位置开始读数据,且读出的数据位置不会超过当初写入的数据长度(因为将limit设置为写入是的position了),因此我把这个方法称作写读模式转换,示例如下:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
charBuffer.put('1');
charBuffer.put('2');
System.out.println("capacity:" + charBuffer.capacity() + " limit:" + charBuffer.limit() + " position:" + charBuffer.position());
charBuffer.flip();
for (int i = 0; i < charBuffer.limit(); i++) {
	System.out.println(charBuffer.get(i));
}

输出结果:
capacity:3 limit:3 position:2
1
2

2.4.10 数据重读

  final Buffer rewind(),这名字还算形象,实现也很简单,将position设置为0,mark设置为-1,limit不变,这样实现的目的是为了方便重新读取缓冲数据,示例如下:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
for (int i = charBuffer.position(); i < charBuffer.limit(); i++) {
	System.out.println(charBuffer.get() + " position:" + charBuffer.position());
}
charBuffer.rewind();
System.out.println(charBuffer.position());

输出结果:
a position:1
b position:2
c position:3
0

三 缓冲区API

  章节2.4 参数操作API中介绍了操作缓冲区核心参数相关的API,除此之外,基类Buffer还提供了许多缓冲区相关的方法,这些方法未必会在程序设计中大量使用,但是对NIO和一些关键设计的理解非常有帮助。

3.1 缓冲区是否只读

  boolean isReadOnly(),判断此缓冲区数据是否只读,这是一个抽象方法,由具体的缓冲区类型提供是否只读信息,说实话我不是很喜欢这个方法命名,因为所有的缓冲区都是可读的,差别仅在于某些缓冲数据是不可写的,如缓冲一个只读文件时,缓冲数据是不可写的状态。

 CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
System.out.println(charBuffer.isReadOnly());

输出结果:
false

  当然我们可以通过一个可读写的缓冲区生成一个只读的缓冲区,通过asReadOnlyBuffer方法实现:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
System.out.println(charBuffer.isReadOnly());
CharBuffer newCharBuffer = charBuffer.asReadOnlyBuffer();
System.out.println(newCharBuffer.isReadOnly());

输出结果:
false
true

3.2 是否为直接缓冲区

  章节1.2 直接缓冲区中已经介绍过了什么是直接缓冲区,那么程序中通过boolean isDirect()方法来进行判定。基本上静态函数wraphe allocate创建的缓冲区都是非直接缓冲区,而通过allocateDirect方法创建的缓冲区是直接缓冲区,但是一定要注意因为直接缓冲区数据交互不在JVM中,那么数据类型就必须是系统能接受的类型,可用类型为ByteBuffer,其他Buffer的直接派生类中没有此方法:

 CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
System.out.println(charBuffer.isDirect());
charBuffer = CharBuffer.allocate(4);
System.out.println(charBuffer.isDirect());
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(10);
System.out.println(byteBuffer.isDirect());

输出结果:
false
false
true

3.3 是否由数组实现

  前文中介绍过,缓冲区实际上就是一块内存区,且Buffer的直接派生类仅仅是针对不同的数据类型来将其存储输出的数组包装为缓冲区类型,那么这个数组是否创建在JVM内存中呢?通过方法final boolean hasArray()进行判定。包含中间缓冲区的一定是由JVM数组实现的,而直接缓冲区数据直接和内核交互,因此不存在JVM数组:

CharBuffer charBuffer = CharBuffer.wrap(new char[]{'a', 'b', 'c'});
System.out.println(charBuffer.hasArray());
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(10);
System.out.println(byteBuffer.hasArray());

输出结果:
true
false

四 结语

  其实缓冲区没那么复杂,牢牢记住四个核心参数的大小关系,并且按使用场景来理解各个API,缓冲区就很简单了,比如说重写数据clear,写完要读flip,再读一遍rewind,等等。

  如果想关注更多硬技能的分享,可以参考积少成多系列传送门,未来每一篇关于硬技能的分享都会在传送门中更新链接。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章