文章目录
Linux虚拟内存
在现代操作系统中,多任务已是标配。多任务并行,大大提升了 CPU 利用率,但却引出了多个进程对内存操作的冲突问题,虚拟内存概念的提出就是为了解决这个问题。
上图是虚拟内存最简单也是最直观的解释。
操作系统有一块物理内存(中间的部分),有两个进程(实际会更多)P1 和 P2,操作系统偷偷地分别告诉 P1 和 P2,我的整个内存都是你的,随便用,管够。可事实上呢,操作系统只是给它们画了个大饼,这些内存说是都给了 P1 和 P2,实际上只给了它们一个序号而已。只有当 P1 和 P2 真正开始使用这些内存时,系统才开始使用辗转挪移,拼凑出各个块给进程用,P2 以为自己在用 A 内存,实际上已经被系统悄悄重定向到真正的 B 去了,甚至,当 P1 和 P2 共用了 C 内存,他们也不知道。
操作系统的这种欺骗进程的手段,就是虚拟内存。对 P1 和 P2 等进程来说,它们都以为自己占用了整个内存,而自己使用的物理内存的哪段地址,它们并不知道也无需关心。
分页和页表
虚拟内存是操作系统里的概念,对操作系统来说,虚拟内存就是一张张的对照表,P1 获取 A 内存里的数据时应该去物理内存的 A 地址找,而找 B 内存里的数据应该去物理内存的 C 地址。
我们知道系统里的基本单位都是 Byte 字节,如果将每一个虚拟内存的 Byte 都对应到物理内存的地址,每个条目最少需要 8字节(32位虚拟地址->32位物理地址),在 4G 内存的情况下,就需要 32GB 的空间来存放对照表,那么这张表就大得真正的物理地址也放不下了,于是操作系统引入了 页(Page)
的概念。
在系统启动时,操作系统将整个物理内存以 4K 为单位,划分为各个页。之后进行内存分配时,都以页为单位,那么虚拟内存页对应物理内存页的映射表就大大减小了,4G 内存,只需要 8M 的映射表即可,一些进程没有使用到的虚拟内存,也并不需要保存映射关系,而且Linux 还为大内存设计了多级页表,可以进一页减少了内存消耗。操作系统虚拟内存到物理内存的映射表,就被称为页表
。
内存寻址和分配
我们知道通过虚拟内存机制,每个进程都以为自己占用了全部内存,进程访问内存时,操作系统都会把进程提供的虚拟内存地址转换为物理地址,再去对应的物理地址上获取数据。CPU 中有一种硬件,内存管理单元 MMU(Memory Management Unit)
专门用来将翻译虚拟内存地址。CPU 还为页表寻址设置了缓存策略,由于程序的局部性,其缓存命中率能达到 98%。
以上情况是页表内存在虚拟地址到物理地址的映射,而如果进程访问的物理地址还没有被分配,系统则会产生一个缺页中断
,在中断处理时,系统切到内核态为进程虚拟地址分配物理地址。
注意,通常程序第一次调用 操作系统公共函数库分配的内存地址都是虚拟内存地址,此时并没有分配具体的物理内存,当程序第一次使用这个虚拟内存地址的时候,发现对应的地址在物理内存中不存在,则产生缺页中断。
并且,需要访问的内存含有多少个页,则产生多少次缺页中断(一页一页的映射和访问)
虚拟内存空间分布
Linux 使用虚拟地址空间,大大增加了进程的寻址空间,由低地址到高地址分别为:
- 只读段:该部分空间只能读,不可写;(包括:代码段、rodata 段(C常量字符串和#define定义的常量) )
- 数据段:保存全局变量、静态变量的空间;
- 堆 :就是平时所说的动态内存,
malloc/new
大部分都来源于此。其中堆顶的位置可通过函数brk
和sbrk
进行动态调整。(brk
和sbrk
是系统调用,malloc
是C函数库提供的API。) - 文件映射区域 :如动态库、共享内存等映射物理空间的内存,一般是 mmap 函数所分配的虚拟地址空间。
- 栈:用于维护函数调用的上下文空间,一般为 8M ,可通过 ulimit –s 查看。
- 内核虚拟空间:用户代码不可见的内存区域,由内核管理(页表就存放在内核虚拟空间)。 下图是 32 位系统典型的虚拟地址空间分布(来自《深入理解计算机系统》)。
malloc和free是如何分配和释放内存?
如何查看进程发生缺页中断的次数?
用ps -o majflt,minflt -C program命令查看。
majflt代表major fault,中文名叫大错误,minflt代表minor fault,中文名叫小错误。
这两个数值表示一个进程自启动以来所发生的缺页中断的次数。
发成缺页中断后,执行了那些操作?
当一个进程发生缺页中断的时候,进程会陷入内核态,执行以下操作:
-
检查要访问的虚拟地址是否合法
-
查找/分配一个物理页(这说明是一页一页分配,如果连续1M内存访问都缺页,那么就中断1M/4k 次)
-
填充物理页内容(读取磁盘,或者直接置0,或者啥也不干)
-
建立映射关系(虚拟地址到物理地址)
重新执行发生缺页中断的那条指令 如果第3步,需要读取磁盘,那么这次缺页中断就是majflt,否则就是minflt。
内存分配的原理
从操作系统角度来看,进程分配内存有两种方式,分别由两个系统调用完成:brk和mmap(不考虑共享内存)。
- brk是将数据段(.data)的最高地址指针_edata往高地址推;
- mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。
这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。
在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk,mmap,munmap这些系统调用实现的。
malloc(brk,sbrk)和mmap分配内存方式的比较
既然堆内碎片不能直接释放,导致疑似“内存泄露”问题,为什么 malloc 不全部使用 mmap 来实现呢(mmap分配的内存可以会通过 munmap 进行 free ,实现真正释放)?而是仅仅对于大于 128k 的大块内存才使用 mmap ?
其实,进程向 OS 申请和释放地址空间的接口 sbrk/mmap/munmap 都是系统调用,频繁调用系统调用都比较消耗系统资源的。并且, mmap 申请的内存被 munmap 后,重新申请会产生更多的缺页中断。例如使用 mmap 分配 1M 空间,第一次调用产生了大量缺页中断 (1M/4K 次 ) ,当munmap 后再次分配 1M 空间,会再次产生大量缺页中断。缺页中断是内核行为,会导致内核态CPU消耗较大。另外,如果使用 mmap 分配小内存,会导致地址空间的分片更多,内核的管理负担更大。
同时堆是一个连续空间,并且堆内碎片由于没有归还 OS ,如果可重用碎片,再次访问该内存很可能不需产生任何系统调用和缺页中断,这将大大降低 CPU 的消耗
。 因此, glibc 的 malloc 实现中,充分考虑了 sbrk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128k) 才使用 mmap 获得地址空间,也可通过 mallopt(M_MMAP_THRESHOLD, ) 来修改这个临界值。
总结来说,brk分配内存和sbrk释放内存,是在虚拟地址空间的堆的最高地址往上推,但是这种方式
需要连续的管理内存
,也就是brk连续分配了 A,B,C三块内存后,如果free 了B,那么B其实是不会释放的(也就是不会调用sbrk),而是处于一种 内存泄漏或者内存碎片的情况,而free释放了C之后,发现C和B总共在最高地址空闲超过128k了,则执行内存紧缩(此时执行sbrk),将B和C的实际内存释放以及地址指针回撤。而mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。可以被直接开辟和释放(mmap,munmap),但是由于mmap可能导致大量的缺页中断,并且如果用mmap创建小内存会产生很多的内存分片导致难以管理。
所以mmap好在会直接的开辟和创建内存,不会产生内存泄漏和碎片,不好在会产生很多缺页中断或者内存分片
brk在对于小内存上,释放的时候可能产生内存碎片导致内存泄漏(浪费),但是对于一些可重用的碎片,即再次申请一个大小等于那个碎片大小的内存,则可以直接返回,并且因为页表都是现成的,物理内存也开辟好了,则不会产生任何的系统调用和缺页中断。(malloc和free并不一定会执行brk或者sbrk,对于可重用的内存,则不会执行系统调用brk,对于连续的内存,释放中间的块,也不会调用sbrk,只有一整块内存紧缩时才会调用sbrk)
Linux内存管理的基本思想之一,是只有在真正访问一个地址的时候才建立这个地址的物理映射。
mmap系统调用实现了更有用的动态内存分配功能,可以将一个磁盘文件的全部或部分内容映射到用户空间中,进程读写文件的操作变成了读写内存的操作。
Java 中的直接内存
三个场景
场景一:将一个文件通过网络发送出去
传统方式
java传统方法的调用如下
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
这是一个从磁盘文件中读取并且通过Socket写出的过程,对应的系统调用如下。
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
程序使用read()系统调用,系统由用户态转换为内核态,磁盘中的数据由DMA(Direct memory access)的方式读取到内核读缓冲区(kernel buffer)。DMA过程中CPU不需要参与数据的读写,而是DMA处理器直接将硬盘数据通过总线传输到内存中。
系统由内核态转为用户态,当程序要读的数据已经完全存入内核读缓冲区以后,程序会将数据由内核读缓冲区,写入到用户缓冲区,这个过程需要CPU参与数据的读写。
程序使用write()系统调用,系统由用户态切换到内核态,数据从用户缓冲区写入到网络缓冲区(Socket Buffer),这个过程需要CPU参与数据的读写。
系统由内核态切换到用户态,网络缓冲区的数据通过DMA的方式传输到网卡的驱动(存储缓冲区)中(protocol engine)
可以看到,普通的拷贝过程经历了四次内核态和用户态的切换(上下文切换),两次CPU从内存中进行数据的读写过程,这种拷贝过程相对来说比较消耗系统资源。
java mmap
底层mmap的实现对应到Java层中FileChannel
的map
方法,但FileChannel
实际上是一个抽象类,它的具体实现是FileChannelImpl
public MappedByteBuffer map(MapMode mode, long position, long size)
FileChannelImpl中map关键代码片段
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