javaNIO:缓冲区 Buffer

什么是缓冲区

一个缓冲区对象是固定数量的数据的容器,其作用是一个存储器,或者分段运输区,在这里数据可被存储并在之后用于检索。缓冲区像前篇文章讨论的那样被写满和释放,对于每个非布尔原始数据类型都有一个缓冲区类,尽管缓冲区作用于它们存储的原始数据类型,但缓冲区十分倾向于处理字节,非字节缓冲区可以再后台执行从字符或到字节的转换,这取决于缓冲区是如何创建的。

缓冲区的工作与通道紧密联系。通道是I/O传输发生时通过的入口,而缓冲区是这些数据传输的来源或目标。对于离开缓冲区的传输,待传递出去的数据被置于一个缓冲区,被传送到通道;待传回的缓冲区的传输,一个通道将数据放置在所提供的缓冲区中。这种在协同对象之间进行的缓冲区数据传递时高效数据处理的关键。


Buffer类的家谱

Buffer是nio引入的第一个特性,nio 是在jdk1.4 引入的

下图是Buffer的类层次图。在顶部是通用Buffer类,Buffer定义所有缓冲区类型共有的操作,无论是它们所包含的数据类型还是可能具有的特定行为:

 

传统的I/O不断的浪费对象资源(通常是String)。新I/O通过使用Buffer读写数据避免了资源浪费。Buffer对象是线性的,有序的数据集合,它根据其类别只包含唯一的数据类型。 

