Linux 虚拟内存、Java直接内存和内存映射

本文详细介绍了Linux虚拟内存的工作原理,包括分页、页表、内存寻址和分配,以及malloc和free的内存管理。同时,文章探讨了Java中的直接内存,分析了直接内存的三个应用场景,比较了malloc与mmap分配内存的差异。通过对比,阐述了直接内存和堆外内存的本质以及为何使用直接内存的原因。最后,讨论了DirectByteBuffer与内存映射的区别,强调了它们在减少数据拷贝方面的优势。
摘要由CSDN通过智能技术生成

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 使用虚拟地址空间,大大增加了进程的寻址空间,由低地址到高地址分别为:

  1. 只读段:该部分空间只能读,不可写;(包括:代码段、rodata 段(C常量字符串和#define定义的常量) )
  2. 数据段:保存全局变量、静态变量的空间;
  3. 堆 :就是平时所说的动态内存, malloc/new 大部分都来源于此。其中堆顶的位置可通过函数 brksbrk 进行动态调整。(brksbrk是系统调用,malloc是C函数库提供的API。)
  4. 文件映射区域 :如动态库、共享内存等映射物理空间的内存,一般是 mmap 函数所分配的虚拟地址空间。
  5. 栈:用于维护函数调用的上下文空间,一般为 8M ,可通过 ulimit –s 查看。
  6. 内核虚拟空间:用户代码不可见的内存区域,由内核管理(页表就存放在内核虚拟空间)。 下图是 32 位系统典型的虚拟地址空间分布(来自《深入理解计算机系统》)。

在这里插入图片描述

malloc和free是如何分配和释放内存?

如何查看进程发生缺页中断的次数?
用ps -o majflt,minflt -C program命令查看。
majflt代表major fault,中文名叫大错误,minflt代表minor fault,中文名叫小错误。
这两个数值表示一个进程自启动以来所发生的缺页中断的次数。

发成缺页中断后,执行了那些操作?
当一个进程发生缺页中断的时候,进程会陷入内核态,执行以下操作:

  1. 检查要访问的虚拟地址是否合法

  2. 查找/分配一个物理页(这说明是一页一页分配,如果连续1M内存访问都缺页,那么就中断1M/4k 次)

  3. 填充物理页内容(读取磁盘,或者直接置0,或者啥也不干)

  4. 建立映射关系(虚拟地址到物理地址)
    重新执行发生缺页中断的那条指令 如果第3步,需要读取磁盘,那么这次缺页中断就是majflt,否则就是minflt。

内存分配的原理

从操作系统角度来看,进程分配内存有两种方式,分别由两个系统调用完成:brk和mmap(不考虑共享内存)。

  1. brk是将数据段(.data)的最高地址指针_edata往高地址推;
  2. 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);

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NtGR1QB8-1585815455152)(/Users/lvchentao/Desktop/cloudPoint/学习随笔/java/70.png)]

这是一个从磁盘文件中读取并且通过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层中FileChannelmap方法,但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 
  • 3
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值