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:方法递归太深,栈帧堆太多撑爆了!
比如写个无限递归
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)会将热点代码编译为本地机器码
这些编译后的代码会缓存到方法区(或独立的 “代码缓存区”)
方法区的实现变化
为什么替换为元空间?
永久代内存上限固定,容易因类加载过多(如频繁动态生成类)导致 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 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 本貌的最核心的知识点,必须吃透原理,练出实操手感。
熟练度刷不停,知识点吃透稳,下期接着练~
版权声明: 本文为 InfoQ 作者【DonaldCen】的原创文章。
原文链接:【http://xie.infoq.cn/article/e007d66ea82254604f4f8aadd】。文章转载请联系作者。







评论