大家好,我是程序员强子。
又来刷英雄熟练度咯~今天专攻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→0
- boolean→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):
- 通过栈帧中的动态链接(指向常量池的入口)找到整个常量池;
- 根据指令中的编号(如#5、#8)定位到常量池中对应的符号引用条目;
- 将该条目解析为直接引用(如方法地址、字段偏移量)。
方法返回地址(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)会将热点代码编译为本地机器码
这些编译后的代码会缓存到方法区(或独立的 “代码缓存区”)
方法区的实现变化
| 特性 | 永久代(JDK 1.7 及以前) | 元空间(JDK 1.8 及以后) |
|---|---|---|
| 内存来源 | JVM 堆内存(属于堆的一部分) | 本地内存(JVM 进程外的操作系统内存) |
| 内存上限 | 默认有上限(可通过-XX:MaxPermSize 设置) | 默认无上限(受操作系统内存限制) |
| 垃圾回收 | 属于堆,可被 GC 回收(但效率低) | 属于本地内存,GC 回收废弃类元数据 |
| 溢出类型 | java.lang.OutOfMemoryError: PermGen space | java.lang.OutOfMemoryError: Metaspace |
为什么替换为元空间?
- 永久代内存上限固定,容易因类加载过多(如频繁动态生成类)导致PermGen OOM;
- 元空间使用本地内存,可利用更大的内存空间,减少 OOM 概率;
特点
-
线程共享,JVM 需保证类加载的线程安全(同一类只会被加载一次)
-
内存溢出风险
- 永久代时期:若加载类过多、静态变量 / 常量池过大,会触发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 GC?Old GC?Full 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 间引用
回收时优先选择垃圾多的 Region(Garbage-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 GC
- CPU 敏感:并发阶段会占用 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本貌的最核心的知识点,必须吃透原理,练出实操手感。
熟练度刷不停,知识点吃透稳,下期接着练~