JVM 核心教学文档

本文件呈现策略

为什么这样组织内容?

JVM知识有一条天然的”数据流主线”:.class文件 → 类加载 → 运行时内存 → 对象生命周期 → 垃圾回收 → 执行优化 → 调优实战。这条线就是一个Java程序从磁盘到CPU的完整旅程。按这条线组织,每个章节的输入恰好是上一章的输出,知识不会断裂。

呈现方式选择原则:

  • 机制/原理 → 类比 + ASCII图 + 一句话锚点
  • 流程/过程 → 编号步骤 + 关键判断节点
  • 多选项对比 → 表格
  • 易错/易混 → ⚠️ 警示框独立标出

一、类加载机制

1.1 类的生命周期

一个类从被引用到被卸载,经历7个阶段:

加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
      |←———— 连接 ————→|

加载(Loading):找到 .class 文件的字节流,在方法区生成 Class 对象。来源不限于文件——可以是JAR、网络、动态代理生成的字节码。

验证(Verification):确保字节码不会危害JVM。包括文件格式验证(魔数 0xCAFEBABE)、元数据验证、字节码验证、符号引用验证。

准备(Preparation):为类变量(static字段)分配内存并设零值。注意:static final 的编译期常量在此阶段直接赋目标值。

static int value = 123;        // 准备阶段:value = 0,初始化阶段:value = 123
static final int COUNT = 10;   // 准备阶段就是 COUNT = 10(ConstantValue属性)

解析(Resolution):将符号引用替换为直接引用。符号引用是字符串形式的描述(如 java/lang/Object),直接引用是内存指针或偏移量。

初始化(Initialization):执行 <clinit>() 方法——按源码顺序收集所有 static {} 块和静态变量赋值语句合并而成。JVM保证 <clinit>() 线程安全且只执行一次(这就是为什么枚举单例天然线程安全)。

⚠️ 易混点:准备 vs 初始化 准备阶段赋零值,初始化阶段赋真实值。面试常考:static int x = 100 在准备阶段是0,不是100。但 static final int x = 100 因为有 ConstantValue 属性,准备阶段直接就是100。区分关键:是否 final 且为编译期常量

🎯 记忆锚点:类加载 = “加验准解初”,连接是中间三步打包。准备给零值,初始化给真值,final常量是例外。


1.2 类加载器与双亲委派

三层类加载器

┌─────────────────────────────────┐
│   Bootstrap ClassLoader (C++)   │  启动类加载器 加载 $JAVA_HOME/lib(rt.jar等核心类)
│        Java中表现为 null         │
└──────────────┬──────────────────┘
               ↓ 委派
┌─────────────────────────────────┐
│   Extension/Platform ClassLoader│  扩展类加载器 JDK8: $JAVA_HOME/lib/ext
│   (Java实现)                     │             JDK9+: 改名 PlatformClassLoader
└──────────────┬──────────────────┘
               ↓ 委派
┌─────────────────────────────────┐
│   Application ClassLoader       │  应用类加载器 加载 classpath 下的类
│   (Java实现, 默认的加载器)         │
└──────────────┬──────────────────┘
               ↓ 委派
┌─────────────────────────────────┐
│   Custom ClassLoader            │  用户自定义(热部署、模块隔离等)
└─────────────────────────────────┘

双亲委派模型(Parent Delegation Model)

核心逻辑(一句话版):收到加载请求时,先交给父加载器处理,父加载器搞不定了,自己才动手。

loadClass(name) {
    1. 查缓存:已经加载过?直接返回
    2. 委派:parent.loadClass(name)
    3. 父亲加载失败(ClassNotFoundException)?自己 findClass(name)
}

