「高并发通信框架Netty4 源码解读(四)」NIO缓冲区之字节缓冲区ByteBuffer详解

时间:2022-07-24
本文章向大家介绍「高并发通信框架Netty4 源码解读(四)」NIO缓冲区之字节缓冲区ByteBuffer详解,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

笔者工作中用到最多的就是ByteBuffer缓冲区。因为字节是操作系统及其 I/O 设备使用的基本数据类型。当在 JVM 和操作系统间传递数据时,将其他的数据类型拆分成构成它们的字节是十分必要的。系统层次的 I/O 面向字节的性质可以在整个缓冲区的设计以及它们互相配合的服务中感受到。当然实际上笔者也不会用NIO中的ByteBuffer,而是利用Netty这个NIO框架中的缓冲区,本专题是讲Netty源码的,弄清楚NIO原理是阅读Netty源码的基础。

字节顺序

非字节类型的基本类型,除了布尔型 3都是由组合在一起的几个字节组成的。这些数据类型及其大小总结在下表中

数据类型

大小(以字节表示)

Byte

1

Char

2

Short

2

Int

4

Long

8

Float

4

Double

8

每个基本数据类型都是以连续字节序列的形式存储在内存中。例如, 32 位的 int 值0x037fb4c7(十进制的 58,700,999)可能会如下图所显示的那样被塞入内存字节中(内存地址从左往右增加),即大端字节顺序。

注意前一个句子中的“可能”一词。尽管字节大小已经被确定,但字节顺序问题一直没有被广泛认同。表示一个整型值的字节可能在内存中仅仅如下图所示的那样被简单地排列。

多字节数值被存储在内存中的方式一般被称为 endian-ness(字节顺序)。如果数字数值的最高字节——big end(大端),位于低位地址,那么系统就是大端字节顺序。如果最低字节最先保存在内存中,那么小端字节顺序。字节顺序很少由软件设计者决定;它通常取决于硬件设计。字节顺序的两种类型有时被称为字节性别,在当今被广泛使用。两种方式都具有自身的优势。 Intel 处理器使用小端字节顺序涉及。摩托罗拉的 CPU 系列、 SUN 的 Sparc 工作站,以及 PowerPC 的 CPU 架构都采用大端字节顺序。字节顺序的问题甚至胜过CPU硬件设计。当Internet的设计者为互联各种类型的计算机而设计网际协议(IP)时,他们意识到了在具有不同内部字节顺序的系统间传递数值数据的问题。因此, IP协议规定了使用大端的网络字节顺序概念。所有在IP分组报文的协议部分中使用的多字节数值必须先在本地主机字节顺序和通用的网络字节顺序之间进行转换。

在 java.nio 中,字节顺序由 ByteOrder 类封装。

package java.nio;
public final class ByteOrder
{
public static final ByteOrder BIG_ENDIAN
  public static final ByteOrder LITTLE_ENDIAN
  public static ByteOrder nativeOrder( )
  public String toString( )
}

ByteOrder 类定义了决定从缓冲区中存储或检索多字节数值时使用哪一字节顺序的常量。这个类的作用就像一个类型安全的枚举。它定义了以其本身实例预初始化的两个 public区域。只有这两个 ByteOrder 实例总是存在于 JVM 中,因此它们可以通过使用--操作符进行比较。如果您需要知道 JVM 运行的硬件平台的固有字节顺序,请调用静态类函数nativeOrder()。它将返回两个已确定常量中的一个。调用 toString()将返回一个包含两个文字字符串 BIG_ENDIAN 或者 LITTLE_ENDIAN 之一的 String。 每个缓冲区类都具有一个能够通过调用 order()查询的当前字节顺序设定。比如CharBuffer

public abstract class CharBuffer extends Buffer
implements Comparable, CharSequence
{
// This is a partial API listing
public final ByteOrder order( )
}

