美文网首页
Java网络编程之NIO-Buffer

Java网络编程之NIO-Buffer

作者: 程序员汪汪 | 来源:发表于2022-11-12 22:31 被阅读0次

    1. 前言

    上一篇文章中,粗略的介绍了BIO,有兴趣的可以去看一看。

    Java NIO部分,我大致会写4篇文章,前三篇介绍NIO的三个核心组件,第四篇介绍NIO的简单应用

    下面先了解下什么是NIO。

    2. 什么是NIO?

    JDK 1.4之前,基于Java的所有Socket通信都使用的是同步阻塞I/OBlocking I/O,即BIO),从JDK 1.4开始推出了java.nio包,就是新的IO包(New IO),时至今日,这个包已经不新了,但是它提供的是同步非阻塞的相关的I/O操作API,所以又把它称作为(No Blocking IO,即非阻塞IO)。

    Java NIO有三个核心组件:缓冲区(Buffer)、通道(Channel)、选择器(Selector)。这篇文章就介绍一下缓冲区(Buffer)。

    3. 什么是缓冲区(Buffer)?

    缓冲区(Buffer),就是用于存取固定数量的数据的容器,本质上是一个数组,但是相比较于数组,它功能更加强大。缓冲区是与通道(Channel)紧密相连的,通道传输的数据,就来自于缓冲区。

    Buffer类是所有缓冲区类的父类,Buffer的类层次结构图如下:

    可以看到,除了boolean类型,Java的其他基本数据类型都提供了相应的缓冲区类。网络编程中,网络通信使用的都是字节,所以用的最多的是ByteBuffer类。

    4. Buffer类中的四个重要属性

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

    1. 容量(Capacity

      缓冲区能够容纳的数据元素的最大数量。

    2. 上界(Limit

      缓冲区的第一个不能被读或写的元素,换句话说就是当前缓冲区中存了多少数据。稍后画个图就能理解了。

    3. 位置(Position

      下一个要被读或写的元素的索引(从0开始)。位置会自动由相应的get( )put( )函数更新。

    4. 标记(Mark

      用来标记当前位置(position),通过调用mark()来设置,标记在设置前是未定义的,在源码中初始值为-1,但这个值很明显是不可用的,并且没有提供方法可以直接拿到它的值。

    这四个属性之间遵循以下关系:

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

    假设现在创建了一个容量为5ByteBuffer,那么它的逻辑视图和四个属性如下所示:

    初始状态下,刚创建的容量为5ByteBuffer,四个属性如图所示,位置(position)设置为0,上界(limit)和容量(capacity)被设置为5,容量是固定的,缓冲区一旦创建,就不可改变,标记(mark)、位置(position)、上界(limit)这三个属性在进行相关操作时会被改变。

    5. Buffer API

    Buffer类中的部分方法:

    package java.nio;
    public abstract class Buffer { 
        // 获取缓冲区容量
        public final int capacity(); 
        // 获取位置
        public final int position() ;   
        // 设置新的位置,如果(标记)mark大于新的位置,那标记会丢失。
        public final Buffer position (int newPositio);  
        // 获取上界
        public final int limit();   
        // 设置新的上界,如果position > newLimit,那么position设置为newLimit,如果设置了mark,并且mark > newLimit,则mark会被丢弃
        public final Buffer limit (int newLimit) 
        // 在当前的位置(position)上设置标记
        public final Buffer mark();
        // 将position重置为先前标记的位置
        public final Buffer reset();
        // 清空缓冲区,丢弃标记,position设置为0,limit设置为容量
        public final Buffer clear();
        // 翻转缓冲区(切换为读模式),limit设置为当前position,position设置为0,如果定义了mark,那么它会被丢弃
        public final Buffer flip();
        // 翻转缓冲区,position设置为0,mark会被丢弃
        public final Buffer rewind();
        // 返回position到limit之间的元素个数
        public final int remaining();
        // 判断position和limit之间是否有元素
        public final boolean hasRemaining();
        // 判断缓冲区是否只读
        public abstract boolean isReadOnly();
    }
    

    这些API中,像clear()这类方法,是允许链式调用的,因为它的返回值是他的调用对象。例如:

    buffer.mark().position(4).reset();
    // 等价于
    buffer.mark();
    buffer.position(4);
    buffer.reset();
    

    所有的缓冲区(Buffer)都是可读的,但不是所有的都是可写的,每个具体的缓冲区类都通过执行isReadOnly()方法,来标示是否允许该缓冲区的内容被修改。尝试对只读的缓冲区进行修改,会抛出ReadOnlyBufferException异常。

    6. ByteBuffer 常用方法

    Java网络编程中,ByteBuffer是最常用的类,下面以它为例,介绍一些它常用的API,其它类型的Buffer的用法也是类似的。

    6.1 创建缓冲区

    创建缓冲区有 4 种方法,如下所示:

    public abstract class ByteBuffer extends Buffer implements Comparable{
        public static ByteBuffer allocateDirect(int capacity);  // 创建直接缓冲区 字节缓冲区特有
        public static ByteBuffer allocate(int capacity);        // 创建缓冲区,capacity为初始容量
        // 将数组作为存储空间来创建缓冲区
        public static ByteBuffer wrap (byte [] array);
        // 按照提供的 offset 和 length 参数 初始化 position 和 limit
        public static ByteBuffer wrap (byte [] array, int offset, int length);
    }
    

    但是这里有一个方法比较特殊,allocateDirect()创建的是直接缓冲区,它是ByteBuffer类特有的方法。而其它 3 个方法,创建的都是非直接缓冲区。

    非直接缓冲区传递给一个通道进行写操作时,通道每次都可能执行以下的步骤:

    1. 创建一个临时的直接ByteBuffer对象。
    2. 将非直接缓冲区的内容复制到临时缓冲区中。
    3. 使用临时缓冲区执行低级I/O操作。
    4. 临时缓冲区对象离开作用域,最终被垃圾回收。

    这就可能导致缓冲区在每个I/O上复制并产生大量对象,这是要尽量避免的事。

    那么什么是直接缓冲区?直接缓冲区是通过调用本地代码(native code)在物理内存中建立缓冲区,通道可以通过native code直接访问该内存区域,这样就大大提高了效率。但是这是有一定的风险的,直接缓冲区使用的内存是由操作系统分配的,绕过了JVM ,这意味着它不受垃圾回收机制的约束,什么时候销毁由操作系统决定。

    直接缓冲区与非直接缓冲区的性能会因为JVM、操作系统以及代码的设计的不同而不同,所以选择使用直接缓冲区还是非直接缓冲区,个人认为,还是先让程序跑起来,优化是后面的事情。

    创建一个新的缓冲区可以通过allocate()方法或者wrap()方法。allocate()方法创建一个缓冲区对象并分配一个私有的空间来存储capacity大小的数据元素。wrap()方法创建一个缓冲区对象,但是不分配任何空间来存储数据元素,它使用你提供的数组作为存储空间来存储缓冲区中的数据元素。

    使用allocate()创建一个容量为100ByteBuffer

    ByteBuffer buffer = ByteBuffer.allocate(100);
    

    这段代码隐含地从堆空间中分配了一个byte类型型数组来储存100byte类型的数据。

    如果你想自己提供一个数组来存储数据,那就可以使用wrap()方法:

    byte[] bytes = new byte[100];
    ByteBuffer buffer = ByteBuffer.wrap(bytes);
    

    这段代码创建了一个新的缓冲区,但是数据元素会存储在这个数组中。这意味着通过调用put()方法造成的对缓冲区的改动会直接影响这个数组,反之也是一样。

    使用带有offsetlength作为参数的wrap()方法则会构造一个按照您提供的offsetlength参数值初始化位置(position)和上界(limit)的缓冲区。比如:

    byte[] bytes = new byte[100];
    ByteBuffer buffer = ByteBuffer.wrap(bytes, 12, 20);
    

    这段代码创建了一个position值为12limit值为32,容量为bytes.length的缓冲区。这并不意味着创建的缓冲区只能访问这个数组的一部分空间,这个缓冲区依旧可以访问这个数组的所有存储空间(需要先调用clear()方法),offsetlength参数只是设置了初始的状态。

    代码示例:

    public class ByteBufferTest {
        public static void main(String[] args) {
    
            ByteBuffer buffer1 = ByteBuffer.allocate(100);  // 创建缓冲区
    
            // 提供一个数组作为存储空间,创建缓冲区
            byte[] bytes = new byte[100];
            ByteBuffer buffer2 = ByteBuffer.wrap(bytes);
    
            // 提供一个数组作为存储空间,创建缓冲区,并初始化 position 和 limit
            ByteBuffer buffer3 = ByteBuffer.wrap(bytes, 12, 20);
    
            // 输出三个缓冲区的 position、limit、capacity
            System.out.println("buffer1 position = " + buffer1.position() + " limit = " + buffer1.limit() + " capacity = " + buffer1.capacity());
            System.out.println("buffer2 position = " + buffer2.position() + " limit = " + buffer2.limit() + " capacity = " + buffer2.capacity());
            System.out.println("buffer3 position = " + buffer3.position() + " limit = " + buffer3.limit() + " capacity = " + buffer3.capacity());
    
            // 调用clear()
            buffer3.clear();
            System.out.println("*********** buffer3 调用clear()之后 ***********");
            System.out.println("buffer3 position = " + buffer3.position() + " limit = " + buffer3.limit() + " capacity = " + buffer3.capacity());
        }
    }
    /**
     * 输出:
     * buffer1 position = 0 limit = 100 capacity = 100
     * buffer2 position = 0 limit = 100 capacity = 100
     * buffer3 position = 12 limit = 32 capacity = 100
     * *********** buffer3 调用clear()之后 ***********
     * buffer3 position = 0 limit = 100 capacity = 100
     */
    

    6.2 数据的存取

    每个Buffer类都有get()put()方法,但是他们所需的参数类型以及返回类型对于每个Buffer子类来说都是唯一的,所以不能在父类(Buffer)中定义。

    ByteBuffer类数据的存取常用方法如下:

    public abstract class ByteBuffer extends Buffer implements Comparable{
        // 获取缓冲区当前位置(position)的字节,每调用一次 position 都会自动+1
        public abstract byte get(); 
        // 读取给定索引位置的字节
        public abstract byte get (int index); 
        // 将给定的字节写入缓冲区,每调用一次 position 都会自动+1
        public abstract ByteBuffer put (byte b); 
        // 将给定的字节,写入指定的索引位置
        public abstract ByteBuffer put (int index, byte b); 
    }
    

    示例代码:

    public class ByteBufferTest {
        public static void main(String[] args) {
    
            ByteBuffer buffer1 = ByteBuffer.allocate(10);  // 创建缓冲区
    
            // 提供一个数组作为存储空间,创建缓冲区
            byte[] bytes = new byte[10];
            ByteBuffer buffer2 = ByteBuffer.wrap(bytes);
    
            // 向 buffer1 缓冲区中填充数据
            for (int i = 0; i < 5; i++) {
                // 每调用一次 put(), position都会自动 +1
                buffer1.put((byte) i);
            }
            // 向 buffer1 缓冲区中填充数据
            for (int i = 0; i < buffer2.capacity(); i++) {
                // 每调用一次 put(), position都会自动 +1
                buffer2.put((byte) (i * 2));
            }
            // 填充完数据后 position 的值
            System.out.println("buffer1 position = " + buffer1.position());
            System.out.println("buffer2 position = " + buffer2.position());
            // 输出缓冲区中的所有数据
            System.out.println("******** buffer1 ********");
            for (int i = 0; i < buffer1.capacity(); i++) {
                System.out.print(buffer1.get(i) + ", ");
            }
            System.out.println();
            System.out.println("******** buffer2 ********");
            for (int i = 0; i < buffer2.capacity(); i++) {
    //            System.out.print(buffer2.get() + ", ");     // 抛出 BufferUnderflowException 异常
                System.out.print(buffer2.get(i) + ", ");
            }
    
            // 修改bytes数组中的数据
            bytes[0] = 20;
            // 输出缓冲区buffer2中的数据
            System.out.println();
            System.out.println("******** 修改了bytes 数组后的 buffer2 ********");
            for (int i = 0; i < buffer2.capacity(); i++) {
                System.out.print(buffer2.get(i) + ", ");
            }
            // 修改buffer2中指定位置的数据
            buffer2.put(0, (byte) 50);
            // 输出bytes 数组中的数据
            System.out.println();
            System.out.println("******** 修改了buffer2缓冲区后的 bytes ********");
            System.out.println(Arrays.toString(bytes));
        }
    }
    /**
     * 输出:
     * buffer1 position = 5
     * buffer2 position = 10
     * ******** buffer1 ********
     * 0, 1, 2, 3, 4, 0, 0, 0, 0, 0, 
     * ******** buffer2 ********
     * 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 
     * ******** 修改了bytes 数组后的 buffer2 ********
     * 20, 2, 4, 6, 8, 10, 12, 14, 16, 18, 
     * ******** 修改了buffer2缓冲区后的 bytes ********
     * [50, 2, 4, 6, 8, 10, 12, 14, 16, 18]
     */
    

    在这个例子中,buffer1缓冲区只填充了索引为0-4,共5个数据,填充数据之后的缓冲区,以及它的四个属性如下图所示:

    ByteBuffer在创建时,会用默认值填充数组,byte类型的默认值是0,所以使用get(index)方法获取数据,索引5-9中取出的数据都为0。如果ByteBuffer中被数据填满了,那么此时使用get()方法取数据,就会抛出java.nio.BufferUnderflowException异常(如例子中的buffer2),因为填满后,position = capacity。此时,如果不进行其他操作,一定要取数据,那么只能指定索引,使用get(index)方法。

    上面的例子,同时还证明了使用wrap()方法创建缓冲区,缓冲区将会使用提供的数组作为存储容器,修改数组中的元素会影响到缓冲区,反之亦然。

    6.3 flip()方法

    flip()方法是Buffer类中的方法,ByteBufferBuffer的子类,所以可以直接调用。

    flip本意是翻转的意思,用在这里并不贴切。缓冲区一般分为读模式写模式,所以flip()方法更确切的说法是:将写模式下的缓冲区切换为读模式

    如果一个缓冲区被写满了,那么现在想要读取缓冲区中的数据,如果直接调用get()方法,肯定会报错,因为写满时,position就等于capacity(上面提到过)。如果将position重置为0,那么此时就可以使用get()方法取出数据了,如果缓冲区中的数据没有写满,那又怎么知道什么时候获取到数据的末端了呢?这个时候limit这个属性就派上用场了。limit属性指明了缓冲区有效内容的末端,所以我们在开始读取数据前需要将limit属性设置为当前位置(position),然后将position重置为0

    代码示例:

    public class ByteBufferTest {
        public static void main(String[] args) {
    
            ByteBuffer buffer = ByteBuffer.allocate(10);  // 创建缓冲区
            // 填充数据
            for (int i = 0; i < 5; i++) {
                buffer.put((byte) i);
            }
    
            // 获取当前的 position 和 limit
            System.out.println("position = " + buffer.position());
            System.out.println("limit = " + buffer.limit());
            System.out.println("****************");
            // 将limit设置为当前position,并将position重置为 0
    //        buffer.limit(buffer.position()).position(0);
            buffer.flip();
            // 获取重置后的 position 和 limit
            System.out.println("position = " + buffer.position());
            System.out.println("limit = " + buffer.limit());
        }
    }
    /**
     * 输出:
     * position = 5
     * limit = 10
     * ****************
     * position = 0
     * limit = 5
     */
    

    除了上述的方法,设计API的大佬也给我们提供了flip()方法实现上面的功能。将上面的这行代码:

    buffer.limit(buffer.position()).position(0);
    

    换成flip()方法,能够达到同样的效果,上面的完整代码,感兴趣可以自己运行一下。

    为了便于理解,画个图看看。下图为填充数据之后的缓冲区及其四个属性的示意简图:

    调用flip()切换为读模式后的缓冲区及其四个属性的示意简图:

    再来看看flip()方法的源码:

    public final Buffer flip() {
        limit = position;
        position = 0;
        mark = -1;
        return this;
    }
    

    就是这么简单。

    值得注意的是,rewind()方法与flip()方法相似,唯一的区别就是rewind()方法不会影响limit属性,它只是将position重置为0

    如果连续调用两次flip()会怎么样?

    如果一个缓冲区连续调用两次flip()方法,那么它就存储不了任何数据了,相当于容量变为了0(不是说capacity属性值为0)。第一次调用flip()方法切换为读模式,会把limit的值设置为position的值,然后将position设置为0。第二次调用flip()方法切换为读模式,那么limit被设置为position的值,position设置为0,此时positionlimit的值都为0,如果调用get()方法会导致BufferUnderflowException异常。调用put()方法则会导致BufferOverflowException异常。这种情况下,那和没有这个缓冲区没啥两样了(查看capacity属性,依旧获取的是ByteBuffer初始化的大小)。这里就不用代码演示了。

    6.4 clear()方法

    clear本意有清理的意思,那用在这里肯定就是清空缓冲区的意思,这句话对也不对,我们看看clear()方法的源码:

    public final Buffer clear() {
        position = 0;
        limit = capacity;
        mark = -1;
        return this;
    }
    

    从源码可以看到,clear()方法并没有改变缓冲区中的任何元素,也没有删除缓冲区中的数据,它只是将上界(limit)设置为容量(capacity)的值,并把position设置为0

    那为什么说它清空了缓冲区呢?

    在将缓冲区中的数据完全读取完毕后,position的值就等于limit属性的值,此时想要重复利用这个缓冲区,就需要将缓冲区中的数据清空,缓冲区本质上就是一个数组,清空一个数组中的数据,仔细想想好像没有这样的方法,既然清空不行,那我将需要存入的数据直接覆盖原有的数据不就行了。需要覆盖原有的数据,就要将positionlimit恢复为初始状态,也就是position = 0limit = capacity,而clear()方法的方法体做的就是这个操作,那么从某种意义上来说,clear()就相当于清空了缓冲区。

    前面我们说缓冲区有读和写两种模式,flip()是将写模式切换为读模式,那么clear()方法可以说是将读模式切换为写模式

    6.5 hasRemaining()和remaining()

    在获得一个缓冲区时,我们不清楚它是否被写满,如果我们想要获得其中的有意义的数据(而不是默认值或垃圾数据),就需要调用flip()方法将缓冲区切换为读模式。切换为读模式之后,我们读取数据时,就需要知道是否已经达到缓冲区的上界。这里就需要用到hasRemaining()方法或者remaining()方法。

    • hasRemaining()方法:判断当前positionlimit之间是否有元素。
    • remaining()方法:返回当前positionlimit之间的元素个数。

    示例代码如下:

    public class ByteBufferTest {
        public static void main(String[] args) {
    
            ByteBuffer buffer = ByteBuffer.allocate(10);  // 创建缓冲区
    
            for (int i = 0; i < 5; i++) {
                buffer.put((byte) i);   // 填充数据
            }
    
            // 切换为读模式
            buffer.flip();
    
            // 获取缓冲区中的数据
    //        while (buffer.hasRemaining()) {
    //            System.out.println(buffer.get());
    //        }
    
            int count = buffer.remaining();
            for (int i = 0; i < count; i++) {
                System.out.println(buffer.get());
            }
    
        }
    }
    

    注意:remaining()搭配for循环使用时,千万不要这么写:

    for (int i = 0; i < buffer.remaining(); i++){
      System.out.println(buffer.get());
    }
    

    buffer.remaining()获取的是positionlimit之间的元素个数,每次执行完get()方法position都会+1,所以buffer.remaining()是一直在变的,这么写会漏掉一部分数据。如果非要这么写,那就使用get(index)方法,带参数的get()方法不会影响position

    7. 总结

    这篇文章以ByteBuffer为例简单的介绍了一下缓冲区,以及部分方法的使用,除了创建直接缓冲区allocateDirect()这个方法是ByteBuffer类独有的,其他方法在其他的缓冲区类中都有相应的方法。文中没讲到的方法,后续有使用到的话,会在其他文章中补齐。

    参考资料:

    《Java NIO》Ron Hitchens

    相关文章

      网友评论

          本文标题:Java网络编程之NIO-Buffer

          本文链接:https://www.haomeiwen.com/subject/uqukxdtx.html