跳到主要内容

四、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)中的元空间来存储类元信息。

演进动机

  1. 规避 OOM 崩溃:永久代的大小在启动时由 -XX:MaxPermSize 固定,由于应用中会大量动态生成类(如 Spring CGLIB 代理、AOP 动态代理、字节码反射),永久代极易被撑爆引发 OutOfMemoryError。元空间使用物理内存,其默认上限为本地物理内存大小,从而大幅度降低了 OOM 的风险。
  2. 简化 GC 设计:永久代中的垃圾回收效率极低,因为判断一个类是否可以被回收的条件非常严苛。合并至元空间后,JVM 无需再为永久代设计复杂的回收算法,简化了垃圾收集器的物理实现。
  3. 融合 HotSpot 与 JRockit:Oracle 收购 BEA 后,为了合并 HotSpot 和 JRockit 虚拟机的优势,由于 JRockit 没有永久代,因而去除永久代成了大势所趋。

28. 对象创建流程与逃逸分析

对象的创建过程

  1. 类加载检查:当 JVM 遇到一条 new 指令时,首先检查能否在常量池中定位到该类的符号引用,并检查该类是否已被加载、解析和初始化。若没有,先执行类加载。
  2. 分配内存:根据对象大小在 Java 堆中划分内存。分配方式根据堆内存是否规整分为指针碰撞(Bump the Pointer)空闲列表(Free List)
    • 并发冲突防范(TLAB):为了防止多线程同时分配内存产生冲突,JVM 默认为每个线程在 Eden 区划分一块专属的本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。线程在自己的 TLAB 内分配内存,只有在 TLAB 空间用尽重新申请时,才需要通过 CAS 进行全局同步锁保护。
  3. 初始化零值:将分配到的内存空间(不包括对象头)中的所有物理数据初始化为零值。
  4. 设置对象头:设置对象的 Mark Word(锁状态、GC 分代年龄、哈希码)、类型指针(Klass Word)以及数组长度(若是数组)。
  5. 执行 <init> 方法:调用构造函数,按照开发者的意图初始化属性,完成对象的完整构建。

逃逸分析与栈上分配

逃逸分析(Escape Analysis)是 JVM 在即时编译(JIT)阶段的一项关键代码优化手段。

  • 原理:分析对象的动态作用域。如果一个对象在方法中被定义后,不仅在方法内部使用,还被外部方法所引用(如作为返回值返回,或赋值给其他类成员变量),则称为方法逃逸线程逃逸
  • 优化手段
    • 栈上分配(Stack Allocation):若判定一个对象没有逃逸,JVM 可以直接在当前方法的虚拟机栈上分配该对象。方法退出时,对象随着栈帧弹出自动销毁,无需再经历堆中的 GC 过程,极大地减轻了堆垃圾收集的压力。
    • 标量替换(Scalar Replacement):如果一个对象不会逃逸,且可以被拆散(例如一个包含 xy 的 Point 对象),JVM 会将其拆解为多个基本类型的局部变量(标量)直接在栈上分配,不再创建该对象的内存实体。
    • 锁消除(Lock Elimination):若判定一个对象不会逃逸出当前线程,那么针对该对象的所有同步加锁操作(如使用 StringBuffer)都会被编译器直接抹去。

29. 对象存活判定与引用类型

