Java NIO之缓存区buffer

1、前言
 
NIO中的两个核心对象是缓冲区和通道,缓冲区对象本质上是一个特殊的数组,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况,如果我们使用get()方法从缓冲区获取数据或者使用put()方法把数据写入缓冲区,都会引起缓冲区状态的变化。
 
一个缓冲区对象是固定数量的数据的容器,其作用是一个存储器,或者分段运输区,在这里数据可被存储并在之后用于检索。缓冲区可以被写满和释放,对于每个非布尔原始数据类型都有一个缓冲区类,尽管缓冲区作用于它们存储的原始数据类型,但缓冲区十分倾向于处理字节,非字节缓冲区可以在后台执行从字节或到字节的转换,这取决于缓冲区是如何创建的。
 
缓冲区的工作与通道紧密联系。通道是I/O传输发生时通过的入口,而缓冲区是这些数据传输的来源或目标。对于离开缓冲区的传输,待传递出去的数据被置于一个缓冲区,被传送到通道;待传回的缓冲区的传输,一个通道将数据放置在所提供的缓冲区中。这种在协同对象之间进行的缓冲区数据传递是高效数据处理的关键。
 
2、缓冲区类谱
下图是Buffer的类层次图。在顶部是通用Buffer类,Buffer定义所有缓冲区类型共有的操作,无论是它们所包含的数据类型还是可能具有的特定行为:
 
概念上,缓冲区是包在一个对象内的基本数据元素数组。Buffer类相比一个简单数组的优点是它将关于数据的数据内容和信息包含在一个单一的对象中,Buffer类以及它专有的子类定义了一个用于处理数据缓冲区的API。下面来看一下Buffer类所具有的属性和方法。
 
所有的缓冲区都具有四个属性来提供关于其所包含的数据元素的信息:
 
position:位置,指定了下一个将要被写入或者读取的元素索引,它的值由get()/put()方法自动更新,在新创建一个Buffer对象时,position被初始化为0,指下一个要被读或写的元素的索引,位置会自动由相应的get()和put()函数更新
 
limit:上界,指缓冲区的第一个不能被读或写的元素,或者说是,缓冲区中现存元素的计数,指定还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时)。
 
capacity:容量,指定了可以存储在缓冲区中的最大数据容量,实际上,它指定了底层数组的大小,或者至少是指定了准许我们使用的底层数组的容量。这一容量在缓冲区创建时被设定,并且永远不能被改变
 
mark: 标记,指一个备忘位置,调用mark()来设定mark=position,调用reset()来设定postion=mark,标记未设定前是未定义的
这四个属性总是遵循以下的关系:0 <= mark <= position <= limit <= capacity;
 
以上属性值之间有一些相对大小的关系:0<=position<=limit<=capacity。如果我们创建一个新的容量大小为10的ByteBuffer对象,在初始化的时候,position设置为0,limit和capacity被设置为10,在以后使用ByteBuffer对象过程中,capacity的值不会再发生变化,而其它两个个将会随着使用而变化。四个属性值分别如图所示:
现在我们可以从通道中读取一些数据到缓冲区中,注意从通道读取数据,相当于往缓冲区中写入数据。如果读取4个自己的数据,则此时position的值为4,即下一个将要被写入的字节索引为4,而limit仍然是10,如下图所示:
下一步把读取的数据写入到输出通道中,相当于从缓冲区中读取数据,在此之前,必须调用flip()方法,该方法将会完成两件事情:
 
1. 把limit设置为当前的position值
2. 把position设置为0
 
由于position被设置为0,所以可以保证在下一步输出时读取到的是缓冲区中的第一个字节,而limit被设置为当前的position,可以保证读取的数据正好是之前写入到缓冲区中的数据,如下图所示:
 
现在调用get()方法从缓冲区中读取数据写入到输出通道,这会导致position的增加而limit保持不变,但position不会超过limit的值,所以在读取我们之前写入到缓冲区中的4个自己之后,position和limit的值都为4,如下图所示:
在从缓冲区中读取数据完毕后,limit的值仍然保持在我们调用flip()方法时的值,调用clear()方法能够把所有的状态变化设置为初始化时的值,如下图所示:
下面用一段代码来验证这个过程,如下所示: 
 
