缓冲区是所有 I/O 的基础,I/O 讲的无非就是把数据移进或移出缓冲区;进程执行 I/O
操作,就是向操作系统发出请求,让它要么把缓冲区的数据排干(写),要么填充缓冲区(读)。
Java 进程发起 Read 请求加载数据大致的流程图
进程发起 Read 请求之后,内核接收到 Read 请求之后,会先检查内核空间中是否已经存在进程所需要的数据,如果已经存在,则直接把数据 Copy
给进程的缓冲区。
如果没有内核随即向磁盘控制器发出命令,要求从磁盘读取数据,磁盘控制器把数据直接写入内核 Read 缓冲区,这一步通过
DMA(直接存储器访问,可以理解为硬件单元,用来解放 CPU 完成文件 IO)完成。
接下来就是内核将数据 Copy 到进程的缓冲区;如果进程发起 Write 请求,同样需要把用户缓冲区里面的数据 Copy 到内核的 Socket
缓冲区里面,然后再通过 DMA 把数据 Copy 到网卡中,发送出去。
你可能觉得这样挺浪费空间的,每次都需要把内核空间的数据拷贝到用户空间中 ,所以零拷贝的出现就是为了解决这种问题的。
这里简单提一嘴,关于零拷贝提供了两种方式分别是:
CPU是通过寻址来访问内存的。32位CPU的寻址宽度是 0~0xFFFFFFFF ,计算后得到的大小是4G,也就是说可支持的物理内存最大是4G。
但在实践过程中,碰到了这样的问题,程序需要使用4G内存,而可用物理内存小于4G,导致程序不得不降低内存占用。
为了解决此类问题,现代CPU引入了 MMU(Memory Management Unit 内存管理单元)。
MMU 的核心思想是利用虚拟地址替代物理地址,即 CPU 寻址时使用虚址,由 MMU 负责将虚址映射为物理地址。
MMU的引入,解决了对物理内存的限制, 对程序来说,就像自己在使用 4G 内存一样 。
虚拟内存 :虚拟内存是一种逻辑上扩充物理内存的技术。
基本思想是用软、硬件技术把内存与外存这两级存储器当做一级存储器来用。虚拟内存技术的实现利用了自动覆盖和交换技术。简单的说就是将硬盘的一部分作为内存来使用。
(例如 Linux 中的 Swap 区)
虚拟地址空间 :虚拟地址空间指的是 CPU 能够寻址到的虚拟内存的范围 ,Linux
系统会给每个进程提供一份虚拟地址空间(只有当它实际被使用时才分配物理内存),在 32 位 CPU 的机器上他的寻址范围在 0x00000000 ~
0xFFFFFFFF 这一段地址中(约 4G),其中高1G的空间为内核空间,由操作系统调用,低3G的空间为用户空间,由用户使用。
虚拟空间的划分
CPU在寻址的时候,是按照虚拟地址来寻址,然后通过 MMU(内存管理单元) 将虚拟地址转换为物理地址。
CPU 虚拟地址寻址
缺页中断 :因为只有程序的一部分加入到内存中,所以会出现所寻找的地址不在内存中的情况(CPU产生缺页异常),如果在内存不足的情况下,就会通过
页面置换算法 来将内存中的页面置换出来,然后将在外存中的页面加入到内存中,使程序继续正常运行。
[常见的
页面置换算法](https://links.jianshu.com/go?to=https%3A%2F%2Fzhuanlan.zhihu.com%2Fp%2F82746153):
image
利用第一条特性 可以把内核空间地址和用户空间的虚拟地址映射到同一个物理地址 , **这样 DMA
就可以填充对内核和用户空间进程同时可见的缓冲区了** 。
image
省去了内核与用户空间的往来拷贝,Java 的 java.nio.channels.FileChannel#map 也利用操作系统的此特性来提升性能。
通过虚拟内存技术,我们使用的内存可以远大于物理内存的大小,例如物理内存 256M,但是我们可以为每个进程分配 4G 大小的虚拟地址空间,当 CPU
执行虚拟地址空间里面的代码时,在内存中寻找不到所需要的页面,就需要到外存中寻找, 外存的这一部分,我们可以当成内存来使用,这也就是虚拟内存
。(用虚拟内存来解释什么是虚拟内存 /笑哭)
虚拟地址空间不等于虚拟内存。 虚拟地址空间是一个空间,不是真正存在的,只是通过CPU的寻址虚拟出来的一个范围 。而虚拟内存是实实在在的硬盘的空间。
说白了,虚拟内存就是为了解决内存不够用的情况;而虚拟地址空间是对虚拟内存的使用进行了一层封装,让进程以为他有一段很大的连续内存空间。
我认为,现代计算机的内存已经够用了(上面说的好处2),完全可以抛弃虚拟内存和虚拟地址空间的概念,但是虚拟内存和虚拟地址空间还有另一个好处,他可以 将一个以上的虚拟地址可以指向同一个物理内存(虚拟内存磁盘)地址 (上面说的好处1),从而实现 Java 的零拷贝。
从硬件上看,Linux系统的内存空间由两个部分构成:物理内存和 SWAP(位于磁盘) 。其中 SWAP 就是虚拟空间,在 Linux
中叫做交换空间,如果你装过 Linux 的话,其中有一个步骤就是让你分配交换空间(SWAP)的大小,所以 从理论上讲,虚拟空间的大小取决于磁盘的大小
。
什么是 SWAP?
swap space是磁盘上的一块区域,可以是一个分区,也可以是一个文件,或者是他们的组合。简单点说, 当系统物理内存吃紧时,Linux会将内存中不常访问的数据保存到swap上(换页) ,这样系统就有更多的物理内存为各个进程服务,而当系统需要访问swap上存储的内容时,再将swap上的数据加载到内存中,这就是我们常说的swap out和swap in。
虚拟地址空间 它表示的是 CPU 能够寻址到虚拟内存的范围
,由于系统会给每个进程提供一份虚拟内存空间(只有当虚拟内存实际被使用时才分配物理内存),所以在 32 位 CPU 的机器上他的寻址范围在
0x00000000 ~ 0xFFFFFFFF 这一段地址中(约 4G),也就是说可以寻找到4G的地址空间。
当然,64 位机器基本就没有限制了,但是 64 位机器内存一般都在 16G 以上,基本上内存已经够用了,“换页”操作出现的可能性也比较小,所以 SWAP
设的很小也行。
MMU :CPU 寻址时使用虚拟地址替代物理地址,然后再由 MMU(Memory Management Unit,内存管理单元)转换成物理地址。
内存分页(Paging) 是在使用MMU的基础上,提出的一种内存管理机制。 **它将虚拟地址和物理地址按固定大小
4K(这个大小好像可以修改)分割成页(page)和页帧(page frame)** ,并保证页与页帧的大小相同。
一块 4K 大小的虚拟地址范围称为 页 ;
一块 4K 大小的物理地址范围称为 页帧 ;
页表 :页表就像一个函数,输入是页号,输出是页桢。 操作系统给每一个进程维护一个页表
。所以不同进程的虚拟地址可能一样。页表给出了进程中每一页所对应的页帧的位置。
三者间关系
假设内存是连续分配的(也就是程序在物理内存上是连续的)
1.进程A进来,向os申请了200的内存空间,于是os把0~199分配给A
2.进程B进来,向os申请了5的内存空间,os把200~204分配给它
3.进程C进来,向os申请了100的内存空间,os把205~304分配给它
4.这个时候进程B运行完了,把200~204还给os
但是很长时间以后,只要系统中的出现的进程的大小>5的话,200~204这段空间都不会被分配出去(只要A和C不退出)。
过了一段更长的时间,内存中就会出现许许多多200~204这样不能被利用的碎片……
而分页机制让程序可以在逻辑上连续、物理上离散。也就是说在一段连续的物理内存上,可能04(这个值取决于页面的大小)属于A,而59属于B,10~14属于C,从而保证任何一个“内存片段”都可以被分配出去。
虽然每个进程拥有4g的虚拟地址空间,但显然 在它运行的每个小段时间内,它需要访问都是少量的空间,并且这些空间一般都是地址连续的 。
如果真的给这个进程分配全部的物理内存,那绝大部分物理内存就浪费了 。所以现在一般
采用分页的技术(将虚拟地址空间和物理内存划分成固定大小的小块),建立页表,把进程的虚拟地址空间页映射到物理内存的页帧上。这里页表保存的就是映射关系
。然后 随着进程的运行,就会按需分配页,那些长时间未使用的页帧又会被操作系统回收 。
程序在执行时呈现出局部性规律,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域,具体来说,局部性通常有两种形式:时间局部性和空间局部性。
在内存中(包括物理内存和虚拟内存)
Page Cache 以页(4K)为单位,缓存文件内容。缓存在Page Cache中的文件数据,能够更快的被用户读取。同时
对于带buffer的写入操作,数据在写入到Page Cache中即可立即返回,而不需等待数据被实际持久化到磁盘
,进而提高了上层应用读写文件的整体性能。
其实内存中的内容无非就两种
从文件加载进来的,例如 .class 文件、FileChannel#map、FileChannel#transforTo 等(PageCache) 代码运行时使用到的内存,map.put()(用户空间内存)
在磁盘上
系统中常常会有一些进程在初始化时要了很多memory(主要是通过malloc获取的匿名page),
初始化完成之后,这部分memory该进程不会经常用到,也没有释放。这就造成了内存的浪费。Linux就想了个办法要把这些memory中的数据置换到磁盘中,然后将这个memory标记为可回收
,然后Linux中页框回收机制(不准备介绍)就会将这些page回收然后将这些page让给有需要的进程来用。
在内存中
磁盘的最小数据单位为sector,每次读写磁盘都是以sector为单位对磁盘进行操作。sector大小跟具体的磁盘类型有关,有的为512Byte,
有的为4K Bytes。 无论用户是希望读取1个byte,还是10个byte,最终访问磁盘时,都必须以sector为单位读取
,如果裸读磁盘,那意味着数据读取的效率会非常低。同样,如果用户希望向磁盘某个位置写入(更新)1个byte的数据,他也必须整个刷新一个sector,言下之意,则是
**在写入这1个byte之前,我们需要先将该1byte所在的磁盘sector数据全部读出来,在内存中,修改对应的这1个byte数据,然后再将整个修改后的sector数据,一口气写入磁盘。为了降低这类低效访问,尽可能的提升磁盘访问性能,内核会在磁盘sector上构建一层缓存,他以sector的整数倍力度单位(block),缓存部分sector数据在内存中,当有数据读取请求时,他能够直接从内存中将对应数据读出。当有数据写入时,他可以直接再内存中直接更新指定部分的数据,然后再通过异步方式,把更新后的数据写回到对应磁盘的sector中。这层缓存则是块缓存Buffer
Cache。**
**Page Cache和Buffer Cache是一个事物的两种表现:对于一个Page而言,对上,他是某个File的一个Page
Cache,而对下,他同样是一个Device上的一组Buffer Cache** 。
在虚拟内存机制出现以前,操作系统使用块缓存系列,但是在虚拟内存出现以后,操作系统管理IO的粒度更大,因此采用了页缓存机制,页缓存是基于页的、面向文件的缓存机制。
目前Linux Kernel代码中,Page Cache和Buffer Cache实际上是统一的, 无论是文件的Page Cache还是Block的Buffer Cache最终都统一到Page上 。
image
由上图可知 FileChannel#map 方法映射出来的文件并没有使用虚拟内存的空间(SwapCache),那总不能 2G
文件全部放内存吧,所以他是不是把那个文件本身当做了虚拟空间的一部分??所以我认为
虚拟内存真正的含义应该是可以把磁盘当做内存来使的一种技术,让应用程序以为在内存中(通过虚拟地址空间),实际上是用到了才会取 。
的内存关系](https://links.jianshu.com/go?to=https%3A%2F%2Fcloud.tencent.com%2Fdeveloper%2Farticle%2F1420898)
JVM以一个进程(Process)的身份运行在Linux系统上,了解Linux与进程的内存关系,是理解JVM与Linux内存的关系的基础。下图给出了硬件、系统、进程三个层面的内存之间的概要关系。
image
从硬件上看,Linux系统的内存空间由两个部分构成: 物理内存和SWAP(位于磁盘) 。
物理内存是Linux活动时使用的主要内存区域;
当物理内存不够使用时,Linux会把一部分暂时不用的内存数据放到磁盘上的SWAP中去,以便腾出更多的可用内存空间;
而当需要使用位于SWAP的数据时,必须 先将其换回到内存中。
从Linux系统上看,除了引导系统的BIN区, **整个内存空间主要被分成两个部分:内核内存(Kernel space)、用户内存(User
space)。**
内核内存是Linux自身使用的内存空间,主要提供给程序调度、内存分配、连接硬件资源等程序逻辑使用。
用户内存是提供给各个进程主要空间, Linux给各个进程提供相同的虚拟内存空间 ;这使得进程之间相互独立,互不干扰。它给
每一个进程一定虚拟地址空间,而只有当实际被使用时,才分配物理内存 。
如下图所示, 对于32的Linux系统来说,一般将0~3G的虚拟内存空间分配做为用户空间 , 将3~4G的虚拟内存空间分配 为内核空间
;64位系统的划分情况是类似的。
image
从进程的角度来看,进程能直接访问的用户内存(虚拟内存空间)被划分为5个部分:代码区、数据区、堆区、栈区、未使用区。
JVM本质就是一个进程,因此其内存空间(也称之为运行时数据区,注意与JMM的区别)也有进程的一般特点。
但是,JVM又不是一个普通的进程,其在内存空间上有许多崭新的特点,主要原因有两个:
JVM进程与普通进程内存模型比较
永久代本质上是Java程序的代码区和数据区。Java程序中类(class),会被加载到整个区域的不同数据结构中去,包括常量池、域、方法数据、方法体、构造函数、以及类中的专用方法、实例初始化、接口初始化等。这个区域对于操作系统来说,是堆的一个部分;而对于Java程序来
说,这是容纳程序本身及静态资源的空间,使得JVM能够解释执行Java程序。
新生代和老年代才是 Java程序真正使用的堆空间 ,主要用于内存对象的存储;但是其 管理方式和普通进程有本质的区别 。
普通进程在运行时给内存对象分配空间时,比如C++执行new操作时,会触发一次分配内存空间的系统调用,由操作系统的线程根据对象的大小分配好空间后返
回;同时,程序释放对象时,比如C++执行delete操作时,也会触发一次系统调用,通知操作系统对象所占用的空间已经可以回收。
JVM对内存的使用和一般进程不同。JVM向操作系统 申请一整段内存区域
(具体大小可以在JVM参数调节)作为Java程序的堆(分为新生代和老年代);当Java程序申请内存空间,比如执行new操作,JVM将在这段空间中按所需大小分配给Java程序,并且Java程序不负责通知JVM何时可以释放这个对象的空间,垃圾对象内存空间的回收由JVM进行。
JVM的内存管理方式的优点是显而易见的,包括:
未使用区是分配新内存空间的预备区域。对于普通进程来说,这个区域被可用于堆和栈空间的申请及释放,每次堆内存分配都会使用这个区
域,因此大小变动频繁;对于JVM进程来说,调整堆大小及线程栈时会使用该区域,而堆大小一般较少调整,因此大小相对稳定。操作系统会动态调整这个区域的大小,并且这个区域通常并没有被分配实际的物理内存,只是允许进程在这个区域申请堆或栈空间。
应用程序通常不直接和内核内存打交道,内核内存由操作系统进行管理和使用;不过随着Linux对性能的关注及改进,
一些新的特性使得应用程序可以使用内核内存,或者是映射到内核空间 。
Java NIO正是在这种背景下诞生的,其充分利用了Linux系统的新特性,提升了Java程序的IO性能。
image
上图给出了Java NIO使用的内核内存在linux系统中的分布情况。nio
buffer主要包括:nio使用各种channel时所使用的ByteBuffer、Java程序主动使用
ByteBuffer.allocateDirector申请分配的Buffer。
而在PageCache里面,nio使用的内存主要包
括:FileChannel.map方式打开文件占用mapped、FileChannel.transferTo和
FileChannel.transferFrom所需要的Cache(图中标示 nio file)。
通过JMX可以监控到NIO Buffer和 mapped
的使用情况,如下图所示。不过,FileChannel的实现是通过系统调用使用原生的PageCache,过程对于Java是透明的,无法监控到这部分内存的使用大小。
image
Linux和Java
NIO在内核内存上开辟空间给程序使用,主要是减少不要的复制,以减少IO操作系统调用的开销。例如,将磁盘文件的数据发送网卡,使用普通方法和NIO时,数据流动比较下图所示:
image
将数据在内核内存和用户内存之间拷贝是比较消耗资源和时间的事情,而从上图我们可以看到, 通过NIO的方式减少了2次内核内存和用户内存之间的数据拷贝
。这是Java NIO高性能的重要机制之一(另一个是异步非阻塞)。
从上面可以看出,内核内存对于Java程序性能也非常重要,因此在划分系统内存使用时候,一定要给内核留出一定可用空间。
传统 IO 将一个文件通过 socket 写出
File f = new File("helloword/data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");
byte[] buf = new byte[(int)f.length()];
file.read(buf);
Socket socket = ...;
socket.getOutputStream().write(buf);
内部工作流程是这样的:
image
用户态与内核态的切换发生了 3 次(这个操作比较重量级),数据 copy 了 4 次。
DMA 也可以理解为硬件单元,用来解放 cpu 完成文件 IO
可以看到中间环节较多,java 的 IO 实际不是物理设备级别的读写, 而是缓存的复制 ,底层的真正读写是操作系统来完成的。
使用 mmap+write 方式代替原来的 read+write 方式,mmap 是一种内存映射文件的方法,即将一个文件或者其他对象映射到进程的地址空间,
实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对应关系 。
这样就可以省掉原来内核 Read 缓冲区 Copy 数据到用户缓冲区,但是还是需要内核 Read 缓冲区将数据 Copy 到内核 Socket 缓冲区。
image
这种方式 减少了一次数据拷贝(从内核空间 copy 缓存到用户空间),用户态与内核态的切换次数没有减少(还是三次)
代码实现:
File f = new File("helloword/data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");
// 其实这时并没有把文件加载内存中,而是把这个文件当做了虚拟内存中的一部分,等使用的时候才加载到内存中
MappedByteBuffer buf = file.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, f.length());
Socket socket = ...;
socket.getOutputStream().write(buf);
image
该方法有三个参数,MapMode,Position 和 Size,分别表示:
重点看一下 MapMode,前两个分别表示只读和可读可写,当然 请求的映射模式受到 Filechannel 对象的访问权限限制 (就是如果你这个
FileChannel 是只读的那么 model 为 READ_WRITE 也会报错), **如果在一个没有读权限的文件上启用 READ_ONLY,将抛出
NonReadableChannelException** 。
PRIVATE 模式表示写时拷贝的映射,意味着 **通过 put() 方法所做的任何修改都会导致产生一个私有的数据拷贝并且该拷贝中的数据只有
MappedByteBuffer 实例可以看到 。该过程 不会对底层文件做任何修改** ,而且一旦缓冲区被施以垃圾收集动作(garbage
collected),那些修改都会丢失。
大致浏览一下 map() 方法的源码:
public MappedByteBuffer map(MapMode mode, long position, long size)
throws IOException
{
...省略...
int pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
long mapSize = size + pagePosition;
try {
// If no exception was thrown from map0, the address is valid
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
// An OutOfMemoryError may indicate that we've exhausted memory
// so force gc and re-attempt map
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError y) {
// After a second OOME, fail
throw new IOException("Map failed", y);
}
}
// On Windows, and potentially other platforms, we need an open
// file descriptor for some mapping operations.
FileDescriptor mfd;
try {
mfd = nd.duplicateForMapping(fd);
} catch (IOException ioe) {
unmap0(addr, mapSize);
throw ioe;
}
assert (IOStatus.checkAll(addr));
assert (addr % allocationGranularity == 0);
int isize = (int)size;
Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
if ((!writable) || (imode == MAP_RO)) {
return Util.newMappedByteBufferR(isize,
addr + pagePosition,
mfd,
um);
} else {
return Util.newMappedByteBuffer(isize,
addr + pagePosition,
mfd,
um);
}
}
大致意思就是 通过 Native 方法获取内存映射的地址,如果失败(内存不够用,OutOfMemoryError),手动 GC 再次映射 。
最后通过内存映射的地址实例化出 MappedByteBuffer,MappedByteBuffer 本身是一个抽象类,其实这里真正实例化出来的是
DirectByteBuffer。
注1:这块内存(指堆外内存,不是 MappedByteBuffer) 不受 jvm 垃圾回收的影响,因此内存地址固定,有助于 IO 读写 。
java 中的 DirectByteBuf 对象仅维护了此内存的虚引用 (PhantomReference),内存回收分成两步
DirectByteBuf 对象被垃圾回收,将虚引用加入引用队列 通过专门线程访问引用队列(sun.misc.Cleaner),根据虚引用释放堆外内存
注2:因为 MappedByteBuffer 是通过虚拟内存技术实现的,所以你的 修改什么时候刷新到磁盘是由操作系统决定的 。不过这也有一个好处就是 即使你的Java程序在写入内存后就挂掉了,只要操作系统工作正常,数据就会写入磁盘。
java.nio.MappedByteBuffer#force 可以 强制操作系统将内存中的内容写入硬盘 ,不过最好少用。
MappedByteBuffer#get 过程
public byte get() {
return ((unsafe.getByte(ix(nextGetIndex()))));
}
public byte get(int i) {
return ((unsafe.getByte(ix(checkIndex(i)))));
}
private long ix(int i) {
return address + (i << 0);
}
map0()函数返回一个地址address,这样就无需调用read或write方法对文件进行读写,通过address就能够操作文件。底层采用unsafe.getByte方法,通过(address
源码参见:sun.nio.ch.FileChannelImpl#map
本质上是由于
java.nio.MappedByteBuffer
直接继承自java.nio.ByteBuffer
,而 ByteBuffer 的索引是 int 类型的,所以 MappedByteBuffer 也只能最大索引到Integer.MAX_VALUE
的位置,所以 FileChannel 的 map 方法会做参数合法性检查。
Sendfile 系统调用在内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间进行的数据传输过程。
Sendfile 系统调用的引入,不仅减少了数据复制,还减少了上下文切换的次数。
image
java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据;比上面的方式 **减少了2次 java
代码(用户态)到操作系统(内核态)的切换** ,连 ByteBuffer 对象都不创建了。
image
代码实现:
File f = new File("helloword/data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");
Socket socket = ...;
file.getChannel().transferTo(0, f.length(), socket.getChannel());
我们看到上一种方式的内核空间中还有一次 copy 使用到了 CPU,那么能不能把这一次 copy 也节省掉呢?
Linux2.4 内核中做了改进,将 Kernel buffer 中对应的数据描述信息(内存地址,偏移量)记录到相应的 Socket
缓冲区当中,这样连内核空间中的一次 CPU Copy 也省掉了。
image
整个过程仅只发生了一次用户态与内核态的切换,数据拷贝了 2 次。
所谓的【零拷贝】,并不是真正无拷贝,而是在不会拷贝重复数据到 jvm 内存中,零拷贝的优点有:
Netty 中的
Zero-copy
与上面我们所提到到 OS 层面上的Zero-copy
不太一样, Netty的Zero-coyp
完全是在用户态(Java 层面)的, 它的Zero-copy
的更多的是偏向于优化数据操作
这样的概念。
Netty 提供了零拷贝的 Buffer,在传输数据时,最终处理的数据会需要对单个传输的报文,进行组合和拆分,NIO 原生的 ByteBuffer
无法做到, Netty 通过提供的 Composite(组合)和 Slice(拆分)两种 Buffer 来实现零拷贝 (减少数据组合时的
copy)。
image
TCP 层 HTTP 报文被分成了两个 ChannelBuffer,这两个 Buffer 对我们上层的逻辑(HTTP 处理)是没有意义的。
但是两个 ChannelBuffer 被组合起来,就成为了一个有意义的 HTTP 报文,这个报文对应的
ChannelBuffer,才是能称之为“Message”的东西,这里用到了一个词“Virtual Buffer”。
可以看一下 Netty 提供的 CompositeChannelBuffer 源码:
public class CompositeChannelBuffer extends AbstractChannelBuffer {
private final ByteOrder order;
private ChannelBuffer[] components;
private int[] indices;
private int lastAccessedComponentId;
private final boolean gathering;
public byte getByte(int index) {
int componentId = componentId(index);
return components[componentId].getByte(index - indices[componentId]);
}
...省略...
Components 用来保存的就是所有接收到的 Buffer,Indices 记录每个 buffer
的起始位置,lastAccessedComponentId 记录上一次访问的 ComponentId。
CompositeChannelBuffer 并不会开辟新的内存并直接复制所有 ChannelBuffer 内容,而是直接保存了所有
ChannelBuffer 的引用,并在子 ChannelBuffer 里进行读写,实现了零拷贝。
RocketMQ 的消息采用顺序写到 commitlog 文件,然后利用 consume queue 文件作为索引。
RocketMQ 采用零拷贝 mmap+write 的方式来回应 Consumer 的请求。
同样 Kafka 中存在大量的网络数据持久化到磁盘和磁盘文件通过网络发送的过程,Kafka使用了 Sendfile 零拷贝方式。
参赛选手 | 3.8M | 394M | 800M |
---|---|---|---|
FileInputStream | 16s 870ms | ||
BufferedInputStream | 170 ~ 180ms | 15s 989ms | 32s 325ms |
BufferedInputStream with byte1024 | 50 ~ 65ms | 1s 243ms | 3s 418ms |
RandomAccessFile with byte1024 | 50 ~ 65ms | 2s 663ms | 5s 782ms |
FileChannel#write() | 80 ~ 90ms | 3s | 5s 494ms |
FileChannel#transferTo() | 30ms | 593ms | 2s 404ms |
MappedByteBuffer | 30~50ms | 1s 286ms | 4s 968ms |
第一名:FileChannel#transferTo()
第二名:BufferedInputStream with byte1024,没想到
第三名:MappedByteBuffer
第四名:RandomAccessFile with byte1024
第五名:FileChannel#write(),也没想到
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。