注意以下几点

 (

1.理解为与系统内核内存打交道的一个中间件,而不用每次频繁按照--(javaheap内存--jvm用户内存缓冲区--OS内核内存缓冲区--OS真实数据文件)这种方式访问OS内核数据,全部先一次性读到java heap,然后一次性(javaheap内存--用户内存缓冲区--OS内核内存缓冲区--OS真实数据文件)操作

2.使用缓存区还分直接内存和非直接内存几点,在笔记最后会讲到,nio引入的只有ByteBuffer.allocateDirect()能创建直接内存,其它的类似CharBuffer等Buffer类只能创建非直接内存。前面这篇文章写到字符流进阶BufferedWriter、BufferedReader虽然也用到了缓存,但是都是非直接内存的,和CharBuffer 等Buffer 实现的效果都是差不多的不同的就是

    1.Buffer比较灵活,能随意操作里面的数据,还能写给另一个Channel,更方便的操作与共享。

    2.传统的传统的I/O不断的浪费对象资源(通常是String)。新I/O通过使用Buffer读写数据避免了资源浪费。Buffer对象是线性的。  

    有序的数据集合其类类别只包含唯一的数据类型。传统io使用byte读取和写入和buffer对比,可能效率差不多。其实nio的buffer更加

    灵活,因为字节是封装到buffer里面的,可以更方便的使用传递,也避免了资源浪费(这里要注意一点,如果一次buffer读不完用

    老方法判断while ((len = channel.read(byteBuffer)) != -1)读取的长度,读了一遍写入到其他媒介之后,记得clear()让bytebuffer

    还原到位置0)

    3.和传统io对比,buffer read的时候不用指定读取多少字节,其内部机制已经实现了这种计算。buffer还可以对其进行子缓存区分片

    操作分成多个缓存区数据共享数据(也可以用于Buffer分片据传递,而不是整个buffer数据传递,只把真正需要的数据给分片出来传递即可)。 

    另外还可以利用多个buffer来实现聚集/分散io读取,实现方式就是ScatteringByteChannel

  GatheringByteChannel两个通道,这两个通道都支持使用数组buffer来读取

  4.总之使用nio buffer好处多多,nio buffer把字节数据封装的更易操作,更速度,更效率 具体可以参考

   http://www.ibm.com/developerworks/cn/education/java/j-nio/

3.操作系统的IO是以字节为单位的,因此,字节缓冲区跟其他缓冲区不同,对操作系统的IO只能是基于字节缓冲区的,所以通道(channel)只接收ByteBuffer作为参数,这也解释上面nio只有ByteBuffer能创建直接缓冲区的原因了,至于非直接缓冲区

 



缓冲区基础

概念上,缓冲区是包在一个对象内的基本数据元素数组。Buffer类相比一个简单数组的优点是它将关于数据的数据内容和信息包含在一个单一的对象中,Buffer类以及它专有的子类定义了一个用于处理数据缓冲区的API。下面来看一下Buffer类所具有的属性和方法:

所有的缓冲区都使用allocateDirect 或 allocate 来指定初始容量不可改变

1、属性

所有的缓冲区都具有四个属性来提供关于其所包含的数据元素的信息,它们是:

属      性作      用
capacity容量,指缓冲区能够容纳的数据元素的最大数量,这一容量在缓冲区创建时被设定,并且永远不能被改变。它跟allocate设置的容量是一致的
limit上界,指缓冲区的第一个不能被读或写的元素,(简单来说,limit当前位置及后面的位置都不能被读取)。或者说是,缓冲区中现存元素的计数(limit的初始值为缓冲区的容量,也就是allocate指定的容量,也是capaticy容量)
position位置,指下一个要被读或写的元素的索引,位置会自动由相应的get()和put()函数更新
mark标记,指一个备忘位置,调用mark()来设定mark=position,调用reset()来设定postion=mark,标记未设定前是未定义的

这四个属性总是遵循以下的关系:0 <= mark <= position <= limit <= capacity

2、方法

下面看一下如何使用一个缓冲区,Buffer中提供了以下的一些方法:

方      法作      用
Object array()返回此缓冲区的底层实现数组
int arrayOffset()返回此缓冲区的底层实现数组中第一个缓冲区还俗的偏移量
int capacity()返回此缓冲区的容量
Buffer clear()清除此缓冲区,postion和limit重置最初状态,但数据不会清除,数据处于被遗忘状态,因为postion位置针对已有的数据位置不准确,无法正常读取
Buffer flip()反转此缓冲区,其实就是开启读取模式把position改为0,limit改为写入数据时的最后postion,这样就能从0坐标读取到最后写数据的坐标,每次读取缓冲区的时候都必须调用该方法
boolean hasArray()告知此缓冲区是否具有可访问的底层实现数组
boolean hasRemaining()告知在当前位置和限制之间是否有元素
boolean isDirect()告知此缓冲区是否为直接缓冲区
boolean isReadOnly()告知此缓冲区是否为只读缓存
int limit()返回此缓冲区的上界
Buffer limit(int newLimit)设置此缓冲区的上界
Buffer mark()在此缓冲区的位置设置标记
int position()返回此缓冲区的位置
Buffer position(int newPosition)设置此缓冲区的位置
int remaining()返回当前位置与上界之间的元素数
Buffer reset()将此缓冲区的位置重置为以前标记的位置,配合mark().返回到mark()标记的位置,可以解决clear清空缓冲区positon为0无法定位到正确数据位置的情况,可以在写入的时候先标记写到哪里了,然后开启读取模式,读完之后clear,然后reset()返回写入时的position继续读取,当然mark()可以用于更多的场景比如重复读某几个位置的数据,先mark()标记,再reset
Buffer rewind()重绕此缓冲区,也就是重置positon为0 ,可以重复读数据

关于这个API有一点值得注意的,像clear()这类函数,通常应当返回的是void而不是Buffer引用。这些函数将引用返回到它们在(this)上被引用的对象,这是一个允许级联调用的类设计方法。级联调用允许这种类型的代码:

buffer.mark();
buffer.position(5);
buffer.reset();

被简写成:

buffer.mark().position(5).reset();

 

缓冲区代码实例

对缓冲区的使用,先看一段代码,然后解释一下:

 1 public class TestMain
 2 {
 3     /**
 4      * 待显示的字符串
 5      */
 6     private static String[] strs = 
 7     {
 8         "A random string value",
 9         "The product of an infinite number of monkeys",
10         "Hey hey we're the monkees",
11         "Opening act for the Monkees:Jimi Hendrix",
12         "Scuse me while I kiss this fly",
13         "Help Me! Help Me!"
14     };
15
16     /**
17      * 标识strs的下标索引
18      */
19     private static int index = 0;
20
21     /**
22      * 向Buffer内放置数据
23      */
24     private static boolean fillBuffer(CharBuffer buffer)
25     {
26         if (index >= strs.length)
27             return false;
28
29         String str = strs[index++];
30         for (int i = 0; i < str.length(); i++)
31         {
32             buffer.put(str.charAt(i));
33         }
34
35         return true;
36     }
37
38     /**
39      * 从Buffer内把数据拿出来
40      */
41     private static void drainBuffer(CharBuffer buffer)
42     {
43         while (buffer.hasRemaining())
44         {
45             System.out.print(buffer.get());
46         }
47         System.out.println("");
48     }
49
50     public static void main(String[] args)
51     {
52         CharBuffer cb = CharBuffer.allocate(100);
53         while (fillBuffer(cb))
54         {
55             cb.flip();
56             drainBuffer(cb);
57             cb.clear();
58         }
59     }
60 }

逐一解释一下:

1、第52行,CharBuffer是一个抽象类,它不能被实例化,因此利用allocate方法来实例化,相当于是一个工厂方法。实例化出来的是HeapCharBuffer,默认大小是100。根据上面的Buffer的类家族图谱,可以看到每个Buffer的子类都是使用allocate方法来实例化具体的子类的,且实例化出来的都是Heap*Buffer

2、第24行~第36行,每次取String数组中的一个,利用put方法放置一个数据进入CharBuffer中

3、第55行,调用flip方法,这是非常重要的。在缓冲区被写满后,必须将其清空,但是如果现在在通道上直接执行get()方法,那么它将从我们刚刚插入的有用数据之外取出未定义数据;如果此时将位置重新设置为0,就会从正确的位置开始获取数据,但是如何知道何时到达我们所插入数据末端呢?这就是上界属性被引入的目的----上界属性指明了缓冲区有效内容的末端。因此,在读取数据的时候我们需要做两件事情:

(1)将上界属性limit设置为当前位置    (2)将位置position设置为0

这两步操作,JDK API给开发者提供了一个filp()方法来完成,flip()方法将一个能够继续添加数据元素的填充状态的缓冲区翻转成一个准备读出元素的释放状态,因此每次准备读出元素前,都必须调用一次filp()方法

4、第41行~第48行,每次先判断一下是否已经达到缓冲区的上界,若存在则调用get()方法获取到此元素,get()方法会自动移动下标position

5、第57行,对Buffer的操作完成之后,调用clear()方法将所有属性回归原位,但是clear()方法并不会改变缓冲区中的任何数据

 

缓冲区比较

缓冲区的比较即equals方法,缓冲区的比较并不像我们想像得这么简单,两个缓冲区里面的元素一样就是相等,两个缓冲区相等必须满足以下三个条件:

1、两个对象类型相同,包含不同数据类型的buffer永远不会像等,而且buffer绝不会等于非buffer对象

2、两个对象都剩余相同数量的元素,Buffer的容量不需要相同,而且缓冲区中剩余数据的索引也不必相同。但每个缓冲区中剩余元素的数目(从position到limit)必须相同

3、在每个缓冲区中应被get()函数返回的剩余数据元素序列必须一致

如果不满足上面三个条件,则返回false。下面两幅图演示了两个缓冲区相等和不相等的场景,首先是两个属性不同的缓冲区也可以相等:

然后是两个属性相同但是被等为不相等的缓冲区:

 

 

批量移动数据

缓冲区的设计目的就是为了能够高效地传输数据。一次移动一个数据元素,其实并不高效,如在下面的程序清单中所看到的那样,Buffer API提供了向缓冲区内外批量移动数据元素的函数:

public abstract class CharBuffer
    extends Buffer
    implements Comparable<CharBuffer>, Appendable, CharSequence, Readable
{
    ...
    public CharBuffer get(char[] dst){...}
    public CharBuffer get(char[] dst, int offset, int length){...}
    public final CharBuffer put(char[] src){...}
    public CharBuffer put(char[] src, int offset, int length){...}
    public CharBuffer put(CharBuffer src){...}
    public final CharBuffer put(String src){...}
    public CharBuffer put(String src, int start, int end){...}
    ...      
}

其实这种批量移动的合成效果和前文的循环在底层实现上是一样的,但是这些方法可能高效得多,因为这种缓冲区实现能够利用本地代码或其他的优化来移动数据。

 

字节缓冲区

字节缓冲区和其他缓冲区类型最明显的不同在于,它们可能成为通道所执行I/O的源头或目标,如果对NIO有了解的朋友们一定知道,通道只接收ByteBuffer作为参数。

如我们所知道的,操作系统在内存区域进行I/O操作,这些内存区域,就操作系统方面而言,是相连的字节序列。于是,毫无疑问,只有字节缓冲区有资格参与I/O操作。也请回想一下操作系统会直接存取进程----在本例中是JVM进程的内存空间,以传输数据。这也意味着I/O操作的目标内存区域必须是连续的字节序列,在JVM中,字节数组可能不会在内存中连续存储,或者无用存储单元收集可能随时对其进行移动。在Java中,数组是对象,而数据存储在对象中的方式在不同的JVM实现中各有不同。

出于这一原因,引入了直接缓冲区(也就是ByteBuffer,可以看出来是由DirectByteBuffer直接缓冲区来实现的,nio的Buffer类只有这个ByteBuffer能创建直接缓冲区)的概念。直接缓冲区被用于与通道和固有I/O线程交互,它们通过使用固有代码来告知操作系统直接释放或填充内存区域,对用于通道直接或原始存取的内存区域中的字节元素的存储尽了最大的努力。

使用非直接缓存(Buffer其它非ByteBuffer的缓冲区类,比如CharBuffer)的原理解析:

直接字节缓冲区通常是I/O操作最好的选择。在设计方面,它们支持JVM可用的最高效I/O机制,非直接字节缓冲区可以被传递给通道,但是这样可能导致性能损耗,通常非直接缓冲不可能成为一个本地I/O操作的目标,如果开发者向一个通道中传递一个非直接ByteBuffer对象用于写入,通道可能会在每次调用中隐含地进行下面的操作:

1、创建一个临时的直接ByteBuffer对象 (因为nio的Buffer类只有ByteBuffer能创建直接内存

2、将非直接缓冲区的内容复制到临时缓冲中

3、使用临时缓冲区执行低层次I/O操作

4、临时缓冲区对象离开作用于,并最终成为被回收的无用数据

这可能导致缓冲区在每个I/O上复制并产生大量对象,而这种事都是我们极力避免的。

直接缓冲区是I/O的最佳选择,但可能比创建非直接缓冲区要花费更高的成本。直接缓冲区使用的内存是通过调用本地操作系统的代码分配的,绕过了标准JVM堆栈。建立和销毁直接缓冲区会明显比具有堆栈的缓冲区更极爱破费,这取决于主操作系统以及JVM实现。直接缓冲区的内存区域不受无用存储单元收集支配,因为它们位于标准JVM堆栈之外。

关于直接缓冲区与非直接缓冲区的两点

 1)非直接内存方式时,数据需要在如下空间进行复制,

    JVM Heap <----> JVM用户内存缓冲区 <----> OS内核内存缓冲区<----->OS真实数据文件;

 2)直接内存方式时,数据需要在如下空间进行复制,
    JVM用户内存缓冲区 <----> OS内核空间缓冲区<----->OS真实数据文件