public class BufferWithNIO {
    public static void main(String[] args)  throws IOException {
        FileInputStream fileInputStream = new FileInputStream("D:\\Files.txt");
        FileChannel fileChannel = fileInputStream.getChannel();
        ByteBuffer buffer  = ByteBuffer.allocate(10);
        output("初始化", buffer);
        fileChannel.read(buffer);
        output("调用read()", buffer);
        buffer.flip();
        output("调用flip()", buffer);
        while (buffer.remaining() > 0) {
            byte b = buffer.get();
            // System.out.print(((char)b));
        }
        output("调用get()", buffer);
        buffer.clear();
        output("调用clear()", buffer);
        fileInputStream.close();
    }
    public static void output(String step, Buffer buffer) {
        System.out.println(step + " : ");
        System.out.print("capacity: " + buffer.capacity() + ", ");
        System.out.print("position: " + buffer.position() + ", ");
        System.out.println("limit: " + buffer.limit());
        System.out.println();
    }
}
初始化 :
capacity: 10, position: 0, limit: 10
调用read() :
capacity: 10, position: 10, limit: 10
调用flip() :
capacity: 10, position: 0, limit: 10
调用get() :
capacity: 10, position: 10, limit: 10
调用clear() :
capacity: 10, position: 0, limit: 10
 
3、缓冲区分配
在前面的几个例子中,我们已经看过了,在创建一个缓冲区对象时,会调用静态方法allocate()来指定缓冲区的容量,其实调用 allocate()相当于创建了一个指定大小的数组,并把它包装为缓冲区对象。或者我们也可以直接将一个现有的数组,包装为缓冲区对象,如下示例代码所示:
public class BufferWrap {
    public void myMethod()
    {
        // 分配指定大小的缓冲区
        ByteBuffer buffer1 = ByteBuffer.allocate(10);
        
        // 包装一个现有的数组
        byte array[] = new byte[10];
        ByteBuffer buffer2 = ByteBuffer.wrap( array );
    }
}

  

4、缓冲区分片

在NIO中,除了可以分配或者包装一个缓冲区对象外,还可以根据现有的缓冲区对象来创建一个子缓冲区,即在现有缓冲区上切出一片来作为一个新的缓冲区,但现有的缓冲区与创建的子缓冲区在底层数组层面上是数据共享的,也就是说,子缓冲区相当于是现有缓冲区的一个视图窗口。调用slice()方法可以创建一个子缓冲区,让我们通过例子来看一下:
 
public void testSliceBuffer(){
    ByteBuffer buffer = ByteBuffer.allocate(10);
    
    // 缓冲区中的数据0-9
    for (int i=0; i<buffer.capacity(); i++) {
        buffer.put( (byte)i );
    }
    
    // 创建子缓冲区
    buffer.position(3);
    buffer.limit(7);
    ByteBuffer slice = buffer.slice();
    
    // 改变子缓冲区的内容
    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.print( buffer.get()  + " ");
    }
}
输出:
0 1 2 30 40 50 60 7 8 9  

在该示例中,分配了一个容量大小为10的缓冲区,并在其中放入了数据0-9,而在该缓冲区基础之上又创建了一个子缓冲区,并改变子缓冲区中的内容,从最后输出的结果来看,只有子缓冲区“可见的”那部分数据发生了变化,并且说明子缓冲区与原缓冲区是数据共享的。

 
 
5、只读缓冲区
只读缓冲区非常简单,可以读取它们,但是不能向它们写入数据。可以通过调用缓冲区的asReadOnlyBuffer()方法,将任何常规缓冲区转换为只读缓冲区,这个方法返回一个与原缓冲区完全相同的缓冲区,并与原缓冲区共享数据,只不过它是只读的。如果原缓冲区的内容发生了变化,只读缓冲区的内容也随之发生变化。
public void testReadOnlyBuffer(){
    ByteBuffer buffer = ByteBuffer.allocate( 10 );
    // 缓冲区中的数据0-9
    for (int i=0; i<buffer.capacity(); ++i) {
        buffer.put( (byte)i );
    }
    // 创建只读缓冲区
    ByteBuffer readonly = buffer.asReadOnlyBuffer();
    // 改变原缓冲区的内容
    for (int i=0; i<buffer.capacity(); ++i) {
        byte b = buffer.get( i );
        b *= 10;
        buffer.put( i, b );
    }
    readonly.position(0);
    readonly.limit(buffer.capacity());
    // 只读缓冲区的内容也随之改变
    while (readonly.remaining()>0) {
        System.out.print( readonly.get() + " ");
    }
}
输出:
0 10 20 30 40 50 60 70 80 90

