尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

从类加载到垃圾回收:今天把 JVM、双亲委派和 G1 串起来了

从类加载到垃圾回收:今天把 JVM、双亲委派和 G1 串起来了 从类加载到垃圾回收今天把 JVM、双亲委派和 G1 串起来了前言今天主要复习 JVM包括一个类从字节码进入内存后的初始化过程、类加载器的双亲委派机制以及 G1 垃圾回收器的工作原理。以前对这些知识点的印象比较零散知道 Java 代码会编译成字节码也知道 JVM 里有堆、栈和垃圾回收器但面试官如果继续追问“类什么时候初始化”“父类和子类谁先初始化”“双亲委派有什么用”“G1 为什么可以控制停顿时间”就很容易讲乱。这次我想把它们放在同一条链路里理解JVM 先加载并初始化类程序运行时创建对象对象主要进入堆内存最后由垃圾回收器识别并回收不再使用的对象。JVM 到底是什么JVM也就是 Java Virtual Machine负责加载并执行 Java 字节码同时提供内存管理、垃圾回收、运行时优化和跨平台能力。Java 源代码的执行过程可以简单表示为.java 源文件 ↓ javac 编译 .class 字节码 ↓ 类加载器加载 JVM 运行时数据区 ↓ 解释执行或即时编译 本地机器指令Java 所说的“一次编译到处运行”并不是字节码可以脱离环境直接运行而是不同操作系统提供了对应的 JVM统一执行同一种字节码格式。JVM 运行时数据区主要包括堆存放绝大多数对象实例和数组是垃圾回收的主要区域。Java 虚拟机栈线程私有每次方法调用都会创建栈帧。程序计数器线程私有记录当前线程执行到的字节码位置。本地方法栈为 Native 方法服务。方法区保存类信息、运行时常量池、字段和方法元数据等。HotSpot 从 JDK 8 开始使用元空间实现方法区。类从字节码到可以使用经历了什么一个类的生命周期通常包括加载、连接、初始化、使用和卸载。连接阶段又可以分成验证、准备和解析。加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载 └─────── 连接 ───────┘其中解析在某些情况下可以推迟到真正使用符号引用时进行所以实际执行顺序不一定能简单理解成所有类都严格一次走完。加载加载阶段主要完成三件事根据类的全限定名找到对应的二进制字节流。把字节流表示的静态存储结构转换成 JVM 方法区中的运行时数据结构。在内存中生成一个代表这个类的java.lang.Class对象作为访问类元数据的入口。字节码不一定只能来自本地.class文件也可能来自 JAR 包、网络、动态代理或运行时生成的字节码。验证验证阶段用于检查字节码是否符合 JVM 规范避免非法字节码破坏运行环境。它会涉及文件格式、元数据、字节码指令和符号引用等方面。例如检查魔数是否正确、继承关系是否合法、操作数栈类型是否匹配。准备准备阶段为类变量也就是static变量分配内存并设置初始零值。例如publicclassUserConfig{privatestaticintmaxUser100;}在准备阶段maxUser通常先得到默认值0真正赋值为100要等到初始化阶段执行类初始化方法。如果字段是编译期就能确定的常量情况有所不同privatestaticfinalintMAX_USER100;它带有常量值属性准备阶段就可以被赋为100。解析解析阶段把运行时常量池中的符号引用替换成直接引用。符号引用可以理解为通过名称描述目标例如某个类名或方法名直接引用则是能够直接定位到目标的指针、句柄或偏移量。初始化初始化阶段才真正执行类变量赋值和静态代码块。编译器会按源码中的出现顺序收集这些语句生成类初始化方法clinit()。例如publicclassDemo{staticinta10;static{a20;b30;}staticintb40;}初始化完成后a是20b是40。虽然静态代码块先给b赋了30但后面的静态变量赋值又把它改成了40。JVM 会保证一个类的clinit()在多线程环境中被正确同步。同一个类只会初始化一次其他线程需要等待初始化完成。哪些情况会触发类初始化常见的主动使用包括使用new创建对象。读取或设置类的静态字段编译期常量除外。调用类的静态方法。使用反射对类进行调用。初始化子类时发现父类还没有初始化。JVM 启动时初始化包含main()方法的主类。下面这些情况通常不会触发目标类初始化通过子类引用父类定义的静态字段只初始化真正声明字段的父类。创建某个类的数组只创建数组类型不初始化数组元素对应的类。访问编译期常量因为常量可能已经被编译进调用类的常量池。例如classParent{static{System.out.println(Parent 初始化);}staticintvalue10;}classChildextendsParent{static{System.out.println(Child 初始化);}}publicclassMain{publicstaticvoidmain(String[]args){System.out.println(Child.value);}}value实际定义在Parent中所以这里会触发Parent初始化但不会因为通过Child访问就必然初始化Child。类初始化和对象初始化不要混淆类初始化执行的是静态变量赋值和静态代码块对应clinit()。对象初始化则发生在执行new创建实例时涉及实例字段赋值、实例代码块和构造方法对应实例初始化方法init()。创建子类对象时大致顺序是父类进行类初始化。子类进行类初始化。为对象分配内存并设置字段默认值。执行父类实例字段赋值和实例代码块。执行父类构造方法。执行子类实例字段赋值和实例代码块。执行子类构造方法。静态部分通常只在类第一次主动使用时执行一次而实例部分每创建一个新对象都会执行。JVM 中有哪些类加载器Java 中常见的类加载器可以分为启动类加载器加载 Java 核心类库由 JVM 底层实现。平台类加载器加载平台相关的标准类库。在 JDK 8 及更早版本中常见说法是扩展类加载器。应用程序类加载器加载应用类路径下的类通常也是默认类加载器。自定义类加载器继承ClassLoader实现特殊来源或隔离需求下的类加载。判断两个类是否相同不能只看全限定类名还要看加载它们的类加载器。同一个.class文件如果由两个互相独立的类加载器加载在 JVM 看来可能是两个不同的类型。什么是双亲委派机制双亲委派并不是简单地由父加载器直接完成所有加载而是一种委派顺序。当类加载器收到类加载请求时它通常不会立刻自己查找类而是先把请求交给父加载器。请求会逐层向上委派只有父加载器无法完成加载时当前加载器才尝试在自己的范围内寻找并加载这个类。自定义类加载器 ↓ 委派 应用程序类加载器 ↓ 委派 平台类加载器 ↓ 委派 启动类加载器 父加载器找不到后再逐层返回并尝试加载这里的“父子关系”通常是组合关系不是 Java 类继承关系。双亲委派解决了什么问题防止核心类被重复加载如果所有类加载器都各自加载一份java.lang.String系统中就可能出现多份互不相同的核心类型整个类型体系会变得混乱。通过向上委派核心类优先由启动类加载器加载应用程序里的多个类加载器可以共享同一份基础类型。保护核心类库即使项目中自己编写一个全限定名为java.lang.String的类按照双亲委派流程请求也会优先交给上层加载器不能轻易替换 JVM 已经提供的核心实现。不过双亲委派是 Java 类加载体系的重要约定不应该把它理解成全部安全边界。真正的安全还需要依靠模块、权限、字节码验证和运行环境等机制。双亲委派可以被打破吗可以。有些场景需要父加载器加载由子加载器提供的实现或者需要实现插件隔离、热部署和同类不同版本共存。常见例子包括JDBC 的服务提供者加载。Tomcat 等容器对不同 Web 应用进行类隔离。OSGi 模块化加载。自定义插件系统和热部署框架。自定义类加载器如果只需要改变查找类文件的方式通常重写findClass()即可仍然保留双亲委派。如果直接重写loadClass()并改变委派顺序就要非常谨慎否则可能造成类型冲突、重复加载和核心类安全问题。JVM 如何判断对象可以被回收垃圾回收的第一步不是马上清理内存而是判断哪些对象仍然存活。现代 JVM 主要使用可达性分析从一组 GC Roots 出发沿着引用关系向下搜索。能够到达的对象被认为仍然存活无法到达的对象才可能被回收。常见的 GC Roots 包括虚拟机栈中局部变量引用的对象。类的静态字段引用的对象。运行时常量引用的对象。JNI 本地方法引用的对象。JVM 内部持有的部分对象和同步锁持有的对象。可达性分析可以处理循环引用。例如对象 A 引用 BB 又引用 A只要它们与 GC Roots 之间已经没有路径两者仍然可以被回收。常见的垃圾回收算法标记—清除先标记存活对象再清理未被标记的对象。实现直接但回收后会产生大量不连续的内存碎片后续分配大对象时可能找不到足够大的连续空间。标记—复制把内存分成区域只使用其中一部分。回收时把存活对象复制到另一块可用区域再整体清空原区域。它没有内存碎片而且适合存活对象较少的新生代但需要额外的复制空间存活对象很多时复制成本也会提高。标记—整理标记存活对象后让存活对象向内存一端移动再清理边界之外的空间。它减少了内存碎片适合对象存活率较高的区域但对象移动和引用更新会带来额外开销。分代收集分代收集根据对象生命周期不同把堆划分成新生代和老年代等区域再为不同区域选择合适的回收方式。新生代对象大多朝生夕死适合复制老年代对象存活率较高更适合标记—清除或标记—整理。分代不是一种单独的底层算法而是一种组合和管理思路。G1 垃圾回收器是什么G1 的全称是 Garbage First。它面向较大堆内存并以可预测停顿为重要目标。从 JDK 9 开始G1 成为 HotSpot 的默认垃圾回收器。传统分代布局通常把堆划分成物理上连续的新生代和老年代。G1 则把整个堆切分成许多大小相等的 Region每个 Region 在某个时刻可以承担 Eden、Survivor、Old 或 Humongous 等角色。┌─────┬─────┬─────┬─────┬─────┬─────┐ │Eden │ Old │空闲 │Surv.│ Old │Eden │ ├─────┼─────┼─────┼─────┼─────┼─────┤ │ Old │空闲 │Hum. │Hum. │Eden │空闲 │ └─────┴─────┴─────┴─────┴─────┴─────┘这种分区方式让 G1 不必每次回收整个老年代。它可以根据各 Region 的垃圾数量和预计回收收益优先选择价值更高的区域这也是 Garbage First 名字的来源。G1 中几个重要概念RegionRegion 是 G1 管理堆内存的基本单位。逻辑上仍然存在新生代和老年代但物理位置不要求连续可以根据运行情况动态调整各类 Region 的数量。Humongous 对象大小达到或超过一个 Region 容量一半的对象会被 G1 当作大对象处理通常占用一个或多个连续的 Humongous Region。大量大对象会增加内存管理压力严重时可能提前触发垃圾回收所以排查 G1 问题时需要关注对象大小和 Region 使用情况。Remembered Set回收某个 Region 时如果为了查找外部引用而扫描整个堆代价会非常高。G1 会使用 Remembered Set 记录其他 Region 指向当前 Region 的引用线索从而减少全堆扫描。引用更新会通过写屏障和卡表等机制被记录。这样提高了回收阶段的效率但也会增加程序正常运行时的额外开销。Collection SetCollection Set 是本次暂停期间计划回收的 Region 集合。G1 会根据停顿目标、Region 回收收益和预测成本决定把哪些 Region 加入集合。G1 的主要回收过程Young GC当 Eden Region 逐渐耗尽时触发 Young GC。所有应用线程会在安全点暂停存活对象被复制到 Survivor Region年龄达到阈值或满足晋升条件的对象进入 Old Region。回收完成后原来的 Eden Region 可以重新使用。并发标记周期当老年代占用达到一定条件后G1 开始统计整个堆中各 Region 的存活情况。主要阶段包括初始标记标记 GC Roots 直接关联的对象需要短暂停顿通常借助 Young GC 完成。根区域扫描扫描 Survivor Region 指向老年代的引用。并发标记与应用线程并发执行从根出发遍历对象图。重新标记处理并发标记期间发生的引用变化需要暂停应用线程。清理统计 Region 存活率并回收完全空闲的 Region为后续选择回收集合提供依据。G1 使用 SATB也就是 Snapshot-At-The-Beginning协助保证并发标记的正确性。它可以大致理解为以并发标记开始时的对象图为逻辑快照并通过写屏障记录标记期间被覆盖的旧引用避免存活对象被漏标。Mixed GC并发标记完成后G1 会在回收新生代 Region 的同时选择一部分垃圾比例较高的 Old Region 一起回收这就是 Mixed GC。它通常不是一次清完所有老年代垃圾而是在多次可控停顿中逐步完成。这样有利于平衡吞吐量和单次停顿时间。Full GC如果对象分配速度过快、疏散失败、并发标记来不及完成或者堆空间过于紧张G1 仍然可能退化到 Full GC。Full GC 通常需要更长时间的 Stop-The-World因此调优目标之一就是避免频繁出现这种情况而不是认为使用 G1 后就完全不会发生 Full GC。G1 为什么能尽量控制停顿时间G1 可以通过参数设置期望的最大暂停时间例如-XX:MaxGCPauseMillis200这表示期望把 GC 暂停控制在大约 200 毫秒以内但它是软目标不是绝对保证。G1 会记录各 Region 的回收成本、存活对象数量和历史耗时在垃圾回收时选择一个预计能够满足停顿目标的 Collection Set。与每次回收整个连续老年代相比这种按收益选择 Region 的方式更容易控制单次工作量。但暂停目标也不能设置得过于激进。目标越短单次能回收的 Region 越少GC 可能变得更频繁最终影响系统吞吐量。G1 的本质仍然离不开基础算法G1 并不是一种完全脱离标记、复制和整理的新算法。它会通过可达性分析标记存活对象在回收选中的 Region 时把存活对象疏散复制到其他 Region原 Region 被整体清空后重新使用。由于存活对象被集中搬移整体上也能减少内存碎片。所以可以把 G1 理解为以 Region 为单位、结合分代思想、并发标记和复制整理能力的垃圾回收器。使用和观察 G1 时要注意什么停顿时间和吞吐量需要权衡GC 调优不是把暂停参数调得越小越好。需要结合接口延迟、系统吞吐量、堆大小和对象分配速度一起观察。合理设置堆大小堆太小会造成垃圾回收频繁堆太大则可能让部分回收阶段的工作量增加。生产环境中通常需要结合监控和压测决定而不是直接照搬固定参数。关注对象分配和晋升如果短命对象大量产生Young GC 会非常频繁如果对象过早晋升到老年代又会增加 Mixed GC 和并发标记压力。代码层面应避免无意义的大量临时对象和超大对象。先看日志再做调优遇到 GC 问题时应先分析 GC 日志中的暂停原因、回收前后内存、并发周期、疏散失败和 Full GC再决定调整堆、停顿目标或应用代码。不基于数据直接堆叠 JVM 参数往往只会把问题暂时隐藏起来。今日总结今天把 JVM 中三块容易分开背的内容串到了一起。类首先经过加载、验证、准备、解析和初始化才能被程序正常使用。准备阶段主要设置类变量的零值初始化阶段才执行静态赋值和静态代码块类初始化与每次创建对象时执行的实例初始化也不是一回事。类加载时通常遵循双亲委派先让父加载器尝试父加载器无法完成后再由当前加载器处理。它保证了核心类库的统一也减少了类被重复加载的问题但在容器、插件和服务提供者等场景中可以有控制地改变加载方式。对象进入堆以后JVM 通过可达性分析判断对象是否存活。G1 把堆拆成多个 Region通过并发标记统计各区域收益并在 Young GC 和 Mixed GC 中复制存活对象、优先回收垃圾更多的 Region从而在吞吐量和停顿时间之间取得平衡。这次复习之后我对 G1 最重要的理解是它不是一个完全独立的新回收算法而是把分区、分代、标记、复制、整理、并发执行和停顿预测组合成了一套更完整的垃圾回收方案。
返回列表