JVM 知识整理 📅 发布时间:2026/8/29 18:08:37 👁 浏览次数: JVM 是 Java Virtual Machine 的简称意为 Java虚拟机。虚拟机是指通过软件模拟的具有完整硬件功能的、运行在⼀个完全隔离的环境中的完整计算机系统。真实的操作系统中对于进程的地址空间是进行了分区域的设计JVM也就仿照了操作系统的情况也进行了分区域的设计.JVM 是 Java 运行的基础也是实现⼀次编译到处执行的关键。1. JVM 的组成JVM的组成主要包括类加载器、运行时数据区堆、虚拟机栈等、执行引擎和本地方法接口四大部分组成。JVM执行流程程序在执行之前先要把java代码转换成字节码class文件JVM 首先需要把字节码通过⼀定的方式类加载器ClassLoader把文件加载到内存中运行时数据区Runtime Data Area而字节码文件是 JVM 的⼀套指令集规范并不能直接交个底层操作系统去执行因此需要特定的命令解析器执行引擎将字节码翻译成底层系统指令再交由CPU去执行而这个过程中需要调用其他语言的接⼝ 本地库接口Native Interface来实现整个程序的功能这就是这4个主要组成部分的职责与功能。2. JVM 运行时数据区JVM 运行时数据区域也叫内存布局但需要注意的是它和 Java 内存模型(Java Memory Model简 称 JMM完全不同属于完全不同的两个概念它由以下 5 大部分组成2.1 程序计数器线程私有程序计数器的作用用来记录当前线程执行的行号的。程序计数器是⼀块比较小的内存空间可以看做是当前线程所执行的字节码的行号指示器。如果当前线程正在执行的是⼀个Java方法这个计数器记录的是正在执行的虚拟机字节码指令的地址如果正在执行的是⼀个Native方法这个计数器值为空。程序计数器内存区域是唯⼀⼀个在JVM规范中没有规定任何OOM情况的区域。2.2 Java虚拟机栈线程私有Java 虚拟机栈的作用Java 虚拟机栈的生命周期和线程相同Java 虚拟机栈描述的是 Java 方法执行的内存模型每个方法在执行的同时都会创建⼀个栈帧Stack Frame用于存储局部变量表、操作数栈、动态链接、方法出口等信息。咱们常说的堆内存、栈内存中栈内存指的就是虚拟机栈。方法从调用到执行完毕对应一个栈帧在虚拟机栈中从入栈到出栈的过程。Java 虚拟机栈中包含了以下 4 部分局部变量表存放了编译器可知的各种基本数据类型(8大基本数据类型)、对象引用。局部变量表所需的内存空间在编译期间完成分配当进入⼀个方法时这个方法需要在帧中分配多大的局部变量空间是完全确定的在执行期间不会改变局部变量表大小。简单来说就是存放方法参数和局部变量。操作栈每个方法会生成⼀个先进后出的操作栈。动态链接指向运行时常量池的方法引用。方法返回地址PC 寄存器的地址。2.3 本地方法栈线程私有本地方法栈和虚拟机栈类似只不过 Java 虚拟机栈是给 JVM 使用的而本地方法栈是给本地方法 Native 方法使用的。native 关键字修饰的本地方法被执行的时候在本地方法栈中也会创建一个栈帧用于存放该 native 本地方法的局部变量表、操作数栈、动态链接、方法返回地址。方法执行完毕后相应的栈帧也会出栈并释放内存空间。2.4 堆线程共享作用所有线程共享的内存区域几乎所有的对象实例和数组都在这里分配内存。它是 JVM 管理的内存中最大的一块也是垃圾回收器GC主要活动的区域因此常被称为“GC 堆”。例如 Test t new Test();如果 t 是一个局部变量 t 就在栈上如果 t 是一个成员变量非静态成员变量 t 就在堆上如果 t 是一个静态成员变量 t 就在元数据区上关于 堆 中的 新生代 和 老生代 的概念在垃圾回收GC部分讲解。2.5 方法区/元空间线程共享作用所有线程共享用于存储已被虚拟机加载的 类型信息类名、访问修饰符、属性、继承哪个类、实现哪个接口等、常量、静态变量static、即时编译器JIT编译后的代码缓存等。在《Java虚拟机规范中》把此区域称之为“方法区”而在 HotSpot 虚拟机的实现中在 JDK 7 时此区域叫做永久代PermGenJDK 8 中叫做元空间Metaspace。元空间是 JDK 8 中 HotSpot 虚拟机对方法区的实现方法区是规范接口元空间Metaspace是 JDK 1.8 后的具体实现实现类替代原永久代。运行时常量池是方法区的一部分用于存放 class 文件中的字面量如字符串常量 abc和符号引用。2.6 总结3. JVM 类加载3.1 类加载过程对于⼀个类来说它的生命周期是这样的其中前 5 步是固定的顺序并且也是类加载的过程其中中间的 3 步我们都属于连接所以对于类加载来说过程分为五个阶段加载、验证、准备、解析、初始化。① 加载类加载的起点JVM 要做三件事找到类文件通过类的全限定名如java.lang.String找到对应的.class二进制字节流。转成静态数据结构将字节流解析成 JVM 内部特定的数据结构即方法区/元空间中的运行时数据。生成入口对象在堆内存中创建一个java.lang.Class对象作为外界访问该类元数据的“总入口”。② 验证验证是连接阶段的第⼀步这⼀阶段的目的是确保 Class文件 的字节流 中包含的信息符合《Java虚拟机规范》的全部约束要求保证这些信息被当作代码运行后不会危害虚拟机自身的安全。主要检查文件格式魔数CAFEBABE是否正确版本号是否匹配当前 JVM。元数据类是否有父类除了 Object是否非法继承了 final 类。字节码方法体里的指令是否合法比如跳转指令不会跳到方法体之外。符号引用即将引用的其他类、方法、字段是否真实存在这部分会在解析阶段再次确认。③ 准备这个阶段正式为类变量static分配内存并设置初始值默认零值。分配对象只在方法区/元空间中给static变量划地盘。实例变量不归它管实例变量等 new 对象时再说。赋的值是数据类型的默认值int→0boolean→false引用→null。特例如果同时被 final 修饰如 static final int a 10;由于值已知且不会再变JVM 在准备阶段就会直接赋值为 10。④ 解析编译成 .class 文件时代码里的引用比如调了 System.out.println只是符号引用一个字符串名字如 java/io/PrintStream。此时 JVM 还不知道这个类具体加载到内存的哪个位置。解析阶段JVM 将常量池中的符号引用替换为直接引用即指向元空间中的内存地址、方法偏移量等也就是初始化常量的过程。⑤ 初始化这是类加载的最后一步也是真正执行 Java 代码逻辑的开始。JVM 会执行类的构造器方法 clinit()该方法由两部分组成静态变量的显式赋值如 static int a 123;此时才把 123 写进去。静态代码块static { ... }中的代码。注意JVM 会保证父类的 clinit() 先执行即若有父类先初始化父类再初始化子类以上就是类加载的全过程一个进程中一个类的加载只会触发一次。3.2 双亲委派模型 类加载触发的时机 类加载的触发时机核心原则只有一句话按需加载懒加载Lazy Loading。JVM 绝不会一启动就把所有类全加载进内存而是等用到时再加载。在Java代码中用到哪个类就会触发哪个类的加载构造这个类的实例时调用/使用 类静态属性/静态方法时使用某个类的时候如果它的父类还没有加载也会触发父类的加载 类加载器 JVM 中有专门的模块负责类加载即为类加载器。JVM 默认提供了三种类加载器BootstrapCLassLoader 启动类加载器ExtensionCLassLoader 扩展类加载器ApplicationCLassLoader 应用程序类加载器除了上述三种默认的加载器还有 自定义类加载器 在 Java 中开发者可以继承 java.lang.ClassLoader 类重写 findClass 等方法来自定义类加载器。这三种类加载器彼此之间是父子关系注意不是父类子类而是通过 parent 这样的引用指向的父子关系类似于链表。这三个类加载器首当其冲就是要进行找 .class 文件的环节例如给定一个类名类似于 java.lang.String 这三个类加载器负责找的目录范围是不同的通过上述可以总结出双亲委派模型的过程进行类加载通过全限定类名找到 .class 文件的时候就会从 ApplicationCLassLoader 作为入口开始然后把 加载类 这样的任务委托给 父亲 ExtensionCLassLoader 来进行而 ExtensionCLassLoader 也不会立即进行查找而是也委托给 父亲 BootstrapClassLoader 来进行BootstrapClassLoader 也想委托给 父亲由于它指向的是 null也就是没有父亲只能自己进行类加载根据类名找标准库范围看看是否存在匹配的 .class 文件。如果 BootstrapClassLoader 没有找到再把任务还给 孩子 ExtensionCLassLoader 接下来 ExtensionCLassLoader 来负责进行找 .class 文件 的过程找到就加载没有找到也就把任务还给 孩子 ApplicationCLassLoader 接下来 ApplicationCLassLoader 负责找 .class 文件找到就加载没有找到就抛出异常。上述的这一套流程目的是为了约定 优先级收到一个类名之后一定是先在 标准库 中找再扩展库找最后才是第三方库找。4. 垃圾回收GC手动释放free 内存比较麻烦容易忘记就会出现内存泄露的问题Java中引入了 垃圾回收进行内存的自动释放JVM 会自动识别出某个内存是不是后续不再使用了是的话就自动释放。上面讲了Java运行时内存的各个区域。对于程序计数器、虚拟机栈、本地方法栈这三部分区域而言 其生命周期与相关线程有关随线程而生随线程而灭。并且这三个区域的内存分配与回收具有确定性因为当方法结束或者线程结束时内存就自然跟着线程回收了。因此我们课所讲的有关内存分配和回收关注的是Java 堆 与 方法区 这两个区域。GC 的工作过程找到 垃圾不再使用的对象释放 垃圾 对应的内存释放掉4.1 找到垃圾Java堆中存放着几乎所有的对象实例垃圾回收器在对堆进行垃圾回收前首先要判断这些对象哪些还存活哪些已经死去。判断对象是否已死有如下几种算法。内存 VS 对象在 Java 中所有的对象都是要存在内存中的也可以说内存中存储的是⼀个个对象因此我们将内存回收也可以叫做死亡对象的回收。死亡对象的判断方法①引用计数算法每个对象在 new 的时候都搭配一个小的空间来保存一个整数这个整数就表示当前对象有多少个引用指向它。具体为给对象增加⼀个引用计数器每当有⼀个地⽅引用它时计数器就1当引用失效时计数器就 -1 任何时刻计数器为 0的对象就是不能再被使用的即对象已死。引用计数法实现简单判定效率也比较高在大部分情况下都是⼀个不错的算法。比如 Python 语言就采用引用计数法进行内存管理。每次进行引用赋值的时候一定是通过引用来完成的如果引用计数为0了就说明没有引用指向这个对象了这个对象就是垃圾。但是引用计数算法也有缺点内存消耗的更多尤其是对象本身比较小引用计数消耗的空间比例就更大。假设引用计数是4个字节对象本身是8个字节引用计数就相当于占了一半的内存空间。可能出现 循环引用 的问题在主流的JVM中没有选用引用计数法来管理内存最主要的原因就是引用计数法无法解决对象的循环引用问题循环引用 问题假设有两个对象 A 和 BA 持有 B 的引用B 同时持有 A 的引用且两者都没有被其他外部对象引用。此时在引用计数算法中A 和 B 的引用计数器始终为 1互相引用永远不会变为 0因此无法被回收。②可达性分析算法Java并不采用引用计数法来判断对象是否已死而采用 可达性分析 来判断对象是否存活。此算法的核心思想为 : 通过⼀系列称为 GC Roots 的对象作为起始点从这些节点开始向下搜索搜索走过的路径称之为引用链当⼀个对象到 GC Roots 没有任何的引用链 相连时(从GC Roots 到这个对象不可达) 时证明此对象是不可用的。可作为 GC Roots 的对象1栈上的局部变量引用类型2常量池引用指向的对象3静态成员变量引用类型从这些起始节点起尽可能遍历判定某个对象是否能访问到每次访问到一个对象都会把这个对象标记为 可达 当完成所有的对象的遍历后未被标记成 可达 的对象就是 不可达 。JVM 中一共有多少个对象JVM 自身是知道的通过可达性分析知道了哪些是 可达 的剩下的就是 不可达 的这些 不可达 就是要回收的垃圾。假设有这样的代码创建一棵二叉树class Node { String val; Node left; Node right; } Node build { Node a new Node(a); Node b new Node(b); Node c new Node(c); Node d new Node(d); Node e new Node(e); Node f new Node(f); Node g new Node(g); a.left b; a.right c; b.left d; b.right e; e.left g; c.right f; return a; }正常情况下所有的节点都是 可达 的 如果让 root.right.right null; 这样的操作就会使 f 这个节点 不可达 那么在下一轮的 GC 过程中此处的 f 节点就会被当作 垃圾 进行回收。下一轮是因为 可达性分析 这个过程是 周期性的每隔一定的时间触发一次这样的可达性分析的遍历。而如果是将 root.right null; 那么 c 节点就会被当作垃圾同时要想访问 f 节点也要通过 c 节点但是 c 不可达就会使得 f 节点也不可达f 节点也会被当作垃圾处理。——————当已经知道哪些对象是垃圾了接下来就是解决如何释放垃圾的问题。4.2 释放垃圾通过上面的学习我们可以将死亡对象标记出来了标记出来之后我们就可以进行垃圾回收操作了。垃圾回收算法①标记-清除算法标记-清除算法是最基础的收集算法。算法分为标记和清除两个阶段 : 首先标记出所有需要回收的对象在标记完成后统⼀回收所有被标记的对象。缺点效率问题 : 标记和清除这两个过程的效率都不高空间问题 : 标记清除后会产生大量不连续的内存碎片空间碎片太多可能会导致以后在程序运行中需要分配较大对象时无法找到足够连续内存而不得不提前触发另⼀次垃圾收集。申请内存空间都是申请连续的内存不能是多个部分拼到一起内存碎片如果非常多总的空闲空间虽然很大但是但凡想申请一个稍微大一点的内存都会失败。②复制算法复制算法是为了解决标记-清理的效率问题。它将可用内存按容量划分为大小相等的两块每次只使用其中的⼀块。当这块内存需要进行垃圾回收时会将此区域还存活着的对象复制到另⼀块上面 然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收内存分配时也就不需要考虑内存碎片等复杂情况只需要移动堆顶指针按顺序分配即可。此算法实现简单运行高效。缺点内存的空间利用率很低一旦不是垃圾的对象较多复制的成本就会很高③标记-整理算法标记过程仍与标记-清除过程⼀致但后续步骤不是直接对可回收对象进行清理而是让所有存活对象都向⼀端移动然后直接清理掉端边界以外的内存。类似于顺序表的 搬运缺点内存搬运数据的操作开销是挺大的复制成本问题仍然存在。④分代收集算法Java 释放对象/垃圾 就使用了分代回收的算法将上述三种算法优点结合起来分代算法通过区域划分实现不同区域和不同的垃圾回收策略从而实现更好的垃圾回收。当前 JVM 垃圾收集都采用的是 分代收集(GenerationalCollection)算法 这个算法并没有新思想 只是根据对象存活周期的不同将内存划分为几块。⼀般是把Java堆 分为新生代和老年代。在新生代中每次垃圾回收都有大批对象死去只有少量存活因此我们采用复制算法而老年代中对象存活率高、没有额外空间对它进行分配担保就必须采用标记-清理或者标记-整理算法。代 就是 对象的年龄也就是 GC 轮次某个对象经历一轮 GC 可达性分析之后不是垃圾此时对象的年龄就 1初始情况为 0。哪些对象会进入新生代哪些对象会进入老年代新生代⼀般创建的对象都会进入新生代新生代中的对象大部分会快速消亡 GC 频次较高老年代大对象和经历了 N 次⼀般情况默认是 15 次垃圾回收依然存活下来的对象会从新生代移动到老年代。老生代的对象大部分生命周期较长GC 频次较低针对不同年龄的对象采取不同的策略在新生代中又分为伊甸区 和 幸存区两块大小一样新创建的对象会放到 伊甸区绝大部分的伊甸区的对象都活不过第一轮 GC所以幸存区比伊甸区小当能够活过一轮GC后就会通过复制算法将存活的对象从 伊甸区 复制到 幸存区中而能够进行复制的对象规模很少复制开销可控。在幸存区中的对象也要经历 GC 扫描每一轮 GC 都会消灭一大部分对象剩余存活的对象再次通过 复制算法 复制到另一个 幸存区 中。如果这个对象在幸存区中经历了多次复制都存活下来了对象的年龄就大了就会晋升到 老生代在 老生代中也会经历 GC每一轮的 GC都是采用 标记整理 或者 标记清楚 算法扫描的。