四、JVM 运行时与垃圾回收
本章涵盖 JVM 内存布局、对象生命周期、主流垃圾回收器底层设计以及生产排障实战指南。
27. JVM 内存区域划分与元空间演进
JVM 在运行时将其管理的内存划分为多个不同的数据区域。
运行时数据区划分
- 线程私有区域:
- 程序计数器(Program Counter Register):记录当前线程正在执行的字节码指令地址,唯一不会发生 OOM 的区域。
- 虚拟机栈(JVM Stack):服务于 Java 方法的执行,由一个个栈帧(Stack Frame)组成,存储局部变量表、操作数栈、动态链接、方法出口。
- 本地方法栈(Native Method Stack):类似于虚拟机栈,服务于 Native 方法。
- 线程共享区域:
- 堆(Heap):Java 对象实例的分配区,垃圾回收器工作的主战场。
- 方法区(Method Area):存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存。
元空间(Metaspace)取代永久代(PermGen)的原因
JDK 8 起,HotSpot 虚拟机废除了永久代,改用本地内存(Native Memory)中的元空间来存储类元信息。
演进动机
- 规避 OOM 崩溃:永久代的大小在启动时由
-XX:MaxPermSize固定,由于应用中会大量动态生成类(如 Spring CGLIB 代理、AOP 动态代理、字节码反射),永久代极易被撑爆引发OutOfMemoryError。元空间使用物理内存,其默认上限为本地物理内存大小,从而大幅度降低了 OOM 的风险。 - 简化 GC 设计:永久代中的垃圾回收效率极低,因为判断一个类是否可以被回收的条件非常严苛。合并至元空间后,JVM 无需再为永久代设计复杂的回收算法,简化了垃圾收集器的物理实现。
- 融合 HotSpot 与 JRockit:Oracle 收购 BEA 后,为了合并 HotSpot 和 JRockit 虚拟机的优势,由于 JRockit 没有永久代,因而去除永久代成了大势所趋。
28. 对象创建流程与逃逸分析
对象的创建过程
- 类加载检查:当 JVM 遇到一条
new指令时,首先检查能否在常量池中定位到该类的符号引用,并检查该类是否已被加载、解析和初始化。若没有,先执行类加载。 - 分配内存:根据对象大小在 Java 堆中划分内存。分配方式根据堆内存是否规整分为指针碰撞(Bump the Pointer)与空闲列表(Free List)。
- 并发冲突防范(TLAB):为了防止多线程同时分配内存产生冲突,JVM 默认为每个线程在 Eden 区划分一块专属的本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。线程在自己的 TLAB 内分配内存,只有在 TLAB 空间用尽重新申请时,才需要通过 CAS 进行全局同步锁保护。
- 初始化零值:将分配到的内存空间(不包括对象头)中的所有物理数据初始化为零值。
- 设置对象头:设置对象的 Mark Word(锁状态、GC 分代年龄、哈希码)、类型指针(Klass Word)以及数组长度(若是数组)。
- 执行
<init>方法:调用构造函数,按照开发者的意图初始化属性,完成对象的完整构建。
逃逸分析与栈上分配
逃逸分析(Escape Analysis)是 JVM 在即时编译(JIT)阶段的一项关键代码优化手段。
- 原理:分析对象的动态作用域。如果一个对象在方法中被定义后,不仅在方法内部使用,还被外部方法所引用(如作为返回值返回,或赋值给其他类成员变量),则称为方法逃逸或线程逃逸。
- 优化手段:
- 栈上分配(Stack Allocation):若判定一个对象没有逃逸,JVM 可以直接在当前方法的虚拟机栈上分配该对象。方法退出时,对象随着栈帧弹出自动销毁,无需再经历堆中的 GC 过程,极大地减轻了堆垃圾收集的压力。
- 标量替换(Scalar Replacement):如果一个对象不会逃逸,且可以被拆散(例如一个包含
x、y的 Point 对象),JVM 会将其拆解为多个基本类型的局部变量(标量)直接在栈上分配,不再创建该对象的内存实体。 - 锁消除(Lock Elimination):若判定一个对象不会逃逸出当前线程,那么针对该对象的所有同步加锁操作(如使用
StringBuffer)都会被编译器直接抹去。
29. 对象存活判定与引用类型
存活判定方式
- 引用计数法(Reference Counting):给对象添加一个引用计数器,有引用时加 1,失效时减 1。
- 缺陷:无法解决对象之间循环引用的判定(如 A 引用 B,B 引用 A,两对象皆无外部引用,但计数器永远不为 0),因此现代 JVM 均不采用此算法。
- 可达性分析算法(Reachability Analysis):
- 从一组被称为
GC Roots的根对象作为起点出发,根据引用关系向下搜索,搜索所走过的路径称为引用链(Reference Chain)。如果一个对象到 GC Roots 没有任何引用链相连,则证明该对象是不可达的,标记为垃圾。 - GC Roots 包含对象:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象。
- 本地方法栈(JNI Native 方法)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象(如字符串常量池)。
- 正在被
synchronized锁持有的对象。
- 从一组被称为
四种引用类型与回收时机
| 引用类型 | 回收时机 | 典型应用场景 |
|---|---|---|
| 强引用(Strong Reference) | 只要强引用关系还在,垃圾收集器永远不会回收该对象。 | 绝大部分默认声明的对象。 |
| 软引用(Soft Reference) | 在系统将要发生**内存溢出(OOM)**之前,会对其进行二次回收。若回收后内存依然不足,才抛出 OOM。 | 适合实现内存敏感的高速缓存。 |
| 弱引用(Weak Reference) | 只要垃圾收集器开始工作,无论内存是否足够,都会被直接回收。 | 用于解决 Map 内存泄露(如 ThreadLocalMap 中的 Key,WeakHashMap)。 |
| 虚引用(Phantom Reference) | 无法通过它获取对象实例。随时会被回收。必须配合 ReferenceQueue 队列使用。 | 用于跟踪对象被垃圾回收器回收的活动,进行堆外内存(Direct Memory)的释放回收通知。 |
30. 经典垃圾回收器底层设计对比
随着硬件的演进,垃圾回收器从追求最大吞吐量向亚毫秒级低停顿演进。
G1 的 Region 与 Remembered Set
- Region 划分:G1 取消了物理上的年轻代与老年代边界,将堆内存划分为数千个大小相等的独立区域(Region)。每个 Region 可以根据需要动态扮演 Eden、Survivor 或 Old 角色。
- Remembered Set(RSet,记忆集):
- 解决的问题:在对某个 Region 进行垃圾回收(通常是 Mixed GC)时,不需要进行全堆扫描来寻找跨代/跨 Region 的引用。
- 实现:每个 Region 内部都维护了一个 RSet,用于记录“谁指向了我”。在可达性分析时,直接通过 RSet 即可将指向本 Region 的外部引用作为 GC Roots 进行扫描,避免了全堆扫描,使得 G1 能在用户设定的期望停顿时间内(
-XX:MaxGCPauseMillis)完成局部清理。
ZGC 的染色指针与读屏障
ZGC(Z Garbage Collector)是一款能管理 TB 级内存且将停顿时间控制在 1 毫秒以内的并发低停顿垃圾回收器。
- 染色指针(Colored Pointers):
- 传统的 GC 标记位存在对象头中,而 ZGC 将标记信息直接存储在指向该对象的 64 位指针本身中。
- 64 位指针的高 16 位被弃用,ZGC 占用了其中的 4 位来存储该对象的“Marked0”、“Marked1”、“Remapped”以及“Finalizable”状态。这使得 ZGC 在进行对象存活标记时,无需访问对象头,直接检查指针位即可。
- 读屏障(Read Barrier):
- ZGC 是并发垃圾回收器,即在 GC 整理和移动对象位置时,Java 业务线程仍在并行工作。
- 当业务线程尝试去读取一个对象指针时,会触发 JVM 插入的读屏障代码。如果发现该指针指向的对象已被移动(处于 Remapped 阶段但尚未更新指针),读屏障会拦截该读取动作,并根据染色指针中的映射关系自动将其“自愈(Self-Healing)”修正为新地址,随后返回新对象。这种自愈机制使得 ZGC 在并发整理期间,业务线程读取对象几乎没有任何卡顿。
分代 ZGC(Generational ZGC)的改进(Java 21)
在 Java 21 之前,ZGC 是不分代的。这意味着无论对象存活期长短,都必须一视同仁地进行扫描和回收,在内存分配速度极高时,容易发生“分配速率跑赢回收速率”导致退化为停顿(Stop-The-World)GC。
- 改进点:根据“弱分代假说”,绝大多数对象都是朝生夕死的。Java 21 引入的分代 ZGC 将堆划分为了年轻代(Young Generation)和老年代(Old Generation)。
- 优势:
- 极高频率地只扫描和回收年轻代,将昂贵的老年代回收频率大幅度降低。
- 在维持亚毫秒停顿的同时,大幅提升了垃圾回收的吞吐量,减少了 CPU 物理消耗,从根本上杜绝了无分代 ZGC 容易出现的内存分配速率过载问题。
31. 类加载机制与打破双亲委派
类加载是将 .class 文件中的二进制数据读取到内存中,并将其转为方法区内运行时数据结构的过程。
类加载全过程
- 加载(Loading):通过类的全限定名获取二进制字节流,在内存中生成该类的
java.lang.Class对象。 - 验证(Verification):确保 Class 文件的字节流包含的信息符合当前虚拟机的要求,无安全危害。
- 准备(Preparation):为类变量(static 变量)分配内存并设置初始零值(在方法区/堆中)。
- 解析(Resolution):将常量池内的符号引用替换为直接引用(物理内存指针)。
- 初始化(Initialization):执行类构造器
<clinit>()方法,真正执行类变量赋值与 static 静态代码块逻辑。
双亲委派模型(Parents Delegation Model)
- 机制:当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的**启动类加载器(Bootstrap ClassLoader)**中。只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
- 意义:保证了 Java 核心 API 的安全性和唯一性。例如,任何人都无法通过自定义加载器去加载一个恶意的
java.lang.String来篡改系统的核心行为,因为加载请求最终都会委派给启动类加载器去加载 JDK 官方的 String。
如何打破双亲委派机制
在特定场景下,双亲委派机制会限制系统的扩展性,必须将其打破:
- 通过线程上下文类加载器(ContextClassLoader)打破:
- 典型场景:SPI(Service Provider Interface)机制,如 JDBC。
- 痛点:核心 API
java.sql.DriverManager位于启动类加载器(Bootstrap),而具体的数据库驱动(如 MySQL Connector)属于第三方 Jar 包,Bootstrap 无法加载它。 - 解决:Bootstrap 层的代码通过
Thread.currentThread().getContextClassLoader()获取当前线程的上下文类加载器(通常是应用程序类加载器 AppClassLoader),反向委托子加载器去完成驱动类的加载,从而打破了自下而上的委派链。
- 通过自定义双亲委派逻辑打破:
- 典型场景:Tomcat 服务器。
- 痛点:同一个 Web 容器中部署两个不同的 Web 应用,它们依赖了同一个第三方类库的不同版本(如 Spring 4 和 Spring 5)。如果遵从双亲委派,共同的父类加载器只能加载其中一个版本,导致另一个应用崩溃。
- 解决:Tomcat 自定义了
WebappClassLoader。当收到加载请求时,它打破常规,优先自己加载(从应用本身的/WEB-INF/classes目录查找),只有在自己找不到时,才委派给父类加载器(CommonClassLoader 等)去加载,实现了 Web 应用之间的类隔离。
32. 线上性能诊断与故障排查实战
CPU 飙高排查四步法
当服务器 CPU 利用率达到 100% 时,可通过以下步骤快速定位代码:
-
查找占用 CPU 最高的进程 ID:
top假设获取到进程 ID 为
12345。 -
查找该进程下占用 CPU 最高的线程 ID:
top -Hp 12345在列表中找出 CPU 占用最高的线程 ID(十进制),假设是
12360。 -
将线程 ID 转换为 16 进制:
printf "%x\n" 12360得到 16 进制输出为
3048。 -
使用 jstack 定位代码:
jstack 12345 | grep -A 20 "0x3048"根据输出的堆栈信息,直接定位到具体的方法行数(如正在发生死循环、频繁自旋或死锁的代码)。
OOM 与内存泄漏排查
当发生 OutOfMemoryError 时,表明堆内存已被耗尽,多由于内存泄漏(对象生命周期已结束但仍被强引用关联)引起。
- 生成堆转储文件(Dump 文件):
- 在 JVM 启动参数中配置:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/,在崩溃时自动生成。 - 或手动通过命令行导出:
jmap -dump:format=b,file=heap.hprof 12345。
- 在 JVM 启动参数中配置:
- 使用 MAT(Memory Analyzer Tool)分析:
- 将
heap.hprof载入 MAT。 - 寻找泄漏源:查看 Dominator Tree(支配树),寻找保留内存(Retained Heap)占比最高的可疑对象。
- 查看引用链:针对该对象,使用 Path to GC Roots(排除弱引用、软引用等),追踪其到 GC Roots 的强引用调用链,找出是哪个全局容器或未关闭的连接池(如 ThreadLocal、未关闭的连接)持有了该对象导致无法回收。
- 将
Full GC 频繁排查
若 jstat 观察到 FGC 频率极高,说明大量对象在 Minor GC 时无法被回收,或者直接进入了老年代。
- 排查大对象:大对象(如大字节数组、未分页的数据库全表查询)会绕过新生代直接在老年代分配,老年代满则触发 FGC。
- 排查老年代空间分配:新生代与老年代比例是否合适,是否存在分配担保失败。
- 显式 System.gc() 滥用:第三方 Jar 包或代码中显式调用了
System.gc()触发 Full GC。可通过-XX:+DisableExplicitGC进行禁用。
33. 生产环境常用 JVM 参数模板
在生产环境中,合理的 JVM 参数配置是保障系统高可用与低延迟的护城河。
# 1. 内存大小设定:初始堆与最大堆设为一致,防止堆在扩容时产生停顿与内存碎片
-Xms8g
-Xmx8g
# 2. 元空间大小配置:避免元空间不断扩容触发 Full GC 停顿
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
# 3. 开启 ZGC(JDK 15+ 生产级可用,JDK 21+ 推荐开启分代 ZGC)
-XX:+UseZGC
-XX:+ZGenerational # JDK 21+ 显式开启分代 ZGC 提升吞吐
# 4. 设定 GC 期望停顿时间目标(ZGC 会自动以此参数调整分配步长)
-XX:MaxGCPauseMillis=10
# 5. 故障转储配置:在 OOM 崩溃时自动生成转储文件进行现场保留
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/java/heap_oom.hprof
# 6. GC 详细日志输出设置
-Xlog:gc*,gc+phases=debug:file=/var/log/java/gc.log:time,uptime,pid:filecount=5,filesize=100M