1.Linux虚拟内存管理
当程序运行起来之后就变成了进程,业务数据结构的引用在进程的视角里全都都是虚拟内存地址,因为进程无论是在用户态还是在内核态能够看到的都是虚拟内存空间,物理内存空间被操作系统所屏蔽进程是看不到的。
进程通过虚拟内存地址访问这些数据结构的时候,虚拟内存地址会在内存管理子系统中被转换成物理内存地址,通过物理内存地址就可以访问到真正存储这些数据结构的物理内存了。随后就可以对这块物理内存进行各种业务操作,从而完成业务逻辑。
在现代操作系统中往往支持多个进程,需要处理多进程之间的协同问题,在多进程系统中直接使用物理内存地址操作内存所带来的问题(为每个进程究竟要分配多少内存?内存紧张的时候该怎么办?如何避免进程与进程之间的地址冲突?)就变得非常复杂了。
程序局部性原理表现为:时间局部性和空间局部性。时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。
根据程序局部性原理,无论一个进程实际可以占用的内存资源有多大,根据程序局部性原理,在某一段时间内,进程真正需要的物理内存其实是很少的一部分,我们只需要为每个进程分配很少的物理内存就可以保证进程的正常执行运转。
而虚拟内存的引入正是要解决上述的问题,虚拟内存引入之后,进程的视角就会变得非常开阔,每个进程都拥有自己独立的虚拟地址空间,进程与进程之间的虚拟内存地址空间是相互隔离,互不干扰的。
进程的虚拟内存空间所包含的主要区域:
- 用于存放进程程序二进制文件中的机器指令的代码段
- 用于存放程序二进制文件中定义的全局变量和静态变量的数据段和 BSS 段。
- 用于在程序运行过程中动态申请内存的堆。
- 用于存放动态链接库以及内存映射区域的文件映射与匿名映射区。
- 用于存放函数调用过程中的局部变量和函数参数的栈。
程序并不会直接通过 CPU 寻址访问到实际的物理内存,而是通过引入 MMU(Memory Management Unit 内存管理单元)与实际物理地址隔了一层虚拟内存的抽象。这样,程序申请以及访问的其实是虚拟内存地址,MMU 会将这个虚拟内存地址映射为实际的物理内存地址。同时,为了减少内存碎片,以及增加内存分配效率,在 MMU 的基础上 Linux 抽象了内存分页(Paging)的概念,将虚拟地址按固定大小分割成页(默认是 4K),并在页被实际使用写入数据的时候,映射同样大小的实际的物理内存(页帧,Page Frame),或者是在物理内存不足的时候,将某些不常用的页转移到其他存储设备比如磁盘上。
页表是每个进程独立的,操作系统会在给进程映射物理内存用来保存用户数据的时候,将物理内存保存到进程的页表里面。然后,进程访问虚拟内存空间的时候,通过页表找到物理内存。一般 CPU 里面都有一个 TLB(Translation Lookaside Buffer,翻译后备缓冲),TLB 负责缓存虚拟内存与实际物理内存的映射关系,一般 TLB 容量很小。每次访问虚拟内存,先查看 TLB 中是否有缓存,如果没有才会去页表查询。
2.JVM内存reserved/committed含义
VM 的每个子系统(例如 Java 堆,元空间,JIT 代码缓存,GC 等等等等),如果需要的话,在初始化的时候首先 Reserve 要分配区域的最大限制大小的内存(这个最大大小,需要按照页大小对齐(即是页大小的整数倍)。
这样做的好处是:
- 隔离每个 JVM 子系统使用的内存的虚拟空间,这样在 JVM 代码有 bug 的时候(例如发生 Segment Fault 异常),通过报错中的虚拟内存地址可以快速定位到是哪个子系统出了问题。
- 可以很方便的限制这个区域使用的最大内存大小。
- 便于管理,Reserve 不会触发操作系统分配映射实际物理内存,这个区域可以在 Reserve 的区域内按需伸缩。
- 便于一些 JIT 优化,例如故意将这个区域保留起来但是故意不将这个区域的虚拟内存映射物理内存,访问这块内存会造成 Segment Fault 异常。JVM 会预设 Segment Fault 异常的处理器,在处理器里面检查发生 Segment Fault 异常的内存地址属于哪个子系统的 Reserve 的区域,判断要做什么操作。null 检查抛出 NullPointerException 异常的优化,全局安全点,抛出 StackOverflowError 的实现,都和这个机制有关。
JVM 的每个子系统,按照各自的策略,通过 Commit 已经Reserve区域的一部分来申请内存(大小也一般页大小对齐的),从而向操作系统申请映射物理内存,通过 Uncommit 已经 Commit 的内存来释放物理内存给操作系统。Commit 内存之后,并不是操作系统会立刻分配物理内存,而是在向 Commit 的内存里面写入数据的时候,操作系统才会实际映射内存。
总结:
- JVM 中大块内存,基本都是先 reserve 一大块,之后 commit 其中需要的一小块,然后开始读写处理内存,在 Linux 环境下,底层基于 mmap(2) 实现。
如果使用的是 SerialGC,ParallelGC 或者 CMS GC,老年代的内存在有对象晋升到老年代之前,可能是不会映射物理内存的,虽然这块内存已经被 commit 了。并且年轻代可能也是随着使用才会映射物理内存。如果你用的是 ZGC,G1GC,或者 ShenandoahGC,那么内存用的会更激进些(主要因为分区算法划分导致内存被写入),这是你在换 GC 之后看到物理内存内存快速上涨的原因之一。