JavaEE 初阶第三十一期:JVM,一次Full GC的架构级思考(下)



个人主页:手握风云
目录
一、类加载机制
1.1. 类加载完整过程
在 Java 虚拟机的执行流程中,和程序员关系最为密切的环节就是类加载的过程。对于一个 Java 类而言,它的完整生命周期包含了七个固定的阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)以及卸载(Unloading)。其中,加载、验证、准备、解析和初始化这前五个步骤构成了完整的类加载过程。同时,中间的验证、准备和解析这三个具体的步骤又被统称为连接(Linking)阶段。
1. 加载(Loading)
需要特别注意的是,“加载”(Loading)仅仅是整个“类加载”(Class Loading)过程中的第一个阶段,这两个概念不能混淆 。在这个阶段,Java 虚拟机主要负责完成以下三件核心工作:
- 获取字节流:通过一个类的全限定名(Fully Qualified Name)来获取定义此类的二进制字节流 。这个字节流可以从 ZIP 包读取(如 JAR、WAR)、从网络获取、或是由动态代理在运行时计算生成。
- 转化数据结构:将这个二进制字节流所代表的静态存储结构转化为方法区(Method Area)的运行时数据结构 。
- 生成访问入口:在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区中这个类的各种数据的访问入口 。
2. 验证(Verification)
验证是连接阶段的第一步,其核心目的是确保 Class 文件的字节流中包含的信息完全符合《Java虚拟机规范》的全部约束要求 。这是 JVM 保护自身免受恶意代码或错误编译器产出危害的重要安全防线 。验证阶段主要包含以下几个维度的检查:
- 文件格式验证:检查字节流是否符合 Class 文件格式的规范(例如魔数是否正确、主次版本号是否在当前 JVM 接受范围内) 。
- 字节码验证:对类的方法体进行严格的数据流和控制流分析,确保程序语义是合法的、符合逻辑的,不会做出破坏虚拟机结构的行为 。
- 符号引用验证:在解析阶段发生之前,对类自身以外的各类信息进行匹配性校验,确保后续的解析行为能够正常执行 。
3. 准备(Preparation)
准备阶段是类加载中极易产生认知偏差的一个环节。在这个阶段,JVM 会正式为类中定义的静态变量(即被 static 修饰的变量)分配内存,并设置类变量的初始值 。
这里的“初始值”通常是指数据类型的零值(如 0、false、null 等),而不是程序员在代码中显式赋予的值。
假设代码中定义了 public static int value = 123; 。在准备阶段结束后,变量 value 的初始值会被设置为 0,而不是 123 。将 value 真正赋值为 123 的动作会在后续的初始化阶段才会被执行。
4. 解析(Resolution)
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程 。它本质上是对常量的初始化过程 。
- 符号引用:在编译阶段,Java 类并不知道引用的具体内存地址,只能使用一组符号(如类的完全限定名、方法名)来描述目标。
- 直接引用:解析阶段将其转换为直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。使得程序在实际运行时能够准确找到对应的内存位置。
5. 初始化(Initialization)
初始化阶段是类加载过程的最后一步。直到这个阶段,Java 虚拟机才真正开始执行类中编写的 Java 程序代码(字节码),并将主导权正式移交给应用程序 。
- 执行类构造器:初始化阶段本质上就是执行类构造器方法(<clinit>())的过程 。<clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static {})中的语句合并产生的。
- 回顾准备阶段的案例,正是到了初始化这个阶段,public static int value = 123; 才会被真正赋值为 123。
1.2. 双亲委派模型
1. 核心概念与工作原理
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。每一个层次的类加载器在收到请求时都是如此处理,因此所有的加载请求最终都应该一层层向上传递,传送到最顶层的启动类加载器(Bootstrap ClassLoader)中。只有当父加载器反馈自己无法完成这个加载请求(即在它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载动作。
站在 Java 虚拟机的绝对底层视角来看,其实只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器是使用 C++ 语言实现的,属于虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器全部由 Java 语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
2. 类加载器的层级划分
站在 Java 开发人员的角度来看,类加载器的划分应当更加细致。自 JDK 1.2 以来,Java 一直保持着三层类加载器、双亲委派的类加载架构器。这三个核心层次分别负责不同区域类的加载工作:
-
启动类加载器(Bootstrap ClassLoader): 这是位于模型最顶层的加载器。它主要负责加载 JDK 中
lib目录中的 Java 核心类库,即$JAVA_HOME/lib目录下的核心底层代码。由于其由 C++ 实现,Java 代码无法直接获取到它的引用。 -
扩展类加载器(Extension ClassLoader / Platform ClassLoader): 作为启动类加载器的子类,它负责加载
lib/ext目录下的类。这部分包含了 Java 平台扩展的标准 API 实现。 -
应用程序类加载器(Application ClassLoader / System ClassLoader): 这是程序中默认的类加载器。它负责加载我们写的应用程序,即环境变量
CLASSPATH指定的所有 jar 或目录下的类。
3. 核心优势
Java 体系之所以强制推行双亲委派模型,是因为它为整个 Java 程序的稳定运行提供了极大的保障。一方面,该模型通过向上委派的机制能够有效避免类的重复加载,确保类在内存中只被加载一次,例如当A类和B类共同依赖父类C类时,A类加载过程中会由顶层类加载器完成C类的加载,后续B类加载时便无需重复加载,既节省了内存资源,也保证了类型的一致性;另一方面,双亲委派模型还能提供强大的安全性保障,也就是Java的沙箱安全机制,它可以有效防止Java核心API被恶意篡改,若没有该模型约束,用户自定义的如java.lang.Object这类核心类可能会被随意加载,进而导致Java核心体系崩溃,而通过双亲委派,相关类加载请求最终都会交由启动类加载器加载官方的核心类,从而拦截此类危险行为,守护Java运行环境的安全。
4. 破坏双亲委派模型
尽管双亲委派模型具有诸多优点,是 Java 类加载的标准范式,但在某些特定场景下,这种严格的层级调用关系也存在一定的问题并被有意“破坏”。
最典型的案例发生在 Java 中 SPI 机制中的 JDBC 实现上。SPI 的作用主要是为那些被扩展的系统 API 寻找第三方的服务实现。JDBC 的核心 Driver 接口定义在 JDK 的原生库中,其具体的驱动实现却是由各个数据库的服务商来提供的。
按照双亲委派模型,JDBC 的核心管理类 DriverManager 位于 rt.jar 包中,它理应由处于顶层的 BootStrap 启动类加载器加载。然而,DriverManager 在执行过程中需要动态加载并实例化具体的数据库驱动包(如 mysql.jar),而这些第三方驱动包位于应用的 classpath 下,启动类加载器根本“看不见”也无法加载它们。
为了解决这个悖论,DriverManager 在调用具体的类实现时,主动获取了子类加载器(即线程上下文加载器 Thread.currentThread().getContextClassLoader())来加载具体的数据库驱动包。这种由父类加载器(BootStrap)请求子类加载器(Application ClassLoader)去加载类的行为,打破了自下而上的委派规则,构成了对双亲委派模型的破坏。这是 Java 为了兼顾核心接口统一定义与第三方灵活实现而做出的必要妥协与架构扩展。
二、垃圾回收算法
2.1. 对象存活判断算法
在进行垃圾回收前,JVM 需要判断哪些对象还存活,哪些已经“死亡”(即不再被使用)。主要的判断算法有两种:
- 引用计数算法:给对象增加一个引用计数器,有地方引用时计数器加 1,引用失效时减 1 。计数器为 0 时对象即被判定为死亡 。该算法实现简单且效率高,但主流 JVM 并未使用它,主要原因是它无法解决对象之间相互循环引用的问题 。
- 可达性分析算法:这是 Java 实际采用的算法 。它以一系列被称为 "GC Roots" 的对象作为起始点向下搜索,搜索走过的路径称为 "引用链" 。如果一个对象到 GC Roots 之间没有任何引用链相连(即不可达),则证明此对象是不可用的,将被判定为可回收对象 。可作为 GC Roots 的对象包括:虚拟机栈(本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象,以及本地方法栈中 JNI(Native 方法)引用的对象 。
2.2. 引用分类(强度递减)
强引用 → 软引用(内存不足回收) → 弱引用(GC 必回收) → 虚引用(仅回收通知)。
- 强引用 (Strong Reference):程序中普遍存在的引用(如 Object obj = new Object())。只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象 。
- 软引用 (Soft Reference):描述还有用但非必须的对象 。在系统将要发生内存溢出之前,会把这些对象列入回收范围进行第二次回收;如果内存依然不够,才会抛出内存溢出异常 。
- 弱引用 (Weak Reference):强度弱于软引用,被关联的对象只能生存到下一次垃圾回收发生之前 。当垃圾回收器工作时,无论内存是否够用,都会回收掉只被弱引用关联的对象 。
- 虚引用 (Phantom Reference):最弱的引用关系,完全不影响对象的生存时间,也无法通过它取得对象实例 。其唯一目的是在对象被回收时收到一个系统通知 。
2.3. 核心 GC 算法
1. 标记-清除算法
“标记-清除”算法是最基础的垃圾收集算法,后续的许多收集算法其实都是基于它的基本思路,并针对其存在的不足加以改进而产生的。这个算法的整个运行过程被清晰地分为“标记”和“清除”两个阶段。在最开始,垃圾收集器会遍历内存空间,找出并标记出所有需要被回收的死亡对象。当标记工作全部完成后,收集器就会进行统一的处理,将之前被标记的对象所占用的内存空间全部回收掉。
尽管这种思想非常简单直观,但它在实际应用中存在两个明显的缺陷。首先是执行效率问题,不论是查找标记的过程,还是最终执行清除的过程,它们的效率都不算高。其次是内存分配的空间问题,在进行标记和清除之后,内存中会产生大量不连续的空间碎片。这种碎片化现象会导致一个严重的隐患:当程序在后续运行中需要分配较大的对象时,可能会因为找不到足够大的连续内存空间,而不得不提前触发另一次垃圾收集动作,这无疑会影响程序的运行性能。
2. 复制算法
为了解决“标记-清除”算法在效率低下和产生空间碎片方面的问题,“复制”算法应运而生。它的核心运作策略是将可用的内存空间按容量平分为大小完全相等的两块,但在程序运行时,每次只使用其中的一块。当正在使用的这一块内存即将耗尽,需要进行垃圾回收时,垃圾收集器就会把这块内存中仍然存活着的对象全部复制到另一块未被使用的内存区域上面。在复制完成后,再把刚才使用过的那块旧内存空间一次性全部清理干净。
采用这种做法的好处显而易见:每次回收都是对整个半区进行完整的内存清理,在分配新对象的内存时,也就完全不需要考虑内存碎片等复杂的边界情况。收集器只需要简单地移动堆顶的指针,按顺序分配即可,不仅实现起来非常简单,而且运行起来也极其高效。在如今的商用虚拟机(例如 HotSpot)中,通常都采用这种算法来专门回收新生代内存。不过,因为新生代中有 98% 的对象都具备“朝生夕死”的特性,所以 HotSpot 并没有采用 1:1 的死板比例来划分内存,而是将内存划分为一块较大的 Eden 空间和两块较小的 Survivor 空间(比例默认为 8:1:1),每次回收时将 Eden 和其中一块已使用的 Survivor 中存活的对象复制到另一块未使用的 Survivor 中。当然,如果 Survivor 空间不足以容纳所有的存活对象,就需要依赖其他内存(即老年代)来进行分配担保。
3. 标记-整理算法
复制算法虽然高效,但它存在一个严苛的前提条件:对象的存活率必须比较低。如果对象的存活率很高,收集器就需要进行大量的数据复制操作,这不仅会导致回收效率大幅降低,还需要有额外的空间进行担保。因此,在对象存活率普遍较高的老年代区域,通常是不能适用复制算法的。
针对老年代的这种特性,JVM 引入了“标记-整理”算法。这个算法的第一个阶段与“标记-清除”算法完全一致,依然是先遍历并标记出所有需要回收的对象。但在标记完成之后的后续步骤却截然不同,它不是直接对可回收的对象进行就地清理,而是让所有存活下来的对象都向内存的一端集中移动。当所有存活对象都向一端移动并紧凑排列好之后,收集器会直接清理掉端边界以外的所有剩余内存空间。这种方式既避免了复制算法在对象存活率高时的低效,又完美解决了标记-清除算法带来的内存碎片问题。
4. 分代算法
分代算法与上述三种具体的执行算法在概念上有所不同,它并没有提出创新的回收底层思路,而是根据对象存活周期的差异,提出了一种更为宏观的内存区域划分和管理策略。目前主流的 JVM 垃圾收集基本都采用了这种“分代收集”思想。它一般会将 Java 堆内存划分为几个不同的区域,通常分为新生代和老年代。这就好比实施“一国两制”的方针,针对不同区域的特定情况和需求,设置最符合当地实际运行规律的规则,从而实现更优的整体管理效果。
在具体的实施上,由于新生代中每次发生垃圾回收时,都会有大批的对象死去,只有极少量的对象能存活下来,所以 JVM 会选择在这个区域采用“复制算法”,只需要付出极少存活对象的复制成本就能快速完成收集。相反,在老年代中,由于对象都是经历了多次回收依然存活下来的,存活率极高,并且由于没有其他额外的空间能够为它进行分配担保,因此 JVM 必须在这个区域采用“标记-清理”或者“标记-整理”算法来进行垃圾回收。
2.4. GC 类型
在分代收集架构下,GC 主要包含两种核心行为:一种是 Minor GC,也叫新生代 GC,指发生在新生代的垃圾收集。由于 Java 对象大多具备朝生夕灭的特性,Minor GC 触发非常频繁,且回收速度相对较快。另一种则是 Full GC 或 Major GC,也称为老年代 GC,是发生在老年代的垃圾收集。出现 Major GC 时通常会伴随至少一次 Minor GC,其执行速度一般会比 Minor GC 慢 10 倍以上。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)