文章目录
- 1. 简介
- 2. 相关属性
- 3. 相关方法
- 4. 直接内存深入理解
- 5. 零拷贝
- 6. Java生态中的0拷贝
1. 简介
Buffer缓冲区实际上就是一个数组,把数组的内容和信息包装成一个Buffer对象,它提供了一组访问这些信息的方法。
2. 相关属性
- Capacity
作为一个内存块,Buffer 有一个固定的大小值,也叫“capacity”。你只能往里写 capacity 个 byte、long,char 等类型。一旦 Buffer 满了,需要将其清空(通过读数据或者清除数据) 才能继续写数据往里写数据。
- Position
当你写数据到 Buffer 中时,position 表示当前能写的位置。初始的 position 值为 0。当一 个 byte、long 等数据写到 Buffer 后, position 会向前移动到下一个可插入数据的 Buffer 单元。position 最大可为 capacity – 1。当读取数据时,也是从某个特定位置读。当将 Buffer 从写模式切换到读模式,position 会被重置为0.。当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。
- Limit
在写模式下,Buffer 的 limit 表示你最多能往 Buffer 里写多少数据。 写模式下,limit 等 于 Buffer 的capacity。当切换 Buffer 到读模式时, limit 表示你最多能读到多少数据。因此,当切换 Buffer 到读模式时,limit 会被设置成写模式下的 position 值。换句话说,你能读到之前写入的所有数据(limit 被设置成已写数据的数量,这个值在写模式下就是 position)。
3. 相关方法
public abstract class Buffer {
SPLITERATOR_CHARACTERISTICS =
Spliterator.SIZED | Spliterator.SUBSIZED | Spliterator.ORDERED;
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
//这个属性只能用于直接内存
long address;
//构造方法
Buffer(int mark, int pos, int lim, int cap) { // package-private
if (cap < 0)
throw new IllegalArgumentException("Negative capacity: " + cap);
this.capacity = cap;
limit(lim);
position(pos);
if (mark >= 0) {
if (mark > pos)
throw new IllegalArgumentException("mark > position: ("
+ mark + " > " + pos + ")");
this.mark = mark;
}
}
//返回当前Buffer的容量
public final int capacity() {
return capacity;
}
//返回当前position在buffer数组的下标
public final int position() {
return position;
}
//这个可以用来设置position
public final Buffer position(int newPosition) {
//判断position是否合法
if ((newPosition > limit) || (newPosition < 0))
throw createPositionException(newPosition);
//mark的位置必须小于position,如果设置新的positon小于mark的位置,则mark失效,设置为-1
if (mark > newPosition) mark = -1;
//设置新的postion
position = newPosition;
return this;
}
//获得当前buffer的limit指针所在的位置
public final int limit() {
return limit;
}
//设置limit
public final Buffer limit(int newLimit) {
//在写模式下limit必须小于capacity
if ((newLimit > capacity) || (newLimit < 0))
throw new IllegalArgumentException();
limit = newLimit;
//position永远小于limit,limit可以理解为position操作位置的上限,如果设置的limit的位置小于
//position,原来position的位置就设置成和新的limit的值一样
if (position > newLimit) position = newLimit;
//mark同样
if (mark > newLimit) mark = -1;
return this;
}
//标记mark为当前position的位置,有了mark我们对buffer的操作就更加灵活了,例如可以进行回溯等操作
public final Buffer mark() {
mark = position;
return this;
}
//这个方法可以理解为利用mark标记做回溯操作
public final Buffer reset() {
int m = mark;
//如果调用了该方法,但mark没有设置就会报错
if (m < 0)
throw new InvalidMarkException();
position = m;
return this;
}
//清空buffer缓存,在这里我们可以得到一个结论,即buffer的清空并不是真正删除buffer里面的数据,而是设置
//position,limit和mark的位置即可
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
//切换buffer的读写模式
public final Buffer flip() {
limit = position;
position = 0;
//切换模式mark会失效
mark = -1;
return this;
}
// 将position设置为0,取消mark标志位
public final Buffer rewind() {
position = 0;
mark = -1;
return this;
}
//返回当前position位置与limit之间的数量
public final int remaining() {
int rem = limit - position;
return rem > 0 ? rem : 0;
}
// 判断当前position后面是否还有可处理的数据,即判断position与limit之间是否还有数据可处理
public final boolean hasRemaining() {
return position < limit;
}
//判断当前的buffer是否使用直接内存
public abstract boolean isDirect();
}
上面对Buffer抽象类的方法进行了梳理,有了上面的方法我们就可以很灵活的来操作Buffer缓存。
4. 直接内存深入理解
在学习JVM的内存管理区域,我们了解到了一个陌生的直接内存,我们知道JVM本身也是一个进程,它也有自己的内存空间,而JVM内存空间就是我们了解的方法区、虚拟机栈等,然后直接内存并不是虚拟机运行时数据区的一部分,我们了解的NIO就可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用来进行操作,这就是直接内存。站在IO的角度,我们看看Java使用直接内存的意义在哪里?
在所有的网络通信和应用程序中,每个 TCP 的 Socket 的内核中都有一个发送缓冲区 (SO_SNDBUF)和一个接收缓冲区(SO_RECVBUF),可以使用相关套接字选项来更改该缓冲区大小。
当某个应用进程调用 write 时,内核从该应用进程的缓冲区中复制所有数据到所写套接字的发送缓冲区。如果该套接字的发送缓冲区容不下该应用进程的所有数据(或是应用进程的缓冲区大于套接字的发送缓冲区,或是套接字的发送缓冲区中已有其他数据),假设该套接字是阻塞的,则该应用进程将被投入睡眠。
Socket 是应用程序与操作系统之间的一种接口,它在应用程序中表现为一个文件描述符(File Descriptor),实际上是操作系统内核中的数据结构。Socket API 提供了一组函数,允许应用程序创建 Socket、绑定地址、监听连接、发送和接收数据等操作。
内核将不从 write 系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发 送缓冲区。因此,从写一个 TCP 套接字的 write 调用成功返回仅仅表示我们可以重新使用原来的应用进程缓冲区,并不表明对端的 TCP 或应用进程已接收到数据。
我们这里理解一下SelectionKey中的OP_WRITE和OP_READ,当我们的Socket写缓冲中还有空间时,就会触发OP_WRITE事件,如果我们的Socket接受缓冲的数据已经准备好就会触发OP_READ数据。
所以在应用程序中调用Socket的发送数据方法
时,此时操作系统需要通过系统调用将应用程序的数据拷贝到操作系统底层Socket的发送缓冲中,然后操作系统将数据发送出去(这个过程涉及用户态到内核态的切换)。
Java 程序自然也要遵守上述的规则。那我们思考一个问题:既然可以在堆内存上直接分配Buffer,为什么还要搞出一个直接内存缓冲?
这个其实也是JVM出于一个性能的考量,我们首先了解一下,如果直接在堆内存上分配Buffer,JVM是如何将数据发送出去的。
看上面操作,在堆中分配一个buffer,我们在堆外内存还是需要一个缓冲,java程序发送数据时,需要从堆内存中的buffer拷贝到堆外内存的应用进程缓冲区的。然后再拷贝到Socket的写缓冲,这样看来就多了一次拷贝过程,这是一种对性能的损耗。那我们再思考一下为什么JVM要进行这一次多余的拷贝,直接将数据发送到socket缓冲不行吗?
答案是不行的,我们知道数组对象都是分配在java堆上的,而Java堆有个很重要的机制即GC机制,如果我们直接调用write将数据写到Socket缓冲中,假如我们正在写,而GC在堆上发生了,此时我们的buffer数组在堆上的位置可能会发生变化(标记复制算法和标记整理算法都可以移动对象),导致write出现了错误,所以JVM才需要再创建一个应用进程缓冲区。
所以NIO才会出现NIONative函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用来进行操作。(这和操作系统的零拷贝原理优点类似)
5. 零拷贝
零拷贝(英语: Zero-copy) 技术是指计算机执行操作时,CPU 不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省 CPU 周期和内存带宽。
➢ 零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率
➢ 零拷贝技术减少了用户进程地址空间和内核地址空间之间因为上下文切换而带来的 开销
可以看出零拷贝没有说不需要拷贝,只是说减少冗余[不必要]的拷贝。 下面这些组件、框架中均使用了零拷贝技术:Kafka、Netty、Rocketmq、Nginx、Apache。
CPU的资源是很宝贵的,如果CPU操作都浪费到了IO过程上那对程序的性能是不友好的,所以就引入了DMA。(Direct Memory Access,直接内存存储)DMA操作是一种计算机系统中的技术,用于实现设备之间的数据传输,而无需CPU的介入。通常情况下,数据传输需要通过CPU来进行中转,但是使用DMA可以绕过CPU,直接在设备和内存之间进行数据传输。在DMA操作中,有一个专门的DMA控制器,它负责管理数据传输的整个过程。当设备需要将数据从外部传输到内存或者从内存传输到外部时,设备会向DMA控制器发送请求。DMA控制器根据设备的请求配置相应的传输参数,并控制数据在总线上的传输。在数据传输完成后,DMA控制器会发送一个中断信号给CPU,以便CPU能够知道传输已经完成。通过使用DMA操作,可以提高数据传输的效率,减少CPU的负载。特别是在需要大量数据传输的情况下,使用DMA可以显著提高系统的性能。常见的应用包括网络数据传输、硬盘读写等。
分析上述的过程,虽然引入 DMA 来接管 CPU 的中断请求,但四次 copy 是存在“不必要的拷贝”的。实际上并不需要第二个和第三个数据副本。应用程序除了缓存数据并将其传 输回套接字缓冲区之外什么都不做。相反,数据可以直接从读缓冲区传输到套接字缓冲区。显然,第二次和第三次数据 copy 其实在这种场景下没有什么帮助反而带来开销,这也正是零拷贝出现的背景和意义。
- 内存映射(MMAP)
硬盘上文件的位置和应用程序缓冲区(application buffers)进行映射(建立一种一一对应 关系),由于 mmap()将文件直接映射到用户空间,所以实际文件读取时根据这个映射关系, 直接将文件从硬盘拷贝到用户空间(不需要将数据拷贝到内核空间再拷贝到用户空间),只进行了一次数据拷贝,不再有文件内容从硬盘拷贝到内核空间的一个缓冲区。通过MMAP我们减少了一次数据拷贝,但是没有减少上下文切换次数,因为调用MMAP也是一种系统调用。
- sendfile
当调用 sendfile()时,DMA 将磁盘数据复制到 kernel buffer,然后将内核中的 kernel buffer 直接拷贝到 socket buffer;但是数据并未被真正复制到socket关联的缓冲区内。取而代之的是,只有记录数据位置和长度的描述符被加入到socket 缓冲区中。DMA 模块将数据直接从内核缓冲区传递给协议引擎,从而消除了遗留的最后一次复制。但是要注意,这个需要 DMA 硬件设备支持,如果不支持,CPU 就必须介入进行拷贝。一旦数据全都拷贝到 socket buffer,sendfile()系统调用将会return、代表数据转化的完成。socket buffer 里的数据就能在网络传输了。
上面我们只需要进行3(2)次拷贝,2次上下文切换。
为什么可能是3次拷贝可能是2次拷贝,这个就要看你DMA支不支持这个功能,如果支持CPU拷贝的时候我们不需要将数据拷贝到Socekt的buffer中,而是发送一个在文件读取缓冲区中该数据的记录数据位置和长度的描述符,然后在DMA拷贝中可以根据这个记录直接从文件读取缓冲中拿数据,此时就只需要两次拷贝,否则CPU拷贝就需要将文件读取缓冲区中的所有数据拷贝到Socket的缓冲。
- slice()
Linux 从 2.6.17 支持 splice,数据从磁盘读取到 OS 内核缓冲区后,在内核缓冲区直接可将其转成内核空间其他数据buffer,而不需要拷贝到用户空间。如下图所示,从磁盘读取到内核 buffer 后,在内核空间直接与 socket buffer 建立 pipe管道。和 sendfile()不同的是,splice()不需要硬件支持。注意 splice 和 sendfile 的不同,sendfile是DMA 硬件设备不支持的情况下将磁盘数据加载到 kernel buffer 后,需要一次 CPU copy,拷贝到 socket buffer。而 splice 是更进一步,连这个 CPU copy 也不需要了,直接将两个内核空间的 buffer 进行 pipe。
pipe可以理解为将这两个缓冲区连接起来了,共享了一份内存区域
6. Java生态中的0拷贝
- NIO 提供的内存映射MappedByteBuffer
NIO中的FileChannel.map()方法其实就是采用了操作系统中的内存映射方式,底层就是调用Linux mmap()实现的。将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射。这种方式适合读取大文件, 同时也能对文件内容进行更改,但是如果其后要通过 SocketChannel 发送,还是需要CPU进行数据的拷贝。
- NIO 提供的 sendfile
Java NIO 中提供的 FileChannel 拥有 transferTo 和 transferFrom 两个方法,可直接把 FileChannel 中的数据拷贝到另外一个 Channel,或者直接把另外一个Channel 中的数据拷贝到 FileChannel。该接口常被用于高效的网络 / 文件的数据传输和大文件拷贝。在操作系统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于 Java IO 中提供的方法。
- Kafka 中的零拷贝
Kafka 两个重要过程都使用了零拷贝技术,且都是操作系统层面的狭义零拷贝,一是Producer 生产的数据存到 broker,二是 Consumer从broker读取数据。Producer 生产的数据持久化到broker,broker 里采用mmap文件映射,实现顺序的快速写入;Customer 从 broker 读取数据,broker 里采用 sendfile,将磁盘文件读到 OS 内核缓冲区后,直接转到 socket buffer进行网络发送。
- Netty 的零拷贝实现
在网络通信上,Netty的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写 入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。在缓存操作上,Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个 逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝。通过 wrap 操作,我们可以将 byte[]数组、ByteBuf、 ByteBuffer 等包装成一个 Netty ByteBuf 对象,进而避免了拷贝操作。ByteBuf支持slice 操作,因此可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf, 避免了内存的拷贝。在文件传输上,Netty 的通过 FileRegion 包装的 FileChannel.tranferTo 实现文件传输,它 可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的 内存拷贝问题。