从上面可以看出使用DirectByteBuffer直接缓冲区是直接和用户内存缓冲区打交道的,也就是不再heap里,所以这个我们不能来回收,虚有jvm底层来回收,省去了读到java heap内存中,自然就更快了

直接ByteBuffer是通过调用具有所需容量的ByteBuffer.allocateDirect()函数产生的:

public abstract class ByteBuffer
    extends Buffer
    implements Comparable<ByteBuffer>
{
    ...
    public static ByteBuffer allocateDirect(int capacity)
    {
        return new DirectByteBuffer(capacity);
    }
    ...
}

 


ps:
1.在100万次ByteBuffer读写测试下,DirectBuffer(直接内存) use : 105ms,而ByteBuffer(非直接内存) use : 225ms,快了一倍多。
2.在20000次创建对象的请求下,运行时参数-XX:MaxDirectMemorySize=10M -Xmx10M使用DirectBuffer的代码段相对耗时297ms,而使用ByteBuffer的相对耗时仅15ms


总结:DirectBuffer的读写操作比普通Buffer快,但它的创建、销毁却比普通Buffer慢。因此,在遇到数据比较少的情况,不建议使用直接内存,因为创建、销毁的效率已经完全把读取写入的优势给抹杀掉了。反而,如果遇到数据比较庞大的情况,使用直接内存是最合适了,这点还是要根据实际情况来看

posted on 2021-02-24 17:41  signheart  阅读(255)  评论(0编辑  收藏  举报

导航