如果尝试修改只读缓冲区的内容,则会报ReadOnlyBufferException异常。只读缓冲区对于保护数据很有用。在将缓冲区传递给某个对象的方法时,无法知道这个方法是否会修改缓冲区中的数据。创建一个只读的缓冲区可以保证该缓冲区不会被修改。只可以把常规缓冲区转换为只读缓冲区,而不能将只读的缓冲区转换为可写的缓冲区。

 
6、Buffer中提供的方法介绍:
Object array()    返回此缓冲区的底层实现数组
int arrayOffset()    返回此缓冲区的底层实现数组中第一个缓冲区还俗的偏移量
int capacity()    返回此缓冲区的容量
Buffer clear()    清除此缓冲区
Buffer flip()    反转此缓冲区
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()    将此缓冲区的位置重置为以前标记的位置
Buffer rewind()    重绕此缓冲区

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

buffer.mark();
buffer.position(5);
buffer.reset();
被简写成:
buffer.mark().position(5).reset();
 
7、缓冲区代码实例
对缓冲区的使用,先看一段代码,然后解释一下:
import java.nio.CharBuffer;
public class TestBuffer {
    /**
     * 待显示的字符串
     */
    private static String[] strs =
    {
        "A random string value",
        "The product of an infinite number of monkeys",
        "Hey hey we're the monkees",
        "Opening act for the Monkees:Jimi Hendrix",
        "Scuse me while I kiss this fly",
        "Help Me! Help Me!"
    };
    
    /**
     * 标识strs的下标索引
     */
    private static int index = 0;
 
    /**
     * 向Buffer内放置数据
     */
    private static boolean fillBuffer(CharBuffer buffer){
        if(index >= strs.length){
            return false;
        }
        String str = strs[index++];
        for(int i=0;i<str.length();i++){
            buffer.put(str.charAt(i));
        }
        return true;
    }
    
    /**
     * 从Buffer内把数据拿出来
     */
    private static void drainBuffer(CharBuffer buffer){
        
        while(buffer.hasRemaining()){
            System.out.print(buffer.get());            
        }
        System.out.println("");
    }
    
    public static void main(String[] args){
        CharBuffer cb = CharBuffer.allocate(100);
        while(fillBuffer(cb)){
            cb.flip();
            drainBuffer(cb);
            cb.clear();
        }
    }
}
逐一解释一下:
 
1、CharBuffer是一个抽象类,它不能被实例化,因此利用allocate方法来实例化,相当于是一个工厂方法。实例化出来的是HeapCharBuffer,默认大小是100。根据上面的Buffer的类家族图谱,可以看到每个Buffer的子类都是使用allocate方法来实例化具体的子类的,且实例化出来的都是Heap*Buffer。
 
2、第28行~第37行,每次取String数组中的一个,利用put方法放置一个数据进入CharBuffer中。
 
3、第54行,调用flip方法,这是非常重要的。在缓冲区被写满后,必须将其清空,但是如果现在在通道上直接执行get()方法,那么它将从我们刚刚插入的有用数据之外取出未定义数据;如果此时将位置重新设置为0,就会从正确的位置开始获取数据,但是如何知道何时到达我们所插入数据末端呢?这就是上界属性被引入的目的----上界属性指明了缓冲区有效内容的末端。因此,在读取数据的时候我们需要做两件事情:
 
(1)将上界属性limit设置为当前位置    (2)将位置position设置为0
 
这两步操作,JDK API给开发者提供了一个filp()方法来完成,flip()方法将一个能够继续添加数据元素的填充状态的缓冲区翻转成一个准备读出元素的释放状态,因此每次准备读元素read()前,都必须调用一次filp()方法。
 