这个函数从 ByteOrder 返回两个常量之一。对于除了 ByteOrder 之外的其他缓冲区类,字节顺序是一个只读属性,并且可能根据缓冲区的建立方式而采用不同的值。除了ByteBuffer , 其他通过分配或包装一个数组所创建的缓冲区将从order()返回与ByteOrder.nativeOrder()相同的数值。这使因为包含在缓冲区中的元素在 JVM 中将会被作为基本数据直接存取。 ByteBuffer 类有所不同:默认字节顺序总是 ByteBuffer.BIG_ENDIAN,无论系统的固有字节顺序是什么。 Java 的默认字节顺序是大端字节顺序,这允许类文件等以及串行化的对象可以在任何 JVM 中工作。如果固有硬件字节顺序是小端,这会有性能隐患。在使用固有硬件字节顺序时,将 ByteBuffer 的内容当作其他数据类型存取(很快就会讨论到)很可能高效得多。 很可能您会对为什么 ByteBuffer 类需要一个字节顺序设定这一问题感到困惑。字节就是字节,对吗?当很可能您会对为什么 ByteBuffer 类需要一个字节顺序设定这一问题感到困惑。字节就是字节,对吗?当然,ByteBuffer 对象像其他基本数据类型一样,具有大量便利的函数用于获取和存放缓冲区内容。这些函数对字节进行编码或解码的方式取决于 ByteBuffer 当前字节顺序的设定。 ByteBuffer 的字 符顺序 设 定可 以随 时 通过 调用 以 ByteOrder.BIG_ENDIAN 或ByteOrder.LITTL_ENDIAN 为参数的 order()函数来改变。

public abstract class ByteBuffer extends Buffer
implements Comparable
{
    // This is a partial API listing
    public final ByteOrder order( )
    public final ByteBuffer order (ByteOrder bo)
}

如果一个缓冲区被创建为一个 ByteBuffer 对象的视图,那么order()返回的数值就是视图被创建时其创建源头的 ByteBuffer 的字节顺序设定。视图的字节顺序设定在创建后不能被改变,而且如果原始的字节缓冲区的字节顺序在之后被改变,它也不会受到影响。

直接缓冲区

字节缓冲区跟其他缓冲区类型最明显的不同在于,它们可以成为通道所执行的 I/O 的源头和/或目标。其实,通道只接收 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 堆栈之外。

使用直接缓冲区或非直接缓冲区的性能权衡会因JVM,操作系统,以及代码设计而产生巨大差异。通过分配堆栈外的内存,您可以使您的应用程序依赖于JVM未涉及的其它力量。当加入其他的移动部分时,确定您正在达到想要的效果。我以一条旧的软件行业格言建议您:先使其工作,再加快其运行。不要一开始就过多担心优化问题;首先要注重正确性。 JVM实现可能会执行缓冲区缓存或其他的优化, 这会在不需要您参与许多不必要工作的情况下为您提供所需的性能。

直接 ByteBuffer 是通过调用具有所需容量的 ByteBuffer.allocateDirect()函数产生的,就像我们之前所涉及的 allocate()函数一样。注意用一个 wrap()函数所创建的被包装的缓冲区总是非直接的。

public abstract class ByteBuffer
extends Buffer implements Comparable
{
    // This is a partial API listing
    public static ByteBuffer allocate (int capacity)
    public static ByteBuffer allocateDirect (int capacity)
    public abstract boolean isDirect( );
}