为什么需要双亲委派?

  • 安全:防止用户编写的 java.lang.Object 覆盖核心类
  • 唯一性:同一个类只会被加载一次,避免类型混乱(两个不同ClassLoader加载的同名类是不同类型

打破双亲委派的三个经典场景

场景怎么打破为什么要打破
JNDI/SPI线程上下文类加载器(Thread.setContextClassLoader核心类需要加载第三方实现(如JDBC驱动),但Bootstrap无法看到classpath
OSGi网状委派,不是树状模块化热部署,每个Bundle有自己的ClassLoader
Tomcat每个WebApp一个ClassLoader,优先自己加载不同应用可能依赖同一个库的不同版本

⚠️ 面试高频陷阱:双亲委派不是”规范”,是”约定”。loadClass() 里的逻辑可以被覆盖。JDK的SPI机制本身就打破了它——ServiceLoader 用线程上下文ClassLoader加载实现类。

🎯 记忆锚点:双亲委派 = “先问爸爸,爸爸不行我再来”。打破它的核心原因都是”上层需要加载下层的类”。


1.3 类加载的触发时机

JVM规范严格定义了6种必须初始化的情况(即”主动引用”):

  1. newgetstaticputstaticinvokestatic 这四条字节码指令:new一个对象、读取静态字段、给静态字段赋值、调用静态方法
  2. 反射调用(Class.forName()
  3. 初始化子类时发现父类未初始化,先初始化父类
  4. 包含 main() 方法的启动类
  5. JDK7+ MethodHandle 解析到的类
  6. JDK8+ 接口的 default 方法,实现类初始化时接口也要初始化

不会触发初始化的典型案例

// 1. 子类引用父类的静态字段 → 只初始化父类
System.out.println(SubClass.parentStaticField);

// 2. 数组定义 → 不会初始化元素类型
MyClass[] arr = new MyClass[10];

// 3. 引用编译期常量 → 常量传播优化,编译期已嵌入调用方
System.out.println(MyClass.CONSTANT); // static final String

🎯 记忆锚点:主动引用才初始化。数组、常量、子类访问父类字段——这三个”看起来像但不是”要记牢。


二、运行时数据区

2.1 内存区域全景图

![[Gemini_Generated_Image_mvisfumvisfumvis.png|717]]

┌───────────────────────────────────────────────────────────┐
│                    JVM 运行时数据区                         │
├──────────── 线程私有 ──────────┬──────── 线程共享 ──────────┤
│                               │                           │
│  ┌─────────────────────┐      │  ┌─────────────────────┐  │
│  │   程序计数器 (PC)    │      │  │        堆 (Heap)     │  │
│  │  当前线程执行位置     │      │  │  对象实例 + 数组      │  │
│  │  唯一不会OOM的区域   │      │  │  GC的主战场          │  │
│  └─────────────────────┘      │  └─────────────────────┘  │
│                               │                           │
│  ┌─────────────────────┐      │  ┌─────────────────────┐  │
│  │  Java虚拟机栈        │      │  │   方法区/元空间       │  │
│  │  每个方法 = 一个栈帧  │      │  │  类信息、常量池       │  │
│  │  -Xss 控制大小       │      │  │  JDK8+用本地内存     │  │
│  └─────────────────────┘      │  └─────────────────────┘  │
│                               │                           │
│  ┌─────────────────────┐      │                           │
│  │   本地方法栈         │      │                           │
│  │   Native方法服务     │      │                           │
│  └─────────────────────┘      │                           │
└───────────────────────────────┴───────────────────────────┘

2.2 程序计数器(PC Register)

  • 每个线程一个,记录当前正在执行的字节码指令地址
  • 执行 Native 方法时值为 Undefined
  • 唯一一个没有 OutOfMemoryError 的区域——因为它的大小固定,就是一个指针宽度

2.3 Java虚拟机栈

每个线程创建时同步创建。每调用一个Java方法,就压入一个栈帧(Stack Frame)

┌─────────────────────────────┐ ← 栈顶(当前方法)
│  栈帧: methodC()            │
│  ┌───────────────────────┐  │
│  │ 局部变量表             │  │  存放方法参数和局部变量
│  │ (Local Variable Table)│  │  基本类型直接存值,引用类型存指针
│  ├───────────────────────┤  │
│  │ 操作数栈              │  │  字节码指令的工作区(如 iadd 从栈顶取两个int相加)
│  │ (Operand Stack)       │  │
│  ├───────────────────────┤  │
│  │ 动态链接              │  │  指向运行时常量池中该方法的引用
│  │ (Dynamic Linking)     │  │  支持多态:运行时确定调用哪个版本
│  ├───────────────────────┤  │
│  │ 返回地址              │  │  方法结束后回到调用者的哪一行
│  │ (Return Address)      │  │
│  └───────────────────────┘  │
├─────────────────────────────┤
│  栈帧: methodB()            │
├─────────────────────────────┤
│  栈帧: methodA()            │
└─────────────────────────────┘ ← 栈底

两种异常

  • StackOverflowError:栈深度超限(典型:无限递归)
  • OutOfMemoryError:动态扩展时内存不足(HotSpot的栈不支持动态扩展,所以只会SOE)

局部变量表的基本单位是 Slot(变量槽),32位以内类型占1个Slot,long/double 占2个Slot。实例方法的第0个Slot固定是 this

⚠️ 易混点:HotSpot中虚拟机栈和本地方法栈合二为一,-Xss 同时控制二者总大小。但规范上它们是两个独立区域。

🎯 记忆锚点:虚拟机栈 = 方法调用的”函数调用栈”,每个方法一个栈帧,帧里有四样东西:局部变量表、操作数栈、动态链接、返回地址。


2.4 堆(Heap)

所有对象实例和数组在此分配(JIT的逃逸分析可能导致栈上分配,但这是优化例外,不是规则)。

堆的分代结构(JDK8 经典布局)

                          -Xms / -Xmx 控制总大小
┌───────────────────────────────────────────────────────────┐
│                         Java Heap                         │
├────────────── 新生代 ──────────────┬────── 老年代 ─────────┤
│  -Xmn 或 -XX:NewRatio 控制比例    │                       │
│                                   │                       │
│  ┌──────┬────────┬────────┐       │                       │
│  │ Eden │  S0    │  S1    │       │     Old Generation    │
│  │ (8)  │  (1)   │  (1)  │       │                       │
│  └──────┴────────┴────────┘       │                       │
│    -XX:SurvivorRatio=8            │                       │
│    默认 Eden:S0:S1 = 8:1:1       │                       │
│    新对象在Eden分配               │  长期存活的对象晋升至此  │
├───────────────────────────────────┴───────────────────────┤
│  默认新生代:老年代 = 1:2 (-XX:NewRatio=2)                  │
└───────────────────────────────────────────────────────────┘

对象分配流程

new Object()


[1] 先判断:对象是否很大?
   ├─ 是
   │   ▼
   │  直接尝试分配到老年代

   └─ 否

[2] 当前线程开启了 TLAB 吗?
   ├─ 否
   │   ▼
   │  去 Eden 公共区分配(CAS / 重试)

   └─ 是

[3] 当前线程自己的 TLAB 还放得下吗?
   ├─ 能放下
   │   ▼
   │  直接在 TLAB 分配(指针碰撞,快)

   └─ 放不下

[4] 能不能重新申请一个新的 TLAB?
   ├─ 能
   │   ▼
   │  换新 TLAB,再分配对象

   └─ 不能

[5] 去 Eden 公共区分配(CAS / 重试)

TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区:每个线程在Eden区预先划一小块私有区域,分配对象时不需要加锁。默认开启(-XX:+UseTLAB),通常占Eden的1%。

🎯 记忆锚点:堆 = 对象的家。新生代是”短租房”(Eden+两个Survivor),老年代是”长期住户”。TLAB让分配对象像在自己家里拿东西一样快。


2.5 方法区

实现方式:

  • JDK 7 以前永久代(PermGen)。物理上在 JVM 内存中,受限于虚拟机内存大小。

  • JDK 8 以后元空间(Metaspace)。物理上在**本地内存(Native Memory)**中,受限于系统可用内存。

组件名称JDK 6 位置JDK 7 位置JDK 8+ 位置最终去向
类元数据永久代永久代元空间本地内存
运行时常量池永久代永久代元空间本地内存
字符串常量池永久代
静态变量永久代

⚠️ String.intern() 行为差异): 由于字符串常量池移到了堆,导致 intern() 逻辑改变:

  • JDK 6:常量池在永久代,不在池中则复制对象,返回新地址。
  • JDK 7+:常量池在堆中,不在池中则记录堆中引用,返回原地址。

运行时常量池

  • 是方法区的一部分,每个Class文件的常量池表在类加载后进入此区域,每个类都有一份
  • 运行时常量池具有动态性。例如,通过 String.intern() 方法,程序可以在运行期将原本不在 Class 文件常量池中的字符串放入字符串常量池中,从而实现常量的动态添加。
  • 内存不足时抛出 OutOfMemoryError

🎯 记忆锚点:永久代是JVM管的”固定仓库”,元空间是操作系统管的”弹性仓库”。JDK8把仓库搬到了JVM外面,所以不容易爆了。字符串常量池在JDK7就先搬到堆里了。


2.6 直接内存(Direct Memory)

  • 不属于JVM运行时数据区,但频繁使用(NIO的 DirectByteBuffer
  • 通过 Unsafe.allocateMemory() 分配,受 -XX:MaxDirectMemorySize 限制
  • 不受GC直接管理,但 DirectByteBuffer 对象被回收时会通过 Cleaner 释放对应的直接内存
  • 典型问题:堆内存充足但物理内存不足时OOM,容易被忽略

🎯 记忆锚点:直接内存就是“堆外的特区”:绕过 JVM 的繁琐拷贝,直接跟系统对接。读写快,但申请麻烦,且容易“死在”堆外不被释放。


三、对象的创建、内存布局与访问

3.1 对象创建全流程

new 指令


1. 类加载检查 → 常量池中能否找到类的符号引用?该类是否已加载、解析、初始化?
   │              否 → 先触发类加载

2. 分配内存
   ├─ 堆内存规整(Serial/ParNew等带压缩的收集器)→ 指针碰撞(Bump the Pointer)
   │    指针右移对象大小的距离
   └─ 堆内存不规整(CMS等标记清除算法)→ 空闲列表(Free List)
   │    从列表中找一块够大的空间

   │  并发安全保障:
   │  方案A: CAS + 失败重试
   │  方案B: TLAB(默认方案,更快)

3. 内存空间初始化为零值(保证字段不赋值也能使用)

4. 设置对象头(Mark Word、类型指针、数组长度)

5. 执行 <init>() 方法(构造函数)

3.2 对象的内存布局

![[Gemini_Generated_Image_7uwahm7uwahm7uwa.png]]

┌──────────────────────────────────────┐
│           对象头 (Object Header)      │
│  ┌────────────────────────────────┐  │
│  │ Mark Word (8字节/64位JVM)      │  │  hashCode、GC分代年龄、锁状态标志
│  │                                │  │  偏向线程ID、偏向时间戳
│  ├────────────────────────────────┤  │
│  │ 类型指针 (Klass Pointer)       │  │  指向方法区中的Class元数据
│  │ 开启压缩指针时4字节,否则8字节  │  │  JVM通过它确定对象是哪个类的实例
│  ├────────────────────────────────┤  │
│  │ [数组长度] (4字节, 仅数组对象)  │  │
│  └────────────────────────────────┘  │
├──────────────────────────────────────┤
│           实例数据 (Instance Data)    │  各字段的值,按分配策略排列
│  排列顺序:longs/doubles → ints →   │  (相同宽度的字段分配在一起)
│  shorts/chars → bytes/booleans →    │  父类字段在子类字段之前
│  oops(引用)                          │
├──────────────────────────────────────┤
│           对齐填充 (Padding)          │  保证对象大小是8字节的整数倍
└──────────────────────────────────────┘

Mark Word 在不同状态下的内容(64位JVM)

锁状态25bit31bit1bit4bit1bit(偏向)2bit(锁标志)
无锁unusedhashCodeunused分代年龄001
偏向锁线程ID(54bit)Epoch(2bit)unused分代年龄101
轻量级锁指向栈中锁记录的指针(62bit)00
重量级锁指向Monitor的指针(62bit)10
GC标记11

⚠️ 关键细节:调用过 hashCode() 的对象无法进入偏向锁状态——因为偏向锁需要用Mark Word存储线程ID,没地方放hashCode了。已经处于偏向锁状态的对象调用 hashCode() 会直接膨胀为重量级锁。

3.3 对象访问方式

方式实现优点缺点代表
句柄访问堆中有句柄池,引用→句柄→对象GC移动对象只改句柄,引用稳定多一次间接寻址
直接指针引用直接存对象地址速度快,少一次间接GC移动对象需更新所有引用HotSpot

🎯 记忆锚点:对象 = 对象头 + 实例数据 + 对齐填充。对象头里的 Mark Word 是”万用表”——随锁状态变脸。HotSpot用直接指针访问对象(快)。


四、垃圾收集(GC)

4.1 判断对象是否存活

引用计数法(JVM不使用)

每个对象维护一个引用计数器。被引用+1,引用失效-1,为0则可回收。

致命缺陷:循环引用无法回收。

A.field = B;  // B引用计数=1
B.field = A;  // A引用计数=1
A = null;     // A引用计数仍=1(B.field还指着)
B = null;     // B引用计数仍=1(A.field还指着)
// A和B永远不会被回收

可达性分析(JVM采用的方案)

GC Roots 出发,沿引用链遍历。不可达的对象即为可回收。

GC Roots 包括

  • 虚拟机栈中引用的对象(局部变量)
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象
  • JVM内部引用(基本数据类型对应的Class对象、常驻异常对象、系统ClassLoader)
  • 所有被同步锁(synchronized)持有的对象
  • JMXBean、JVMTI中注册的回调、本地代码缓存等

四种引用类型

强引用 > 软引用 > 弱引用 > 虚引用
  │        │        │        │
  │        │        │        └─ PhantomReference: 无法通过它获取对象
  │        │        │           配合ReferenceQueue做资源清理(替代finalize)
  │        │        │
  │        │        └─ WeakReference: 下次GC必回收
  │        │           典型:WeakHashMap、ThreadLocal的Entry
  │        │
  │        └─ SoftReference: 内存不足时才回收
  │           典型:缓存(图片缓存、页面缓存)

  └─ 强引用: Object o = new Object(); GC绝不回收

finalize() 的”缓刑”机制

对象不可达后并非立即死亡:

  1. 第一次标记:不可达 → 检查是否覆盖了 finalize() 且未执行过
  2. 若有 finalize():放入 F-Queue,由低优先级Finalizer线程执行
  3. 第二次标记:若 finalize() 中重新建立了引用链 → 逃脱回收
  4. finalize() 只执行一次——第二次不可达时直接回收

⚠️ 实战建议finalize() 已在 JDK9 标记为 @Deprecated,永远不要依赖它做资源清理。用 try-with-resourcesCleaner

🎯 记忆锚点:判断对象存活 = 从GC Roots画一张图,走不到的就是垃圾。四种引用 = 强软弱虚,强度递减,回收越来越容易。


4.2 垃圾收集算法

基础算法对比

算法原理优点缺点适用场景
标记-清除标记存活对象,清除其余简单内存碎片、效率不稳定CMS的老年代
标记-复制存活对象复制到另一块,原空间整体清除无碎片、高效(存活率低时)浪费一半空间新生代(Eden+Survivor优化版)
标记-整理标记存活对象,向一端移动,清理边界外无碎片、空间利用率高移动对象需要STW(暂停线程)老年代

新生代的复制算法优化

IBM研究表明:新生代98%的对象”朝生夕死”。所以不需要1:1划分空间:

分配时使用 Eden + 其中一个Survivor(如S0)
           ↓ Minor GC
存活对象复制到另一个Survivor(S1),Eden和S0清空
           ↓ 下次Minor GC
存活对象从Eden+S1复制到S0,Eden和S1清空
           ↓ 如此交替...

分配担保:当Survivor空间不足以容纳Minor GC后的存活对象时,这些对象直接通过分配担保(Handle Promotion)进入老年代。

分代收集理论的两个假说

  1. 弱分代假说:绝大多数对象都是朝生夕死的(支撑新生代用复制算法)
  2. 强分代假说:熬过越多次GC的对象越难以消亡(支撑老年代与新生代分开收集)

由此衍生问题:新生代GC时,老年代对象可能引用新生代对象,难道要扫描整个老年代?

解决方案:记忆集(Remembered Set)与卡表(Card Table)

老年代被划分为一个个Card Page(通常512字节)


┌───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 0 │ 1 │ 0 │ 1 │ 0 │ 0 │  ← Card Table(字节数组)
└───┴───┴───┴───┴───┴───┴───┘
          ↑       ↑
     Card Page中有对象引用了新生代对象就改为1
     Minor GC时只需扫描这些"脏卡"对应的区域

通过**写屏障(Write Barrier)**在引用赋值时维护卡表的脏标记。

🎯 记忆锚点:三种算法 = 清除(有碎片)、复制(费空间)、整理(要停顿)。新生代用”复制”因为死亡率高,老年代用”整理”因为存活率高。


4.3 垃圾收集器详解

收集器全景与搭配关系(JDK8 HotSpot)

        新生代                    老年代
  ┌──────────────┐         ┌──────────────┐
  │    Serial    │────────→│  Serial Old  │
  └──────────────┘         └──────────────┘
  ┌──────────────┐         ┌──────────────┐
  │   ParNew     │────────→│     CMS      │
  └──────────────┘         └──────────────┘
  ┌──────────────┐         ┌──────────────┐
  │Parallel Scav.│────────→│ Parallel Old │  ← JDK8默认组合
  └──────────────┘         └──────────────┘

  ┌─────────────────────────────────────────┐
  │              G1 (全堆管理)               │  ← JDK9+默认
  └─────────────────────────────────────────┘
  ┌─────────────────────────────────────────┐
  │             ZGC (全堆管理)               │  ← JDK15+生产可用
  └─────────────────────────────────────────┘
  ┌─────────────────────────────────────────┐
  │          Shenandoah (全堆管理)           │  ← OpenJDK
  └─────────────────────────────────────────┘

各收集器核心特征

Serial / Serial Old 串行

  • 单线程,GC时必须暂停所有用户线程(STW)
  • 新生代:标记-复制 | 老年代:标记-整理
  • 适用场景:客户端模式、小堆(几十到一百MB)
  • 开启参数:-XX:+UseSerialGC

ParNew

  • Serial的多线程并行版本,仅新生代
  • 意义:唯一能配合CMS使用的新生代收集器(JDK9后不再推荐)
  • 开启参数:-XX:+UseParNewGC,线程数 -XX:ParallelGCThreads

Parallel Scavenge / Parallel Old

  • 关注点:吞吐量(= 用户代码时间 / 总时间)
  • JVM 会根据历史的 GC 时间、用户代码运行时间、对象存活率和晋升情况,通过自适应调节策略动态调整新生代大小、Eden/Survivor 比例、晋升阈值和堆容量,从而减少 GC 总时间占比,提高用户代码执行时间在总时间中的比例。
  • 提供两个精确控制参数:
    • -XX:MaxGCPauseMillis:最大停顿时间目标
    • -XX:GCTimeRatio:吞吐量目标(默认99,即GC时间≤1%)
  • -XX:+UseAdaptiveSizePolicy:自适应调节Eden/Survivor比例和晋升年龄
  • JDK8默认收集器-XX:+UseParallelGC

CMS(Concurrent Mark Sweep)

关注点:最短停顿时间。老年代收集器,使用标记-清除算法。

     STW          并发           STW              并发
      │            │              │                │
      ▼            ▼              ▼                ▼
┌──────────┬─────────────┬───────────────┬──────────────┐
│ 初始标记  │  并发标记    │   重新标记     │   并发清除    │
│ 标记GC   │ 追踪引用链   │  修正并发期间  │  清除死亡对象  │
│ Roots直  │ 与用户线程   │  变动的标记    │  与用户线程   │
│ 接关联的  │ 同时运行     │  增量更新      │  同时运行     │
│ 对象     │             │              │              │
└──────────┴─────────────┴───────────────┴──────────────┘
  非常快        最耗时          较快            较耗时

CMS的三大问题

  1. CPU敏感:并发阶段占用线程,默认 (CPU核数+3)/4。CPU少于4核时影响大
  2. 浮动垃圾:并发清除阶段用户线程产生的新垃圾本次无法回收。需预留空间给用户线程(-XX:CMSInitiatingOccupancyFraction,JDK6默认92%)。预留不足触发 Concurrent Mode Failure → 退化为 Serial Old(灾难性停顿)
  3. 内存碎片:标记-清除算法必然产生碎片。-XX:+UseCMSCompactAtFullGC(默认开启)在Full GC后整理,-XX:CMSFullGCsBeforeCompaction 控制几次Full GC后整理一次

⚠️ CMS已在JDK9标记废弃,JDK14正式移除。 但面试中仍是高频考点。

G1(Garbage First)

革命性设计:打破了物理上的分代边界,将堆划分为大小相等的 Region

┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E  │ E  │ S  │    │ O  │ O  │ H  │ H  │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ O  │    │ E  │ E  │    │ O  │ H  │ O  │
├────┼────┼────┼────┼────┼────┼────┼────┤
│    │ S  │ O  │    │ E  │    │ O  │    │
└────┴────┴────┴────┴────┴────┴────┴────┘

E = Eden  S = Survivor  O = Old  H = Humongous  空白 = 空闲
每个Region大小 = 1~32MB(2的幂),由 -XX:G1HeapRegionSize 设定

核心特性

  • 可预测的停顿-XX:MaxGCPauseMillis=200(默认200ms)。G1跟踪每个Region的”回收价值”(可回收空间 / 回收耗时),优先回收价值最大的Region
  • Humongous Region:大于Region一半的对象视为巨型对象,分配在连续的H Region中
  • Mixed GC:不只收集新生代,还选择部分老年代Region一起收集

G1的回收过程

1. 初始标记(STW)          ← 标记GC Roots直接关联的对象,借用Minor GC的STW
2. 并发标记                 ← SATB快照算法保证一致性
3. 最终标记(STW)          ← 处理SATB缓冲区残留的引用变化
4. 筛选回收(STW)          ← 按回收价值排序,选择Region,复制存活对象到空Region

SATB vs 增量更新:CMS用增量更新(记录新增引用),G1用SATB(Snapshot At The Beginning,记录删除引用)。SATB更适合Region化内存布局。

G1相比CMS的优势

  • 可控停顿时间
  • 不产生内存碎片(Region间复制 = 整理)
  • 全堆管理,无需搭配其他收集器

G1适用场景:堆大小 ≥ 6GB,停顿要求 < 500ms。JDK9+默认收集器。

G1 是 HotSpot 中面向服务端、大堆场景的低停顿垃圾收集器,从 JDK9 开始成为默认收集器。它把整个堆划分为多个大小相等的 Region,虽然仍保留分代思想,但新生代和老年代不再是物理连续空间,而是由一组 Region 动态组成。G1 会维护每个 Region 的 Remembered Set,避免回收时全堆扫描;一次回收只选择一批 Region 组成 Collection Set,并结合停顿预测模型尽量满足 MaxGCPauseMillis 这样的停顿目标。它既有日常的 Young GC,也会在全局并发标记后做 Mixed GC,把年轻代和一部分垃圾比例高的老年代 Region 一起回收。相比 CMS,G1 通过复制存活对象完成整理,减少内存碎片,并且更强调停顿可预测性,但实现更复杂,RSet 和写屏障也会带来额外开销。

ZGC(Z Garbage Collector)

设计目标:停顿时间不超过1ms,且不随堆大小增长。

特性
停顿时间< 1ms(亚毫秒级)
支持堆大小8MB ~ 16TB
核心技术染色指针(Colored Pointers) + 读屏障(Load Barrier)
并发阶段几乎全程并发,STW仅在根节点枚举

染色指针:在64位指针中拿出4个bit存储GC元数据(Marked0、Marked1、Remapped、Finalizable),因此ZGC不支持压缩指针,不支持32位平台。

64位指针布局:
[16位 unused] [4位 GC标记] [44位 对象地址]
                               └─ 最大寻址空间 = 2^44 = 16TB

ZGC的并发整理原理:通过读屏障 + 转发表实现并发对象移动。用户线程读取对象时,读屏障检查指针颜色,若发现对象已移动则自修复指针。

开启:JDK15+ -XX:+UseZGC,JDK21+ 分代ZGC -XX:+UseZGC -XX:+ZGenerational(JDK23+默认分代)

Shenandoah

RedHat开发,目标类似ZGC但技术路线不同:用Brooks转发指针 + 读/写屏障实现并发整理。Oracle官方JDK不包含,仅OpenJDK。

收集器选择决策表

场景推荐收集器关键参数
小堆(<100MB)/客户端Serial-XX:+UseSerialGC
后台计算/批处理(吞吐量优先)Parallel-XX:+UseParallelGC
Web应用(JDK8,低延迟)ParNew + CMS-XX:+UseConcMarkSweepGC
大堆(≥6GB)/JDK9+G1-XX:+UseG1GC
超大堆/极致低延迟/JDK15+ZGC-XX:+UseZGC

🎯 记忆锚点:Serial(单线程)→ Parallel(多线程/吞吐量)→ CMS(并发/低延迟)→ G1(Region/可控停顿)→ ZGC(染色指针/亚毫秒),GC发展史就是”STW越来越短”的历史。


4.4 GC关键概念补充

Minor GC / Major GC / Full GC

类型范围触发条件
Minor GC (Young GC)新生代Eden区满
Major GC (Old GC)老年代仅CMS有真正意义上的单独Old GC
Full GC整个堆 + 方法区①老年代空间不足 ②方法区空间不足 ③System.gc()(建议,非强制)④CMS Concurrent Mode Failure ⑤空间分配担保失败

⚠️ 概念辨析:很多文章把 Major GC 等同于 Full GC,但严格来说不同。Major GC只收集老年代,Full GC收集全堆。实际对话中注意上下文语义。

对象晋升老年代的条件

  1. 年龄阈值:对象在Survivor区每熬过一次Minor GC,年龄+1,达到 -XX:MaxTenuringThreshold(默认15,CMS默认6)时晋升
  2. 动态年龄判定:Survivor中相同年龄所有对象大小之和 > Survivor空间的一半 → 该年龄及以上的对象直接晋升
  3. 大对象直接进入老年代-XX:PretenureSizeThreshold(仅Serial和ParNew有效)

空间分配担保

Minor GC前,JVM检查:老年代最大可用连续空间 > 新生代所有对象总空间?

  • 是 → 安全,直接Minor GC
  • 否 → 检查 -XX:HandlePromotionFailure 是否允许担保(JDK6 Update 24后默认允许)
    • 允许 → 检查老年代最大可用连续空间 > 历次晋升的平均大小?是则冒险Minor GC,否则Full GC
    • 不允许 → Full GC

🎯 记忆锚点:对象进老年代 = 年龄够了 / Survivor太挤 / 对象太大。Full GC是”大扫除”,触发条件要记住5个。


五、JIT编译优化

5.1 解释执行与编译执行

HotSpot采用混合模式:解释器 + JIT编译器共存。

Java字节码

    ├─→ 解释器逐条执行(启动快,执行慢)

    └─→ 检测到"热点代码" → JIT编译为本地机器码(编译慢,执行快)

         ├─ C1编译器(Client Compiler):快速编译,轻量优化
         │   适用:启动速度敏感的客户端应用

         └─ C2编译器(Server Compiler):深度优化,编译慢但执行极快
             适用:长期运行的服务端应用

    JDK8+默认:分层编译(-XX:+TieredCompilation),C1和C2协同工作

分层编译的5个层级

层级描述
0解释执行(收集性能监控数据)
1C1编译,无性能监控
2C1编译,带方法调用计数和回边计数
3C1编译,带全部性能监控(最慢的C1层)
4C2编译,深度优化

常见路径:0 → 3 → 4(先C1全量监控,再C2深度优化)

5.2 热点探测

怎么判断代码是”热点”? 基于计数器:

  • 方法调用计数器:方法被调用次数。阈值:Client模式1500次,Server模式10000次。可通过 -XX:CompileThreshold 修改
  • 回边计数器:循环体执行次数(回边 = 字节码中向后跳转的指令)。触发栈上替换(OSR, On Stack Replacement)——循环还在执行中就替换为编译后的代码

热度衰减:方法调用计数器不是绝对次数,而是”一段时间内的相对频率”。超过半衰期未达到阈值,计数减半。-XX:-UseCounterDecay 关闭衰减。

5.3 关键JIT优化技术

方法内联(Method Inlining)

将被调用方法的代码直接嵌入调用处,消除方法调用开销,更重要的是为后续优化打开空间

// 内联前
int result = add(a, b);
private int add(int x, int y) { return x + y; }

// 内联后
int result = a + b;  // 消除了方法调用,后续可以做常量折叠等优化
  • JVM最重要的优化,没有之一。大多数其他优化都依赖内联后的代码
  • 限制:方法体太大(默认 > 325字节不内联)、虚方法(需要先做类型推测)
  • -XX:MaxInlineSize(小方法直接内联阈值,默认35字节)
  • -XX:FreqInlineSize(热方法内联大小上限,默认325字节)

逃逸分析(Escape Analysis)

分析对象的动态作用域,判断对象是否”逃逸”出方法或线程。

不逃逸 ─→ 可做标量替换、栈上分配
方法逃逸(传给其他方法)─→ 仍可做锁消除(如果不逃逸出线程)
线程逃逸(被其他线程访问)─→ 无法优化

基于逃逸分析的三大优化:

① 栈上分配(Stack Allocation) 对象不逃逸出方法 → 直接在栈帧上分配,方法结束自动回收,不进入堆。

HotSpot实际上通过标量替换间接实现了栈上分配的效果。

② 标量替换(Scalar Replacement) 将对象拆解为其中的基本类型字段,分别存放在局部变量表中。

// 优化前
Point p = new Point(1, 2);
System.out.println(p.x + p.y);

// 标量替换后(不再创建Point对象)
int x = 1;
int y = 2;
System.out.println(x + y);

-XX:+EliminateAllocations(默认开启)

③ 锁消除(Lock Elimination) 对象不逃逸出线程 → 这个对象上的同步操作可以消除。

// StringBuffer.append() 内部有 synchronized
// 但如果sb不逃逸出方法,锁可以被消除
public String concat(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}

-XX:+EliminateLocks(默认开启)

其他重要优化

  • 公共子表达式消除a*b + a*b → 计算一次 a*b,复用结果
  • 数组边界检查消除:编译期能证明下标不越界时,去掉运行时检查
  • 锁粗化:连续对同一对象加锁解锁 → 合并为一次更大范围的锁

🎯 记忆锚点:JIT三大法宝 = 方法内联(最重要)、逃逸分析(最智能)、分层编译(C1快启动 + C2深优化)。逃逸分析带来三个红利:栈上分配、标量替换、锁消除。


六、性能调优工具与实战

6.1 JDK自带监控工具

工具全称用途典型命令
jpsJVM Process Status列出Java进程jps -mlvV
jstatJVM Statistics MonitoringGC统计信息jstat -gcutil <pid> 1000 10
jinfoConfiguration Info查看/修改JVM参数jinfo -flags <pid>
jmapMemory Map堆内存快照jmap -dump:format=b,file=heap.hprof <pid>
jstackStack Trace线程快照jstack -l <pid>
jcmdJVM Command万能工具(JDK7+)jcmd <pid> GC.heap_dump /tmp/heap.hprof

jstat 关键输出解读

$ jstat -gcutil <pid> 1000
  S0     S1     E      O      M     CCS    YGC    YGCT   FGC    FGCT     GCT
  0.00  87.44  62.31  43.22  95.81  92.35   28    0.182    3    0.456   0.638
  • S0/S1:Survivor区使用率(%),任何时刻必有一个是0
  • E:Eden区使用率
  • O:Old区使用率
  • M:Metaspace使用率
  • YGC/YGCT:Young GC次数/耗时
  • FGC/FGCT:Full GC次数/耗时
  • 关注重点:FGC频率和FGCT耗时。如果FGC频繁或FGCT持续增长,需要排查

jstack 关键场景

死锁检测jstack 会在输出末尾自动检测并打印死锁信息:

Found one Java-level deadlock:
=============================
"Thread-1": waiting to lock monitor 0x00007f...
  which is held by "Thread-0"
"Thread-0": waiting to lock monitor 0x00007f...
  which is held by "Thread-1"

CPU飙高排查步骤

# 1. 找到CPU最高的Java进程
top -c

# 2. 找到该进程中CPU最高的线程
top -Hp <pid>

# 3. 线程ID转十六进制
printf "%x\n" <tid>

# 4. 在jstack输出中搜索该线程
jstack <pid> | grep -A 30 "nid=0x<hex_tid>"

6.2 GC日志分析

开启GC日志

# JDK8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log

# JDK9+(统一日志框架)
-Xlog:gc*:file=/path/gc.log:time,uptime,level,tags

GC日志示例(G1)

[2024-01-15T10:30:15.123+0800] GC pause (G1 Evacuation Pause) (young)
   [Eden: 204.0M(204.0M)->0.0B(186.0M)
    Survivors: 8192.0K->26.0M
    Heap: 320.5M(512.0M)->128.3M(512.0M)]
   [Times: user=0.08 sys=0.01, real=0.03 secs]

解读:Eden从204M全部清空,Survivor从8M涨到26M(存活对象),堆总使用从320M降到128M,实际停顿时间看 real(0.03秒=30ms)。

6.3 常见调优场景与实战

场景一:频繁Full GC

排查路径

jstat观察 → FGC频繁增长

    ├─ 老年代占用持续增长不下降 → 可能存在内存泄漏
    │   └─ jmap dump堆快照 → MAT/VisualVM分析大对象和引用链

    ├─ 老年代占用不高就Full GC → 元空间不足?
    │   └─ 检查 -XX:MaxMetaspaceSize,是否动态加载了大量类

    └─ 大量对象直接进入老年代 → 对象过大?Survivor太小?
        └─ 调整 -XX:PretenureSizeThreshold 或增大Survivor

场景二:Young GC耗时过长

可能原因与对策

  • 存活对象太多:检查是否有不必要的大对象在新生代中,是否有缓存泄漏
  • 引用处理耗时:弱引用/软引用/虚引用过多,-XX:+ParallelRefProcEnabled 开启并行引用处理
  • 新生代过大:减小 -Xmn,让每次GC扫描范围更小

场景三:内存泄漏排查

1. 确认现象:jstat观察Old区持续增长,Full GC后无法有效回收
2. 获取堆转储:
   jmap -dump:live,format=b,file=heap.hprof <pid>
   或 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/
3. 分析工具:
   - Eclipse MAT: Leak Suspects → Dominator Tree → 找到占用最大的对象
   - 查看 GC Roots 到泄漏对象的引用链
4. 常见泄漏模式:
   - 静态集合类持有对象引用(如 static Map 不断 put 不 remove)
   - 未关闭的资源(Connection、Stream)
   - ThreadLocal 未 remove(线程池场景下线程复用,ThreadLocal不会被回收)
   - 内部类持有外部类引用

场景四:常用JVM参数模板

# 通用生产环境基线(JDK8 + G1,8核16G机器,堆分配4G)
-Xms4g -Xmx4g                          # 堆大小固定,避免动态扩缩
-XX:+UseG1GC                            # G1收集器
-XX:MaxGCPauseMillis=200                # 目标最大停顿200ms
-XX:+HeapDumpOnOutOfMemoryError         # OOM时自动dump
-XX:HeapDumpPath=/data/logs/heapdump.hprof
-XX:+PrintGCDetails -XX:+PrintGCDateStamps # GC日志
-Xloggc:/data/logs/gc.log
-XX:MetaspaceSize=256m                  # 元空间初始大小
-XX:MaxMetaspaceSize=256m               # 避免无限增长

⚠️ 关键原则-Xms-Xmx 设为相同值。原因:避免堆大小动态调整时的性能开销和Full GC。MetaspaceSizeMaxMetaspaceSize 也建议设相同值。

GC调优核心指标

指标健康范围告警信号
Young GC频率10~30秒一次每秒多次
Young GC耗时< 50ms> 200ms
Full GC频率几小时甚至几天一次每分钟一次
Full GC耗时< 1s> 5s
堆使用率(GC后)< 70%> 90%持续不降

🎯 记忆锚点:调优四步走 = 看指标(jstat)→ 看日志(GC log)→ 看堆(jmap + MAT)→ 看线程(jstack)。核心目标:减少Full GC,控制停顿时间。


七、JVM知识图谱总览

                        .class 字节码文件

                    ┌─────────▼──────────┐
                    │     类加载子系统     │
                    │  加载→连接→初始化    │
                    │  双亲委派模型        │
                    └─────────┬──────────┘

              ┌───────────────▼───────────────┐
              │         运行时数据区            │
              │                               │
              │  线程私有:                      │
              │    PC寄存器 │ 虚拟机栈 │ 本地方法栈│
              │                               │
              │  线程共享:                      │
              │    堆(分代) │ 方法区/元空间      │
              └───────┬───────────┬───────────┘
                      │           │
              ┌───────▼───┐  ┌───▼────────────┐
              │  对象分配   │  │   执行引擎      │
              │  TLAB/CAS  │  │  解释器 + JIT   │
              │  内存布局   │  │  方法内联/逃逸   │
              └───────┬───┘  └────────────────┘

              ┌───────▼───────────┐
              │    垃圾收集器       │
              │  Serial→Parallel  │
              │  →CMS→G1→ZGC     │
              │  标记/复制/整理     │
              └───────┬───────────┘

              ┌───────▼───────────┐
              │    性能监控与调优    │
              │  jstat/jmap/jstack │
              │  GC日志/堆分析     │
              └───────────────────┘

这套体系从字节码进入JVM开始,沿着”加载 → 存储 → 分配 → 回收 → 优化 → 调优”的主线一路贯穿。掌握了这条线,JVM的知识不再是碎片化的,而是一个有机的整体。