4、第42行~第49行,每次先判断一下是否已经达到缓冲区的上界,若存在则调用get()方法获取到此元素,get()方法会自动移动下标position。
 
5、第56行,对Buffer的操作完成之后,调用clear()方法将所有属性回归原位,但是clear()方法并不会改变缓冲区中的任何数据。

  

 
8、缓冲区比较
 
缓冲区的比较即equals方法,缓冲区的比较并不像我们想像得这么简单,两个缓冲区里面的元素一样就是相等,两个缓冲区相等必须满足以下三个条件:
 
1、两个对象类型相同,包含不同数据类型的buffer永远不会像等,而且buffer绝不会等于非buffer对象。
 
2、两个对象都剩余相同数量的元素,Buffer的容量不需要相同,而且缓冲区中剩余数据的索引也不必相同。但每个缓冲区中剩余元素的数目(从position到limit)必须相同。
 
3、在每个缓冲区中应被get()函数返回的剩余数据元素序列必须一致。
 
如果不满足上面三个条件,则返回false。下面两幅图演示了两个缓冲区相等和不相等的场景,首先是两个属性不同的缓冲区也可以相等:
然后是两个属性相同但是被等为不相等的缓冲区:
 
 
9、批量移动数据
 
缓冲区的设计目的就是为了能够高效地传输数据。一次移动一个数据元素,其实并不高效,如在下面的程序清单中所看到的那样,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){...}
    ...      
}

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

 
10、字节缓冲区
 
字节缓冲区和其他缓冲区类型最明显的不同在于,它们可能成为通道所执行I/O的源头或目标,如果对NIO有了解的朋友们一定知道,通道只接收ByteBuffer作为参数。
 
如我们所知道的,操作系统在内存区域进行I/O操作,这些内存区域,就操作系统方面而言,是相连的字节序列。于是,毫无疑问,只有字节缓冲区有资格参与I/O操作。也请回想一下操作系统会直接存取进程----在本例中是JVM进程的内存空间,以传输数据。这也意味着I/O操作的目标内存区域必须是连续的字节序列,在JVM中,字节数组可能不会在内存中连续存储,或者无用存储单元收集可能随时对其进行移动。在Java中,数组是对象,而数据存储在对象中的方式在不同的JVM实现中各有不同。
 
出于这一原因,引入了直接缓冲区的概念。直接缓冲区被用于与通道和固有I/O线程交互,它们通过使用固有代码来告知操作系统直接释放或填充内存区域,对用于通道直接或原始存取的内存区域中的字节元素的存储尽了最大的努力。
 
直接字节缓冲区通常是I/O操作最好的选择。在设计方面,它们支持JVM可用的最高效I/O机制,非直接字节缓冲区可以被传递给通道,但是这样可能导致性能损耗,通常非直接缓冲不可能成为一个本地I/O操作的目标,如果开发者向一个通道中传递一个非直接ByteBuffer对象用于写入,通道可能会在每次调用中隐含地进行下面的操作:
 
1、创建一个临时的直接ByteBuffer对象
2、将非直接缓冲区的内容复制到临时缓冲中
3、使用临时缓冲区执行低层次I/O操作
4、临时缓冲区对象离开作用于,并最终成为被回收的无用数据
 
这可能导致缓冲区在每个I/O上复制并产生大量对象,而这种事都是我们极力避免的。
 
直接缓冲区是I/O的最佳选择,但可能比创建非直接缓冲区要花费更高的成本。直接缓冲区使用的内存是通过调用本地操作系统的代码分配的,绕过了标准JVM堆栈。建立和销毁直接缓冲区会明显比具有堆栈的缓冲区更极爱破费,这取决于主操作系统以及JVM实现。直接缓冲区的内存区域不受无用存储单元收集支配,因为它们位于标准JVM堆栈之外。
 
直接ByteBuffer是通过调用具有所需容量的ByteBuffer.allocateDirect()函数产生的:
 
public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>{
    ...
    public static ByteBuffer allocateDirect(int capacity){
        return new DirectByteBuffer(capacity);
    }
    ...
}

  

 
 
posted @ 2020-05-31 21:55  jrliu  阅读(472)  评论(0编辑  收藏  举报