写点什么

Java 王者修炼手册【JVM 篇 - 底层原理】:从类加载到 JVM 调优与 OOM 诊断修炼

作者:DonaldCen
  • 2025-12-05
    广东
  • 本文字数:10040 字

    阅读完需:约 33 分钟

Java 王者修炼手册【JVM篇 - 底层原理】:从类加载到 JVM 调优与 OOM 诊断修炼

大家好,我是程序员强子。

又来刷英雄熟练度咯~今天专攻 Jvm~

我们来看一下,今晚我们准备练习哪些内容:

  • 类加载机制:类加载全过程,类加载器,双亲委派模型 等

  • JVM 运行时数据区 : 运行时数据区整体结构,线程私有区域/共享区域

  • GC 类型:Minor GC、Old GC、Full GC 特点,触发场景等

  • GC 算法: 对象存活判定方法, GC 算法

  • GC 收集器:传统收集器,CMS 收集器,G1 收集器,低延迟收集器

发车啦,系好安全带~

类加载机制

类加载是 JVM 将 class 文件加载到内存,生成 Class 对象初始化的过程

要分为加载连接(验证 + 准备 + 解析)、初始化三个大阶段(细分共五个步骤)

类加载的核心是 按需加载(懒加载)

JVM 不会在启动时加载所有类,而是在首次使用(主动引用)时才触发加载,这也是优化 JVM 内存的重要机制

加载(Loading)

核心作用