存活判定方式

  1. 引用计数法(Reference Counting):给对象添加一个引用计数器,有引用时加 1,失效时减 1。
    • 缺陷:无法解决对象之间循环引用的判定(如 A 引用 B,B 引用 A,两对象皆无外部引用,但计数器永远不为 0),因此现代 JVM 均不采用此算法。
  2. 可达性分析算法(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 文件中的二进制数据读取到内存中,并将其转为方法区内运行时数据结构的过程。

类加载全过程

  1. 加载(Loading):通过类的全限定名获取二进制字节流,在内存中生成该类的 java.lang.Class 对象。
  2. 验证(Verification):确保 Class 文件的字节流包含的信息符合当前虚拟机的要求,无安全危害。
  3. 准备(Preparation):为类变量(static 变量)分配内存并设置初始零值(在方法区/堆中)。
  4. 解析(Resolution):将常量池内的符号引用替换为直接引用(物理内存指针)。
  5. 初始化(Initialization):执行类构造器 <clinit>() 方法,真正执行类变量赋值与 static 静态代码块逻辑。

双亲委派模型(Parents Delegation Model)

  • 机制:当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的**启动类加载器(Bootstrap ClassLoader)**中。只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
  • 意义:保证了 Java 核心 API 的安全性和唯一性。例如,任何人都无法通过自定义加载器去加载一个恶意的 java.lang.String 来篡改系统的核心行为,因为加载请求最终都会委派给启动类加载器去加载 JDK 官方的 String。

如何打破双亲委派机制

在特定场景下,双亲委派机制会限制系统的扩展性,必须将其打破:

  1. 通过线程上下文类加载器(ContextClassLoader)打破
    • 典型场景:SPI(Service Provider Interface)机制,如 JDBC。
    • 痛点:核心 API java.sql.DriverManager 位于启动类加载器(Bootstrap),而具体的数据库驱动(如 MySQL Connector)属于第三方 Jar 包,Bootstrap 无法加载它。
    • 解决:Bootstrap 层的代码通过 Thread.currentThread().getContextClassLoader() 获取当前线程的上下文类加载器(通常是应用程序类加载器 AppClassLoader),反向委托子加载器去完成驱动类的加载,从而打破了自下而上的委派链。
  2. 通过自定义双亲委派逻辑打破
    • 典型场景:Tomcat 服务器。
    • 痛点:同一个 Web 容器中部署两个不同的 Web 应用,它们依赖了同一个第三方类库的不同版本(如 Spring 4 和 Spring 5)。如果遵从双亲委派,共同的父类加载器只能加载其中一个版本,导致另一个应用崩溃。
    • 解决:Tomcat 自定义了 WebappClassLoader。当收到加载请求时,它打破常规,优先自己加载(从应用本身的 /WEB-INF/classes 目录查找),只有在自己找不到时,才委派给父类加载器(CommonClassLoader 等)去加载,实现了 Web 应用之间的类隔离。

32. 线上性能诊断与故障排查实战

CPU 飙高排查四步法

当服务器 CPU 利用率达到 100% 时,可通过以下步骤快速定位代码:

  1. 查找占用 CPU 最高的进程 ID

    top

    假设获取到进程 ID 为 12345

  2. 查找该进程下占用 CPU 最高的线程 ID

    top -Hp 12345

    在列表中找出 CPU 占用最高的线程 ID(十进制),假设是 12360

  3. 将线程 ID 转换为 16 进制

    printf "%x\n" 12360

    得到 16 进制输出为 3048

  4. 使用 jstack 定位代码

    jstack 12345 | grep -A 20 "0x3048"

    根据输出的堆栈信息,直接定位到具体的方法行数(如正在发生死循环、频繁自旋或死锁的代码)。

OOM 与内存泄漏排查

当发生 OutOfMemoryError 时,表明堆内存已被耗尽,多由于内存泄漏(对象生命周期已结束但仍被强引用关联)引起。

  1. 生成堆转储文件(Dump 文件)
    • 在 JVM 启动参数中配置:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/,在崩溃时自动生成。
    • 或手动通过命令行导出:jmap -dump:format=b,file=heap.hprof 12345
  2. 使用 MAT(Memory Analyzer Tool)分析
    • heap.hprof 载入 MAT。
    • 寻找泄漏源:查看 Dominator Tree(支配树),寻找保留内存(Retained Heap)占比最高的可疑对象。
    • 查看引用链:针对该对象,使用 Path to GC Roots(排除弱引用、软引用等),追踪其到 GC Roots 的强引用调用链,找出是哪个全局容器或未关闭的连接池(如 ThreadLocal、未关闭的连接)持有了该对象导致无法回收。

Full GC 频繁排查

若 jstat 观察到 FGC 频率极高,说明大量对象在 Minor GC 时无法被回收,或者直接进入了老年代。

  1. 排查大对象:大对象(如大字节数组、未分页的数据库全表查询)会绕过新生代直接在老年代分配,老年代满则触发 FGC。
  2. 排查老年代空间分配:新生代与老年代比例是否合适,是否存在分配担保失败。
  3. 显式 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