别再死记硬背!从 C++ 底层视角拆解 JVM 内存、类加载与 GC 原理 一、JVM 内存区域划分1.1 为什么要划分操作系统中有原生的栈、堆等都在进程地址空间中存在但是要实现Code once, run everywhere就要虚拟出一套统一的标准而 JVM作为虚拟机也虚拟出了一套类似的结构屏蔽了原生操作系统结构。JVM 本质上是一个 C 写出来的操作系统中的进程本质上和下面的 C 代码编译出的可执行文件没有区别#includeiostreamintmain(){std::coutHello Worldstd::endl;return0;}而我们谈的内存区域本质上是 JVM 进程申请的一块空间而在这块空间中划分出的区域就是 JVM 为了模拟原生操作系统并加以管理而创建的概念主要包括:**线程私有**程序计数器、虚拟机栈、本地方法栈JVM 中的线程对应操作系统原生的线程**进程内线程共有**方法区、堆1.2 怎样划分的1.2.1 程序计数器为了满足多线程环境下不同线程代码的有序切换需要记录当前线程运行到的字节码可以认为这就是 JVM 虚拟出的 CPU 上的物理硬件 PC寄存器PC 寄存器保证分时操作系统在切换进程、线程时能够不影响机器码的执行1.2.2 元数据区元数据区用来保存加载到内存中的类的信息。我们写的 Java 代码放在 .java 文件中.java 文件经过 javac 编译生成字节码不可直接执行.class 文件随后字节码被加载到内存供给 JVM 运行这个过程就是类加载元数据区主要是三个部分方法元信息、类元信息、运行时常量池。所谓的元信息其实就是属性对于方法来说元信息可以是方法名、参数个数、类型、返回值类型等等对于类来说可以是类名、访问限定、继承情况、接口情况等等而运行时常量池保存着字面量、符号引用、基本类型常量值等1.2.3 栈前面提到过JVM 本质上是一个 C 程序这个程序在运行时必然是由自己的方法调用的这里使用的栈就是操作系统原生的栈进程虚拟地址空间中的这些栈中有一个就进行了构建虚拟机栈的工作申请了一块空间并进行划分作为执行字节码时供给方法调用使用的栈在执行 java 代码时有时会调用 C 方法这时使用的栈也是操作系统原生的栈叫做本地方法栈总之本地方法栈和虚拟机栈在概念上是相似的但是实际上是两个不同的东西前者对应到操作系统原生的栈后者是申请空间构建出的逻辑栈关于如何构建的在 Linux 下为了优化read、write 这两个系统调用而提供了 mmap这里 JVM 的虚拟机栈就是通过 mmap 实现的。都从页缓存没有目标内容的最坏情况考虑read 流程是磁盘 - 内核页缓存 - 用户读缓冲区其中涉及到两次拷贝有了 mmap我们可以通过进程独占的页表把页缓存映射到进程的地址空间中这样读就可以直接从内核页缓存的相应位置读了减少一次拷贝write 流程是用户写缓冲区 - 内核页缓存 - 磁盘落盘有操作系统自己执行不属于 write 的范畴有了 mmap 之后程序就可以直接写内核页缓存的相应位置减少第一次的用户 - 内核的拷贝1.2.4 堆一个变量如果是局部变量那么在栈中保存是成员变量在堆中保存是静态变量就保存在元数据区堆是 JVM 内存划分中最大的部分当堆上的对象不再使用了就会被释放掉至于怎么判定这个不再使用怎么释放就是后面垃圾回收中的内容了二、类加载Java 的类加载主要注意两个方面一个是类加载的流程另一个是双亲委派模型2.1 类加载流程加载加载是通过全限定路径名例如java.lang.Thread找到对应的.class文件把文件加载到内存中校验.class文件是有格式要求的这个阶段会校验二进制的.class文件是否符合结构化的格式要求并将文件内容转化为二进制数据准备给对象开辟空间这里开辟的是全0的、未初始化的空间解析字符串常量本身就包含在.class文件中解析阶段会把这些字符串常量存放到内存中即元数据区的运行时常量池中初始化初始化阶段主要针对的是静态成员、静态代码块这个阶段会进行静态成员的显式赋值并执行静态代码块中对静态成员的操作在初始化阶段如果需要对父类进行加载也会进行父类的类加载2.2 双亲委派模型双亲委派模型描述的是类加载在通过全限定类名找到对应的.class文件的过程。在 JVM 中提供了三种类加载器BootstrapClassLoader负责查找并加载在 Java 标准库中的.class文件ExtensionClassLoader负责查找并加载在 Java 扩展库中的.class文件ApplicationClassLoader负责查找并加载第三方库/当前应用中的.class文件这里的双亲其实描述不是特别恰当或者说可以直接把一个类加载器视为“双亲”。上面三个类加载器从上到下可以依次类比为爷爷、父亲、儿子之所以通过这种方式描述是为了明确.class文件查找并加载的优先级当进行类加载时首先会从ApplicationClassLoader开始把任务委派给ExtensionClassLoader自己并不查找ExtensionClassLoader会继续把任务委托给ApplicationClassLoader自己也不查找。这时ApplicationClassLoader没有人可以委派了所以自己开始查找任务查找范围是所有的 Java 标准库中的.class文件如果找到了完成加载结束类加载的流程没有找到工作流程返回到ExtensionClassLoader在 Java 扩展库中查找同样找到了就加载否则工作流程来到ApplicationClassLoader。如果ApplicationClassLoader也没有找到那么就会抛出异常三、垃圾回收3.1 找到垃圾的策略3.1.1 引用计数类似 C 的std::shared_ptr给每个智能指针对象搭配一个引用计数标记这个对象还在被多少个对象引用如果有不再引用的就将这个计数减小直到为零释放对象。这种方法 Python、Php 都在使用好处是逻辑简单、释放效率也高坏处是这些“计数”会占用大量的空间即这种方式采取的策略是用空间换时间3.1.2 可达性分析可达性分析是将某些对象作为根节点 GCRoots 去遍历只要遍历到的对象都标记为“可达的”否则即“不可达”不可达的对象都认为是需要释放的对象可作为 GCRoots 的对象有栈上的局部变量引用类型常量池引用指向的的对象静态成员引用类型比如以下面的代码为例A、B、C都是我们自定义的类publicTest{privateclassAanewA();privateclassBbnewB();privateclassCcnewC();publicstaticvoidmain(){TesttnewTest();System.gc();}}这时t、a、b、c 都会被认为是可达的。对比引用计数的实现方法不难发现可达性分析策略是时间换空间因为一方面可达性分析不需要保存大量的引用计数另一方面因为需要对大量的对象进行可达性分析会消耗大量的时间可能导致 STWStop The World问题。所以 C 确实考虑过加入 GC但就是因为这不可避免地效率问题所以放弃了3.2 清理垃圾的策略3.2.1 基本策略标记-清除直接清除被标记为不可达的对象清除完毕结束工作。这是几种垃圾清理算法中最简单、快速的但是缺点也是最大的会造成大量的内存碎片化问题。虽然操作系统提供了虚拟地址 - 物理地址的转化映射但是作为映射单位的每个页比如Linux下4kb还是正常存放的所以会造成剩余空间的和很大但是连续剩余空间很小的问题复制算法复制算法是将整个存放区域分成两部分每次使用时都只使用其中一部分。当清除垃圾时将保留下来的对象全都拷贝到另一个区域将原区域剩下的对象全部清除这就是一次垃圾清理。继续存储对象的时候把新的对象再存储到之前拷贝的区域以此往复相对而言复制算法解决了标记-清除的内存碎片化问题但是仍然有很大的缺点内存利用率低每次只能使用一部分被分到的存储空间复制效率低尤其是在只有一小部分对象需要是释放的情况标记-整理算法标记整理算法可以简单类比为顺序表删除中间元素的过程伴随着部分的元素挪动最终结果是一段由存活对象构成的连续的存储空间。标记-整理算法可以认为是前面两种方式的结合体对于各自的缺点都进行了一定的优化但是仍然具有问题3.2.2 Java 分代回收的策略Java 并没有直接采取上面的任何一种而是通过分代回收的策略Java 在堆中有四个部分一个新生代、两个幸存区、一个老年代。同时定义了一个“代”的概念即年龄指的是一个对象经历了多少轮 GC。分代回收的基本思想是大部分对象是朝生息死的在很多轮 GC 中仍未被清除的对象在接下来的 GC 中大概率也不会被清除。当存储对象时都在新生代中进行存储进行 GC 时将存活下来的对象拷贝到两个幸存区中的一个假设为A并将另一个幸存区假设为B依然幸存的对象也拷贝到 A 中接下来进行垃圾清理清除新生代、幸存区B中的所有对象这就是一轮 GC。进行下一轮 GC 时继续上述流程不过是将 A 中依然幸存的对象和新生代中幸存对象拷贝到 B再清除 A 和新生代的剩余对象。每经过一轮仍未被释放的对象代数都会增大当到达一定大小时就会被存放入老年代。对于老年代扫描清理频率更低同时根据前面的假说这里的对象大概率会依然存活所以“垃圾”也更少这里的清除策略也常使用标记-整理策略