通过类的全限定名(如 java.lang.String)获取字节码文件(.class

并将其转换为 JVM 能识别的运行时数据结构(存储在方法区

同时在堆中生成一个代表该类的 java.lang.Class 对象(作为方法区类数据的访问入口

类加载器的参与

  • 启动类加载器(Bootstrap ClassLoader):加载 JRE/lib 核心类库(如 rt.jar 中的 java.lang.*);

  • 扩展类加载器(Extension ClassLoader):加载 JRE/lib/ext 扩展类库;

  • 应用类加载器(Application ClassLoader):加载应用程序 classpath 下的类;

  • 自定义类加载器:通过继承 ClassLoader 实现自定义加载逻辑(如加载网络中的字节码)

字节码来源

  • 本地文件

  • 网络传输(如 RPC 动态加载)

  • 动态生成(如动态代理)

  • ...

连接(Linking)

连接阶段分为验证准备解析三步,是对加载后的类数据进行校验预处理的过程

验证(Verification)

JVM 会校验字节码的格式和语义,防止恶意或错误的字节码导致 JVM 崩溃

  • 文件格式验证:检查字节码是否符合 .class 文件规范(如魔数 0xCAFEBABE、版本号兼容);

  • 元数据验证:检查类的继承关系、字段 / 方法定义是否合法(如不能继承 final 类、方法参数类型匹配);

  • 字节码验证:分析方法体的字节码指令,确保逻辑合理(如栈操作不越界、类型转换合法);

  • 符号引用验证:校验符号引用(如类名、方法名)是否能找到对应的类 / 方法。

准备(Preparation)

在方法区为类的静态变量(类变量)分配内存,并设置默认初始值, 非代码中定义的初始值

  • 仅处理静态变量(static 修饰),实例变量在对象实例化时分配;

  • 默认初始值是数据类型的 “零值”:int→0boolean→false 引用类型→null

  • 若静态变量被 final 修饰(常量),则直接赋值为代码中的常量值如 static final int a=10,准备阶段直接设为 10。

解析(Resolution)

符号引用 转为 直接引用

什么是符号引用?什么是直接引用?

  • 符号引用:字节码中用字符串描述的类、方法、字段(如 Ljava/lang/String;);无论 JVM 内存如何分配,符号引用的描述形式不变编译期即可确定,存储在.class 文件的常量池里因为编译阶段 JVM 还未加载类,无法知道目标在内存中的实际位置,只能用符号来 占位 描述

  • 直接引用:指向内存中实际地址的指针、偏移量等与内存布局强相关:不同 JVM 实例、不同运行时刻,同一目标的直接引用可能不同属于动态引用:运行期生成,是实际执行时使用的 有效引用直接指向目标:无需额外解析,能直接定位到类、方法、字段的内存位置,调用效率高

解析阶段会将类的字段、方法、接口的符号引用替换为直接引用,确保后续调用能直接定位到目标。

简单来说, 因为编译阶段 JVM 还未加载类,无法知道目标在内存中的实际位置,只能用符号来 占位 描述,即符号引用;

随着 JVM 已经加载好类相关信息了,在内存里面都有对应的实际物理位置,称之为直接引用,所以替换为直接引用

初始化(Initialization)

核心任务

执行类构造器() 方法(由编译器自动生成),完成静态变量的赋值和静态代码块的执行

关键细节

  • 由静态变量赋值语句和静态代码块(static{})按代码顺序合并生成;

  • 若类中无静态变量和静态代码块,则不会生成();

  • () 只执行一次(类加载过程中仅初始化一次);

  • 父类的() 会优先于子类执行(初始化子类前必须先初始化父类)

初始化的触发条件

  • 创建类的实例(new 对象);

  • 调用类的静态方法或访问静态变量(非 final 常量);

  • 通过反射调用类(Class.forName("xxx"));

  • 初始化子类时(父类未初始化则先初始化父类);

  • JVM 启动时执行 main() 方法的主类(必须初始化)。

双亲委派模型

工作流程

当一个类加载器收到加载请求时,先委托给父加载器尝试加载;

若父加载器无法加载(找不到文件),再自己加载

设计目的

安全 和 去重

  • 安全防止核心类被篡改

  • 去重同一类由父加载器加载,避免重复

为什么要打破双亲委派?

比如在 SPI 的情景之下

JDBC 的 DriverManager 由启动类加载器加载(属于下级加载器),但具体驱动(如 MySQL 驱动)在 classpath 下(属于上级加载器

需通过线程上下文类加载器(双亲委派规定,一定要从上级加载器拿,而不是下级,因此必须要绕开双亲委派)加载,线程上下文类加载器属于一个桥梁作用

JVM 运行时数据区

整体结构

JVM 的内存布局严格划分了 专属区域公共区域

  • 线程私有:每个线程独立拥有,随线程生灭(程序计数器、虚拟机栈、本地方法栈);

  • 线程共享:所有线程共用,存放全局数据(堆、方法区 / 元空间)。

就像剧院里每个演员有自己的化妆台私有),但道具库舞台是大家共用的(共享)。

线程私有区域

  • 程序计数器记录当前线程执行的字节码指令地址比如线程 A 执行到 invokevirtual 指令时被切换,下次恢复时能精准回到这个位置

  • **虚拟机栈 **存储 Java 方法的栈帧,每个方法对应一个栈帧比如调用 UserService.save()时,就会创建一个栈帧压入栈

  • 本地方法栈专门存 Native 方法的栈帧和虚拟机栈原理类似

线程私有区域经常出现的异常有哪些?

StackOverflowError:方法递归太深,栈帧堆太多撑爆了!

比如写个无限递归

public static void loop() {     loop(); // 运行直接抛StackOverflowError
复制代码

OutOfMemoryError:虚拟机栈默认可动态扩展(通过-Xss 设置大小,比如-Xss1m),如果扩展时内存不够,就会 OOM

那什么是栈帧呢?我们接着剖析细节~~

栈帧

定义

栈帧是 JVM 虚拟机栈(JVM Stack)中用于封装单个方法调用的内存结构,属于线程私有

结构

局部变量表(Local Variable Table)

存储方法的局部变量(包括方法参数、方法内定义的局部变量、this 引用)

变量槽(Slot)为单位

其中,1 个 Slot 可存储 int/float/reference 等类型,long/double 占 2 个 Slot

实例方法的第 0 个 Slot 固定存储 this 引用(静态方法无 this)

参数按声明顺序存储在局部变量表的前几个 Slot,后续 Slot 存储方法内局部变量

操作数栈(Operand Stack)

方法执行时的 临时运算区

通过压栈(push)、出栈(pop)操作完成算术运算、方法调用等逻辑

如执行 a+b 时,先将 a 和 b 压入操作数栈,再执行 iadd 指令弹出相加,结果压回栈

动态链接(Dynamic Linking)

动态链接是栈帧中指向当前类的运行时常量池整体。

当方法执行到需要解析某个符号引用的指令时(比如 invokevirtual #5、getfield #8):

  1. 通过栈帧中的动态链接(指向常量池的入口)找到整个常量池;

  2. 根据指令中的编号(如 #5、#8)定位到常量池中对应的符号引用条目;

  3. 将该条目解析为直接引用(如方法地址、字段偏移量)。

方法返回地址(Return Address)

存储方法执行完毕后,返回给调用者的 指令地址(即调用该方法的位置)

若方法正常返回(return),则回到调用者的下一条指令;

若异常返回,则通过异常表确定返回位置

线程共享区域

  • 方法区: 存类的元信息(类名、方法名、字段、常量池)、静态变量、JIT 编译后的代码

  • : 堆是 JVM 里最大的内存区域,几乎所有对象都诞生在这里

方法区 是我们学习 Jvm 的重点,接下来我们详细学习一下~

方法区

定义

方法区 并非某个具体的实现,而是 JVM 规范的 逻辑概念,即代码我们常说的 接口

在 JDK 8 前用 永久代 实现,JDK 8 及以后用 **元空间 **实现 ,这些才是具体的 实现类

存储内容

类的元数据(Class Metadata)

  • 类的全限定名(如 java.lang.String)、访问修饰符(public/final/abstract);

  • 类的父类信息(除 java.lang.Object 外,所有类都有父类)、实现的接口列表

  • 类的字段信息(字段名、类型、修饰符)、方法信息(方法名、返回值类型、参数类型、修饰符、方法字节码、异常表等);

  • 类的运行时常量池指针(指向该类的运行时常量池)

运行时常量池(Runtime Constant Pool)

是类的.class 文件中常量池表的运行时表示

  • 编译期生成的字面量(如字符串常量"hello"、数值常量 100);

  • 符号引用(类的全限定名、字段 / 方法的名称 + 描述符等);

  • 运行时动态生成的常量(如 String.intern()方法产生的 “驻留字符串”)

每个类 / 接口都有独立的运行时常量池,类加载时从.class 文件的常量池表初始化。

静态变量(Class Variables)

static 修饰的变量,属于类而非实例,存储在方法区

JDK 8 前直接存在永久代,JDK 8 后存在元空间的类元数据中

即时编译器(JIT)编译后的代码缓存

HotSpot 的 JIT 编译器(如 C1、C2)会将热点代码编译为本地机器码

这些编译后的代码会缓存到方法区(或独立的 “代码缓存区”)

方法区的实现变化

为什么替换为元空间?

  • 永久代内存上限固定,容易因类加载过多(如频繁动态生成类)导致 PermGen OOM;

  • 元空间使用本地内存,可利用更大的内存空间,减少 OOM 概率;

特点

  1. 线程共享,JVM 需保证类加载的线程安全(同一类只会被加载一次

  2. 内存溢出风险永久代时期:若加载类过多、静态变量 / 常量池过大,会触发 PermGen OOM 元空间时期:若元空间本地内存分配不足,会触发 Metaspace OOM。

与其他内存区域的关联

  • 与堆的关联 JDK 8 前,永久代是属于堆内存;JDK 8 后,元空间与堆分离,使用本地内存,但堆中的 Class 对象 会指向方法区的类的元数据

  • 与栈帧的关联:栈帧中的动态链接指向方法区的运行时常量池,方法调用时通过动态链接解析符号引用

  • 与类加载的关联:类加载 最终将类的 元数据 存入 方法区,运行时常量池也在这个时候初始化。

定义

最大的一块线程共享内存区域, 存储对象实例和数组。


整体结构

  • 新生代占堆内存的 1/3 左右,可配置 Eden 区新对象优先分配到 Eden 区占新生代的 80% (如果默认配置不修改的话)Survivor 区 From Survivor(S0)和 To Survivor(S1)两个相等区域(各占新生代的 **10%**)用于存放 Eden 区 GC 后存活的对象

  • 老年代老年代占堆内存的 2/3 左右存放新生代中存活时间长、体积大的对象

新生代为啥分 Eden:Survivor=8:1:1?

新生代里,Eden 区占 80%,两个 Survivor 区各占 10%

新对象先放 Eden,Eden 满了触发 Minor GC,存活对象移到 From 区,年龄+1

接着,对象陆续新增,还是放到 Eden 区,当 Eden 区再次满了之后触发 Minor GC

Eden + From 区存活对象移到 To 区,清空 From 区 ,年龄 + 1

如此循环反复,两个 Survivor 区 轮流上岗

直到年龄足够大,大到能晋升老年代的时候

15 次,默认阈值,可通过-XX:MaxTenuringThreshold 修改

若设置为 0,则发生 Minor GC 后 对象直接晋升 老年代

对象 晋升 到老年代的条件

年龄够大

  • 对象在 Survivor 区熬过 15 次 GC,默认阈值,可通过-XX:MaxTenuringThreshold 改

  • 若设置为 0, Minor GC 后直接进入老年代;

动态年龄判定:Minor GC 后,Survivor 区中所有存活对象的总大小 ≥ Survivor 区容量的 50% ,JVM 会从年龄最大的对象开始,依次选择对象晋升老年代,直到 Survivor 区剩余空间能容纳剩余对象。

举个例子:

配置如下

  • 新生代总大小 = 100MB,-XX:SurvivorRatio=8 ,即 Eden=80MB,S0=10MB,S1=10MB;

  • -XX:MaxTenuringThreshold=15(默认)。

Minor GC 过程:

  • Eden 区满(80MB)触发 MinorGC,GC 后 Eden 中存活对象共 7MB,S0 中原有年龄 1 的对象 2MB

  • 存活对象总大小 = 7+2=9MB(接近 S1 的 10MB,但未超),这个时候可以移到 S1

  • 年龄分别变为:Eden 存活对象年龄 1,S0 存活对象年龄 2

  • 下次 Minor GC:Eden 存活 8MB,S1 中存活对象(年龄 1 + 年龄 2)共 8MB → 总存活 = 16MB远超 S0 的 10MB);

  • 触发提前晋升:从年龄最大的对象开始晋升老年代 JVM 会计算 需要晋升多少才能让剩余对象≤S0 容量(10MB)需晋升 16-10=6MB 选择年龄 2(2MB)+ 部分年龄 1(4MB)晋升,剩余 10MB 移到 S0。

大对象特殊待遇

  • 比如 100MB 的数组,直接进老年代

  • 若 Minor GC 后,某单个对象的大小 超过 Survivor 区的总容量(比如 Survivor=10MB,某对象 = 12MB),则该对象直接跳过 Survivor 区,晋升老年代(即使是新创建的对象)

学到这里,大家会不会跟强子一样好奇?

  • 什么是 Minor GCOld GCFull GC?

  • 他们有什么异同?

来来来,我们深入分析一下~

GC 分类

Minor GC(Young GC)


是什么?

仅针对新生代(Eden 区 + Survivor 区)的垃圾回收,是 JVM 中最频繁的 GC 类型

核心目标是清理新生代的临时对象

回收范围是什么?

新生代的 Eden 区 + 一个 Survivor 区(S0/S1)

Minor GC 后,存活对象会被复制到另一个空闲的 Survivor 区年龄 + 1

若达到年龄阈值Survivor 区空间不足部分对象晋升老年代

触发条件是什么?

  • Eden 区无足够内存分配新对象时触发

  • 大对象分配时,Eden 区 + Survivor 区无法容纳

  • G1 收集器中,新生代 Region 占比达到阈值(默认 45%)

核心特点是什么?

  • 使用复制算法, 这样只需复制少量存活对象

  • STW(Stop-The-World)停顿时间极短(毫秒级)


Old GC(Tenured GC)

回收范围是什么?

仅针对老年代的垃圾回收,不涉及新生代

回收算法是什么?

老年代采用标记 - 清除标记 - 整理算法

因老年代对象存活率高,复制算法效率低

不同垃圾回收器不同特点,具体情况根据具体的垃圾回收器 选择 标记-清除 或者 标记-整理

  • CMS 收集器: 并发标记 - 清除

  • G1 收集器 : G1 无单独的 Old GC,而是通过 Mixed GC(混合回收)

  • Serial/Parallel Old 收集器: 无单独 Old GC,老年代满时直接触发 Full GC


核心特点是什么?

  • 频率低:老年代对象存活时间长,Old GC 通常几分钟甚至几小时触发一次

  • 耗时较长:老年代空间大(占堆的 2/3),且采用标记 - 清除 / 整理算法,STW 停顿比 Minor GC 长(但短于 Full GC)

  • 收集器依赖:仅 CMS 等少数收集器支持单独 Old GC,多数收集器通过 Full GC 回收老年代


Full GC

是什么?

Full GC 是 回收整个堆(新生代 + 老年代)+ 方法区(永久代/元空间) 的垃圾回收

是 JVM 中最耗时的 GC 类型,会导致长时间 STW(秒级甚至更长),应尽量避免。

核心特点是什么?

  • 频率极低:通常几小时甚至几天触发一次(正常优化的应用,如果太频繁的话说明应用异常,需优化);

  • STW 停顿长回收面积广(全堆 + 元空间),算法复杂(标记 - 清除 / 整理 + 新生代复制),停顿时间可达秒级,严重影响应用响应;

  • 性能损耗大全堆扫描清理占用大量 CPU 资源,导致应用吞吐量骤降。

触发条件是什么?

  • 老年代空间不足:老年代被填满,无法容纳新晋升的对象;

  • 空间分配担保失败:Minor GC 前,老年代最大可用连续空间 < 新生代所有对象总大小,且担保失败;

  • 元空间(永久代)不足/永久代(JDK7-)满:JDK8 + 中,元空间内存耗尽(如动态生成类过多)或者 元空间 大小设置过低;永久代存储类元数据、常量池等,空间不足时触发 Full GC

  • 显式调用 System.gc():代码中调用 System.gc()(JVM 可忽略,但多数情况会触发 Full GC);

  • G1 收集器Mixed GC 无法及时回收老年代 Region,或新生代回收失败时触发

空间分配担保失败是什么意思?

老年代承诺为新生代 Minor GC 后的晋升对象提供内存空间

JVM 通过预先检查判断老年代是否有能力兑现这个 承诺

若最终老年代无法兑现(即 担保失败),就会触发 Full GC

GC 算法

要回收垃圾对象,首先得确定哪些对象 活着(被引用)、哪些 死了(无引用)

那我们要了解对象存活判定方法,接下来跟着强子去深入了解吧~

对象存活判定方法

引用计数法

原理是怎么样的?

为每个对象维护一个引用计数器,记录被引用的次数

  • 当对象被引用时,计数器+1,比如 : obj = new Object()

  • 当引用失效时,计数器 - 1,比如 obj = null

  • 若计数器值为 0,判定对象 死亡可被回收


优缺点是怎么样的?

  • 优点:实现简单、判定效率高;

  • 缺点无法解决循环引用问题如 A.obj = B 且 B.obj = A,即使两者无其他引用,计数器仍为 1,永远无法回收~

  • 应用:Python 仍在使用,但 Java 不采用

可达性分析算法

原理是怎么样的?

GC Roots 为起点,遍历所有对象的引用关系,形成 引用链

  • 若对象能通过引用链连接到 GC Roots,判定为 存活

  • 若对象无法到达 GC Roots(引用链断裂),判定为 可回收

什么是 GC Roots?

存活且永远不会被回收的起始引用集合。

JVM 中被明确标记为 存活且永远不会被回收 的对象引用,作为遍历所有对象引用链的起点

GC Roots 有哪些常见的类型 ?

  • 方法区中类静态属性引用的对象,比如 static Object obj;

  • 方法区中常量引用的对象,如 final Object obj;

  • 本地方法栈 JNI 中引用的对象,如 Native 方法调用的对象;

  • JVM 内部的核心对象,如 Class 对象、ClassLoader 对象

GC Roots 有哪些优缺点

  • 优点:解决了循环引用问题;

  • 缺点:需遍历整个对象图,耗时较长全程 STW,必须暂停所有用户线程,直到整个对象图遍历完成;遍历效率低, 没有状态记录,每次遍历都是 从头开始 ,无法复用中间状态

  • 应用:Java、C# 等主流语言的 JVM/CLR 均采用此方法

三色标记法

为什么说三色标记法是可达性分析算法的优化?

通过给对象标记不同颜色来记录遍历状态

解决了原始可达性分析全程 STW 的性能痛点

核心定义是什么?

用三种颜色标记对象的可达性遍历状态

  • 白色: 初始状态,对象尚未被 GC 线程遍历到,最终仍为白色的对象会被判定为垃圾

  • 灰色:GC 线程已发现该对象,但该对象的所有引用的子对象还未遍历完毕处于 待处理 状态

  • 黑色:GC 线程已遍历该对象及其所有子对象,对象本身及引用链均可达判定为存活对象,后续不会再被处理

遍历流程是怎么样的?

  • 初始标记:所有对象默认标记为白色,GC Roots 对象标记为灰色,放入标记队列,需要 STW,耗时短;

  • 标记阶段:从标记队列中取出灰色对象,遍历其所有引用的子对象若子对象是白色,将其标记为灰色并加入队列当前灰色对象的所有子对象遍历完毕后,将其标记为黑色不需要 STW,耗时长,支持并发标记

  • 终止阶段:标记队列为空时,所有剩余的白色对象即为不可达的垃圾对象,等待回收需要 STW,耗时短

三色标记法为何是可达性分析法的优化?

三色标记法的本质是给可达性分析增加了 状态管理机制

解决了原始实现的 STW 痛点效率问题

  • 仅在标记的 初始标记最终标记两个短阶段需要 STW

  • 标记阶段不需要 STW,并且是并发标记的,效率很高

哪些垃圾回收器使用三色标记法?

CMS 和 G1 都用三色标记法做并发标记

CMS 为了 (低延迟),实现得简单直接;

G1 为了 (大堆适配 + 延迟可控),做得更精细

CMS 和 G1 在三色标记法的防漏标有哪些差别

CMS增量更新,发现新关系就重新查

比如,人口普查时,已经登记完的住户(黑色),突然新增了一个同住的亲戚(白色)

普查员就把这家标记为 待复核(灰色),下次再来查一遍

G1原始快照SATB),先拍快照按快照算

GC 一开始就给内存中活着的对象拍张 存活快照, 后续所有判断都以这张照片为准。

照片里的对象,无论后续如何被删除引用,都被视为 活着

当对象引用被修改 (如 obj.field=null) 时,写屏障会立即记录被删除的旧引用,确保这些可能被遗漏的对象被专门追踪并标记为存活

SATB 算法采用 宁可多标也不漏标 的保守策略

绝对不会让存活对象被错误回收 (漏清),但可能会保留一些实际已死亡的对象 (多清除)

这些 浮动垃圾 会在下一轮 GC 中被清理。

GC 算法

标记 - 清除算法

原理是什么?

  • 标记阶段:通过可达性分析标记所有存活对象;

  • 清除阶段:遍历堆内存,回收所有未被标记的垃圾对象,释放内存空间。

优缺点是什么?

  • 优点:实现简单,无需移动对象;

  • 缺点:产生内存碎片,回收后的空闲内存分散,无法分配大对象;效率低,需两次遍历堆:标记 + 清除

适用场景是什么?

老年代对象存活率高移动成本高,如 CMS 收集器的老年代回收。

标记 - 复制算法

原理是什么?

将内存划分为大小相等的两块,只用其中一块

  • 标记阶段:标记存活对象;

  • 复制阶段:将存活对象复制到另一块空闲内存;

  • 清除阶段:清空原内存块的所有对象。

优缺点是什么?

  • 优点:无内存碎片,复制效率高,存活对象少;

  • 缺点:内存利用率低,仅用 50% 内存,新生代优化后利用率 90%;

适用场景是什么?

新生代对象存活率低,复制成本低,如 Serial/Parallel/ParNew 收集器的新生代回收

标记 - 整理算法

原理是什么?

结合 标记 - 清除标记 - 复制 的优点

  • 标记阶段:标记存活对象;

  • 整理阶段:将所有存活对象向内存一端 压缩 移动,保证连续

  • 清除阶段:清空存活对象外侧的所有垃圾内存。

优缺点是什么?

  • 优点:无内存碎片,内存利用率 100%;

  • 缺点:需移动对象,耗时较长,老年代对象存活率高,移动成本高

适用场景是什么?

老年代需连续内存,如 Serial Old/Parallel Old 收集器的老年代回收

分代收集算法

原理是什么?

  • 新生代:用标记 - 复制算法,存活对象少,复制效率高;

  • 老年代:用标记 - 清除算法或标记 - 整理算法,存活对象多,避免频繁复制

分区收集算法

原理是什么?

将堆划分为多个大小相等的 Region,每个 Region 独立回收

  • 每个 Region 可视为 小堆,包含新生代 / 老年代的混合区域;

  • 回收时只需处理部分 Region,无需全堆扫描,降低 STW 停顿;

适用场景是什么?

G1/ZGC,

G1 将堆分为 2048 个 Region,大小 1MB~32MB

通过 “Remembered Set” 记录 Region 间引用

回收时优先选择垃圾多的 RegionGarbage-First),用标记 - 复制算法回收

GC 收集器

新生代收集器

新生代的特点是对象 “朝生夕死”,垃圾回收频繁,所以收集器追求高效快速

统一采取复制算法

Serial 收集器

核心原理是什么?

  • 单线程执行垃圾回收

  • 回收期间暂停所有用户线程(Stop The World,STW)

优缺点是什么?

  • 优点是实现简单、内存占用小

  • 缺点单线程,堆内存大时 STW 会很明显

ParNew 收集器

核心原理是什么?

  • Serial 的 多核升级版

  • 支持和 CMS 收集器搭配

优缺点是什么?

  • 优点是多核环境下性能远超 Serial,兼容 CMS;

  • 缺点是线程切换有开销,单核环境下反而不如 Serial

Parallel Scavenge

核心原理是什么?

同样是并行收集器,但它的目标是最大化吞吐量

优缺点是什么?

  • 优点是吞吐量优先,适合计算密集型任务;

  • 缺点是无法和 CMS 搭配,对延迟敏感的场景不友好

老年代收集器

老年代对象存活时间长、数量少,收集器侧重内存利用率稳定性

Serial Old

Serial 的老年代版本,单线程,标记 - 整理算法


Parallel Old

Parallel Scavenge 的老年代版本,多线程,标记 - 整理算法

CMS

核心原理是什么?

并发低延迟收集器 ,标记 - 清除算法 ,三色标记法(增量更新)

优缺点是什么?

  • 优点:并发收集、STW 时间短。

  • 缺点内存碎片:标记 - 清除算法会产生大量碎片,老年代满了会触发 Full GCCPU 敏感:并发阶段会占用 CPU 资源,多核服务器还好,单核 / 双核服务器会导致用户线程卡顿。浮动垃圾:并发清除时用户线程还在创建新对象,这些对象只能下次 GC 再回收

G1

核心原理

JDK1.7 推出、JDK9 成为默认的收集器, 采用分区化增量式回收

  • 分区模型把堆内存拆分成多个大小相等的独立 Region(默认 2MB - 32MB)每个 Region 可以动态扮演 Eden、Survivor、Old 区,不用固定分区大小

  • 优先回收(Garbage - First): 后台维护一个优先级列表,每次优先回收垃圾最多回收收益最大的 Region, 按需回收

  • 停顿预测模型:用户可以设置 期望的最大 STW 时间,比如 10ms,G1 会根据历史回收数据,动态调整回收的 Region 数量,尽量满足时间要求。

优缺点

优点:

兼顾低延迟和吞吐量,支持大堆内存(几十 GB 甚至上百 GB)

自动避免内存碎片,使用 Region 复制算法

好东西唯一缺点:

G1 也不例外,好处很多,缺点却是需要占用大量的内存~

ZGC

特点

  • 超大堆支持:JDK17 已支持 4TB 堆,GB 到 TB 级内存无缝适配;

  • 无内存碎片:本质是复制算法,回收时自动整理内存,不用怕 CMS 的 Full GC 兜底卡顿;

  • 动态 Region:2MB-64MB Region 自动适配对象大小,大对象单独用巨型 Region,内存利用率拉满;

底层核心

  • 核心逻辑:64 位指针中拿高 4 位状态标记(可达 / 重定位),既定位对象地址,又附带 GC 状态,不用额外占用对象头空间。

  • 低延迟关键:靠染色指针实现并发移动对象, 用户线程访问时触发读屏障自动更新旧引用,GC 线程异步整理内存,彻底打破传统 GC 移动对象必 STW 的情况

回收流程

ZGC 全程几乎并发,只有两步短暂停,耗时不随堆大小增长:

  • 初始标记(STW):标记 GC Roots 直接关联对象,微秒 / 毫秒级;

  • 并发标记 + 并发预备重定位:遍历引用链 + 规划对象新地址,和用户线程并行;

  • 重定位(STW):仅更新 GC Roots 的指针,比初始标记更快,1ms 内搞定

注意点

  • 小堆(<4GB)别用 ZGC:优势发挥不出来,不如 G1 高效;

  • 堆内存必须设为固定值:动态扩容会抵消 ZGC 的稳定性优势;

  • JDK17 才适合生产:JDK11 是实验版,JDK17 修复大量 bug,还默认开启分代 ZGC

总结

今天学了类加载机制,知道类加载的全过程~ 知道了 JVM 运行时数据区有哪些,还了解了有哪些类型的 GC,有哪些 GC 算法,以及 GC 收集器~

这些可是了解 JVM 本貌的最核心的知识点,必须吃透原理,练出实操手感。

熟练度刷不停,知识点吃透稳,下期接着练~

发布于: 刚刚阅读数: 4
用户头像

DonaldCen

关注

有个性,没签名 2019-01-13 加入

跟我在峡谷学Java 公众号:程序员悟空的宝藏乐园

评论

发布
暂无评论
Java 王者修炼手册【JVM篇 - 底层原理】:从类加载到 JVM 调优与 OOM 诊断修炼_JVM_DonaldCen_InfoQ写作社区