ByteBuffer diByteBuffer = ByteBuffer.allocateDirect(100);看allocateDirect的源码:

 public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity);
    }
    DirectByteBuffer(int cap) {                   // package-private

        super(-1, 0, cap, cap);
        boolean pa = VM.isDirectMemoryPageAligned();
        int ps = Bits.pageSize();
        long size = Math.max(1L, (long)cap + (pa ? ps : 0));
        Bits.reserveMemory(size, cap);

        long base = 0;
        try {
            base = unsafe.allocateMemory(size);
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
        unsafe.setMemory(base, size, (byte) 0);
        if (pa && (base % ps != 0)) {
            // Round up to page boundary
            address = base + ps - (base & (ps - 1));
        } else {
            address = base;
        }
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;
    }

这里面有一行分配内存的方法base = unsafe.allocateMemory(size);是用来分配堆外内存的,源码:

    public native long allocateMemory(long var1);

本地方法,由操作系统实现。

视图缓冲区

就像我们已经讨论的那样, I/O基本上可以归结成组字节数据的四处传递。在进行大数据量的 I/O 操作时,很又可能您会使用各种ByteBuffer类去读取文件内容,接收来自网络连接的数据,等等。一旦数据到达了您的 ByteBuffer, 您就需要查它以决定怎么做或者在将它发送出去之前对它进行一些操作。 ByteBuffer 类提供了丰富的 API 来创建视图缓冲区。

视图缓冲区通过已存在的缓冲区对象实例的工厂方法来创建。这种视图对象维护它自己的属性,容量,位置,上界和标记,但是和原来的缓冲区共享数据元素。我们已经在 上一篇博文过了这样的简单例子,在例子中一个缓冲区被复制和切分。但是 ByteBuffer 类允许创建视图来将 byte 型缓冲区字节数据映射为其它的原始数据类型。例如, asLongBuffer()函数 创建一个将八个字节型数据当成一个 long 型数据来存取的视图缓冲区。 下面列出的每一个工厂方法都在原有的 ByteBuffer 对象上创建一个视图缓冲区。调用其中的任何一个方法都会创建对应的缓冲区类型,这个缓冲区是基础缓冲区的一个切分,由基础缓冲区的位置和上界决定。新的缓冲区的容量是字节缓冲区中存在的元素数量除以视图类型中组成一个数据类型的字节数(参见上面的表格)。在切分中任一个超过上界的元素对于这个视图缓冲区都是不可见的。视图缓冲区的第一个元素从创建它的 ByteBuffer 对象的位置开始( positon()函数的返回值)。具有能被自然数整除的数据元素个数的视图缓冲区是一种较好的实现。 下面创建一个IntBuffer视图缓存

public abstract class ByteBuffer
extends Buffer implements Comparable
{
// This is a partial API listing
public abstract CharBuffer asCharBuffer( );
public abstract ShortBuffer asShortBuffer( );
public abstract IntBuffer asIntBuffer( );
public abstract LongBuffer asLongBuffer( );
public abstract FloatBuffer asFloatBuffer( );
public abstract DoubleBuffer asDoubleBuffer( );
}

举个例子asIntBuffer

public class Test {
    public static void main(String[] args) {
        ByteBuffer byteBuffer = ByteBuffer.allocate(100);
        IntBuffer intBufferBuffer = null;
        byteBuffer.put((byte) 'H');
        byteBuffer.put((byte) 'e');
        byteBuffer.put((byte) 'l');
        byteBuffer.put((byte) 'l');
        byteBuffer.flip();
        //小端存储
        byteBuffer = byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
        intBufferBuffer =  byteBuffer.asIntBuffer();
        System.out.println(byteBuffer.get());
        //大端存储
        byteBuffer = byteBuffer.order(ByteOrder.BIG_ENDIAN);
        intBufferBuffer =  byteBuffer.asIntBuffer();
        System.out.println(byteBuffer.get());
    }
}

打印如下

asIntBuffer源码如下:

    public IntBuffer asIntBuffer() {
        int size = this.remaining() >> 2;
        int off = offset + position();
        return (bigEndian
                ? (IntBuffer)(new ByteBufferAsIntBufferB(this,
                                                             -1,
                                                             0,
                                                             size,
                                                             size,
                                                             off))
                : (IntBuffer)(new ByteBufferAsIntBufferL(this,
                                                             -1,
                                                             0,
                                                             size,
                                                             size,
                                                             off)));
    }

ByteBufferAsIntBufferB(ByteBuffer bb,
                                     int mark, int pos, int lim, int cap,
                                     int off)
    {

        super(mark, pos, lim, cap);
        this.bb = bb;
        offset = off;
    }
 ByteBufferAsIntBufferL(ByteBuffer bb,
                                     int mark, int pos, int lim, int cap,
                                     int off)
    {
        super(mark, pos, lim, cap);
        this.bb = bb;
        offset = off;
    }

其实底层维护了一个ByteBuffer的引用,一切操作都基于ByteBuffer。

无论何时一个视图缓冲区存取一个 ByteBuffer 的基础字节,这些字节都会根据这个视图缓冲区的字节顺序设定被包装成一个数据元素。当一个视图缓冲区被创建时,视图创建的同时它也继承了基础 ByteBuffer 对象的字节顺序设定。这个视图的字节排序不能再被修改。 当直接从 byte 型缓冲区中采集数据时,视图缓冲区拥有提高效率的潜能。如果这个视图的字节顺序和本地机器硬件的字节顺序一致,低等级的(相对于高级语言而言)语言的代码可以直接存取缓冲区中的数据值,而不是通过比特数据的包装和解包装过程来完成。

数据元素视图

ByteBuffer 类提供了一个不太重要的机制来以多字节数据类型的形式存取 byte 数据组。 ByteBuffer 类为每一种原始数据类型提供了存取的和转化的方法:

public abstract class ByteBuffer
extends Buffer implements Comparable
{
public abstract char getChar( );
public abstract char getChar (int index);
public abstract short getShort( );
public abstract short getShort (int index);
public abstract int getInt( );
public abstract int getInt (int index);
public abstract long getLong( );
public abstract long getLong (int index);
public abstract float getFloat( );
public abstract float getFloat (int index);
public abstract double getDouble( );
public abstract double getDouble (int index);
public abstract ByteBuffer putChar (char value);
public abstract ByteBuffer putChar (int index, char value);
public abstract ByteBuffer putShort (short value);
public abstract ByteBuffer putShort (int index, short value);
public abstract ByteBuffer putInt (int value);
public abstract ByteBuffer putInt (int index, int value);
public abstract ByteBuffer putLong (long value);
public abstract ByteBuffer putLong (int index, long value);
public abstract ByteBuffer putFloat (float value);
public abstract ByteBuffer putFloat (int index, float value);
public abstract ByteBuffer putDouble (double value);
public abstract ByteBuffer putDouble (int index, double value);
}

这些函数从当前位置开始存取 ByteBuffer 的字节数据,就好像一个数据元素被存储在那里一样。根据这个缓冲区的当前的有效的字节顺序,这些字节数据会被排列或打乱成需要的原始数据类型。比如说,如果 getInt()函数被调用,从当前的位置开始的四个字节会被包装成一个 int 类型的变量然后作为函数的返回值返回。

存取无符号数据

Java 编程语言对无符号数值并没有提供直接的支持(除了 char 类型)。但是在许多情况下您需要将无符号的信息转化成数据流或者文件,或者包装数据来创建文件头或者其它带有无符号数据区域结构化的信息。 ByteBuffer 类的 API 对此并没有提供直接的支持,但是要实现并不困难。 您只需要小心精度的问题。当您必须处理缓冲区中的无符号数据时,下例中的工具类可能会非常有帮助

import java.nio.ByteBuffer;

/**
 * 向 ByteBuffer 对象中获取和存放无符号值的工具类。
 * 这里所有的函数都是静态的,并且带有一个 ByteBuffer 参数。
 * 由于 java 不提供无符号原始类型,每个从缓冲区中读出的无符号值被升到比它大的
 * 下一个基本数据类型中。
 * getUnsignedByte()返回一个 short 类型, getUnsignedShort( )
 * 返回一个 int 类型,而 getUnsignedInt()返回一个 long 型。 There is no
 * 由于没有基本类型来存储返回的数据,因此没有 getUnsignedLong( )。
 * 如果需要,返回 BigInteger 的函数可以执行。
 * 同样,存放函数要取一个大于它们所分配的类型的值。
 * putUnsignedByte 取一个 short 型参数,等等。
 *
 * @author Ron Hitchens (ron@ronsoft.com)
 */
public class Unsigned {
    public static short getUnsignedByte(ByteBuffer bb) {
        return ((short) (bb.get() & 0xff));
    }

    public static void putUnsignedByte(ByteBuffer bb, int value) {
        bb.put((byte) (value & 0xff));
    }

    public static short getUnsignedByte(ByteBuffer bb, int position) {
        return ((short) (bb.get(position) & (short) 0xff));
    }

    public static void putUnsignedByte(ByteBuffer bb, int position,
                                       int value) {
        bb.put(position, (byte) (value & 0xff));
    }

    // ---------------------------------------------------------------
    public static int getUnsignedShort(ByteBuffer bb) {
        return (bb.getShort() & 0xffff);
    }

    public static void putUnsignedShort(ByteBuffer bb, int value) {
        bb.putShort((short) (value & 0xffff));
    }

    public static int getUnsignedShort(ByteBuffer bb, int position) {
        return (bb.getShort(position) & 0xffff);
    }

    public static void putUnsignedShort(ByteBuffer bb, int position,
                                        int value) {
        bb.putShort(position, (short) (value & 0xffff));
    }

    // ---------------------------------------------------------------
    public static long getUnsignedInt(ByteBuffer bb) {
        return ((long) bb.getInt() & 0xffffffffL);
    }

    public static void putUnsignedInt(ByteBuffer bb, long value) {
        bb.putInt((int) (value & 0xffffffffL));
    }

    public static long getUnsignedInt(ByteBuffer bb, int position) {
        return ((long) bb.getInt(position) & 0xffffffffL);
    }

    public static void putUnsignedInt(ByteBuffer bb, int position,
                                      long value) {
        bb.putInt(position, (int) (value & 0xffffffffL));
    }
}

内存映射缓冲区

映射缓冲区是带有存储在文件,通过内存映射来存取数据元素的字节缓冲区。映射缓冲区通常是直接存取内存的,只能通过 FileChannel 类创建。映射缓冲区的用法和直接缓冲区类似,但是 MappedByteBuffer 对象可以处理独立于文件存取形式的的许多特定字符。笔者将在Channle章节详细讲解。