← 学习库JVM学习手册JDK8 / HotSpot

JVM学习手册

从知识点到完整理解

版本、来源与学习路线

《深入理解 Java 虚拟机》深入学习手册(JDK 8 / HotSpot)

目标:不是按原书章节复述,而是把《深入理解 Java 虚拟机》中最值得深入理解、最容易混淆、最能串起JVM机制的知识重新组织成一套系统的学习路径。

适用:Java 后端 / 架构师 / 中高级开发 / 性能排障学习。

版本重点:JDK 8 HotSpot。个别内容会补充学习时常与本书一起分析的 AQS、CAS、Netty 堆外内存等知识。

修订日期:2026年10月1日。已核对技术边界并补充第60~75节:全书导航、对象完整旅程、内存图解、GC与类加载、字节码、JMM证明和纯阅读案例。2026年10月8日复审修正卡表、TLAB、CMS、SPI与顺序一致性表述。

纯阅读版:无需做实验。代码仅作短例说明;案例直接提供现象、原因与结论。规范、HotSpot8实现和新版差异分别说明。

先读路线:第56节→第60节选路线→第61节串执行→第62、65~69节读机制→第72~74节复习。

阅读导航

主题 起点
全书路线与执行主线 第60节 · 第61节
内存与对象 第2节 · 第62节 · 第63节
GC与CMS/G1 第9节 · 第65节 · 第66节
类加载与字节码 第6节 · 第67节 · 第68节
并发与JIT 第69节 · 第70节 · 第71节
故障、案例与复习 第72节 · 第73节 · 第74节
展开完整章节目录(第0~75节)

章节01 / 完整阅读

0. 先看结论:哪些章节最重要?

如果学习时间有限,优先级建议如下。

优先级 知识模块 学习优先级 典型追问深度
S JVM 运行时数据区 ★★★★★ 堆、栈、元空间、直接内存、OOM 场景
S 对象创建与对象内存布局 ★★★★★ TLAB、CAS、Mark Word、压缩指针
S GC Roots 与可达性分析 ★★★★★ 四种引用、finalize、GC Root 来源
S 新生代 / 老年代回收 ★★★★★ Eden、Survivor、晋升、动态年龄
S 并发标记理论(三色标记) ★★★★★ 漏标条件、增量更新 vs SATB、读写屏障
S CMS ★★★★★ 4 阶段、浮动垃圾、碎片、Concurrent Mode Failure、Preclean
S G1 ★★★★★ Region、RSet、SATB、Mixed GC、Humongous、JDK 8 Full GC 单线程
S GC 日志与故障排查 ★★★★★ Full GC、jstat、jmap、jstack、MAT
S 类加载机制 ★★★★★ 双亲委派、类隔离、Tomcat/SPI
A JMM / volatile / synchronized ★★★★★ happens-before、重排序、内存屏障、锁优化、final 语义
A Safepoint / Safe Region ★★★★☆ 为什么 GC 不能随时停线程、TTSP 观测
A 字节码 / 方法调用 ★★★★☆ invokevirtual、invokestatic、vtable/itable、单分派多分派
A 异常表 / 语法糖 ★★★★☆ finally 字节码、泛型擦除、桥接方法、Lambda
A JIT ★★★★☆ 分层编译、C1/C2、逃逸分析、标量替换、锁消除、去优化
B Class 文件结构 ★★★☆☆ 常量池、方法表、属性表、BootstrapMethods
B 编译优化 ★★★☆☆ 方法内联、CSE、数组边界检查消除、OSR
B JVM 参数调优 ★★★★☆ Xms/Xmx、分代比例、CMS/G1 参数、MetaspaceSize 语义
B 版本演进 / 现代 JVM ★★★☆☆ JDK 8 默认 GC、G1 默认、CMS 移除、虚拟线程

一句话记忆:

内存 → 对象 → 类加载 → GC → 并发标记理论 → CMS/G1 → 日志排障 → JMM → JIT → 版本演进。

注:带「三色标记 / 分配担保 / finalize / vtable / 锁优化 / 异常表 / 分层编译 / 版本演进 / NMT / 虚拟线程」等增补内容的章节,是本手册在原版基础上补强的部分。


章节02 / 完整阅读

1. JVM 整体架构

图01

javac

Java Source
.java

Bytecode
.class

ClassLoader

Runtime Data Areas

Execution Engine

Interpreter

JIT Compiler
C1 / C2

GC

JNI / Native Library

可横向滚动;点“放大查看”查看细节。

学习时需要建立这样一个完整视角:

  1. Java 源码先编译成 Class 字节码。
  2. ClassLoader 将类加载进 JVM。
  3. JVM 为类、线程、对象建立运行时数据结构。
  4. 字节码先由解释器执行,热点代码再由 JIT 编译成本地机器码。
  5. 对象主要存储在堆中,由 GC 管理生命周期。
  6. JNI 可以调用本地代码;DirectByteBuffer 等还会使用 JVM 堆之外的本地内存。

章节03 / 完整阅读

2. JVM 运行时数据区 —— 第一高频题

2.1 总体结构

图02

HotSpot8类元数据实现

JVM Process

线程共享

线程私有

本地内存

Java Heap

Method Area
规范中的逻辑区域

Program Counter

Java VM Stack

Native Method Stack

Direct Memory

Metaspace

Code Cache

可横向滚动;点“放大查看”查看细节。

2.2 学习必须回答清楚的表

区域 线程共享 存什么 典型异常
Java Heap 是 普通对象、数组 OutOfMemoryError: Java heap space
Metaspace 是 类元数据、方法元数据等 OutOfMemoryError: Metaspace
Java VM Stack 否 Stack Frame StackOverflowError / OOM
Program Counter 否 当前线程执行字节码的位置 JVMS中唯一未规定OutOfMemoryError的运行时数据区
Native Method Stack 否 Native 方法调用栈 SOE / OOM
Direct Memory 进程级 NIO / Netty 等堆外内存 OutOfMemoryError: Direct buffer memory
Code Cache 进程级 JIT 编译后的机器码 CodeCache 满导致 JIT 受限

2.3 JDK 8 为什么取消永久代?

JDK 7 及以前 HotSpot 常用永久代实现方法区的一部分。

JDK 8 以后主要改成 Metaspace:

JDK 7-
Heap
 ├── Young
 ├── Old
 └── PermGen

JDK 8+
Heap
 ├── Young
 └── Old

Native Memory
 └── Metaspace

主要收益:

  • 类元数据不再受固定 PermGen 空间限制。
  • Metaspace 默认可根据本地内存动态扩容。
  • 减少过去 PermGen space 调优问题。

但注意:

Metaspace 不是无限。

大量动态生成 Class、ClassLoader 泄漏依然会导致 Metaspace OOM。

典型参数:

-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

2.4 高频追问:字符串常量池在哪?

JDK 7 以后字符串常量池已经移入 Java Heap。

所以不要回答:

“字符串常量池在永久代 / 元空间。”

这是典型旧知识。


2.5 方法区内部结构:运行时常量池、静态变量迁移、元空间

2.5.1 方法区里到底有什么

  • 类型信息(全限定名、父类、接口、修饰符)
  • 字段信息、方法信息(含字节码)
  • 运行时常量池
  • 静态变量(JDK 7 起逻辑上仍属方法区语义,HotSpot 实现里已随 Class 对象放进堆)
  • JIT 编译后的代码放在 Code Cache,不算方法区数据

2.5.2 运行时常量池 Runtime Constant Pool

  • Class文件的Constant Pool是二进制表;加载时据此构建每个类/接口的运行时常量池。JVMS将其归入方法区,HotSpot将相关元数据放在元空间,解析得到的对象引用另有堆内存储。
  • 类加载后,符号引用可以在运行期按需解析。String.intern()维护全局字符串驻留关系,不能把它理解成向某个类的运行时常量池追加Class文件常量项。
  • **字符串常量池(StringTable)**要区分驻留的String对象与表结构:JDK7起驻留字符串对象在Java堆中;JDK8 HotSpot的哈希桶、表项等管理结构位于本地内存。详见第62节。

2.5.3 JDK 7 的两次迁移(高频陷阱)

内容 JDK 6 JDK 7+
驻留String对象 永久代 Java堆;管理表结构另分
静态变量 永久代 Java 堆(随 Class 对象)
符号内容(Symbol) 永久代相关分配 native memory;不要把Symbol与所有符号引用/表结构混为一谈

所以 JDK 8 被问"静态变量在哪",标准答案:

逻辑上属于方法区,HotSpot 实现里随 Class 对象存在于堆中。

2.5.4 元空间的内部结构

  • Metaspace 使用 Native Memory,由 ClassLoaderData 组织,按 Metachunk 分配。
  • 分为 Non-Class Space(方法、常量、注解等元数据)与 Class Space(Klass 结构)。
  • Class Space 用压缩类指针寻址,上限由 -XX:CompressedClassSpaceSize 控制,默认 1 GiB。
  • 因此元空间 OOM 有两种日志:
OutOfMemoryError: Metaspace               -> Non-Class Space 或整体
OutOfMemoryError: Compressed class space  -> Class Space(默认 1G)打满

2.5.5 参数语义辨析

参数 语义
-XX:MetaspaceSize=256m 不是初始大小,而是"首次触发元空间 GC 的水位线",达到后 GC 并动态调整
-XX:MaxMetaspaceSize 默认 -1(不限制),生产建议封顶
-XX:CompressedClassSpaceSize Class Space 保留上限,默认 1G

追问:元空间比永久代好,为什么还会 OOM?

虽然不受固定容量限制,但受进程可用 native memory 限制;动态生成类 + ClassLoader 泄漏仍会打满。

章节04 / 完整阅读

3. Java 栈到底放了什么?

每一次 Java 方法调用都会创建一个 Stack Frame(栈帧)。

图03

Java Thread

JVM Stack

Frame: foo

Frame: bar

Frame: baz

Local Variable Table

Operand Stack

Dynamic Linking

Return Address

可横向滚动;点“放大查看”查看细节。

一个栈帧主要包括:

3.1 局部变量表

存:

  • 基本类型值
  • 对象引用
  • returnAddress

64 位的 long / double 在规范模型中占两个 Slot。

3.2 操作数栈

JVM 是典型的基于栈的指令集虚拟机。

例如:

int c = a + b;

字节码大致是:

iload_1
iload_2
iadd
istore_3

过程:

图04

load a

Operand Stack
a

load b

Operand Stack
a,b

iadd

Operand Stack
a+b

可横向滚动;点“放大查看”查看细节。

3.3 动态连接

指向运行时常量池中该栈帧所属方法的引用,为动态方法调用等提供支持。

3.4 方法返回地址

方法执行完后,要知道返回调用方的哪里继续执行。


章节05 / 完整阅读

4. 一个 Java 对象到底有多大?

这是值得逐层理解的核心问题。

4.1 HotSpot 对象布局

普通对象:

+---------------------------+
| Mark Word                 |
+---------------------------+
| Klass Pointer             |
+---------------------------+
| Instance Fields           |
+---------------------------+
| Padding                   |
+---------------------------+

数组对象:

+---------------------------+
| Mark Word                 |
+---------------------------+
| Klass Pointer             |
+---------------------------+
| Array Length              |
+---------------------------+
| Array Elements            |
+---------------------------+
| Padding                   |
+---------------------------+

4.2 Mark Word 存什么?

Mark Word 会根据对象状态复用其中的位。

常见内容包括:

  • HashCode
  • GC Age
  • 锁标志位
  • 是否偏向锁
  • 偏向线程 ID
  • 指向 Lock Record / Monitor 的指针

所以:

synchronized 的锁状态,本质上和对象头 Mark Word 强相关。

4.3 为什么对象需要对齐?

HotSpot 默认通常按 8 字节对齐。

如果对象实际数据是 13 Bytes,可能补齐为 16 Bytes。

原因包括:

  • 地址计算更高效
  • 压缩指针实现依赖对齐

4.4 压缩指针

64 位 JVM 中,如果所有对象引用都使用完整 64bit 指针,内存成本会明显增加。

HotSpot 可启用:

-XX:+UseCompressedOops
-XX:+UseCompressedClassPointers

典型 64 位 HotSpot + 压缩类指针情况下:

Mark Word       8 bytes
Klass Pointer   4 bytes
-----------------------
Object Header  12 bytes

普通无字段对象再按 8B 对齐,常见会达到 16B。


4.5 字段重排序与对齐

HotSpot 不按源码顺序摆放实例字段,而是按宽度分组重排,以减少 padding:

longs / doubles   (8B)
ints / floats     (4B)
shorts / chars    (2B)
bytes / booleans  (1B)
oops / references (压缩指针 4B,否则 8B)

规则:

  • 字段布局受继承、字段类型、CompactFields、FieldsAllocationStyle、@Contended及具体实现影响。
  • 子类字段可能利用父类布局留下的空隙,不能把“父类全部字段在前”当成绝对规则。
  • 目的:在满足布局约束的前提下减少空隙;源码顺序不是可移植的内存布局承诺。

因此:

不要用字段声明顺序推断对象大小,要用 JOL(org.openjdk.jol)或 Instrumentation.getObjectSize() 实测。

4.6 identity hashCode 是"延迟写"的

  • 对象刚创建时 Mark Word 里没有 hashCode。
  • 第一次需要identity hash code时生成并保存;未加锁时通常保存在Mark Word中,锁膨胀后可能保存在Monitor关联的displaced header中,不能理解成始终直接写在当前对象头里。
  • HotSpot 默认 -XX:hashCode=5,用 Marsaglia xor-shift 生成。
  • 这解释了 Mark Word 为什么能复用:不调 hashCode 时,这块位可以存偏向线程 ID。
  • 一旦生成了 identity hashCode,对象就无法再进入偏向状态。

4.7 压缩指针的边界与寻址

  • -XX:+UseCompressedOops 开启时用 32 位表示引用。
  • 默认8字节对齐时,可按地址 = 基址 + (压缩值 << 3)解码,编码则是相反过程;理论覆盖量级为2^32 × 8 = 32GiB。
  • 默认8字节对齐下,压缩指针的可用边界在约32GiB附近,受堆地址布局和保留空间影响,不是Xmx≤32G必定开启。改变对象对齐会改变边界;以目标进程VM.flags -all/PrintFlagsFinal为准。引用变宽可能增加内存占用。
  • Zero-Based Compressed Oops:堆基址为 0 时无需再加基址,寻址更快;4G 以下甚至可用 Unscaled 模式。
  • 开启压缩 oop 时,-XX:+UseCompressedClassPointers 默认跟着开(Klass 指针压缩进 CCS)。

章节06 / 完整阅读

5. new Object() 背后发生了什么?

这是非常高频的“对象创建流程”题。

图05

No

Yes

new instruction

Class loaded?

Load / Link / Init Class

Allocate Memory

Zero Memory

Initialize Object Header

Execute <init> Constructor

Object Ready

可横向滚动;点“放大查看”查看细节。

5.1 第一步:检查类是否已加载

new 指令的参数是常量池中的一个符号引用。

JVM 需要先确认:

  • 类是否加载
  • 类是否解析
  • 类是否初始化

5.2 第二步:分配内存

常见有两种方式。

指针碰撞 Bump the Pointer

适合堆内存连续的情况。

Allocated | Free
----------^------------------
          pointer

新对象只需移动指针。

复制算法、整理算法通常能保持较连续空间。

空闲列表 Free List

如果内存不连续,需要维护哪些区块空闲。

CMS 的老年代因为存在碎片,更偏向这种管理方式。

5.3 多线程下如何保证分配安全?

方法一:CAS

多个线程争抢同一个堆分配指针,通过 CAS 更新。

方法二:TLAB

TLAB:Thread Local Allocation Buffer,线程本地分配缓冲区。

图06

Eden

Thread1 TLAB

Thread2 TLAB

Thread3 TLAB

可横向滚动;点“放大查看”查看细节。

每个线程在自己的 TLAB 内分配对象,大多数时候不需要竞争全局分配指针。

参数:

-XX:+UseTLAB

5.4 第三步:零值初始化

Java 中实例字段不显式赋值也有默认值:

int    -> 0
boolean -> false
Object -> null

这个语义依赖 JVM 在对象分配后做零值初始化。

5.5 第四步:初始化对象头

设置:

  • 对象属于哪个 Class
  • GC Age
  • HashCode 状态
  • 锁状态等

5.6 第五步:执行构造方法 <init>

注意:

分配对象内存 与 执行 Java 构造器 是两个阶段。


5.7 TLAB 深挖

TLAB 是 Eden 内一块线程私有区域,默认开启(-XX:+UseTLAB)。

5.7.1 关键参数

参数 默认 含义
-XX:+UseTLAB true 开关
-XX:+ResizeTLAB true 动态调整 TLAB 大小
-XX:TLABSize 0(自适应) 固定 TLAB 大小
-XX:MinTLABSize 2K 最小
-XX:TLABWasteTargetPercent 1 允许 TLAB 内浪费占 Eden 的百分比(初始大小参考)
-XX:TLABRefillWasteFraction 64 初始refill waste limit约为期望TLAB大小/64;剩余空间较多时保留TLAB、走共享分配,较少时退役并补充

5.7.2 分配路径

fast path: 当前 TLAB 指针 + size <= TLAB end  -> 指针碰撞
slow path: TLAB 剩余不足
           -> 剩余大于 refill waste limit:保留当前TLAB,本对象走共享分配
           -> 剩余不大于 limit:退役当前TLAB并申请新TLAB(可能触发Young GC)
           -> 大对象直接走 Eden

5.7.3 三个易混概念

  • TLAB:仍在 Eden,是线程私有分配区,解决分配竞争,不是栈。
  • 标量替换/分配消除:JDK8 HotSpot C2可能把对象拆为若干值,从而消除完整对象分配。值可在寄存器或栈槽中,不等于把完整对象搬到栈上;该实现不提供通用的完整对象栈分配。
  • 对象通常优先分配在Eden:TLAB外的普通分配可走共享路径;收集器支持的预分配、G1 Humongous等属于特殊路径,不能把所有大对象都归为Eden分配。

追问:TLAB 会浪费内存吗?

会。对象放不下时,剩余空间较多可保留当前TLAB,让本次对象走TLAB外共享分配;剩余较少时可退役并申请新TLAB。退役尾部通常填充为可解析对象,留待GC回收,并非立即退回Eden供其他线程随意分配。参见make_parsable;TLABWasteTargetPercent与TLABRefillWasteFraction影响大小和退役取舍。

章节07 / 完整阅读

6. 类加载机制

6.1 生命周期

图07

Loading

Verification

Preparation

Resolution

Initialization

Using

Unloading

可横向滚动;点“放大查看”查看细节。

连接 Linking 包括:

Verification
Preparation
Resolution

6.2 Loading 做什么?

核心是:

  1. 根据类的全限定名找到二进制字节流。
  2. 将字节流转换成 JVM 方法区内部数据结构。
  3. 在 Java Heap 中生成一个 java.lang.Class 对象作为访问入口。

6.3 Verification

验证 Class 文件是否符合 JVM 规范且安全。

例如:

  • 魔数
  • 版本
  • 元数据
  • 字节码语义
  • 符号引用

6.4 Preparation —— 容易混淆的细节

准备阶段给类变量分配内存并设置“零值”。

例如:

public static int value = 123;

Preparation 后通常是:

value = 0

真正赋值 123 在 <clinit> 初始化阶段。

但是:

public static final int VALUE = 123;

从HotSpot8实现的阶段划分看,这类字段可借助ConstantValue在类链接相关处理中设置常量值,无需<clinit>执行赋值。严格的JVMS8§5.4.2规定准备阶段设置默认值,§5.5将常量字段赋值描述在初始化步骤中;学习常见“准备阶段赋常量”是实现/教材口径,不能写成所有JVM的规范承诺。

6.5 Resolution

将常量池中的符号引用转换为直接引用。

例如:

com.foo.UserService

最终解析为 JVM 内部可以直接定位到目标类 / 字段 / 方法的数据结构。

6.6 Initialization

执行类构造器:

<clinit>()

它由编译器自动收集:

  • static 字段赋值
  • static 代码块

按照源码顺序合并生成。


章节08 / 完整阅读

7. 什么时候会触发类初始化?

学习常见。

典型主动使用包括:

  1. new
  2. getstatic
  3. putstatic
  4. invokestatic
  5. 反射调用类
  6. 初始化子类时,需要先初始化父类
  7. JVM启动时执行main所在类
  8. JDK7+特定MethodHandle的首次调用涉及尚未初始化的类
  9. JDK8初始化类时,还会初始化其声明default方法的相关父接口;单纯初始化一个接口不会自动初始化全部父接口

7.1 不会触发初始化的经典题

通过子类引用父类静态字段

class Parent {
    static int x = 1;
}

class Child extends Parent {
}

System.out.println(Child.x);

实际声明字段的是 Parent,因此一般只初始化 Parent。

引用编译期常量

static final int X = 1;

编译后可能直接把 1 放进调用方常量池,不需要初始化定义类。

创建数组

User[] users = new User[10];

创建的是数组类,不等于初始化 User。


章节09 / 完整阅读

8. 双亲委派模型

图08

Application ClassLoader

Extension ClassLoader

Bootstrap ClassLoader

可横向滚动;点“放大查看”查看细节。

加载一个类时:

AppClassLoader
  -> 先问 Ext
      -> Ext 先问 Bootstrap
          -> Bootstrap 找不到
      -> Ext 自己找
  -> App 最后自己找

8.1 为什么这么做?

最重要的答案不是“避免重复加载”,而是:

保证 Java 核心类型体系的一致性和安全性。

否则你自己写一个:

java.lang.String

理论上可能替换系统 String,破坏整个 JVM 安全边界。

8.2 类的唯一性如何判断?

JVM 判断一个 Class 是否相同,不只是看类名。

真正身份是:

Class = Fully Qualified Name + Defining ClassLoader

所以两个不同 ClassLoader 加载同一个 jar:

clazz1 != clazz2

对象也不能直接强转。

8.3 哪些场景会打破双亲委派?

SPI

例如 JDBC:

核心接口由 Bootstrap 加载,但具体驱动实现位于应用 ClassPath。

Bootstrap 看不到 Application ClassLoader 中的实现。

因此需要:

Thread Context ClassLoader

由SPI代码显式选择线程上下文类加载器(TCCL)等加载器查找实现;这不等于修改Bootstrap的默认loadClass委派算法。JDK8的DriverManager还检查驱动是否对调用方可见,不能只看到驱动已加载就认定所有调用方都可用。

Tomcat

多个 WebApp:

WebApp A -> lib/common.jar v1
WebApp B -> lib/common.jar v2

要求不同应用互相隔离,因此需要不同 WebAppClassLoader。

OSGi / 热部署框架

类加载关系可能变为网状结构,而不是传统树状。


章节10 / 完整阅读

9. JVM 如何判断对象已经“死了”?

9.1 引用计数为什么不够?

假设:

A.instance = B;
B.instance = A;

A、B 互相引用,但实际上程序已经无法访问它们。

引用计数依旧不是 0。

因此 HotSpot 使用:

Reachability Analysis,可达性分析。

9.2 GC Roots

图09

GC Roots

A

B

C

X

Y

可横向滚动;点“放大查看”查看细节。

A/B/C 可达,保留。

X/Y 不可达,可以回收。

9.3 常见 GC Roots

至少应理解:

  • JVM Stack 中局部变量引用的对象
  • 静态变量引用的对象
  • JNI 引用的对象
  • 活跃线程对象
  • synchronized Monitor 持有的对象
  • JVM 内部使用的 Class / ClassLoader 等

9.4 GC Roots 完整清单

除 9.3 列出的之外,完整版还包括:

  • 活跃类的静态字段、运行时常量解析结果所关联的堆引用。
  • JVM内部保留的类镜像、预分配异常等实现根。不是所有NPE/OOM实例都常驻、不被回收。
  • JNI全局引用、JVMTI/JVM内部持有的强引用,以及执行中代码所需的相关引用。
  • Bootstrap加载类关联的引用。JMX对象若被管理设施强持有,也会形成保活链。

注意:这是根来源及保活链的教学归纳,不是所有收集器统一的根类别清单。StringTable中的驻留项不能一概视为永久强根;不可再访问的驻留字符串可以被回收。普通Class/ClassLoader对象也不自动永久存活。

一句话:

GC Roots 的本质是"虚拟机确信当前所有活跃代码都能直接或间接访问到它的对象"。

9.5 finalize 与对象的"两次标记"

《深入理解JVM》经典内容,手册之前缺失。

9.5.1 对象真正死亡前的两次标记

即使可达性分析判定不可达,也不是立即回收:

图10

否

是

可达 自救成功

仍不可达

可达性分析判定不可达

是否覆盖 finalize 且未执行过?

直接回收

第一次标记 / 放入 F-Queue

Finalizer 线程执行 finalize

第二次标记: 现在可达吗?

移出回收集合

回收

可横向滚动;点“放大查看”查看细节。

9.5.2 对象自救

static Object SAVE;

@Override
protected void finalize() {
    SAVE = this;   // 重新挂到 GC Root,实现"复活"
}

但:

  • finalize 只会被调用一次,第二次不可达时直接回收。
  • 执行时机不确定,可能根本不执行(Finalizer 线程被拖死)。

9.5.3 为什么 finalize 不推荐

  • Finalizer 线程优先级低,会导致对象回收延迟,甚至 OOM。
  • finalize 抛异常会被吞掉,程序无感知。
  • 从不可达、等待终结到最终回收会引入延迟和额外保活;不能把“两次标记”机械换算为规范保证的“至少两次独立GC”。

9.5.4 正确替代

  • try-with-resources / AutoCloseable
  • JDK 9+ java.lang.ref.Cleaner(JDK 8 是 sun.misc.Cleaner,DirectByteBuffer 内部即用它)

9.5.5 软引用回收时机

  • 软可达对象的回收由GC策略决定,并非“内存不紧张就绝不清理”。HotSpot8的SoftRefLRUPolicyMSPerMB影响基于时间戳和可用堆估计的保留策略,常见默认1000ms/MB;它不是精确TTL或严格LRU保证。抛出堆OOM前要求清除软可达对象的软引用,但程序可能同时保留其他强引用。

章节11 / 完整阅读

10. 强引用 / 软引用 / 弱引用 / 虚引用

类型 回收条件 常见用途
Strong 只要可达就不回收 普通对象
Soft 内存紧张时可能回收 缓存(现在较少推荐)
Weak 收集器判定目标为弱可达时清除弱引用;并非任意一次局部GC必清 WeakHashMap
Phantom 幻象可达后通知队列;JDK8入队不等于对象空间已释放 资源清理协调

10.1 虚引用最容易回答错

PhantomReference:

  • get() 永远返回 null
  • 实际用途需配合ReferenceQueue;构造器技术上允许传null,此时无法得到队列通知
  • 目的不是“访问对象”,而是知道对象已经进入回收流程

JDK 9 Cleaner 的设计思路就和这种资源清理思想有关;JDK 8 中更常见的是 DirectByteBuffer Cleaner。


章节12 / 完整阅读

11. GC 基础算法

11.1 标记-清除 Mark-Sweep

Before
[A][dead][B][dead][dead][C]

After
[A][free][B][free][free][C]

优点:

  • 不需要额外等量空间

缺点:

  • 内存碎片
  • 大对象分配困难

11.2 复制 Copying

图11

copy live objects

From Space
A dead B C dead

To Space
A B C

可横向滚动;点“放大查看”查看细节。

优点:

  • 分配快
  • 无碎片

缺点:

  • 浪费一部分空间
  • 对象存活率高时复制成本高

非常适合新生代,因为大部分对象朝生夕死。

11.3 标记-整理 Mark-Compact

Before
[A][dead][B][dead][C]

Mark
[A][    ][B][    ][C]

Compact
[A][B][C][free][free]

适合老年代。


章节13 / 完整阅读

12. 为什么 JVM 要分代?

经验假设:

绝大部分对象生命周期很短。

所以把对象分为:

图12

Heap

Young Generation

Old Generation

Eden

Survivor 0

Survivor 1

可横向滚动;点“放大查看”查看细节。

年轻对象频繁回收,老对象少回收。

这样 GC 可以采取不同算法。


12.1 分代收集的三个假说

分代收集不是拍脑袋,而是基于三条经验假说:

假说 内容 推论
弱分代假说 Weak Generational 绝大多数对象朝生夕灭 新生代用复制算法,回收成本低
强分代假说 Strong Generational 熬过越多次 GC 的对象越难死 老年代不必频繁回收
跨代引用假说 Intergenerational Reference 跨代引用相对同代引用极少 不必为少量跨代引用扫描整个新生代

12.1.1 跨代引用怎么处理

若老年代持有新生代引用,Minor GC 时扫描整个老年代就失去了分代意义。 于是用空间换时间:在新生代建立 Remembered Set,把跨代引用记下来。

12.2 卡表 Card Table 与写屏障

12.2.1 卡表结构

  • 把堆(或某个代)划分为固定大小 512 字节的卡页(Card Page)。
  • 卡表是一个字节数组,一个元素对应一个卡页。
  • 本页核对的HotSpot8实现中,clean_card=-1,dirty_card=0;还存在预清理、认领等状态,不能把“非0”统一解释成脏卡。脏卡提示该区域需要按收集器规则检查,不等于已经确认存在跨代引用。固定源码枚举。
  • Minor GC 时只扫描脏卡对应的内存区域,不必扫整个老年代。

12.2.2 写屏障(Write Barrier)

引用赋值时,由 JIT 插入的写屏障维护卡表:

obj.field = ref;
-> 若 ref 指向新生代,把 obj 所在卡页标记为 dirty

12.2.3 RSet 与 Card Table 的关系(高频混淆)

概念 层次 说明
Remembered Set 抽象逻辑结构 "记录跨代/跨 Region 引用"
Card Table 具体实现之一 按卡页粒度、points-out
G1 RSet 具体实现 按 Region 组织、points-into,粒度更细

理解时不要直接说"RSet 就是 Card Table"。RSet 是记忆集的抽象,卡表只是其中一种实现;G1 的 RSet 复用了卡表思想,但结构是 points-into。

章节14 / 完整阅读

13. 新生代对象分配与 Minor GC

图13

Minor GC survive

next GC

age threshold

new object

Eden

Survivor From

Survivor To

Old Generation

可横向滚动;点“放大查看”查看细节。

典型规则:

  1. 对象优先进入 Eden。
  2. Eden 不够时触发 Minor GC。
  3. 存活对象复制进入 Survivor。
  4. 对象在相关Survivor存活复制路径上更新年龄。
  5. 达到晋升年龄后进入 Old。

参数:

-XX:MaxTenuringThreshold=15

但不要机械理解为“一定活 15 次才进老年代”。

13.1 动态年龄判定

如果 Survivor 中:

某年龄及以下对象的总大小已经超过 Survivor 一部分阈值,

JVM 可以提前把更老对象晋升到老年代。

这就是为什么实际晋升年龄可能小于 MaxTenuringThreshold。

13.2 大对象直接进老年代

某些收集器下可以通过:

-XX:PretenureSizeThreshold

让特别大的对象绕过 Eden,直接进老年代。

目的:避免大对象在 Survivor 之间不断复制。

注意:不同收集器支持行为并不完全相同,机制讲解时不要说成所有收集器统一规则。


13.3 空间分配担保(Handle Promotion Failure)

《深入理解JVM》第 3 章经典考点。

13.3.1 为什么需要担保

Young GC中部分存活对象需要晋升Old,但目标空间可能不足。不同收集器有各自的容量预测、检查或失败恢复机制;下面是教材担保概念,不代表每次都执行相同检查。

13.3.2 教材规则与实现边界

  • JDK 6u24 以前:由 -XX:+HandlePromotionFailure 决定是否允许冒险(担保失败再 Full GC)。
  • 后续版本不再依靠该旧参数控制此路径。下面是教材帮助理解风险判断的概念模型,不是所有HotSpot8收集器共享的一段代码:
if (老年代最大连续可用空间 > 新生代所有对象总大小
    || 老年代最大连续可用空间 > 历次晋升对象平均大小)
    允许 Minor GC
else
    改为 Full GC

13.3.3 历史参数与失败事件

术语 触发 含义
Handle Promotion Failure 历史参数/晋升失败处理概念 不宜视作与日志事件完全对应的独立错误类型
Promotion Failed(ParNew/CMS日志常见) Young GC复制晋升时无法获得老年代空间 可能因容量或碎片;不要求当时正在并发CMS周期

追问:为什么有时 Minor GC 后日志突然出现 Full GC?

很可能是空间分配担保失败,或 Survivor 放不下导致对象直接进老年代、老年代连续空间不足。

章节15 / 完整阅读

14. Minor GC / Major GC / Full GC 有什么区别?

这个问题术语本身就有历史混乱。

更稳妥回答:

  • Young GC / Minor GC:主要回收新生代。
  • Old GC / Major GC:主要回收老年代,具体术语不同收集器含义可能不同。
  • Full GC:通常会涉及整个 Java Heap,并经常伴随类卸载等更重操作。

理解时不要死背:

“Major GC 就一定等于 Full GC。”

不同 GC 日志语境中并不总严格等价。


14.1 GC 触发条件完整清单

GC 类型 触发条件
Young / Minor GC Eden 满(日志 Allocation Failure);TLAB 申请失败后 Eden 仍不足
Full GC(可能原因) 分配压力、元数据分配压力、显式GC请求、部分收集器失败回退;触发GC不等于必定立即Full GC
CMS Full GC 并发失败、担保失败、显式 GC、元空间回收
G1 Full GC 无法通过常规回收/扩容满足分配时可能回退;Evacuation Failure、Humongous压力和显式GC是重要线索,不是逐项必定Full GC

补充:

  • 常见默认配置将显式GC请求交给Full GC路径,但语言API不保证执行;CMS下可加 -XX:+ExplicitGCInvokesConcurrent 让它走一次并发周期而非 Full GC。
  • Full GC 往往伴随类卸载、引用处理、Code Cache 清理,所以 STW 明显更长。

章节16 / 完整阅读

15. GC 收集器全家桶

图14

Young Collectors

Old Collectors

Serial

ParNew

Parallel Scavenge

Serial Old

CMS

Parallel Old

G1
Whole Heap Region-based

可横向滚动;点“放大查看”查看细节。
收集器 区域 目标 特点
Serial Young 简单 单线程 STW
ParNew Young 搭配 CMS Serial 多线程版
Parallel Scavenge Young 吞吐量 多线程,吞吐优先
Serial Old Old 简单 / 后备 标记整理
Parallel Old Old 吞吐量 标记整理
CMS Old 低停顿 并发标记清除
G1 Whole Heap 可预测停顿 Region + Mixed GC

15.1 并发标记的理论基础:三色标记与漏标

放在 CMS/G1 之前看,这是"并发标记为什么正确"的总纲,也是学习从背流程升级到讲原理的关键。

15.1.1 三色标记

图15

被扫描到

引用扫描完

白色 未访问

灰色 已访问,引用未扫完

黑色 已访问,引用扫完

可横向滚动;点“放大查看”查看细节。
  • 白色:尚未被 GC 访问,标记结束仍是白色即可回收。
  • 灰色:已被访问,但它引用的对象还没全部扫描。
  • 黑色:自己和所有引用都已扫描完。

并发标记时业务线程同时改引用,可能漏标本应存活的对象。

15.1.2 经典对象图模型下的漏标条件

在忽略根变化、新对象分配和实现细节的经典三色模型中,危险的引用迁移同时涉及:

  1. 某个黑色对象新增了指向该白色对象的引用;
  2. 该白色对象的所有灰色引用路径被删除(没有灰色对象还能到达它)。

只要破坏其中任意一个条件,就不会漏标。

15.1.3 两种解法(核心原理对比)

方案 破坏条件 屏障类型 记录什么 使用者
增量更新 Incremental Update 条件 1 写后屏障 post-write 新增的引用 CMS
原始快照 SATB 条件 2 写前屏障 pre-write 被删除的旧引用 G1 / Shenandoah
  • 增量更新:把"黑色对象新增的引用"记下来,重新标记阶段以这些黑色对象为根重新扫描。
  • SATB:把"要断开的旧引用"记下来,以快照态存活对象为根扫描,宁可多标(浮动垃圾)也不漏标。

15.1.4 为什么"多标"可以接受

SATB 会把本轮已死的对象也标记为存活,产生浮动垃圾,留到下一轮回收。这是用少量空间换正确性的典型设计。

15.1.5 读屏障

  • 上面两种都是写屏障,用于标记阶段的引用变化。
  • 读屏障用于并发移动对象(ZGC、Shenandoah),读取引用时修正指针(自愈 / 染色指针),CMS/G1 不用。

追问:CMS 和 G1 都用并发标记,为什么一个用增量更新一个用 SATB?

两者选择不同的并发标记不变量和屏障设计。CMS通过脏卡等机制补扫新增引用,G1通过SATB保留开始快照的可达性。不能仅凭“是否复制/移动”推出必须选择哪种标记方案;标记正确性与对象移动是不同问题。

章节17 / 完整阅读

16. CMS —— 重点机制与常见误解

CMS:Concurrent Mark Sweep。

核心目标:

尽量减少老年代 GC 时 Stop-The-World 时间。

16.1 CMS 核心流程

图16
CMS GCApplication ThreadsCMS GCApplication ThreadsSTW,扫描根与年轻代的老年代引用与业务线程并发STW,修正并发期间发生变化的引用并发清除死亡对象Initial MarkResumeConcurrent MarkRemarkResumeConcurrent Sweep
可横向滚动;点“放大查看”查看细节。

严格实现中还有预清理等步骤,但学习主流程先答四阶段。

16.2 为什么 Initial Mark 很快?

CMS初始标记建立老年代标记的起点,处理根并将年轻代作为根来源,标记相关老年代对象;不是完整遍历整个老年代对象图。不能只背“根直接关联对象”而遗漏年轻代扫描。参见checkpointRootsInitialWork中的younger gens are roots。阶段耗时仍受根和年轻代状态影响。

16.3 为什么 Remark 必须 STW?

Concurrent Mark 时业务线程仍在修改对象引用:

A -> B

并发过程中:
A -> C
B 不再被引用

GC 的标记结果可能失真。

因此 Remark 需要暂停线程,修正并发期间引用变化。

16.4 什么是 Floating Garbage?

CMS Concurrent Mark / Sweep 时业务线程仍然运行。

假设某对象开始 GC 时是活的,但 GC 进行过程中变成垃圾。

CMS 可能来不及在本轮回收它。

这种对象叫:

浮动垃圾。

它会留到下一轮 GC。

所以 CMS 不能等老年代 100% 满了再开始。

16.5 CMS 最大缺点:内存碎片

CMS 使用 Mark-Sweep,不做持续压缩。

[A][free][B][free][C][free]

总 free 可能很多,但没有足够大的连续区域。

于是一个大对象依然分配失败。

16.6 Concurrent Mode Failure

这是 CMS 最重要的故障场景之一。

发生过程:

图17

CMS Concurrent Collection

Application keeps allocating

Old Gen runs out of space

Concurrent Mode Failure

Fallback to Stop-The-World Full GC

可横向滚动;点“放大查看”查看细节。

原因经常是:

  • CMS 启动太晚
  • 老年代增长太快
  • 晋升对象突然增多
  • 大对象过多
  • 碎片严重

结果:

原本为了低停顿,反而触发一次非常长的 STW Full GC。

16.7 CMS 四个核心问题

Q1:CMS 为什么不能在老年代满了以后再回收?

因为它在并发回收过程中,应用线程还需要继续分配内存。

Q2:CMS 为什么有碎片?

Mark-Sweep 只清除,不整理。

Q3:CMS 为什么有浮动垃圾?

并发标记期间新产生的垃圾可能已经错过本轮标记周期。

Q4:CMS 为什么仍然需要 STW?

Initial Mark 和 Remark 需要获得一致对象图视图。


16.8 CMS 的完整阶段与预清理

概括主流程可先理解四个阶段,但完整实现是七步:

1 初始标记 (STW)
2 并发标记
3 并发预清理 (Concurrent Preclean)
4 可中断预清理 (Abortable Preclean)
5 重新标记 (STW, Remark)
6 并发清除 (Concurrent Sweep)
7 并发重置 (Concurrent Reset,为下次做准备)

预清理的意义:在Remark之前尽量处理并发期间变脏的卡,减少最终重扫工作。可中断预清理会按时间、Eden状态等条件结束;CMSScavengeBeforeRemark的Young GC则由最终标记入口checkpointRootsFinal在满足条件时触发,不能归为可中断预清理线程主动执行。该选项还可能增加这次暂停中的Young GC成本。

16.8.1 关键参数

参数 作用
-XX:CMSInitiatingOccupancyFraction=70 老年代占用达到 70% 启动并发周期
-XX:+UseCMSInitiatingOccupancyOnly 固定用上面阈值,不让 JVM 自动调整
-XX:CMSScavengeBeforeRemark Remark 前先做一次 Young GC
-XX:CMSMaxAbortablePrecleanTime=5000 可中断预清理最长 5s
-XX:+UseCMSCompactAtFullCollection Full GC 时整理(默认开)
-XX:CMSFullGCsBeforeCompaction=0 每几次 Full GC 做一次压缩,0 = 每次都做

16.8.2 System.gc() 与 CMS

常见CMS配置将显式GC请求交给Full GC路径;语言API不承诺执行。CMS下可加:

-XX:+ExplicitGCInvokesConcurrent

让它走一次并发周期(但仍应避免在代码里调用 System.gc())。

章节18 / 完整阅读

17. G1 —— JDK 8 学习重点

G1:Garbage First。

与传统固定 Young / Old 连续空间不同,G1 把 Heap 划分成大量等大小 Region。

+----+----+----+----+
| E  | O  | S  | E  |
+----+----+----+----+
| O  | H  | E  | O  |
+----+----+----+----+
| S  | E  | O  | O  |
+----+----+----+----+

E = Eden
S = Survivor
O = Old
H = Humongous

逻辑上仍有分代,但物理上不要求连续。

17.1 G1 为什么叫 Garbage First?

它会估算不同 Region:

回收收益 / 回收成本

优先回收“垃圾多、收益高”的 Region。

17.2 G1 的核心目标

不仅追求吞吐量,还希望用户可以指定:

-XX:MaxGCPauseMillis=200

注意:

这是软目标,不是硬 SLA。

17.3 G1 Young GC

Young GC 主要回收 Eden / Survivor Region。

图18

Evacuate live objects

Promote

Eden Regions

Survivor Regions

Old Regions

可横向滚动;点“放大查看”查看细节。

G1 采用 Evacuation:

把存活对象复制到新的 Region。

因此天然获得整理效果,减少 CMS 式碎片。

17.4 G1 并发标记周期

图19

Initial Mark
STW

Root Region Scan

Concurrent Mark

Remark
STW

Cleanup

Mixed GC

可横向滚动;点“放大查看”查看细节。

Initial Mark

通常借助一次 Young GC 完成,标记 GC Roots 直接关联对象。

Root Region Scan

扫描 Survivor Region 中对 Old Region 的引用。

Concurrent Mark

遍历老年代对象图。

Remark

处理并发期间发生的引用变化。

G1 使用 SATB 思想保证标记正确性。

Cleanup

统计各 Region 存活率和回收收益。

之后进入 Mixed GC。

17.5 Mixed GC 是什么?

Mixed GC 一次不只收 Young:

Young Regions
+
部分垃圾比例较高的 Old Regions

所以叫 Mixed。

17.6 Remembered Set 为什么重要?

问题:

如果只想回收某几个 Region,难道每次都扫描整个 Heap,寻找谁引用了它吗?

当然不行。

G1 为 Region 维护 RSet:

图20

reference

Region A

Region B

Remembered Set
records external refs

可横向滚动;点“放大查看”查看细节。

这样回收 B 时,可以快速知道:

哪些其他 Region 可能引用 B。

17.7 Card Table

堆进一步划分为 Card。

当引用发生跨代 / 跨 Region 修改时,通过 Write Barrier 标记对应 Card 为 dirty。

Heap Region
+----+----+----+----+
| C0 | C1 | C2 | C3 |
+----+----+----+----+
          ^
        dirty

避免每次 GC 扫描整个老年代。

17.8 SATB

SATB:Snapshot At The Beginning。

思想:

把并发标记开始时的对象图看作一张“逻辑快照”。

例如:

A -> B

业务线程把引用改成:

A -> C

G1 的 pre-write barrier 会把旧引用 B 记录下来。

即使 A 不再指向 B,也不会因为并发修改错误地漏标 B。

17.9 Humongous Object

JDK8 HotSpot G1中,对齐后的完整对象大小严格大于半个Region时按Humongous对象处理,需计入对象头和数组长度等。

它可能占用多个连续 Region。

问题:

  • 大对象多时 Region 利用率下降
  • 分配大对象可能触发 GC
  • 连续 Region 不足可能造成压力

因此排查 G1 问题时,大对象比例非常值得关注。


17.10 G1 的暂停预测与 Full GC(JDK 8 重要坑)

17.10.1 暂停预测模型

G1 不是"设了 MaxGCPauseMillis 就一定达标",而是:

  • 基于历史 GC 停顿的衰减均值预测本次回收集合的耗时;
  • 反推本次最多能收集多少个 Region;
  • 这是一个软目标;存活对象多、可用Region不足或CPU资源受限时可能超出目标。

MaxGCPauseMillis 设得太小会适得其反:Young Region 变小、GC 更频繁、吞吐下降。

17.10.2 JDK 8 的 G1 Full GC 是单线程的

这是 G1 最容易被忽视的坑:

JDK 8 中 G1 的 Full GC 是单线程、STW、Serial 式标记-整理,不走 Region Evacuation。

后果:一旦发生 G1 Full GC,停顿可能非常长。JDK 10(JEP 307)才把 G1 Full GC 并行化。

所以 G1 调优的核心目标是尽量避免 Full GC:

  • 关注 to-space exhausted / Evacuation Failure
  • 关注 Humongous 分配
  • 关注 IHOP(-XX:InitiatingHeapOccupancyPercent)

17.10.3 G1 Full GC 常见触发

Evacuation Failure(to-space exhausted)
Humongous Allocation 失败
显式 GC

17.10.4 Humongous 补充

  • 判定阈值:对齐后的完整对象大小**> Region大小的1/2**;正好等于阈值不走此判断。
  • Humongous分配需要连续Region;分配路径可能尝试扩容、触发回收等,不能说一失败就必定Full GC。部分8u版本支持特定Humongous对象的提前回收。
  • 所以排查 G1 问题必须关注大对象比例,必要时调大 -XX:G1HeapRegionSize。

章节19 / 完整阅读

18. CMS 和 G1 怎么比较?

维度 CMS G1
堆布局 Young / Old 连续分代 Region 化
老年代算法 Mark-Sweep Evacuation / Compact
碎片 明显 相对较少
并发标记 是 是
可预测停顿 较弱 更强
大堆表现 一般 更适合大堆
Remembered Set 传统 Card Table 每 Region RSet
典型故障 Concurrent Mode Failure Evacuation Failure / Humongous 压力

机制讲解不要绝对化说:

“G1 一定比 CMS 快。”

更准确是:

G1 的设计更适合大堆、较可控停顿以及降低碎片;不同业务负载下吞吐量与停顿表现仍需要压测。


章节20 / 完整阅读

19. Safepoint —— 很多人背 GC,却答不好这题

GC 需要枚举 GC Roots。

问题:

能不能随时把 Java 线程停在任意机器指令上?

不能。

如果线程停在完全任意位置,JVM 很难准确知道:

  • 哪个寄存器当前是对象引用
  • 哪个栈 Slot 是对象引用
  • 当前对象状态是否完整

因此 JVM 只在某些安全位置允许线程挂起。

这叫:

Safepoint。

常见位置:

  • 方法调用
  • 循环回跳
  • 异常跳转
  • 方法返回附近

JIT 会生成 OopMap,告诉 GC:

当前哪些位置保存着对象引用。

19.1 Safepoint 停顿为什么可能很长?

GC 本身可能只用了 50ms,但 JVM 等所有线程进入 Safepoint 用了 2s。

这类问题叫:

Time To Safepoint,TTSP。

缺少安全点轮询的长计数循环、线程调度饥饿等可能导致问题。正常处于native状态的线程通常可视作安全;JNI临界区还可能通过GC Locker延迟某些GC,需区分两类机制。

19.2 Safe Region

如果线程处于 sleep / blocked 等状态,它不能主动跑到 Safepoint。

因此 JVM 还需要 Safe Region:

一段线程无论停在哪里,引用关系都不会发生变化的代码区域。


19.3 Safepoint 的可观测性

Safepoint停顿也可能来自非GC操作,是否占主要时间要看证据。可用参数观测:

-XX:+PrintSafepointStatistics
-XX:PrintSafepointStatisticsCount=1
-XX:+PrintGCApplicationStoppedTime
-XX:+SafepointTimeout
-XX:SafepointTimeoutDelay=500

关键指标:

  • vmop:本次安全点操作类型(no vm operation / RevokeBias / GenCollectForAllocation ...)
  • time to safepoint:TTSP,等待所有线程进入安全点的耗时
  • at safepoint:真正执行操作的耗时

排查结论:

如果 time to safepoint 远大于 at safepoint,问题不在 GC 算法,而在"线程迟迟进不了安全点"(缺少有效轮询的计数循环、线程调度饥饿等;JNI临界区另按GC Locker区分)。

章节21 / 完整阅读

20. Stop-The-World 到底是什么?

STW = JVM 暂停应用线程。

典型场景:

  • GC 某些阶段
  • Deoptimization
  • 偏向锁撤销的一些场景
  • Heap Dump 等某些诊断操作

注意:

“并发 GC”不等于“完全没有 STW”。

CMS 有 Initial Mark / Remark。

G1 有 Young GC、Remark、Cleanup 等 STW 阶段。


章节22 / 完整阅读

21. GC 日志怎么看?

JDK 8 常用:

-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintTenuringDistribution
-Xloggc:/logs/gc.log

示意:

2016-01-01T10:00:00.000+0800: 12.345:
[GC (Allocation Failure)
 [PSYoungGen: 65536K->8192K(76288K)]
 200000K->145000K(250000K), 0.025 secs]

怎么读:

PSYoungGen: 65536K -> 8192K

表示 Young 从约 64MB 降至 8MB。

200000K -> 145000K

表示整个 Heap 使用量从约 200MB 降到 145MB。

0.025 secs

STW 约 25ms。

21.1 最重要指标

看日志时优先看:

  1. GC 频率
  2. GC pause
  3. GC 前后 Heap 下降幅度
  4. Old Gen 是否持续上涨
  5. Promotion 是否异常
  6. Full GC 是否越来越频繁
  7. Full GC 后 Old Gen 是否几乎不下降

最后一条非常关键:

Full GC 后 Old Gen 仍然很高,优先怀疑内存泄漏 / 大量长期存活对象。


章节23 / 完整阅读

22. 一套标准 JVM 故障排查流程

图21

High CPU

High Heap

Frequent GC

Metaspace

Direct Memory

Production Issue

Symptom

jstack / top -H

jstat / jmap / Heap Dump

GC Log Analysis

ClassLoader / Class Count

NMT / Netty Metrics

MAT Dominator Tree

Young? Old? Full? Promotion?

Map OS Thread ID to Java Thread

可横向滚动;点“放大查看”查看细节。

22.1 CPU 100%

top

找到 Java PID。

top -H -p <pid>

找到高 CPU 线程 TID。

转十六进制:

printf '%x\n' <tid>

然后:

jstack <pid> > thread.txt

搜索:

nid=0x...

查看该线程正在执行什么。

22.2 GC 频繁

jstat -gcutil <pid> 1000

重点看:

YGC  YGCT
FGC  FGCT
O
E
S0/S1

如果:

FGC 持续增长
Old 使用率长期高位

则继续抓 Heap Dump。

22.3 Heap OOM

建议预先设置:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dump

然后用 MAT 看:

  • Dominator Tree
  • Retained Heap
  • Path To GC Roots
  • Leak Suspects

真正关键的问题不是:

“哪个对象最大?”

而是:

“为什么这个对象还被 GC Root 引用?”


章节24 / 完整阅读

23. 常用 JVM 命令

命令 主要用途
jps 找 Java 进程
jinfo JVM 参数
jstat GC / Class / JIT 统计
jstack Thread Dump
jmap Heap 信息 / Dump
jcmd 综合诊断命令
jvisualvm 可视化分析
MAT Heap Dump 深度分析

常用:

jstat -gcutil PID 1000
jstack PID > thread.dump
jmap -dump:format=b,file=heap.hprof PID

生产环境提醒:

jmap -dump、Heap Dump、某些 Serviceability 操作可能带来明显暂停和 I/O 压力,大堆生产系统要谨慎。


章节25 / 完整阅读

24. Direct Memory —— 架构师很容易被追问

Java Heap 之外还有大量 Native Memory。

典型包括:

Metaspace
DirectByteBuffer
Thread Stack
Code Cache
JNI
GC native structures

NIO:

ByteBuffer.allocateDirect(...)

会分配 Direct Memory。

对象关系:

图22

DirectByteBuffer Object
in Heap

Native Memory

可横向滚动;点“放大查看”查看细节。

Java Heap 里只保存一个 DirectByteBuffer 包装对象。

真正的数据在 Native Memory。

所以可能出现:

Heap 看起来不高
RSS 却持续上涨

24.1 DirectByteBuffer 怎么释放?

JDK 8 中通常通过 Cleaner / PhantomReference 相关机制,在 DirectByteBuffer 对象不可达后释放对应 Native Memory。

这意味着:

Direct Memory 的释放在很多情况下依赖 GC 发现包装对象已经不可达。

如果 GC 不及时发生,Direct Memory 就可能积压。

24.2 Netty 为什么更容易遇到 Direct Memory 问题?

Netty 大量使用 DirectByteBuf,并且为了性能采用:

  • 池化
  • 引用计数
  • 零拷贝

如果 Reference Count 没有正确归零:

ByteBuf.refCnt() > 0

Native Memory 可能一直无法返回池 / 释放。

所以:

Java Heap Dump 不一定能直接告诉你全部 Native Memory 真相。


章节26 / 完整阅读

25. Java Memory Model(JMM)

JMM 解决的核心问题:

多线程情况下,一个线程对变量的修改,
什么时候对另一个线程可见?

现代 CPU:

图23

CPU Core 1

L1/L2 Cache

CPU Core 2

L1/L2 Cache

Main Memory

可横向滚动;点“放大查看”查看细节。

如果 Thread A 改变量只存在 CPU Cache,Thread B 可能暂时看不到。

JMM 在语言层抽象出:

Main Memory
Working Memory

不是说 JVM 真有一块叫“工作内存”的物理区域,而是规范抽象。


章节27 / 完整阅读

26. happens-before —— JMM 核心原理

如果 A happens-before B:

A 的执行结果对 B 可见,并且 A 的执行顺序排在 B 之前。

常见规则:

26.1 Program Order

同线程内:

A
B

A happens-before B。

26.2 Monitor Lock Rule

unlock(m)
    happens-before
lock(m)

所以 synchronized 不只是互斥,还保证内存可见性。

26.3 Volatile Rule

对 volatile 变量的写:

volatile write
    happens-before
subsequent volatile read

26.4 Thread Start

thread.start()

之前的操作,对新线程可见。

26.5 Thread Join

线程中的操作 happens-before:

thread.join() successful return

26.6 Transitivity

A hb B
B hb C
=>
A hb C

章节28 / 完整阅读

27. volatile 到底保证什么?

回答:

  1. 可见性
  2. 一定程度的有序性
  3. 不保证复合操作原子性

例如:

volatile int count = 0;
count++;

并不线程安全。

因为:

read count
+1
write count

是多个步骤。

27.1 volatile 为什么能禁止重排序?

JIT 在 volatile 读写附近插入内存屏障语义。

简化理解:

StoreStore
volatile write
StoreLoad

以及读侧相应 LoadLoad / LoadStore 约束。

理解并发机制时,不必死记CPU指令名称,核心是:

编译器和处理器不能跨越某些 volatile 边界随意重排内存操作。


27.2 重排序的三个层次

Java 源码到最终执行,可能经历三种重排序:

  1. 编译器重排序:javac/JIT 在不改变单线程语义下调整指令顺序。
  2. 指令级并行重排序:CPU 乱序执行、指令流水。
  3. 内存系统重排序:Store Buffer、Invalidate Queue 导致写/读看起来乱序。

JMM 通过 happens-before + 内存屏障约束这些重排序。

27.3 as-if-serial 与顺序一致性

  • as-if-serial:不管怎么重排序,单线程执行结果不能变。
  • 顺序一致性模型:所有线程的动作能放进一个统一总顺序,该顺序保留各线程的程序顺序,每次读看到总顺序中最近的相应写。它不要求把复合操作(如i++)变成一个原子动作,也不规定墙钟时间上的“立即可见”。JMM允许更多执行;正确同步的程序具有顺序一致性保证。参见JLS8§17.4.3与§17.4.5。
  • JMM 是"允许重排序,但用 happens-before 保证正确同步的程序结果"。

27.4 四种内存屏障

屏障 作用
LoadLoad 禁止前面的读与后面的读重排
StoreStore 禁止前面的写与后面的写重排
LoadStore 禁止前面的读与后面的写重排
StoreLoad 禁止前面的写与后面的读重排(开销最大,通常也具备其他三种屏障的效果)

volatile 写的经典语义:

StoreStore 屏障
volatile 写
StoreLoad 屏障

volatile读的教学示意:

volatile读
LoadLoad约束
LoadStore约束
后续普通读写

这是保守屏障模型,实际指令取决于CPU、JIT和优化;不能要求每条volatile访问固定生成这些实体屏障。

理解时不必背 CPU 指令,讲清"volatile 通过屏障禁止跨边界重排序"即可。

27.5 final 的内存语义

JSR-133 增强了 final:

  • 正确构造并且this未提前逸出时,构造结束具有final字段的freeze语义;其他线程获取该对象后读取final字段,可得到正确初始化值。
  • final引用所指对象/数组的构造期状态也有相应可见性规则;后续修改仍需要同步。
  • 这是一组特殊的final字段规则,不能简化成“构造写happens-before任何对象引用读”,也不能扩展为普通字段全部安全。详见第69节。

章节29 / 完整阅读

28. DCL 为什么必须加 volatile?

错误版:

if (instance == null) {
    synchronized (Singleton.class) {
        if (instance == null) {
            instance = new Singleton();
        }
    }
}

问题在:

instance = new Singleton();

概念上可以拆为:

1 allocate memory
2 initialize object
3 assign reference to instance

若 2、3 被重排:

1 allocate
3 assign reference
2 initialize

另一个线程可能看到:

instance != null

但对象还没初始化完成。

所以需要:

private static volatile Singleton instance;

章节30 / 完整阅读

29. synchronized 原理

29.1 字节码层面

同步代码块:

synchronized (lock) {
}

字节码使用:

monitorenter
monitorexit

同步方法则通过方法访问标志:

ACC_SYNCHRONIZED

29.2 对象锁和 Mark Word

锁信息会存储 / 映射在对象头 Mark Word 中。

JDK 8 常见锁状态演进:

图24

No Lock

Biased Lock

Lightweight Lock

Heavyweight Monitor

可横向滚动;点“放大查看”查看细节。

注意:这不是每个对象都必然完整走一遍。

29.3 轻量级锁

无严重竞争时,线程栈创建 Lock Record,通过 CAS 尝试把对象 Mark Word 替换为指向 Lock Record 的指针。

核心:

尽量避免直接进入 OS Mutex 阻塞。

29.4 重量级锁

竞争严重时膨胀为 Monitor。

Monitor 内部需要处理:

  • Owner
  • Entry List
  • Wait Set

线程可能真正挂起 / 唤醒,涉及内核调度,成本更高。


29.5 锁优化全家桶

除偏向 / 轻量级 / 重量级三种状态外,HotSpot 还有这些优化。

29.5.1 自旋锁与自适应自旋

  • 竞争时线程不立即挂起,而是空转重试一段时间,避免用户态/内核态切换。
  • 自适应自旋:根据同一个锁上次是否自旋成功、成功了多少次,动态调整本次自旋次数。
    • 上次成功 → 允许自旋更久;
    • 很少成功 → 直接省略自旋、进入阻塞。
  • 单核 CPU 上自旋无意义(自旋线程占着 CPU,持锁线程无法运行)。

29.5.2 锁消除(Lock Elision)

JIT 通过逃逸分析判定同步块里的锁对象不可能逃逸出当前线程,就消除同步。

public String concat(String a, String b) {
    StringBuffer sb = new StringBuffer(); // 局部变量,不逃逸
    sb.append(a).append(b);
    return sb.toString();                 // append 是 synchronized,可被消除
}

29.5.3 锁粗化(Lock Coarsening)

如果一连串操作反复对同一对象加锁/解锁,JIT 把锁的范围扩大,减少加锁次数。

for (int i = 0; i < 100; i++) {
    synchronized (lock) { doSomething(); }
}
// 可能被粗化为一次加锁包住整个循环

29.5.4 锁的四种状态

教学常见路径:可偏向状态 / 非偏向无锁状态 -> 轻量级锁 -> Monitor膨胀
偏向撤销与重偏向要区分,不是每次撤销都同一结果,也不是每个对象必经全部状态

注意:竞争可能导致偏向撤销或Monitor膨胀,但不是每个对象都走全部状态。对“不会降级”应限定讨论时段;后续Monitor维护、去膨胀等不能被这句简化口号覆盖。

29.6 偏向锁的批量重偏向与批量撤销

JDK8撤销另一线程可能持有的偏向常需Safepoint检查锁记录;部分自身撤销/可重偏向路径不同,不能说任何撤销都必停全局。批量机制减少反复处理代价:

  • 每个类维护一个 epoch 和撤销计数。
  • 达到批量重偏向阈值(常见默认20)时,更新类的epoch等信息,让旧偏向失效,并结合活跃栈中的锁记录处理;不是遍历堆把全部对象立即改为同一个新线程ID。
  • 达到批量撤销阈值(常见默认40)时,使该类后续对象不再采用偏向,并处理活跃锁;旧对象状态可能在后续使用中处理,不应描述成必须立即遍历堆改写所有对象。
-XX:BiasedLockingBulkRebiasThreshold=20
-XX:BiasedLockingBulkRevokeThreshold=40
-XX:BiasedLockingDecayTime=25000

29.7 版本演进(避免答旧知识)

  • JDK8 HotSpot常见默认启用偏向锁;还存在启动延迟,常见BiasedLockingStartupDelay=4000毫秒。不能把压缩指针/逃逸分析的6u23版本节点误当成偏向锁首次默认启用节点。
  • JDK 15(JEP 374)默认禁用偏向锁,并标记废弃。
  • 后续版本偏向锁代码被移除。

学习讨论synchronized时,应按版本说明:JDK8常见偏向路径,JDK15起默认禁用并废弃,后续版本再移除实现;不能把所有“现代JDK”当同一个阶段。

章节31 / 完整阅读

30. CAS —— 为什么是原子的?

CAS:Compare And Swap。

伪代码:

if (memory == expected) {
    memory = newValue;
    return true;
}
return false;

关键点:

比较 + 更新必须是一个不可分割的 CPU 原子操作。

HotSpot 会利用 CPU 的原子指令 / 锁定语义实现。

例如 x86 上通常基于带 lock 前缀的原子指令语义。

30.1 CAS 的问题

ABA

A -> B -> A

CAS 只看最终是不是 A,无法知道中间改过。

解决:

AtomicStampedReference

自旋成本

竞争严重:

CAS fail
retry
fail
retry

会消耗大量 CPU。

只能方便地原子更新有限状态

复杂同步通常需要更高层结构,例如 AQS。


章节32 / 完整阅读

31. 线程状态

Java Thread.State:

图25

start

monitor contention

acquire monitor

wait/join/park

notify/unpark/finish

sleep/wait(timeout)

NEW

RUNNABLE

BLOCKED

WAITING

TIMED_WAITING

TERMINATED

可横向滚动;点“放大查看”查看细节。

注意:

Java 的 RUNNABLE 包含 OS 层面:

  • Running
  • Ready

所以 jstack 里 RUNNABLE 不代表线程此刻一定正在占 CPU。


章节33 / 完整阅读

32. Class 文件结构:需要理解到什么程度?

不用背每一个二进制字段,但要有结构感。

ClassFile
├── magic
├── minor_version
├── major_version
├── constant_pool
├── access_flags
├── this_class
├── super_class
├── interfaces
├── fields
├── methods
└── attributes

魔数:

CAFEBABE

32.1 Constant Pool

常量池是 Class 文件最重要结构之一。

包含:

  • 字符串字面量
  • 类 / 接口符号引用
  • 字段符号引用
  • 方法符号引用
  • NameAndType
  • MethodHandle 等

运行时加载后形成 Runtime Constant Pool。


32.2 异常表与 finally 的字节码真相

32.2.1 异常表 Exception Table

Code 属性里有一张 exception_table,每项四个字段:

start_pc    : 监控范围起点
end_pc      : 监控范围终点(不含)
handler_pc  : 异常处理入口
catch_type  : 捕获的异常类型(常量池索引);0 表示任意异常(any)

catch_type = 0 通常就是编译器为 finally 生成的"兜底"处理器。

32.2.2 finally 是怎么实现的

现代 javac(JDK 6+)把 finally 块复制到每条正常/异常出口,而不是用旧的 jsr/ret:

try {
    return 1;
} finally {
    System.out.println("cleanup");
}

字节码大致:

正常路径: 计算 1 -> 存临时变量 -> 执行 finally -> 取回 1 -> ireturn
异常路径: 异常处理器 -> 执行 finally -> athrow 抛出

32.2.3 三个经典结论

  1. try 里 return 的表达式先求值并保存,再执行 finally,最后返回保存值。
  2. 如果 finally 里也有 return,会覆盖 try 的返回值。
  3. finally 中修改基本类型局部变量不影响 try 的返回值,但修改引用对象的字段会生效。

32.3 语法糖与字节码形态

语法糖 字节码表现
泛型 类型擦除:编译为 Object/上界;Signature 属性保留泛型信息;编译器生成桥接方法
自动装箱/拆箱 Integer.valueOf() / intValue();Integer 缓存 -128~127
变长参数 编译为数组 new T[]{...}
foreach 数组 普通 for + 下标
foreach Iterable iterator() + hasNext()/next()
条件编译 if (false) {...} 中的代码被消除
枚举 继承Enum,生成常量、values()/valueOf()等;有常量专属类体时不能一概说枚举类都是final
内部类 编译为独立 class;非 static 内部类持有外部类引用
字符串拼接 JDK 8 用 StringBuilder;JDK 9+ 用 invokedynamic + StringConcatFactory

32.3.1 桥接方法 Bridge Method

class A { Object get() { return null; } }
class B extends A { String get() { return ""; } }

此例没有泛型,桥接源于协变返回类型;JVM方法描述符含返回类型,因此编译器生成返回Object的桥接入口:

// 伪代码:invokevirtual B.get:()Ljava/lang/String;
// 随后areturn;Java源码不能只靠返回类型重载,所以本示意不能原样编译。
Object get_bridge() { return this.get(); }

这解释了"为什么反射会看到两个 get 方法"。

章节34 / 完整阅读

33. JVM 方法调用指令

这部分有助于检验是否理解多态。

指令 用途
invokestatic static 方法
invokespecial 构造方法、private、super 等
invokevirtual 普通实例方法动态分派
invokeinterface 接口方法
invokedynamic 动态语言 / Lambda 等

33.1 静态分派

重载 Overload:

void foo(Object x)
void foo(String x)

在编译期就决定调用哪个。

33.2 动态分派

重写 Override:

Animal a = new Dog();
a.run();

运行期根据实际类型 Dog 决定调用 Dog.run()。

这就是 Java 多态的重要底层基础。


33.3 动态分派的实现:虚方法表 vtable 与接口方法表 itable

33.3.1 vtable

  • 每个类在方法区有一张虚方法表(vtable),存放该类所有虚方法的实际入口。
  • 子类重写父类方法时,覆盖 vtable 中相同索引;未重写的沿用父类入口。
  • invokevirtual 执行时:
从接收者对象的 Klass Pointer 找到 vtable
-> 按方法在 vtable 中的固定索引取入口
-> 直接跳转

这就是"动态分派"能 O(1) 完成的原因,不用按名字逐个查找。

33.3.2 itable

  • 接口方法用 invokeinterface,每个类维护一张 itable(接口方法表)。
  • 一个类可实现多个接口,itable 按接口分组,查询比 vtable 稍慢。

33.3.3 单分派与多分派(经典易错)

概念 依据 Java 的结论
静态分派(重载 Overload) 方法接收者 + 方法参数(两者都要看) 静态多分派
动态分派(重写 Override) 只看方法接收者的实际类型 动态单分派

所以标准答案:Java 是静态多分派、动态单分派的语言。

33.4 invokedynamic 与 Lambda 的落地

33.4.1 invokedynamic 是什么

  • JDK 7 引入的第五条方法调用指令,把"调用目标由谁决定"交给引导方法 BootstrapMethod。
  • Class 文件新增 BootstrapMethods 属性存放引导方法。
  • 首次执行时链接出 CallSite,之后走已链接的目标,避免重复解析。

33.4.2 Lambda 如何用 invokedynamic 实现

Runnable r = () -> System.out.println("hi");

编译后:

  1. 生成一条 invokedynamic,指向 LambdaMetafactory.metafactory(默认引导方法);
  2. 运行时由 LambdaMetafactory + ASM 动态生成一个实现函数式接口的类(如 Main$$Lambda$1),或直接绑定到合成方法 lambda$main$0;
  3. 返回该实例。

与匿名内部类的区别:

匿名内部类 Lambda
class 文件 编译期生成独立 class 运行时动态生成(或方法引用)
this 指向匿名类实例 Lambda沿用外层词法作用域的this;方法引用另行绑定接收者,不是Lambda的例外规则
捕获 显式 final 变量 可捕获 effectively final
实现 new 指令 invokedynamic

追问:为什么 Lambda 要用 invokedynamic 而不是匿名类?

invokedynamic将链接机制与源代码表达解耦,允许运行时选择实现。HotSpot8的Lambda通常由metafactory生成实现类并关联目标方法;后续JIT可以内联优化,不是JIT自行决定是否生成Lambda类。不捕获状态的实例可能被复用,不能据此假定Lambda对象身份。

章节35 / 完整阅读

34. JVM 为什么是“栈式虚拟机”?

例如:

c = a + b;

JVM:

iload a
iload b
iadd
istore c

相比寄存器型指令集:

优点:

  • 字节码更紧凑
  • 编译器简单
  • 更容易跨平台

缺点:

  • 指令数量可能更多
  • 需要更频繁入栈出栈

JIT 最终会把热点字节码转换为针对真实 CPU 优化的寄存器机器码。


章节36 / 完整阅读

35. JIT:为什么 Java 越跑越快?

HotSpot:

图26

Hot Method

More profiling / hotter

Bytecode

Interpreter

C1 Compiler

C2 Compiler

Optimized Native Code

可横向滚动;点“放大查看”查看细节。

JDK 8 Server VM 常见 Tiered Compilation。

35.1 热点探测

JVM 统计:

  • 方法调用次数
  • 循环回边次数

达到阈值后触发编译。

循环中途直接切换到编译代码称为:

OSR,On-Stack Replacement。

35.2 方法内联

int add(int a, int b) {
    return a + b;
}

调用:

x = add(a, b);

JIT 可能直接优化成:

x = a + b;

收益:

  • 消除函数调用成本
  • 为后续常量传播、逃逸分析提供更大优化空间

所以:

方法内联往往是 JIT 其他优化的基础。


35.3 分层编译 Tiered Compilation

JDK 8 Server 模式默认开启分层编译,共 5 层:

层级 编译器 profiling
0 解释器 可收集profiling信息
1 C1 无
2 C1 基本(方法调用 + 回边计数)
3 C1 完整
4 C2 利用已有profiling生成高优化代码;通常不再承担完整持续profile采集

典型路径:

0 -> 3 -> 4       (默认)
0 -> 2 -> 3 -> 4  (C2 队列忙时先升到 2)
  • C1 编译快、优化浅,负责早期提速。
  • C2 编译慢、优化深,负责峰值性能。

35.3.1 相关参数

参数 说明
-XX:+TieredCompilation 分层编译开关(JDK 8 Server 默认开)
-XX:TieredStopAtLevel=1 只到 C1(启动快,适合短任务)
-XX:-TieredCompilation 关闭分层,直接用 C2
-XX:CompileThreshold 触发 C2 编译的阈值
-XX:OnStackReplacePercentage OSR 相关的回边阈值
-XX:+PrintCompilation 打印编译事件
-XX:+PrintInlining 打印内联决策(diagnostic,需 -XX:+UnlockDiagnosticVMOptions)

35.4 C2 的经典优化

优化 说明
方法内联 其他优化的前提,消除调用开销
公共子表达式消除 (CSE) 重复计算只做一次
数组边界检查消除 (Range Check Elimination) 循环中的边界检查可提到循环外
逃逸分析 为标量替换、分配消除、锁消除提供依据;HotSpot8不等同于通用栈上对象分配
循环优化 循环展开、循环剥离、循环判断外提
分支预测 / 类型推测 基于 profile 投机优化,失败则去优化

记住一条主线:内联是地基,profile 是假设,去优化是兜底。

章节37 / 完整阅读

36. 逃逸分析

判断对象会不会“逃出”当前方法 / 当前线程。

例如:

void foo() {
    Point p = new Point(1, 2);
    int x = p.x + p.y;
}

如果 p 不逃逸,JIT 可能做:

36.1 标量替换

把:

Point(x=1,y=2)

拆成:

int x = 1
int y = 2

对象甚至可能不真正进入 Heap。

36.2 锁消除

如果 JVM 证明某对象只被单线程访问:

synchronized (localObject)

锁可能被消掉。

这也是为什么不能简单说:

“Java 中所有 new 出来的对象一定都在堆里。”

从语言逻辑上对象属于 JVM 管理体系,但 JIT 优化后物理实现可能被标量化。


章节38 / 完整阅读

37. 去优化 Deoptimization

JIT 为了追求性能,会基于运行时 profile 做大胆假设。

例如:

某接口 99.9% 都是实现类 A

JIT 可能高度优化 A 的路径。

突然出现实现类 B:

JIT assumption invalid

JVM 需要退回解释执行 / 较低优化版本。

这叫:

Deoptimization。

所以 JIT 本质上是:

基于运行时假设做投机优化。


章节39 / 完整阅读

38. 常见 OOM 分类

38.1 Java Heap Space

java.lang.OutOfMemoryError: Java heap space

可能原因:

  • 真正内存泄漏
  • 堆确实太小
  • 突发超大数据
  • 大对象过多

38.2 GC Overhead Limit Exceeded

java.lang.OutOfMemoryError: GC overhead limit exceeded

JVM 花大量时间 GC,但只能回收很少内存。

通常意味着 Heap 已接近失控状态。

38.3 Metaspace

OutOfMemoryError: Metaspace

重点排查:

  • 动态代理大量生成 Class
  • CGLIB / ByteBuddy
  • JSP 热部署
  • ClassLoader 泄漏

38.4 Direct Buffer Memory

OutOfMemoryError: Direct buffer memory

重点:

  • NIO DirectByteBuffer
  • Netty DirectByteBuf
  • Cleaner 不及时
  • 引用计数泄漏

38.5 Unable to create new native thread

OutOfMemoryError: unable to create new native thread

不一定是 Heap 不够。

更可能:

  • Thread 太多
  • -Xss 太大
  • OS 进程线程限制
  • Native Memory 不足

章节40 / 完整阅读

39. 高频生产故障:内存泄漏怎么分析?

假设 Old Gen 一直上涨:

20% -> 35% -> 50% -> 65% -> 80%

Full GC 后:

80% -> 75%

很危险。

标准分析链路

图27

Old Gen keeps growing

Check GC Log

Full GC cannot reclaim much

Heap Dump

MAT Dominator Tree

Find high Retained Heap

Path To GC Roots

Find unexpected strong reference

可横向滚动;点“放大查看”查看细节。

常见泄漏源

  • static Map
  • ThreadLocal 未清理
  • Listener 未注销
  • ClassLoader 泄漏
  • Cache 无淘汰
  • Future / Task 队列堆积
  • Thread Pool Queue 无界
  • 大量 Session

章节41 / 完整阅读

40. ThreadLocal 为什么可能内存泄漏?

Thread:

Thread
 └── ThreadLocalMap
      └── Entry
           ├── key = WeakReference<ThreadLocal>
           └── value = strong reference

关键:

key 是弱引用
value 是强引用

当 ThreadLocal 本身被回收:

key = null
value = BigObject

如果线程是线程池长期线程:

Thread 永不结束
 -> ThreadLocalMap 长期存在
 -> value 可能长期存活

所以:

try {
    threadLocal.set(...);
} finally {
    threadLocal.remove();
}

是典型正确用法。


章节42 / 完整阅读

41. HashMap / ConcurrentHashMap 为什么也值得和 JVM 一起准备?

JDK容器不完全属于JVM本身,但理解它们需要结合对象布局、内存可见性与垃圾回收机制。

JDK 8 HashMap

Array
 ├── bucket0 -> linked list
 ├── bucket1 -> red-black tree
 └── bucketN

链表达到一定条件会树化。

典型阈值:

TREEIFY_THRESHOLD = 8
UNTREEIFY_THRESHOLD = 6
MIN_TREEIFY_CAPACITY = 64

JDK 8 ConcurrentHashMap

JDK 7:

Segment + HashEntry

JDK 8:

Node[] + CAS + synchronized + Red-Black Tree

大量操作先 CAS,冲突时锁 bucket 头节点,减少全局锁粒度。


章节43 / 完整阅读

42. 逐层深入的原理问答

下面是最值得重点练的部分。


Q1:JVM 内存区域有哪些?

第一层回答

线程共享:

  • Heap
  • Method Area(HotSpot JDK8 主要用 Metaspace 实现类元数据管理)

线程私有:

  • Program Counter
  • Java VM Stack
  • Native Method Stack

此外还有 Direct Memory、Code Cache 等 Native Memory。

第二层追问:为什么 PC 是线程私有?

因为多线程切换后,每个线程必须知道自己下一条执行到哪里。

第三层追问:Metaspace 在哪里?

Native Memory。


Q2:对象一定分配在 Heap 吗?

标准回答:

语义上对象由 JVM 管理,通常分配在 Heap;但经过逃逸分析、标量替换后,对象可能不会真实以完整对象形式分配在 Heap。

追问:

TLAB 不是 Stack,它仍然属于 Heap 中 Eden 的线程私有分配区域。

这是常见陷阱。


Q3:为什么新生代使用 Copying?

因为新生代大部分对象很快死亡,存活对象少。

复制少量存活对象比整理大量死亡对象更划算。


Q4:为什么 Old 不直接使用 Copying?

老年代对象存活率高。

如果复制算法:

  • 复制成本高
  • 需要巨大备用空间

所以传统老年代更适合 Mark-Sweep / Mark-Compact。


Q5:CMS 为什么低停顿?

因为耗时最长的 Mark / Sweep 大部分和业务线程并发执行。

但不是完全无 STW。


Q6:CMS 的缺点?

必须至少答:

  1. CPU 资源敏感
  2. Floating Garbage
  3. 内存碎片
  4. Concurrent Mode Failure

Q7:G1 为什么比 CMS 更少碎片?

G1 通过 Evacuation 把存活对象复制到新 Region。

复制过程自然完成局部整理。


Q8:G1 为什么不需要扫整个 Heap?

因为:

  • Region
  • Remembered Set
  • Card Table
  • Write Barrier

记录跨 Region 引用。


Q9:GC Roots 包括什么?

至少答:

  • Java Stack 引用
  • static 引用
  • JNI 引用
  • 活跃 Thread
  • Monitor 引用

Q10:Full GC 后内存降不下来怎么办?

答题链:

GC Log
-> Heap Dump
-> MAT Dominator Tree
-> Retained Heap
-> Path To GC Roots
-> 找异常强引用

Q11:System.gc() 一定执行 Full GC 吗?

不应回答“一定”。

System.gc() 本质是一个 GC 建议,请求 JVM 执行显式 GC。

可以通过:

-XX:+DisableExplicitGC

忽略。

不同收集器行为还可能不同。


Q12:为什么 String.intern() 容易和 JVM 内存区域一起问?

因为 JDK 6 / JDK 7+ 的字符串池位置发生过变化。

JDK 8 中字符串常量池在 Heap。


Q13:为什么类卸载很难发生?

要卸载一个 Class,通常要求:

  1. 该类所有实例都不可达
  2. 对应 ClassLoader 不可达
  3. 对应 Class 对象不可达

真正关键往往是:

ClassLoader 能否被回收。

所以应用服务器热部署很容易产生 ClassLoader Leak。


Q14:两个相同类名为什么不能强转?

如果由不同 ClassLoader 加载:

com.foo.User @ LoaderA
!=
com.foo.User @ LoaderB

JVM 认为它们是不同类型。


Q15:volatile 和 synchronized 区别?

volatile:

  • 可见性
  • 有序性
  • 不保证复合操作原子性
  • 不阻塞线程

synchronized:

  • 互斥
  • 可见性
  • happens-before
  • 竞争严重时会阻塞 / 唤醒线程

Q16:CAS 和 synchronized 怎么选?

低竞争、状态简单:CAS 往往更合适。

高竞争 / 临界区复杂:CAS 自旋成本可能非常高,锁反而更稳定。

不要回答:

“CAS 一定比 synchronized 快。”


Q17:为什么 count++ 不是原子操作?

因为:

load
add
store

不是单指令语义。


Q18:为什么 DCL 要 volatile?

防止“对象引用发布”早于“对象初始化完成”的重排序。


Q19:为什么 GC 时要 Safepoint?

因为 JVM 需要在可精确识别对象引用的位置暂停线程。

JIT 可以用 OopMap 告诉 GC 当前哪些位置是对象引用。


Q20:堆没满为什么可能 OOM?

因为 OOM 不只来自 Heap。

可能是:

  • Metaspace
  • Direct Memory
  • Native Thread
  • Code Cache
  • Native Allocation

章节44 / 完整阅读

43. 生产故障分析与自测

场景 1:Young GC 每秒几十次

可能原因:

  • 对象创建速度极高
  • Eden 太小
  • 大量短命临时对象
  • JSON / DTO / byte[] 分配过多

排查:

Allocation Rate
Eden Size
Object Histogram
GC Log

不要第一反应就是“把 Xmx 调大”。


场景 2:Old Gen 持续上涨

可能原因:

  • 真泄漏
  • Cache 无淘汰
  • Promotion Rate 太高
  • Survivor 太小
  • 大对象直接进入 Old

场景 3:Full GC 后 Old 依旧 80%

高概率意味着:

大部分对象仍然可达

Heap Dump 分析是优先级很高的动作。


场景 4:Heap 只用了 40%,进程 RSS 已经 95%

怀疑:

  • Direct Memory
  • Metaspace
  • Thread Stack
  • JNI
  • glibc arena / native fragmentation
  • Netty Pool

场景 5:CMS 突然卡 10 秒

查:

Concurrent Mode Failure?
Promotion Failed?
Full GC?
Old fragmentation?

场景 6:G1 偶发长停顿

看:

  • Humongous Allocation
  • Evacuation Failure
  • Mixed GC 回收集合过大
  • RSet 更新压力
  • Young Region 过多
  • 堆空间余量不足

章节45 / 完整阅读

44. JDK 8 JVM 参数:需要理解的

Heap

-Xms4g
-Xmx4g
-Xmn1g

生产常见让:

Xms = Xmx

目的:减少运行过程中 Heap Resize 带来的不确定性。

但不要机械套参数,要基于容器内存、Native Memory、并发度一起规划。

Thread Stack

-Xss1m

线程很多时,Xss 对 Native Memory 非常重要。

Metaspace

-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

CMS

-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=70
-XX:+UseCMSInitiatingOccupancyOnly

G1

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45

不要把参数学习答成“万能模板”。

进一步分析时应说明:

你为什么这样调,依据是什么,调完看什么指标验证。


44.6 分代比例参数

参数 含义 默认(Server,Parallel)
-Xms / -Xmx 堆初始/最大 ergonomics按内存、架构与模式计算;1/64、1/4只是常见历史量级,不是所有环境固定值
-Xmn 显式指定新生代大小 未指定时按分代比例及自适应策略选择;约1/3只是常见示意
-XX:NewRatio 老年代:新生代 2(即老:新=2:1);自适应策略可调整实际大小
-XX:SurvivorRatio Eden : 单个 Survivor 8(即 8:1:1)
-XX:MaxTenuringThreshold 晋升年龄上限,实际年龄可更低 Parallel/G1常见15,CMS常见6;G1也使用年龄表和晋升阈值
-XX:PretenureSizeThreshold 大对象阈值 0;仅 Serial / ParNew 有效
-XX:TargetSurvivorRatio Survivor 目标使用率,影响动态年龄判定 50

注意冲突:

  • -Xmn 与 -XX:NewRatio 同时设置时,-Xmn 优先。
  • NewRatio划分新/老年代,SurvivorRatio划分新生代内部空间;并非互斥参数。Parallel自适应大小和G1策略会影响最终布局,先观察再调整。

44.7 几个常被误解的参数语义

参数 常见误解 实际
-XX:MetaspaceSize "元空间初始大小" 首次触发元空间 GC 的水位线,之后动态调整
-XX:MaxMetaspaceSize "默认 256M" 默认 -1,即不限制
-XX:MaxDirectMemorySize “限制全部堆外内存” HotSpot8启动参数默认值常为0,JDK运行库通常推导为最大堆量级;主要约束NIO直接缓冲区预留容量,不是全部native内存,NMT也不等于此限额统计
-XX:CompressedClassSpaceSize 无 Class Space 保留上限,默认 1G,打满报 Compressed class space OOM
-XX:InitiatingHeapOccupancyPercent “所有JDK都自动调整” JDK8标准G1常见静态IHOP=45;新版Adaptive IHOP不能直接套用,发行版回移功能需检查实际参数
-XX:ParallelGCThreads 无 并行 GC 线程数;容器中要根据 CPU limit 设置
-XX:ConcGCThreads 无 并发 GC 线程数;由 JVM 按 CPU/收集器计算(CMS 与 G1 公式不同,量级约为 ParallelGCThreads 的 1/4)

章节46 / 完整阅读

45. JVM 参数调优的正确逻辑

错误思路:

GC 慢
-> 网上搜参数
-> 加 20 个 -XX

正确思路:

图28

Observe

Measure

Find Bottleneck

Change One Variable

Load Test

Compare Metrics

可横向滚动;点“放大查看”查看细节。

核心指标:

Throughput
P99 latency
GC pause
Allocation rate
Promotion rate
Old occupancy
CPU
RSS

章节47 / 完整阅读

46. 源码级阅读地图(OpenJDK 8 HotSpot)

不建议从HotSpot全量源码直接开始阅读。

按问题读源码更高效。

问题 建议源码方向
对象分配 CollectedHeap、MemAllocator / 分配相关路径
TLAB threadLocalAllocBuffer.*
对象头 markOop.* / oop.*
类加载 systemDictionary.*、classFileParser.*
CMS concurrentMarkSweepGeneration.*
G1 g1CollectedHeap.*、g1ConcurrentMark.*
Safepoint safepoint.*
Monitor objectMonitor.*、synchronizer.*
Interpreter interpreter.*
C1/C2 c1/、opto/

学习不需要死记源码路径,但如果能把关键数据结构和代码路径讲出来,有助于建立完整理解。


章节48 / 完整阅读

47. 一张图串起整个 JVM

图29

Java Source

javac

Class File

ClassLoader

Metaspace

java.lang.Class in Heap

Java Thread

JVM Stack

Stack Frames

Local Variables

Operand Stack

new

Java Heap

Young

Old

Eden

Survivor

Young GC

CMS

G1 Region

Reachability Analysis

GC

Interpreter

JIT C1/C2

Native Code

Safepoint / OopMap

DirectByteBuffer

Native Direct Memory

可横向滚动;点“放大查看”查看细节。

章节49 / 完整阅读

48. 核心概念速记

JVM 内存

共享:堆 + 方法区
私有:PC + Java 栈 + Native 栈
额外:Direct + Metaspace + CodeCache

对象创建

类检查
-> 分配内存
-> 并发安全 TLAB/CAS
-> 零值
-> 对象头
-> <init>

CMS

初始标记
-> 并发标记
-> 重新标记
-> 并发清除

三个缺点:

CPU
浮动垃圾
碎片

一个灾难:

Concurrent Mode Failure

G1

Region
RSet
Card Table
SATB
Evacuation
Mixed GC
Humongous

排障

GC Log
-> jstat
-> jstack
-> Heap Dump
-> MAT
-> Path To GC Roots

章节50 / 完整阅读

49. 检验理解程度的30道自测题

建议你把下面每题控制在 60~120 秒内回答。

  1. JVM 运行时数据区有哪些?
  2. JDK 8 为什么取消 PermGen?
  3. Stack Frame 有哪些部分?
  4. 对象内存布局是什么?
  5. Mark Word 存什么?
  6. new 一个对象发生什么?
  7. TLAB 是什么?
  8. Class Loading 全过程是什么?
  9. Preparation 和 Initialization 的区别?
  10. 双亲委派为什么存在?
  11. Tomcat 为什么要自己做 ClassLoader?
  12. JVM 怎么判断对象死亡?
  13. GC Roots 有哪些?
  14. 四种 Reference 区别?
  15. 为什么 Young 用 Copying?
  16. 对象什么时候晋升 Old?
  17. Minor / Full GC 区别?
  18. CMS 四阶段?
  19. CMS 为什么有 Floating Garbage?
  20. Concurrent Mode Failure 是什么?
  21. G1 Region 有什么意义?
  22. RSet / Card Table 是什么?
  23. SATB 是什么?
  24. Mixed GC 是什么?
  25. Safepoint 为什么需要?
  26. Full GC 后内存不降怎么排查?
  27. volatile 保证什么?
  28. synchronized 底层是什么?
  29. JIT 为什么能提高性能?
  30. 逃逸分析能做什么?

如果这 30 题能被追问两层还能答,已经建立了较扎实的JVM知识体系。


章节51 / 完整阅读

50. 架构师级别再加 10 题

这 10 个问题更容易区分“背过 JVM”与“实际处理过生产问题”。

1. Heap 只有 50%,为什么进程可能 OOM?

答 Native Memory 全景。

2. 为什么加大 Heap 有时反而让停顿更差?

因为待扫描 / 标记 / Evacuation 的对象集合变大,GC 周期与 pause 可能增加。

3. 为什么对象分配速度比 Heap 大小更重要?

高 Allocation Rate 会直接决定 Young GC 频率。

4. Promotion Rate 过高意味着什么?

大量对象过早进入 Old,会明显增加 Old GC 压力。

5. 为什么 Thread 太多会导致内存问题?

每线程需要 Native Stack,还有线程对象、TLS、调度等成本。

6. 为什么 Heap Dump 本身可能导致生产事故?

大堆 Dump 可能产生:

  • STW
  • 磁盘瞬时写入
  • 大量 I/O
  • 磁盘打满

7. G1 设置 MaxGCPauseMillis 越小越好吗?

不是。

暂停目标太激进可能导致:

  • 更频繁 GC
  • Young Region 变小
  • GC CPU 成本上升
  • 吞吐量下降

8. 为什么 Direct Memory 泄漏 Heap Dump 看不明显?

真正数据在 Native Memory,Heap 中可能只有很小的包装对象 / 引用结构。

9. 为什么 Full GC 次数少也可能性能差?

可能 Young GC 极频繁,或者 TTSP / Reference Processing / JNI 等造成延迟。

10. JVM 调优第一步是什么?

不是改参数。

是:

获取可量化证据,明确问题是吞吐、延迟、内存、CPU 还是 Native Resource。


章节52 / 完整阅读

51. 学习中应避免的常见误解

错误 1

对象都在堆里。

更严谨:JIT 可能通过逃逸分析 / 标量替换消除实际对象分配。

错误 2

Metaspace 就是 JDK 8 的永久代,只是名字不同。

不准确。

Metaspace 使用 Native Memory,实现与 PermGen 差异很大。

错误 3

G1 没有 Stop-The-World。

错误。

G1 仍有大量 STW 阶段。

错误 4

CMS 是并发的,所以没有停顿。

错误。

Initial Mark / Remark 是 STW。

错误 5

volatile 保证线程安全。

错误。

count++ 仍不安全。

错误 6

CAS 一定比锁快。

错误。

高竞争时自旋可能烧 CPU。

错误 7

Full GC 就是老年代 GC。

过度简化。

Full GC 通常涉及更广范围,具体含义需要结合 Collector 与日志语境。

错误 8

Heap 没满就不可能 OOM。

错误。

还有 Metaspace / Direct / Native Thread 等。


章节53 / 完整阅读

52. 快速学习路线

第 1 天:内存 + 对象

目标:

运行时区域
Stack Frame
Object Layout
Object Allocation
TLAB
Metaspace
Direct Memory

第 2 天:GC

目标:

GC Roots
Reference
Mark-Sweep
Copying
Mark-Compact
Young GC
Promotion

第 3 天:CMS

目标:

4 phases
floating garbage
fragmentation
Concurrent Mode Failure

第 4 天:G1

目标:

Region
RSet
Card Table
SATB
Mixed GC
Humongous

第5天:排障阅读

理解工具观察什么,无需执行:

jps
jstat
jstack
jmap
jcmd

阅读场景:

  • 堆OOM与保活
  • 递归栈溢出
  • 交叉锁死锁
  • 直接缓冲区限额

第 6 天:JMM

happens-before
volatile
synchronized
CAS
Thread states

第 7 天:Bytecode + JIT

javap -c
invokevirtual
invokestatic
JIT
inlining
escape analysis
scalar replacement

章节54 / 完整阅读

53. 八个纯阅读场景:现象、原因与结论

不需要运行代码。第73节提供更多逐步推演。

场景 现象 原因与结论
对象持续放进List 堆OOM、回收后空间少 强引用保活,GC不能删除仍可达对象
无终止递归 StackOverflowError 调用深度超过栈容量
生成类并保留加载器 元空间增长 元数据关联加载器生命周期
强持有直接缓冲区 Direct buffer memory 达到直接缓冲区限额
分配大量短命对象 Young GC频繁、Old稳定 高分配不自动等于泄漏
Survivor存活多 晋升加快 年龄、空间与寿命共同影响
两线程交叉锁 无法继续、死锁环 需改变锁协议,GC参数无效
热点局部对象仅做计算 分配可能消除 标量替换不等于完整对象放栈

章节55 / 完整阅读

54. 机制分析与知识梳理方法

碰到 JVM 题,不建议一上来狂背定义。

可以使用这套结构:

1. 先给定义
2. 再讲 JVM 为什么需要它
3. 再讲底层实现
4. 再讲一个故障场景
5. 最后讲怎么验证 / 排查

例如需要分析的问题是:

什么是 CMS?

不要只回答四阶段。

更好的答案结构:

CMS 是 HotSpot 老年代低停顿收集器,核心是让标记和清除大部分时间与业务线程并发。

流程:Initial Mark -> Concurrent Mark -> Remark -> Concurrent Sweep。

它的问题是并发回收期间业务仍在分配,所以会产生 Floating Garbage,并必须提前启动;如果老年代先耗尽,会 Concurrent Mode Failure,退化成长时间 STW Full GC。

另外 CMS Mark-Sweep 会产生碎片,大对象分配可能失败。

生产上我会结合 GC Log 看 CMS 周期、Old Occupancy、Promotion Rate、Concurrent Mode Failure,再判断是启动阈值、对象晋升、大对象还是泄漏问题。

这类回答才更像真正做过 JVM 性能排障的人。


章节56 / 完整阅读

55. 最后一张“知识地图”

图30

JVM Interview

Memory

Heap

Stack

Metaspace

Direct Memory

Object Layout

TLAB

ClassLoading

Loading

Linking

Initialization

Parent Delegation

ClassLoader Isolation

GC

GC Roots

References

Young GC

Promotion

CMS

Floating Garbage

Fragmentation

Concurrent Mode Failure

G1

Region

RSet

Card Table

SATB

Mixed GC

Humongous

Runtime

Safepoint

OopMap

STW

Concurrency

JMM

happens-before

volatile

synchronized

CAS

JIT

Interpreter

C1

C2

Inlining

Escape Analysis

Deoptimization

Troubleshooting

GC Log

jstat

jstack

jmap

Heap Dump

MAT

可横向滚动;点“放大查看”查看细节。

章节57 / 完整阅读

56. 一页纸终极复习版

JVM 内存:
Heap / Stack / PC / Native Stack / Metaspace / Direct Memory

对象:
Class Check -> Allocate -> TLAB/CAS -> Zero -> Header -> <init>

GC:
Reachability + GC Roots
Young = Copying
Old = Mark-Sweep / Mark-Compact
三色标记:白/灰/黑;漏标两条件;CMS=增量更新(写后屏障),G1=SATB(写前屏障)
分配担保:教材风险模型;晋升失败需按收集器区分容量/碎片和实际回退路径
finalize:两次标记 / F-Queue / 对象可自救一次(不推荐)

CMS:
Initial Mark -> Concurrent Mark -> Preclean -> Remark -> Concurrent Sweep -> Reset
问题:CPU / Floating Garbage / Fragmentation / Concurrent Mode Failure

G1:
Region + RSet + Card Table + SATB + Evacuation + Mixed GC
大对象:完整对象大小严格>半Region,Humongous分配需连续Region
JDK8 G1 Full GC = 单线程STW(JDK10 G1 Full GC并行化;不泛化到其他收集器)

动态分派:
vtable (class) / itable (interface);Java = 静态多分派、动态单分派
invokedynamic -> LambdaMetafactory -> Lambda

JMM:
volatile = visibility + ordering, not compound atomicity
synchronized = exclusion + visibility + happens-before
屏障:LoadLoad / StoreStore / LoadStore / StoreLoad
CAS = atomic compare-and-swap, beware ABA / spin cost
JDK8锁优化:偏向 / 轻量级 / 重量级 / 自旋 / 消除 / 粗化 / 批量重偏向;现代版本另看演进

JIT:
Interpreter -> C1 -> C2(分层 0-4)
Inlining / CSE / Range Check Elimination / Escape Analysis / Scalar Replacement / Lock Elimination

排障:
GC Log -> jstat -> jstack -> jcmd -> Heap Dump -> MAT -> Path To GC Roots
NMT查HotSpot跟踪的native分配,结合缓冲池/OS指标补足盲区

章节58 / 完整阅读

57. JDK 版本演进与默认值对照表(防止答旧知识)

版本 关键变化
JDK6u23 压缩普通对象指针、逃逸分析等默认策略的重要版本节点;不要与偏向锁首次默认启用混淆
JDK7 驻留String对象、静态字段关联存储迁移至Java堆;不表示StringTable管理结构全部在堆
JDK8 永久代→元空间;常见Server配置默认Parallel Scavenge+Parallel Old;Lambda通常用invokedynamic;接口default方法不是一律invokedynamic;非常量字符串拼接通常用StringBuilder
JDK 9 G1 成为默认 GC;CMS 标记废弃;模块化;字符串拼接改 invokedynamic;-Xlog 统一日志
JDK 10 G1 Full GC 并行化(JEP 307)
JDK 11 ZGC 实验性;Epsilon;JFR 开源(不再需商用授权)
JDK 12 Shenandoah(JEP 189,部分发行版)
JDK 14 CMS 移除(JEP 363);ZGC 支持 macOS/Windows
JDK 15 偏向锁默认禁用(JEP 374);ZGC/Shenandoah 转正
JDK 16 ZGC 并发栈处理;默认强封装 JDK 内部
JDK 17 密封类
JDK 19 虚拟线程预览(JEP 425)
JDK 21 虚拟线程正式(JEP 444);分代 ZGC

学习提示:

JDK8常见Server环境默认Parallel Scavenge+Parallel Old,CMS/G1一般需显式选择;硬件ergonomics、运行模式和发行版会影响选择,不能把它说成任何JDK8环境都绝对相同。


章节59 / 完整阅读

58. Java 线程模型与虚拟线程(现代加分项)

58.1 JDK 8 的线程模型

  • HotSpot 的 Java 线程一对一映射到操作系统内核线程(1:1)。
  • 优点:某线程阻塞不影响其他线程,能利用多核。
  • 缺点:
    • 创建/销毁/切换成本高(内核态)。
    • 每个线程有本地栈预留/提交开销,容量受-Xss和平台影响(Linux x64常见量级1MiB);不代表创建时全部物理页立即驻留。线程越多,native与调度压力越大。
    • 线程数受 OS 限制,OutOfMemoryError: unable to create new native thread 常由此而来。

58.2 线程调度

  • Java 使用抢占式调度(由 OS 决定),不是协作式。
  • 线程优先级(setPriority)只是建议,跨平台不可靠。

58.3 虚拟线程(JDK21+,独立版本对照)

  • 由 JVM 调度的轻量级线程,M:N 映射到少量平台线程。
  • 适合大量等待的IO并发;数量与内存、任务状态和资源协议有关,不能把“百万级”当固定容量保证。
  • 与 JDK 8 对比:
平台线程 (JDK 8) 虚拟线程 (JDK 21+)
映射 1:1 内核线程 M:N
栈 本地线程栈,容量受平台和-Xss影响 堆内stack chunks,按需增长;运行时可挂载到载体线程
创建成本 高 极低
适用 CPU 密集 / 通用 IO 密集、高并发请求

现代边界:虚拟线程提高大量等待任务的可扩展性,不让CPU密集计算自动变快,也不自动提高数据库连接池容量。JDK21中持有Monitor时阻塞可导致载体线程pinning;JDK24通过JEP491改进Monitor相关行为,仍需区分其他阻塞/native场景。参考Oracle JDK24发布说明。

学习若问"JDK 8 怎么支撑高并发",答案不是虚拟线程,而是线程池 + NIO/Netty + 协程框架(如 Quasar,非标准)。


章节60 / 完整阅读

59. 原生内存与可观测工具补全

59.1 NMT(Native Memory Tracking)

观察HotSpot内部native分配的重要手段;覆盖不完整,不能作为全部native内存账本:

-XX:NativeMemoryTracking=summary     # 或 detail(有性能开销)
jcmd <pid> VM.native_memory summary
jcmd <pid> VM.native_memory baseline       # 建基线
jcmd <pid> VM.native_memory summary.diff   # 看增长

输出分类:Java Heap、Class、Thread、Code、GC、Compiler、Internal、Other、Arena 等。排查"Heap 不高但 RSS 高"时首选。

59.2 jcmd 常用子命令

jcmd <pid> VM.flags                 # 最终生效参数
jcmd <pid> VM.command_line          # 启动命令行
jcmd <pid> VM.system_properties
jcmd <pid> GC.class_histogram       # 类直方图(可代替 jmap -histo)
jcmd <pid> GC.heap_info             # 各代使用量
jcmd <pid> GC.heap_dump /path/x.hprof
jcmd <pid> Thread.print             # 线程栈(可代替 jstack)
jcmd <pid> VM.native_memory summary

jcmd是综合诊断入口,但不替代jstat的所有周期统计;GC.class_histogram、GC.heap_dump等仍可能暂停应用或请求GC。使用同机、同用户、尽量匹配版本的工具,并先执行jcmd PID help检查命令。部分JDK8不提供GC.heap_info/VM.class_hierarchy。

59.3 JFR(Java Flight Recorder)

  • JDK8早期Oracle商业版本常需UnlockCommercialFeatures等;部分较新OpenJDK8u发行版已回移开源JFR。发行版和8u补丁决定可用性,先查jcmd PID help,不能给所有JDK8统一套商业参数。
  • JDK 11+:开源,直接 -XX:StartFlightRecording=duration=60s,filename=rec.jfr 或 jcmd <pid> JFR.start。
  • 可记录GC、锁、分配采样、异常、IO等并配合JMC分析;开销取决于配置、事件和负载,不承诺固定低于1%~2%。
  • 生产环境想低开销持续 profiling,JFR 通常优于 jmap/jstack。

59.4 其他工具

  • async-profiler:低开销采样式 CPU/分配/锁火焰图,现代首选。
  • Arthas:在线诊断(dashboard、thread、heapdump、watch、trace)。
  • Eclipse MAT / JProfiler / YourKit:堆转储深度分析。
  • GCViewer / GCEasy:GC 日志可视化。
  • 注意:jhat 在 JDK 9 已移除,不要再说用 jhat。

59.5 Metaspace / CCS OOM 的正确表述

OutOfMemoryError: Metaspace               -> 元数据区满
OutOfMemoryError: Compressed class space  -> Class Space(默认 1G)满

两者根因通常是动态生成类过多或 ClassLoader 泄漏,排查手段:

-verbose:class
jcmd <pid> VM.class_hierarchy
jcmd <pid> GC.class_histogram
jstat -gc <pid>

章节61 / 完整阅读

60. 从“学习摘要”到“速通全书”:阅读导航

原有第0~59节已经覆盖大部分常见题。继续学习时,最值得补的是对象、类、线程、编译器与收集器之间的因果关系,以及每个结论的适用边界。

本次补充以JDK8 HotSpot为基线。语言规范规定的行为与HotSpot实现细节分别解释;JDK9+只作版本对照,不直接套用到JDK8。这是一份纯阅读手册,不要求运行程序或完成实验。

60.1 全书的五条主线

图31

一个Java程序为什么能正确而高效地运行

内存管理:对象放在哪里、何时回收

执行子系统:类如何进入JVM、字节码如何执行

编译优化:解释执行如何变成机器码

并发:线程如何共享数据、如何同步

实践:如何用证据定位性能与故障

对象生命周期、引用、分代、CMS与G1

Class、类加载、栈帧、调用与异常

javac、JIT、内联、逃逸分析、去优化

JMM、happens-before、Monitor、CAS

GC日志、线程栈、Dump、native内存

可横向滚动;点“放大查看”查看细节。

读图:先记五个问题,再把术语挂到问题下。GC回答“对象什么时候可以消失”,JMM回答“线程如何正确观察对象状态”,JIT回答“怎样在保持语义的前提下省掉操作”。

60.2 对照原书回读的主题

下表按常见第三版组织;第二版可按主题对应。它是阅读导航,不是逐页复述。

原书主题 手册入口 读完应理解
第1章:走近Java 第1、57、60节 JDK、JVM、规范与实现的关系
第2章:内存与OOM 第2~5、24、38~40、62~63节 内存区域、对象大小、不同OOM来源
第3章:GC与分配 第9~20、64~66节 存活判断、分代、晋升与并发正确性
第4~5章:监控与案例 第21~23、43~45、59、72~73节 症状、证据、根因与处置的对应
第6章:Class结构 第32、68节 常量池、描述符、Code与异常表
第7章:类加载 第6~8、67节 生命周期、初始化触发、类型身份
第8~9章:执行与加载实践 第3、33~34、61、67~68节 方法调用、多态、隔离与热部署
第10章:前端编译 第32.3、68、71节 语法糖、擦除、桥接与资源关闭
第11章:后端编译 第35~37、71节 热点、内联、OSR、分配消除
第12章:JMM与线程 第25~28、31、58、69节 可见性、线程模型与同步关系
第13章:线程安全与锁优化 第29~30、70节 同步语义、锁实现与优化层次

章节分组依据出版社图书介绍。以下案例与图解为学习性整理,不复刻原书正文。

60.3 只阅读也能推进的三档路线

时间 阅读顺序 判断是否读懂
3小时 第56→60→61~62→65~67→69→72节 能解释内存、GC、类加载、JMM四条主线
1天 第2~20→32~37→25~30节,结合新增图解 能把定义变成过程,并说明反例
7天 按第52节节奏,每天配读新增对应章节 能在脑中串起对象、类、线程的生命周期

三小时用于建立骨架,不等于掌握所有细节。先理解机制,暂时跳过低频参数与源码文件名;回顾时,再读核心要点与机制总结。

60.4 四层理解:不要求你做实验

  1. 定义:知道RSet、SATB、TLAB是什么。
  2. 过程:知道一条引用赋值、一次分配会触发哪些工作。
  3. 边界:知道什么情况下不能套用,例如G1不照搬CMS分配规则。
  4. 解释:看到一个故障现象,能理解它可能由什么机制产生。

如果一句话只有“提升性能”“保证安全”而没有说明怎么做到,通常还停留在定义层。


章节62 / 完整阅读

61. 一个对象的完整旅程:从源码到回收

61.1 横向执行时间线

图32

成为热点

源码

javac生成Class

加载与链接

主动使用触发类初始化

调用方法、建立逻辑栈帧

分配实例、执行构造链

读取字段、调用实例方法

方法返回

对象可能失去可达路径

适当GC时回收

JIT编译或内联

可横向滚动;点“放大查看”查看细节。

这是一条逻辑链,实际工作可以交错。短程序不一定达到JIT热点条件;方法返回也不意味着其中创建的对象立即死亡,它可能被返回、放进缓存或交给其他线程。

61.2 同一时刻的空间图

假设业务方法中有一个order引用,指向金额为100的订单对象,并计算得到局部结果300。

图33

本地内存

Java堆

当前方法的逻辑栈帧

对象头关联类型

镜像关联

编译结果

局部变量order:引用值

局部变量result:int值300

操作数栈:中间值

订单实例:amount=100

订单类的Class镜像:关联静态字段存储

Klass、方法元数据、运行时常量池

Code Cache:编译机器码

可横向滚动;点“放大查看”查看细节。

读图:

  • 引用是“如何找到对象”的值,不是对象本身。
  • 实例字段通常随每个实例保存;静态字段按类身份关联保存。
  • Class镜像、Klass元数据、实例对象不能混成同一个东西。
  • JVM栈是规范模型;JIT内联后,未必每个源码方法都对应一个独立物理机器栈帧。

61.3 new、<init>与<clinit>

名称 解决的问题 常见误解
new指令 创建尚未完成Java构造的实例 不是构造方法本身
<init> 执行实例构造链与实例初始化 不是静态初始化
<clinit> 汇总静态字段赋值与static块 不会每次new都执行

典型字节码的逻辑是:

new → dup → 准备构造器参数 → invokespecial <init> → 保存实例引用

dup复制引用,使一份用于构造器调用,另一份留下供后续使用。零值初始化先保证实例字段有默认值,再执行Java层构造。

61.4 “作用域结束”不等于“物理内存释放”

源码变量不再使用
≠ 引用已从实际GC根信息中消失
≠ 对象已经不可达
≠ 本次GC覆盖并回收它
≠ JVM把对应物理页归还操作系统

JIT可能提前识别引用不再活跃;解释执行也可能暂时保留旧栈槽。回收堆内空间后,JVM通常可以把它留着供后续分配,而不是立刻还给OS。因此RSS没有立即下降不自动等于泄漏。

空间模型可对照JVMS8数据区与栈帧。


章节63 / 完整阅读

62. 常量池、方法区与堆外内存:把位置和责任分清

62.1 三种经常都叫“常量池”的东西

结构 粒度 作用 不应混成
Class文件常量池 每个Class文件 保存二进制常量、类型/字段/方法描述 堆内Java对象集合
运行时常量池 每个类或接口 支持常量访问、符号解析和链接 全局StringTable
StringTable JVM级驻留管理 按内容复用驻留String实例 全部String对象
图34

ldc解析字符串

调用intern

Class常量池
String与Utf8相关项

运行时常量池

StringTable查找

堆中驻留String实例

运行期intern

普通new String

另一个堆中String实例

可横向滚动;点“放大查看”查看细节。

**JDK7起驻留String对象在堆,JDK8管理表的桶、表项等结构在native内存。**说“字符串常量池在堆”是常见简写,但不能把对象与管理结构都搬进堆。

intern()维护全局驻留关系,不是给某个Class的运行时常量池追加一条Class格式的常量。实现可查HotSpot8 StringTable。

62.2 字符串判断题,只看结果即可

String a = "jvm";
String b = "j" + "vm";
String c = new String("jvm");
String part = "j";
String d = part + "vm";
表达式 结果 原因
a == b true 常量表达式在编译期折叠
a == c false c是新的String实例
a.equals(c) true 内容一致
a == c.intern() true intern返回相同内容的驻留实例
a == d false 此处part不是常量变量,运行期拼接

new String("jvm")不能简单回答“此刻一定创建两个对象”:字面量实例可能已存在;底层数组是否新增还涉及实现。表达式中的新实例、驻留实例、内部数组与这一刻的新增分配数量不是同一口径。

62.3 static final不一定是编译期常量

static final int A = 10;                    // 常量变量
static final String B = "J" + "VM";         // 常量变量
static final Integer C = 10;               // 不是:引用包装类型
static final int D = Integer.parseInt("10");// 不是:非常量表达式
static final int[] E = {1, 2};             // 不是:数组引用

读取A/B通常可用调用方内联值,不主动初始化声明类;C/D/E遵循非编译期常量字段的主动使用规则。final限制重新赋值,不保证对象内部不可变。

62.4 “堆还没满,进程怎么内存不够”

图35

Java进程内存压力

Java堆

Metaspace与Compressed Class Space

线程栈及native线程结构

Code Cache

直接缓冲区与文件映射

GC卡表、RSet、标记位图

JNI、第三方库、分配器开销

可横向滚动;点“放大查看”查看细节。

教学例:容器上限4GiB,Xmx=3GiB不代表可以随便再分配1GiB直接内存。线程、元空间、GC结构和其他native部分也需要预算,实际余量依负载而定。

指标 表示什么 不能等同于
reserved 预留的虚拟地址空间 实际物理占用
committed 已承诺可使用的内存范围 所有页都已驻留
used 某内存池当前使用量 全进程内存
RSS 当前驻留物理页 Java堆使用量或NMT总量

NMT committed、RSS、容器使用量是不同视角,不能直接相加;可能覆盖同一批页。

62.5 直接缓冲区、文件映射与池化的差异

  • allocateDirect:native数据块与堆内包装对象配合。
  • 文件映射:关联文件页、映射和OS管理,与普通native分配不同。
  • 池化:释放一个业务缓冲区可能只是归还池,native块仍留着复用。
  • 引用计数未归零、包装对象仍可达与池保留容量是不同现象。

release()后RSS不降并不自动说明泄漏;持续增长、无法复用且保活链异常才更可疑。可用BufferPoolMXBean理解direct/mapped池的count、capacity、memoryUsed,但它不是全部第三方native分配账本。


章节64 / 完整阅读

63. 对象大小、访问定位与数组:从16字节推导

63.1 先写假设,再算大小

下面假设64位HotSpot8、8字节对齐、压缩普通引用与压缩类指针开启,没有特殊padding。

普通对象浅大小 = 向8对齐(对象头 + 实例字段实际布局)
数组浅大小     = 向8对齐(数组对象头 + 元素数 × 元素宽度)
向8对齐(n)     = ceil(n / 8) × 8
对象 教学计算 常见浅大小
new Object() 12字节头再对齐 16B
new byte[10] 16+10再对齐 32B
new int[10] 16+40 56B
new Object[10] 16+10×4 56B,不含元素指向的对象

实际字段布局可能复用继承中的空隙;不要只按源码字段顺序机械相加。只阅读时记住头、字段、引用、对齐与间接对象这五项即可。

63.2 集合占用为什么容易低估

一个List存一万条订单,不仅有一万条订单实例,还可能有:

List对象
+ 内部引用数组(按容量,不按size)
+ 每个订单实例
+ 订单关联的字符串、数组、其他对象
+ 各对象的头和对齐

共享字符串只应按实际对象计一次;估算“保留多少内存”时还要判断是否被其他路径共享。

63.3 浅大小、保留大小与支配关系

图36

Root1

订单缓存

列表数组

订单1

订单2

共享字典

Root2

可横向滚动;点“放大查看”查看细节。
  • Shallow Size:对象自身。
  • Retained Size:假设它失去可达性,随之一起可以回收的独占保留对象总量。
  • 删除缓存后,列表和订单可以失去可达性;共享字典仍由Root2保活,不属于缓存独占保留部分。

支配关系不是“字段直接引用”:若所有根到某对象的路径都经过A,A才支配该对象。泄漏分析需要同时看对象保留量和具体根路径。

63.4 句柄与直接引用

图37

HotSpot常见直接访问

句柄方案示意

引用

句柄:实例与类型指针

实例数据

类型元数据

引用或压缩引用

对象头与实例字段

Klass元数据

可横向滚动;点“放大查看”查看细节。

句柄让移动对象时可保留稳定的句柄入口,但增加间接访问;HotSpot常见直接引用,移动时需修正相关引用。Java语言不要求引用一定是裸机器地址。

压缩普通引用主要针对对象地址;压缩类指针针对Klass地址,关联CCS。不要把两个开关、两个编码范围混为一谈。

63.5 二维数组不是一整块连续矩阵

图38

int二维数组:三个引用

第0行int数组

第1行int数组

第2行int数组

可横向滚动;点“放大查看”查看细节。

new int[3][4]通常涉及一个外层引用数组和三个内层数组;每行可以换成不同长度数组或null。结构开销、指针追踪和局部性与扁平数组不同,扁平化也需付出下标换算与可读性成本。


章节65 / 完整阅读

64. 引用、类卸载与“活着但没有用了”

64.1 GC不懂业务意图

图39

弱引用key已清除

强引用value

长期活跃线程

ThreadLocalMap

Entry

null

业务已不需要的大对象

可横向滚动;点“放大查看”查看细节。

value仍可达,GC就不能回收它。内存泄漏不是“GC不工作”,而是程序保留了不再需要的对象。ThreadLocal清理与后续访问等有关,不能认为key回收后value立即自动消失。

64.2 引用强度按所有路径判断

可达状态 理解重点
强可达 有普通强路径,GC不回收目标
软可达 GC按策略可清除,不提供稳定TTL/容量
弱可达 相关收集器判定后清除弱引用
幻象可达 回收后期协调,get无法取得对象

若List仍强持有某User,同时存在WeakReference,User仍强可达。**给对象增加一个弱引用,不会削弱其他强引用。**不是任意一次局部GC都会发现和清掉所有弱可达目标。

依据WeakReference与SoftReference。

64.3 JDK8虚引用:入队不等于目标空间已释放

图40

不再普通可达

完成终结相关条件

GC判定幻象可达

虚引用加入队列

消费者处理资源清理

clear或使虚引用失去可达性

目标存储可进一步回收

可横向滚动;点“放大查看”查看细节。

JDK8 API说明:虚引用在该过程中不像软/弱引用那样自动清除,因此“已经入队”和“空间已经释放”必须区分。JDK9+语义有变化,不能套回8。

图是教学流程,不承诺何时GC、何时入队或何时清理。参考JDK8 PhantomReference。

64.4 类卸载与加载器保活

图41

活跃线程或注册设施

旧应用ClassLoader

旧类与Class镜像

静态缓存及相关对象

线程TCCL仍指向旧加载器

可横向滚动;点“放大查看”查看细节。

热部署后,请求已去新应用不代表旧世界可卸载。旧线程、ThreadLocal、驱动/监听器注册或全局缓存仍可能保活旧加载器。

类数量上升不自动等于泄漏,可能是正常懒加载;持续部署后旧ClassLoader数量与元空间基线不断增长才是重要线索。即便无普通实例,Class镜像的强引用也可能保活相应类型体系。

类卸载不是满足几条条件就立刻执行的行为,参考JLS8类卸载。

64.5 GC与资源关闭的责任边界

GC负责托管对象生命周期;文件、连接、native块的及时关闭仍依赖资源协议。Cleaner可兜底,不能代替业务及时close/release。finalize用于理解历史机制,不适合关键资源管理。

try-with-resources使资源在正常或异常退出时关闭;关闭时机不必等GC。因此“对象最终会回收”和“资源及时可用”是两件事。


章节66 / 完整阅读

65. 新生代复制与晋升:把一轮GC画完整

65.1 Survivor的From/To是角色,不是固定名字

图42

仍年轻且To有空间

达到阈值或To不足

Eden对象

Young GC扫描存活对象

本轮From对象

复制到本轮To

晋升Old

清空Eden和旧From
交换From与To角色

可横向滚动;点“放大查看”查看细节。

S0/S1可以交换角色;对象不保证固定先到S0再到S1,更不保证来回十五次。年龄通常在相关Survivor复制路径更新,不是每发生一次任何GC就+1。

65.2 动态年龄算例

假设Survivor容量80MiB,目标使用率50%,目标存活量40MiB:

年龄 本年龄对象大小 累计大小
1 20MiB 20MiB
2 15MiB 35MiB
3 10MiB 45MiB

累计到年龄3超过40MiB,教学模型下动态阈值可降到3,再与最大阈值取较小者。

它不是“年龄1~3全部立刻晋升”。复制时结合实际年龄与空间决定;年龄表计算和阈值使用的时点需按收集器实现理解。降低阈值减轻Survivor压力,也可能提高Old增长速度。

对象寿命、Survivor空间、最大年龄共同影响晋升;不能只背15。

65.3 老到新引用为什么不能漏扫

图43

新增引用

写屏障维护

GC Roots

老年代缓存

新生代对象

脏卡:这里可能有老到新引用

Young GC扫描相关老年代范围

可横向滚动;点“放大查看”查看细节。

Young GC回收年轻区域,但不代表只扫描年轻区域。老对象持有的新对象也必须被发现。卡表是粗粒度索引,不是每条引用的完整精确清单;扫描卡后还要检查具体字段。

GC写屏障维护引用关系,JMM内存屏障约束线程观察顺序。两个“屏障”不要混用。

65.4 收集器要成套理解

JDK8常见组合 Young Old 特点
Serial Serial Serial Old 简单、回收阶段单线程
Parallel Parallel Scavenge Parallel Old 吞吐目标、自适应大小
CMS ParNew CMS Young仍STW,Old部分并发
G1 G1 Young 并发标记、Mixed等 同一收集器管理Region堆

ParNew与Parallel Scavenge不是同一个收集器。不能凭算法名字随便拼接组合;历史支持和弃用状态依版本而异。

65.5 吞吐量、暂停与内存的取舍

教学观察窗口60秒,STW共3秒:

暂停时间占比 = 3 / 60 = 5%
近似应用可运行时间占比 = 95%

这不等于95%CPU都用于业务:并发GC会抢CPU,还有其他系统工作。调优同时看吞吐、P99延迟、CPU、存活集与分配速率。

Parallel的GCTimeRatio=n对应GC时间占比目标约1/(n+1),是目标,不是保证。参考Parallel Collector。

65.6 “大对象直接进老年代”的适用范围

  • Serial/ParNew可有PretenureSizeThreshold相关路径,仍要结合TLAB等实际分配实现。
  • Parallel不能机械套这个参数的Serial行为。
  • G1按完整对象大小与Region阈值走Humongous路径,而不是照搬连续Old的大对象阈值。
  • 对象很大可能因为目标年轻空间放不下而走特殊/失败路径,但不能因此声称所有收集器统一直入Old。

章节67 / 完整阅读

66. CMS与G1补强:并发正确性、RSet与空间失败

66.1 三色漏标的具体过程

初始Root→A→B→C;A已黑、B仍灰、C仍白。

图44

即将删除的旧路径

新增引用

Root

A:黑色

B:灰色

C:白色

可横向滚动;点“放大查看”查看细节。

业务依次新增A→C,删除B→C。若黑对象不再扫描,灰对象被扫描时已失去C,C可能被漏掉。

  • 增量更新补看新增路径;CMS借助脏卡等机制补扫,不是给每个新引用都构造一份完整对象图。
  • SATB记录要删除的旧引用,保住开始快照中的存活对象。
  • 三色是算法抽象,不是每个对象头都有永久颜色字段。

66.2 G1的两个写屏障做不同工作

图45

引用赋值obj.field = newValue

写前屏障:并发标记活跃时记录旧值

SATB队列:维护快照可达性

实际引用写入

写后屏障:记录相关跨Region脏卡

Dirty Card队列

Refinement等处理

更新目标Region的RSet

可横向滚动;点“放大查看”查看细节。

一个保证标记正确,一个帮助局部回收找外部入口。SATB队列、Dirty Card队列和RSet不能混成一张表。部分更新通过异步处理完成,不要求每次写立即完整更新所有索引。

66.3 “开始快照”不是复制堆

G1利用标记位图、屏障与TAMS(Top At Mark Start)等边界区分开始前后的对象。开始后新分配对象在相应本轮标记语境中可视为隐式存活。

低地址                                            高地址
[开始前分配对象][TAMS][开始后分配对象][top][空闲]
    显式标记判断            本轮隐式存活

它不是拷贝所有字段值,也不需要让所有线程整轮停止。SATB允许本轮保留已经变成垃圾的对象,以避免漏掉应存活对象。

66.4 RSet保存“谁从外面可能指向我”

图46

对象引用

对象引用

来源信息

来源信息

Region A的来源卡

Region B中的对象

Region C的来源卡

B的RSet

回收B时扫描相关来源

可横向滚动;点“放大查看”查看细节。

回收B时不必为寻找全部外部引用扫描整个堆,但仍需要扫描根、回收集合里的存活对象及相关辅助信息。RSet只是重要入口索引,不是回收时唯一扫描内容。

66.5 标记完成不等于老年代全部回收

图47

是

否

多次Young GC

Young pause附带Initial Mark

并发标记
期间仍可能Young GC

Remark与Cleanup

回收收益足够吗

若干次Mixed GC

可横向滚动;点“放大查看”查看细节。

并发标记识别存活并评估Old候选;Mixed选择Young加部分Old Region复制回收。Mixed不是Full GC,标记周期也不是持续搬迁所有Old对象。

66.6 Humongous边界算例

假设Region=4MiB,HotSpot8以对齐后的完整对象大小严格大于2MiB判断:

完整对象大小 结果
正好2MiB 不满足严格大于阈值
2MiB+8B Humongous
6MiB 需要2个连续Region,尾部有约2MiB难供普通分配使用

byte[]要加数组头与对齐,不能只拿元素数量比较。

[空][用][空][用][空][用][空]
总共4个空闲Region,但没有两个相邻空闲
→ 仍可能无法满足需要2个连续Region的对象

G1减少普通对象复制后的碎片,但保留Humongous连续空间与尾部浪费问题。加大Region可改变阈值,也会减少Region数量、影响粒度,不能当无代价通用解法。

阈值见HotSpot8 G1CollectedHeap;年龄策略见G1CollectorPolicy。

66.7 四种失败线索分别说明什么

现象 首先理解 不可直接断言
ParNew promotion failed 晋升时拿不到Old空间,可能容量或碎片 不要求正在并发CMS
CMS concurrent mode failure 并发周期未及时完成,常规路径不足以继续 不必然等于泄漏
G1 to-space exhausted 复制目标空间不足,需要失败恢复 不等于每次立刻Full GC
Humongous压力 完整对象大、连续Region不足、尾部浪费 不一定靠调一个参数就解决

CMS需留并发期间增长余量。教学估计:Old增长40MiB/s、周期3s,就要理解约120MiB的期间增长,再留突发晋升余量。它不是精确参数公式。

流程边界参考G1说明与CMS说明。

66.8 重新标记为何仍需要停顿

屏障把并发变化记录下来,不表示后台瞬间处理完全部队列。停止业务修改后,收集器可完成最后的根、缓冲和引用处理,取得可用于后续回收的一致结果。

“并发”意味着某些阶段与业务同跑,不意味着全部阶段无停顿,也不意味着运行时不消耗额外CPU和内存。


章节68 / 完整阅读

67. 类加载补强:懒解析、初始化失败与隔离

67.1 类生命周期不是所有工作完全串行

图48

可较早解析

可按需解析

Loading

Verification

Preparation

Initialization

Using

Resolution

可横向滚动;点“放大查看”查看细节。

加载、验证、准备、初始化存在先后约束,但解析可按需延迟,不能说所有符号都必须解析完成才允许初始化。

67.2 准备阶段的常量:教材口径与规范口径

常见教材将static final int X=123通过ConstantValue直接赋值归入准备阶段;HotSpot8实现有相应链接处理。

严格JVMS8的5.4.2将准备描述为默认值设置,5.5把ConstantValue字段赋值放入初始化步骤。因此:

普通static赋值由初始化逻辑执行;编译期常量可依靠ConstantValue,无需在clinit里生成对应赋值。具体阶段名称应注明讨论的是规范描述还是HotSpot/教材实现划分。

这比绝对背“所有JVM的准备阶段都赋常量123”更稳妥。

67.3 初始化失败不会下次自动重试

图49

成功

异常

首次主动使用

执行类初始化

已初始化

标记初始化错误状态

后续使用同一类身份
NoClassDefFoundError

可横向滚动;点“放大查看”查看细节。

首次失败若抛出非Error异常,通常包装成ExceptionInInitializerError;若已是Error,传播规则不同。后续可能看到Could not initialize class。

**NoClassDefFoundError不只说明缺jar,还可能说明此前初始化失败。**同一个类身份不会自动重新跑static块恢复;使用新的加载器属于另一类身份。

67.4 初始化触发矩阵

操作 是否主动初始化目标类
Type.class 不因类字面量而初始化
声明类型变量 不因声明本身而初始化
new Type[10] 不初始化组件Type
Class.forName(name) 默认请求初始化
Class.forName(name,false,loader) 不请求初始化
常规loader.loadClass(name) 加载,不主动初始化
读取编译期常量 通常被内联,不主动初始化
非常量static字段读写 初始化实际声明字段的类/接口
静态方法调用、new实例 主动使用触发

自定义加载器可主动做额外操作,表中loadClass指正常加载策略,不是对任何用户重写的限制。

67.5 JDK8 default父接口的例外

“初始化类不初始化接口”说得过头:

  • 初始化类时,先初始化其父类。
  • 还需初始化相关、声明default方法的父接口。
  • 没有default方法的接口不只因被实现就必定初始化。
  • 初始化一个接口本身,不会自动初始化全部父接口。

举例:Child继承Parent,实现Plain与WithDefault。Child初始化时Parent先完成,WithDefault因default规则初始化;Plain可直到读其非编译期常量字段才初始化。

触发条件依据JLS8初始化规则。

67.6 类初始化也是线程协调机制

多个线程首次主动使用同一类,JVM协调初始化,不允许它们各自无序同时跑一次static初始化。成功完成的初始化还支持后续使用者获得正确的类静态初始状态。

初始化过程中同线程递归请求可能看到尚未完成赋值的默认值;不同类互相等待初始化可能死锁。在static初始化中做复杂外部IO、等待线程或交叉依赖,风险不只是不够快。

依据JVMS8初始化过程。

67.7 双亲委派是策略,类型身份才是底层约束

  • 常规loadClass先检查已加载类,再委派父加载器,失败后findClass。
  • 自定义加载器通常重写findClass保留惯用流程。
  • 类型身份依赖定义加载器,不是“最先发出请求的加载器”。
  • Bootstrap在Java API中常用null表示,不代表没有加载器语义。
  • 运行时包身份还包含定义加载器,同包名不自动拥有包级访问权。
  • TCCL让库代码借应用指定的加载器寻找实现,不是把核心接口重新交给子加载器定义。
  • Tomcat的应用优先策略存在平台/API类等例外,不是所有类无条件child-first。

依据ClassLoader API。

67.8 热部署的两个世界

图50

不能直接当v2强转

旧加载器

同名类v1

新加载器

同名类v2

旧实例

新实例

共同父加载器定义的接口

可横向滚动;点“放大查看”查看细节。

新class不会自动改变旧实例的类型。跨边界调用常用共同父加载器定义的稳定接口,传值需考虑转换与生命周期。

Instrumentation重定义/重转换和更换加载器是不同机制;标准JDK8重定义有结构限制,不承诺任意新增字段、改继承体系都可无停机热换。


章节69 / 完整阅读

68. 字节码补强:签名、分派、语法糖与异常

68.1 名称、描述符、泛型签名不是一回事

方法String convert(int number,String[] names)的描述符为:

(I[Ljava/lang/String;)Ljava/lang/String;
编码 含义
I/J/F/D int/long/float/double
Z/B/C/S boolean/byte/char/short
V void,仅返回类型
L路径/类名; 引用类型
[ 数组维度
(参数)返回类型 方法描述符

描述符用于链接,Signature保存泛型声明信息。Java8常见Class主版本52;Class格式是大端结构。依据JVMS8类文件格式。

68.2 一次加法的操作数栈

实例方法int add(int a,int b):

局部变量表:slot0=this,slot1=a,slot2=b

指令       执行后栈(左底右顶)
iload_1    [a]
iload_2    [a,b]
iadd       [a+b]
ireturn    [],结果交回调用方

静态方法没有this,参数从slot0开始。max_locals与max_stack是字节码栈帧容量信息,不是-Xss。long/double占两个规范槽,不代表任何CPU都用两个寄存器。

68.3 重载先定签名,重写再定实现

假设Speaker有speak(Animal)与speak(Dog),LoudSpeaker仅重写前者。调用方变量:

Animal dog = new Dog();
Speaker speaker = new LoudSpeaker();
speaker.speak(dog);

执行的是LoudSpeaker的Animal参数版本。

图51

编译期看静态类型

选speak的Animal参数签名

运行期看接收者LoudSpeaker

选该签名的重写实现

可横向滚动;点“放大查看”查看细节。

dog实际是Dog不会重新选择Dog重载。JIT可去虚拟化、内联,但必须保留这套语义。

68.4 泛型擦除不等于声明信息全消失

例如Box<T>.get()与实现TextBox.get():String:

  • 擦除后的接口调用按Object返回描述符链接。
  • 实现类生成桥接入口,转调String返回的方法。
  • 调用点必要时插入checkcast。
  • Signature可保留声明信息供反射读取。
  • List实例不会因此自动携带可检查所有元素的完整泛型类型参数。

协变返回也可生成桥接;第32节的非泛型例子不能解释成泛型擦除。

68.5 finally返回的三个边界

代码意图 结果机制
try返回基本值,finally改局部值 先保存返回值,修改局部值通常不改已保存结果
try返回对象引用,finally改对象字段 引用仍指同一对象,调用者可见字段修改
finally自己return或throw 可能覆盖原返回或异常

finally不是在任何情况下都必定执行,例如进程直接终止、崩溃等;讨论时要限定正常Java控制流。

68.6 try-with-resources如何保留主异常

图52

正常

主异常E1

关闭异常E2

关闭成功

执行资源体

关闭资源

保存E1

仍尝试关闭

添加E2为suppressed

抛出E1

可横向滚动;点“放大查看”查看细节。

多个资源按初始化逆序关闭。业务异常先出现、关闭又异常时,业务异常仍为主异常;关闭异常可从suppressed中查看。它比手写finally中直接throw更能保留现场。

68.7 Lambda的三层实现

  1. 编译期生成合成目标方法等结构和invokedynamic调用点。
  2. 首次链接由metafactory关联函数式接口工厂/调用目标。
  3. 创建或复用实例,JIT随后可能内联优化。

Lambda中的this沿用外层词法作用域;捕获局部变量要求final或effectively final。default接口方法与Lambda不是同一套“都用invokedynamic”的结论。

不捕获状态的Lambda实例可能复用,不能依赖对象身份或每次都产生新实例。依据LambdaMetafactory。


章节70 / 完整阅读

69. JMM补强:用可见性链代替“刷新主内存”

69.1 三个层次

你在问什么 使用哪层知识
对象放哪、线程栈多大 数据区与HotSpot实现
A写入后B能否看见 JMM与同步关系
某屏障如何落实 CPU指令、编译器映射、缓存体系

堆共享不等于字段自动同步;局部引用在线程栈,不等于所指对象不共享。

69.2 一次volatile发布的完整证明

int payload;
volatile boolean ready;

// 生产者,一轮发布
payload = 42; // W1
ready = true; // W2

// 消费者
if (ready) {  // R1,观察到此次发布
    use(payload); // R2
}

这只是两线程协议片段,use代表消费动作。

图53

程序顺序

volatile同步边

程序顺序

传递性保证可见

W1写payload

W2写ready

R1读ready

R2读payload

可横向滚动;点“放大查看”查看细节。

前提:同一对象、一轮发布,无其他线程并发覆盖payload。多轮复用boolean可能无法区分数据属于哪一轮,应有更完整协议。

不要只说“volatile把所有变量刷到主内存”;指出哪次读写和哪条同步边才是有效解释。

69.3 三个常见错误推论

  • volatile int[]:数组引用读写有volatile语义,不自动使每个元素如此。
  • volatile Map:发布Map引用不自动使并发增删线程安全。
  • volatile count++:读、计算、写复合操作仍可能丢更新。

单变量原子操作也不自动维护多个字段组成的业务不变量。例如库存与冻结量要保持关系,单独使用两个AtomicInteger仍需整体协议。

69.4 时间先后不是同步关系

A看起来先执行完 ≠ 已建立happens-before
sleep一秒        ≠ 发布协议
打印后现象消失   ≠ 原代码线程安全

正确同步可获得JMM的顺序一致性保障,不保证线程调度顺序或业务事务原子性,更不保证无死锁。

69.5 final不是深度不可变

final class Config {
    final int port;
    final int[] limits;
    Config() {
        port = 8080;
        limits = new int[] {100};
    }
}

正确构造且this不提前逸出时,final字段及相关构造期状态具有特殊可见性规则。之后修改limits[0]仍需同步;final只是不能重新把limits指向另一数组。

构造中把this放进全局变量、注册外部回调等,可能破坏正确构造前提。final不是“构造写happens-before任何对象引用读取”的通用规则。

依据JLS8线程与final字段语义。

69.6 屏障方向不要背反

发布侧:普通写 → release约束 → volatile写
获取侧:volatile读 → acquire约束 → 后续普通读写

LoadLoad/LoadStore作为教学约束位于volatile读之后;不同架构和JIT可能用不同指令或消除冗余屏障。GC写屏障也不是这些内存顺序屏障。


章节71 / 完整阅读

70. Monitor与wait/notify:把语义、实现和优化分层

70.1 同步到底锁了谁

形式 锁对象
实例同步方法 this
静态同步方法 声明类的Class镜像
synchronized(obj) 实际传入的obj

两个实例的this不同;两个加载器定义的同名类镜像也不同。锁不同却操作同一共享资源,不能产生期待的互斥。

70.2 竞争入口与Wait Set

图54

成功

wait释放指定Monitor

被通知或其他唤醒原因

退出或释放

尝试获取锁

Monitor竞争入口

Owner执行同步代码

Wait Set等待条件

可横向滚动;点“放大查看”查看细节。

概念图不保证FIFO;HotSpot内部还有其他队列结构。

  • wait要求已持有该对象Monitor,否则异常。
  • wait释放的是这个Monitor,不是持有的所有其他锁。
  • notify不立即交出锁;被通知者还要重新竞争。
  • synchronized不保证公平。

70.3 为什么等待条件必须用while

synchronized (lock) {
    while (!ready) {
        lock.wait();
    }
    useData();
}

可能虚假唤醒,也可能醒来后条件被其他线程改变。通知表示“值得重新检查”,不表示“条件永远成立”。

检查条件、更新条件与通知使用一致的锁和协议,才能避免漏通知等错误。依据Object.wait/notify契约。

70.4 sleep、wait、park、join对照

操作 先持对象Monitor 自动释放此Monitor 主要目的
sleep 不需要 不释放用户持有的锁 定时暂停
wait 需要 释放指定Monitor 条件等待
park 不需要 不释放用户持有的锁 permit式阻塞
join 调用者无需手工持锁 不释放调用者其他锁 等待目标线程结束

park/unpark的permit不是可累计计数信号量;仍需检查条件和中断。这属于并发工具扩展,不是字节码指令。

70.5 锁优化的三个层次

  • 语义:互斥、可重入、可见性。
  • 运行时实现:偏向、轻量级、Monitor膨胀等。
  • JIT优化:锁消除与锁粗化。

局部锁可消除不代表共享资源保护可随意删除。偏向撤销、Monitor维护等也使“所有锁只能按四级单向升级、永不恢复”成为过度简化;要注明讨论竞争过程还是后续维护。

70.6 Java线程状态不等同OS状态

图55

start

竞争Monitor

获得锁

无期限等待

等待完成可继续

定时等待

等待完成

run结束

NEW

RUNNABLE

BLOCKED

WAITING

TIMED_WAITING

TERMINATED

可横向滚动;点“放大查看”查看细节。

图简化了wait唤醒后可能先进入BLOCKED重新竞争的过程。RUNNABLE不保证此刻占CPU;AQS等待常显示WAITING,BLOCKED主要对应Monitor竞争。


章节72 / 完整阅读

71. 编译优化补强:javac、OSR、内联与去优化

71.1 前端与后端不是同一个编译过程

图56

Java源码

javac:语法、类型检查与语法糖转换

Class字节码

解释器执行与profile

C1与C2生成机器码

Code Cache

可横向滚动;点“放大查看”查看细节。

javac通常不依据线上热度做激进机器级优化;JIT可得到运行期类型、分支等信息。注解处理器处理源码模型/生成代码,不是ClassLoader,也不是JIT。

JVM规范描述字节码语义,不要求所有实现必须先解释、再用C1/C2;这里说的是HotSpot常见运行方式。

71.2 为什么解释器也要采集profile

分层编译0层不等于“无profile”。解释器和部分C1层采集计数、类型、分支信息,C2利用这些信息生成更优化代码。

方法不必严格经过每一层;队列、复杂度、调用/回边计数等都影响决策。CompileThreshold在分层模式下不能简单解释成“执行固定次数必立刻C2”。

71.3 OSR:只调用一次的方法也能很热

一个方法执行巨大循环,只调用一次,方法调用计数不高但循环回边很热。OSR让当前执行从解释状态转到某个字节码位置对应的编译代码。

图57

解释执行循环

回边成为热点

编译OSR入口

转换当前局部与栈状态

从循环中途进入机器码

可横向滚动;点“放大查看”查看细节。

它不用等整个方法返回才加速。编译日志中的%常提示OSR,@后常给入口bci;这是读日志知识,不要求运行实验。

71.4 内联为何带来连锁优化

消除方法调用边界
→ 看见参数、类型和控制流
→ 常量传播、分支简化
→ 发现对象不逃逸
→ 消除某些分配和局部锁

短方法不保证内联,热度、调用点类型多样性、编译预算等也重要。接口调用常见一个类型时可推测内联;新类型出现后仍必须维持多态语义。

71.5 标量替换与栈上分配彻底分开

图58

可消除

不能消除

去优化需要时

源码new Point

逃逸分析与其他优化条件

拆为x、y等标量

寄存器、栈槽或直接常量结果

通常按常规路径分配堆对象

重新物化对象

可横向滚动;点“放大查看”查看细节。

HotSpot8 C2重点是消除完整对象分配,不提供通用的完整对象栈分配。即便对象不逃逸,复杂控制流、未内联调用等也可能阻碍优化。

去优化需要时,JVM可以依据记录重新物化对象,恢复解释执行需要的状态。依据HotSpot性能增强说明。

71.6 去优化是正确性保障

机器码可能内联了调用、合并了变量、消除了对象。优化假设失效时,JVM需要恢复相应局部变量、操作数栈和必要对象,以继续按字节码语义执行。

常见线索是类型变化、类层次变化使依赖失效、uncommon trap等。去优化不是自动等于JVM故障,但频繁重编译/失效可能增加开销。

71.7 Code Cache满为什么Xmx帮不上忙

Code Cache存机器码及相关结构,独立于Java堆。空间不足可能让编译活动受限,应用继续运行却不能得到预期热点优化。

它与Java heap space不是同类问题。定位需要Code Cache统计、编译警告与编译活动,而不是无条件增大Xmx。

71.8 性能结果为什么不能只凭一次计时

常见现象 容易误判的原因
某段代码几乎零耗时 无用结果可能被消除
常量输入跑得极快 可能被折叠成常量
前几次慢、之后快 编译、加载、缓存等预热
第二方案总比第一方案快 测试顺序与profile相互污染
关闭逃逸分析后GC变多 支持分配优化推理,但不是独立充分证明

你不需要自己做基准,只要理解:语义正确、物理执行、测量结果是三层证据。看网上跑分先核对JDK、参数、预热、结果使用和负载。


章节73 / 完整阅读

72. 故障阅读方法:先看证据能说明什么

72.1 从Young GC日志推算晋升量

第21节示意:

Young:65536K → 8192K
Heap :200000K → 145000K

在传统分代、且本次无Old回收等干扰的假设下:

Old_before = 200000 - 65536 = 134464K
Old_after  = 145000 - 8192  = 136808K
推算晋升量 = 2344K

Young减少 = 57344K
Heap净减少 = 55000K
差额2344K没有消失,而是到了Old

G1的Region角色变化、Humongous回收及并发过程不能机械套此简化式。目的在于理解“年轻区减少”不完全等于“内存释放”。

72.2 分配速率与晋升速率算例

假设相邻Young GC间隔2秒,Eden从近0增长到160MiB,本次晋升20MiB:

Eden分配速率近似 = 160 / 2 = 80MiB/s
晋升速率近似     = 20 / 2  = 10MiB/s

这是教学近似,可能遗漏TLAB浪费、直入Old等。

趋势 更可能需要理解
分配高、存活低 短命对象、临时字符串、大量装箱
晋升高 Survivor压力、对象寿命、突发保留
回收后Old基线持续升 长期存活集或异常保活
Full GC降很多却很快再涨 分配/晋升压力,不自动等于泄漏

72.3 暂停总时间与GC本体时间

图59

收到VM操作请求

等待线程进入安全状态:TTSP

安全点内执行GC或其他操作

恢复线程

可横向滚动;点“放大查看”查看细节。

若TTSP=900ms,GC=40ms,只用40ms解释业务长暂停会遗漏主要原因。TTSP小、复制时间很长,则更应看存活量、带宽与并行资源。

普通native状态线程、安全点协议与JNI临界区GC Locker是不同机制;长JNI调用不自动等于安全点等待长。

72.4 统计列看增量,不只背缩写

相隔10秒的两次采样:

A:YGC=100,YGCT=2.000,FGC=2,FGCT=1.200
B:YGC=120,YGCT=2.500,FGC=2,FGCT=1.200

说明10秒内新增20次Young GC、累计时间增加0.5秒,平均约25ms/次。平均不能体现最慢一次与P99;CMS等收集器的计数/时间含义还需结合日志语境。

72.5 由症状选证据

图60

CPU高

CPU低但延迟高

堆回收后仍高

RSS高而堆正常

长暂停

观察到异常

主要症状

线程CPU与采样:热点、GC或自旋

线程栈:锁、IO、连接池与队列

对象保留量与根路径

NMT、缓冲池、线程与OS映射

GC明细与Safepoint:TTSP还是操作本体

可横向滚动;点“放大查看”查看细节。

单次栈只是瞬时状态;反复出现的热点或等待链更有解释价值。不要把一个RUNNABLE就当“必定吃CPU”,也不要把一个WAITING就当“死锁”。

72.6 工具命令也是有边界的

  • jcmd是入口,实际影响取决于所执行的操作。
  • 类直方图、Dump可能有明显停顿或请求GC,换入口不消除代价。
  • JDK8不保证拥有新版GC.heap_info、VM.class_hierarchy等命令。
  • top -H -p是Linux常见用法,不是macOS通用命令。
  • nid与OS线程id关联方式依平台而异。
  • NMT通常需启动时开启,不能事后完整补回之前的账。

你只阅读时,记住“看什么证据、它能证明什么”即可,不需要执行命令。工具列表依据JDK8 jcmd说明。

72.7 NMT没有增长,不等于没有native泄漏

NMT               → HotSpot内部跟踪视角
BufferPoolMXBean  → 特定JDK缓冲池视角
RSS与OS映射       → 进程驻留与映射视角
第三方池统计       → 对应库自己的管理视角

RSS增长而NMT没有同幅增长,可能涉及未覆盖分配、文件映射或分配器行为。不能直接宣布“没有泄漏”。

JDK8官方明确有覆盖盲区,参考NMT说明。

72.8 可直接阅读和口述的排障闭环

先明确异常窗口、流量、发布和资源上限,用GC日志区分分配、晋升、停顿与回收后存活基线。保活问题看对象保留量及根路径;堆正常但进程内存高,结合native、线程和缓冲池证据。根据证据提出原因,修改相关因素,再用相同口径比较资源趋势和请求延迟。

这里是方法论和教学推演,不表示你本人曾取得某个未经提供的优化成绩。


章节74 / 完整阅读

73. 纯阅读推演:看结果、理解机制,不做实验

以下均是按明确假设展开的教学案例,数字用于解释机制,不是本机或生产实测。

73.1 短命分配与持续保留

背景:32MiB堆,累计分配125MiB字节数组。

  • 情况A:每次只保留最新数组,旧数组可失去可达性。GC可反复复用空间,累计分配大于堆容量不代表OOM。
  • 情况B:全部放进List强持有。存活集超过堆容量,GC再勤快也无法删除这些仍可达对象。

结论:判断OOM看峰值存活需求,而不是累计分配量。情况B是否叫泄漏,还需看业务是否本来要求保留。

73.2 堆还有余量,直接内存先达到限额

背景:Xmx=64MiB,直接缓冲区限额8MiB,连续强持有1MiB直接缓冲区。

native容量达到限额后可出现Direct buffer memory;堆中仅有包装对象,堆不一定很满。GC不能清理仍被List强持有的缓冲区。

结论:不同资源有不同上限;MaxDirectMemorySize不是全部native内存上限。机制可查JDK8 Bits。

73.3 初始化第一次与第二次失败不同

背景:static初始化调用配置解析,抛IllegalStateException。

第一次主动使用 → ExceptionInInitializerError
同类身份再次使用 → NoClassDefFoundError

结论:第二次只查缺jar会走错方向,要理解首次失败与错误状态的关系。

73.4 类字面量、父字段与子类初始化

背景:Child继承Parent,双方static块各输出自己的名称。

动作 教学结果
取Child.class 不因该动作运行Child静态初始化
创建Child数组 不主动初始化Child组件类
读Child引用的Parent非常量静态字段 初始化实际声明者Parent
再new Child 完成Child所需初始化,Parent不重复

结论:看实际声明者和使用动作,不只看源码写哪个类名。

73.5 两个加载器,同名仍不是同类

背景:L1/L2各自定义同一份User.class。

二进制名称相同:是
Class身份相同:否
L1实例直接强转成L2的User:失败

结论:隔离来自定义加载器边界;共享接口由双方共同可见的加载器定义。

73.6 finally改值,为什么返回仍是旧值

背景:局部int n=1,try返回n,finally把n=2。

先计算返回表达式并保存1
→ 执行finally把局部n改为2
→ 返回先前保存的1

若返回引用,再修改对象字段,调用者会看到同一对象的改变。若finally自己return,则会覆盖原结果。

73.7 交叉锁为什么形成死锁

图61

持有

被线程2等待

持有

被线程1等待

线程1

锁A

线程2

锁B

可横向滚动;点“放大查看”查看细节。

线程1持A等B,线程2持B等A,形成循环。GC、Xmx和volatile都不能解除锁协议问题。统一锁顺序可破坏循环等待,也可设计适当超时/回退。

73.8 Full GC后Old仍高不必定是泄漏

背景:缓存合法保留2GiB数据,堆4GiB,Full GC后Old约2GiB。

这是合理存活集的一种可能。若缓存无边界、过期对象强持有、基线持续升,则更可疑。

结论:需要业务解释、增长趋势和保活证据,不能只看百分比。

73.9 复制目标不足但总堆还没到100%

背景:G1复制暂停需要空Region容纳存活对象,但目标空间不足。

源Region暂时存着旧对象,目标空间又要容纳新副本;剩余空间百分比不等于足够完成此次疏散。Humongous还可能要求连续Region。

结论:算法有临时空间与布局约束,不能只看全堆利用率。

73.10 对象消除后为何还能正确运行

背景:Point仅用于计算x+y,无外部可观察身份。

JIT可能只保留x/y,甚至折叠计算;需要去优化时可重新物化必要对象。

结论:源码对象不保证每次保留完整堆对象,但也不是把完整Point搬到栈。


章节75 / 完整阅读

74. 反例与口述答案:收束全书

74.1 二十个值得记住的反例

问题 正确理解 对应节
1. 堆共享,字段就一定可见吗 共享地址不等于同步关系 69
2. 局部变量里的对象一定私有吗 引用局部,对象可共享 61、69
3. TLAB对象不能跨线程吗 TLAB只隔离分配,仍在堆 5
4. 不逃逸必栈上分配吗 可消除分配,但不保证成功 71
5. 加弱引用就下一次GC回收吗 需无强路径及相关判定 64
6. intern字符串永不回收吗 驻留不是永久强根 62、64
7. GC释放空间RSS必降吗 堆与池可保留内存 62
8. Young减少就是等量释放吗 一部分可能晋升 72
9. G1标记结束就是Old回收完吗 标记与Mixed不同 66
10. G1完全无碎片吗 连续Region与尾部浪费仍在 66
11. 写前/写后屏障同一职责吗 SATB与RSet不同 66
12. 活不到15轮一定不晋升吗 动态阈值、空间影响 65
13. NoClassDefFoundError必缺jar吗 可因此前初始化失败 67
14. 发起加载者就是定义者吗 两角色可不同 67
15. default都用invokedynamic吗 与Lambda不同 68
16. Dog实参会重选Dog重载吗 静态类型先选签名 68
17. volatile数组元素都安全吗 约束引用,不自动约束元素 69
18. notify后立即执行吗 还需竞争与复查 70
19. RUNNABLE一定吃CPU吗 状态不是利用率 70、72
20. jcmd Dump无暂停吗 影响取决于操作 72

74.2 六段可直接口述的答案

内存与OOM:规范区分堆、方法区、程序计数器与各线程栈。HotSpot8用元空间保存类元数据,堆里有实例、数组和Class镜像。进程还包含直接内存、Code Cache与GC结构,所以堆未满也可因其他资源失败。先看异常种类与资源边界,再找对应证据。

对象创建:主动使用可能先触发类加载、链接和初始化。实例创建分配空间、保证零值、设置对象头并完成构造链。TLAB减少竞争,仍在堆。JIT可消除分配,所以源码new不保证对应完整物理对象。

GC与分代:GC从根遍历对象图,不懂业务是否还需要缓存。年轻对象存活少,复制回收常较经济;Survivor、年龄和目标空间共同影响晋升。老到新引用由卡表等支持扫描,Young GC不等于完全不看Old。

CMS与G1:CMS并发标记清除Old,降低部分停顿但有浮动垃圾、碎片和周期余量要求。G1按Region组织堆,用SATB保证标记、RSet支持局部回收,通过Young/Mixed疏散对象。仍有STW,暂停目标是软目标。

类加载与调用:加载建立类型表示,链接处理验证、准备与符号引用,初始化建立静态状态。身份由名称与定义加载器决定。重载签名由静态类型选择,再按接收者实际类型选重写实现,JIT优化不能改变语义。

JMM与优化:用happens-before证明可见性。volatile可发布前面的普通写,但复合计数和多字段关系仍需同步。锁提供互斥与可见性;JIT可内联、消除分配和局部锁,假设失效后去优化恢复正确执行。

74.3 贯穿全书的理解链

图62

保持语义

可能消除分配

Class提供类型与字节码

加载建立类型身份

初始化建立初始状态

线程执行:栈帧、引用与调用

共享数据:JMM与同步

对象分配形成存活图

GC按可达性和空间策略回收

JIT依据profile优化

性能与故障现象

日志、线程栈与内存证据

可横向滚动;点“放大查看”查看细节。

共同目标是类型与执行合法、并发观察正确、资源可管理,并在这些约束下提高效率。


章节76 / 完整阅读

75. 修订依据与版本使用说明

75.1 本次重点修正

原有易误导说法 修正方向
StringTable整个在堆 区分对象与native表
intern追加每类常量池 区分全局驻留与每类池
标量替换等于栈上分配 解释分配消除、物化
父类字段必全部在前 注明空隙复用与实现约束
TLAB剩余判断方向 较多时保留,少量时退役
volatile读前LoadLoad 读后acquire约束
final写HB所有引用读 freeze与正确构造规则
G1晋升年龄参数无效 G1也用年龄表
Humongous大于等于半Region 完整对象严格大于
G1失败立即Full GC 区分线索与回退条件
IHOP所有8都自适应 区分标准8与回移
Lambda/default都用invokedynamic 区分两种调用机制
解释器不采profile 修正分层职责
所有8 JFR需商业参数 注明发行版、补丁
NMT完整覆盖native 标注覆盖盲区
jcmd天然无停顿 按操作理解影响

相关正文同步修订;未运行Java实验,原实验清单改为纯阅读案例。

75.2 第一手核验资料

类型 资料 内容
规范 JVMS8结构 数据区与栈帧
规范 JVMS8类文件 常量池、Code
规范 JVMS8加载初始化 身份、阶段、失败
规范 JLS8执行 触发与父接口
规范 JLS8线程锁 HB与final
API ClassLoader 定义与委派
API PhantomReference 8的特殊语义
API Object wait/notify
指南 GC ergonomics 默认选择
指南 代大小 容量取舍
指南 G1调优 Region与停顿
指南 NMT 覆盖限制
指南 HotSpot增强 分层、逃逸
源码 TLAB 分配缓冲区
源码 HotSpot参数 类型与初值

规范回答必须怎样,源码回答这个实现如何做。参数最终值还受CPU、模式、发行版和8u补丁影响。jdk8u/master持续更新,查历史需对应实际tag/commit。

75.3 阅读格式

MD保留Mermaid图。另附离线HTML,将图预渲染为内嵌SVG,不依赖CDN;可用目录、全文搜索和图放大阅读。

MD是编辑入口,HTML是对应阅读版,修改MD后需重新生成HTML。数字案例为教学推演,不表示测得了某个性能结果。

结语

《深入理解 Java 虚拟机》真正值得学习的,不是记住某个定义,而是建立下面这条因果链:

代码创建对象
↓
对象如何分配
↓
对象存在哪里
↓
什么时候变成垃圾
↓
GC 如何找到它
↓
Collector 如何回收
↓
为什么会 STW
↓
为什么会出现长停顿 / OOM
↓
你如何通过 GC Log、Thread Dump、Heap Dump 找到根因

如果这条链能完整讲出来,并能继续解释 CMS / G1 / Safepoint / JMM / JIT,JVM 这部分就不再只是“背题”,而是形成真正可用于架构和生产排障的知识体系。

章节77 / 完整阅读

附录A · 每个请求都new XStream(),为什么会增加CMS回收压力?

下载正文(Markdown)

展开本附录目录

一段XML反序列化代码,每次调用都创建一个XStream。XML很小,解析出来的对象也没有被缓存。请求结束后,对象就可以回收,看起来不会留下多少内存负担。

public Order parse(String xml) {
    XStream xstream = new XStream();
    xstream.alias("order", Order.class);
    return (Order) xstream.fromXML(xml);
}

运行环境是Oracle JDK8、XStream1.4.4,使用CMS。XStream选择Sun14ReflectionProvider创建对象,每请求新建实例会持续生成反射类,增加元空间的回收压力。

问题出在XStream为对象准备构造器的方式上。1.4.4的Sun14ReflectionProvider会生成一个反射类,并将构造器缓存在自己的实例字段中。每个请求重新创建XStream,也就重新创建了这份缓存。同一种业务对象在上一个请求里生成过的反射类,到了下一个请求又要生成一遍。

这些类可以在CMS周期中卸载。持续的请求会让元空间反复积累,促使CMS启动回收;如果等待并发回收期间仍无法满足元数据分配,还可能进入停顿式Full GC。

1. 解析一个对象,为什么会加载新类?

实验只解析一个Order,包含String类型的id和int类型的amount:

<order><id>10001</id><amount>128</amount></order>

Oracle JDK8的java.vm.vendor为Oracle Corporation,XStream1.4.4会据此选择Sun14ReflectionProvider。

一组已有JDK8参考实验执行了20万次请求,总加载类数增量为200,660。每个请求只解析一种POJO,类加载量却几乎与请求数相同。后文用这组记录说明反射类的生成规律;CMS是否发生Full GC、发生多少次,需要看业务进程的CMS日志。

2. XStream把构造器缓存在哪里?

每个XStream都有自己的provider

XStream的构造器会创建一个JVM辅助对象,再通过它选择反射provider。1.4.4的XStream.java构造器开头如下:

    public XStream(
        ReflectionProvider reflectionProvider, HierarchicalStreamDriver driver,
        ClassLoader classLoader, Mapper mapper, ConverterLookup converterLookup,
        ConverterRegistry converterRegistry) {
        jvm = new JVM();
        if (reflectionProvider == null) {
            reflectionProvider = jvm.bestReflectionProvider();
        }
        this.reflectionProvider = reflectionProvider;

bestReflectionProvider()检查厂商、Java版本以及内部类是否可用。条件满足时,它创建Sun14ReflectionProvider;否则回退到PureJavaReflectionProvider。这个provider保存在当前JVM辅助对象上,随XStream实例一起使用。

因此,每次new XStream()都会得到一套新的provider及其缓存。

要确认线上是否走这条路径,可以直接打印:

System.out.println(xstream.getReflectionProvider().getClass().getName());

Oracle JDK8下,这里应输出com.thoughtworks.xstream.converters.reflection.Sun14ReflectionProvider。

第一次遇到一种业务类,就生成一个构造器

普通POJO由反射转换器处理。它在创建对象时调用reflectionProvider.newInstance(type),进入Sun14ReflectionProvider.newInstance(),后者再调用getMungedConstructor(type)。

缓存字段定义在Sun14ReflectionProvider实例上:

    private transient ReflectionFactory reflectionFactory = ReflectionFactory.getReflectionFactory();
    private transient Map constructorCache =new HashMap();

getMungedConstructor()的代码是问题所在:

    private Constructor getMungedConstructor(Class type) throws NoSuchMethodException {
        synchronized (constructorCache) {
            Constructor ctor = (Constructor)constructorCache.get(type);
            if (ctor == null) {
                ctor = reflectionFactory.newConstructorForSerialization(type, Object.class.getDeclaredConstructor(new Class[0]));
                constructorCache.put(type, ctor);
            }
            return ctor;
        }
    }

第一次解析Order时,缓存里没有这个Class,于是调用newConstructorForSerialization(),生成构造器并放进缓存。同一个XStream再次解析Order,就会命中缓存。

如果下一个请求创建了新的XStream,这份缓存又是空的。虽然处理的仍是同一个Order.class,却需要再生成一次构造器。

缓存按Class计数。一个请求中有100个Order对象,当前provider也只需要为Order生成一次构造器;如果对象图中有Order和Customer两种走此路径的业务类,则各生成一次。重复生成的数量,取决于创建了多少个provider,以及每个provider实际遇到了多少种业务类。

另外,1.4.4的这条实例化路径直接取得合成构造器,并不会先尝试业务类自己的无参构造器。实验中的Order本来就有无参构造器,仍然会触发类生成。

3. 一个构造器,为什么会变成一个新类?

newConstructorForSerialization()是JDK内部为序列化准备构造器的方法。XStream传入两个参数:要创建的业务类,以及Object的无参构造器。

JDK8的实现如下:

    public Constructor<?> newConstructorForSerialization
        (Class<?> classToInstantiate, Constructor<?> constructorToCall)
    {
        // Fast path
        if (constructorToCall.getDeclaringClass() == classToInstantiate) {
            return constructorToCall;
        }
        return generateConstructor(classToInstantiate, constructorToCall);
    }

只有“要创建的类”与“构造器所属的类”相同时,才直接返回已有构造器。这里分别是Order和Object,因而会调用generateConstructor()。

generateConstructor()首先通过MethodAccessorGenerator生成一个ConstructorAccessor,再将它装入新构造器。下面是方法开头的节选:

    private final Constructor<?> generateConstructor(Class<?> classToInstantiate,
                                                     Constructor<?> constructorToCall) {


        ConstructorAccessor acc = new MethodAccessorGenerator().
            generateSerializationConstructor(classToInstantiate,
                                             constructorToCall.getParameterTypes(),
                                             constructorToCall.getExceptionTypes(),
                                             constructorToCall.getModifiers(),
                                             constructorToCall.getDeclaringClass());

生成类的名称是sun.reflect.GeneratedSerializationConstructorAccessorN,末尾的数字递增。生成好的字节码交给ClassDefiner定义:

    static Class<?> defineClass(String name, byte[] bytes, int off, int len,
                                final ClassLoader parentClassLoader)
    {
        ClassLoader newLoader = AccessController.doPrivileged(
            new PrivilegedAction<ClassLoader>() {
                public ClassLoader run() {
                        return new DelegatingClassLoader(parentClassLoader);
                    }
                });
        return unsafe.defineClass(name, bytes, off, len, newLoader, null);
    }

每次定义类时,都会创建一个新的DelegatingClassLoader。JDK这样做,是为了让生成类能够单独卸载,而不必等业务类的加载器一起回收。

这次类生成发生在调用newConstructorForSerialization()时。它没有跨调用缓存,也不需要等到反射调用达到某个次数。普通Method.invoke()的inflation机制解释不了这里的现象。

打开-XX:+TraceClassLoading后,可以直接看到这些类。下面这组类生成参考实验记录于2026年10月8日:

运行方式 请求或调用数 生成的序列化accessor类
1.4.4,每请求新建XStream 1,000 1,000
1.4.4,复用一个XStream 1,000 1
1.4.5,每请求新建XStream 1,000 0

实验只处理一种POJO,因此每请求新建实例时,恰好每个请求生成一个accessor。复用实例后,只有第一次解析需要生成。

总类加载数会稍大一些,因为初始化XStream、执行监控以及其他反射操作也会加载类。判断这条路径的生成量,应计数GeneratedSerializationConstructorAccessor的加载记录。

4. 请求结束以后,这些类去哪了?

构造器缓存在XStream实例内,引用关系可以简化为:

XStream
  └─ Sun14ReflectionProvider
       └─ constructorCache
            └─ Constructor
                 └─ ConstructorAccessor实例
                      └─ 生成类及其DelegatingClassLoader

这些Java对象位于堆中;生成类的元数据由Metaspace保存。请求结束后,如果没有其他引用留下,XStream、构造器和临时加载器都可以变成垃圾。

临时加载器虽然指向长期存活的业务类加载器,但父加载器不会因为这个关系就反向持有它。业务类仍在使用,临时accessor类依然可以卸载。

CMS通过CMSClassUnloadingEnabled控制是否在收集周期中卸载类。开启后,不可达的临时加载器及其类元数据可以在CMS周期中被清理,普通Young GC不会完成这项工作。

随着请求继续执行,未卸载的类越来越多,元空间需要扩展。元数据分配压力可以促使CMS启动收集,回收后用量下降;后续请求又生成新的类,于是用量再次上涨。关闭类卸载,或临时加载器仍然被引用,都会影响这轮回收能释放多少空间。

CMS周期包含初始标记和重新标记等停顿阶段,其余工作主要并发进行。监控里的FGC计数需要结合CMS日志解读,不能只凭计数增长就认定发生了一次全程停顿式Full GC。

5. MetaspaceSize在CMS下怎样起作用?

高水位控制扩展额度

元空间有两个常见指标:used是已经存放元数据的空间,committed是JVM已经提交的容量。GC高水位控制的是扩展额度,与某次日志里的used不是同一个量。

JDK8的MetaspaceGC::allowed_expansion()计算还能扩展多少:

size_t MetaspaceGC::allowed_expansion() {
  size_t committed_bytes = MetaspaceAux::committed_bytes();
  size_t capacity_until_gc = capacity_until_GC();

  assert(capacity_until_gc >= committed_bytes,
        err_msg("capacity_until_gc: " SIZE_FORMAT " < committed_bytes: " SIZE_FORMAT,
                capacity_until_gc, committed_bytes));

  size_t left_until_max  = MaxMetaspaceSize - committed_bytes;
  size_t left_until_GC = capacity_until_gc - committed_bytes;
  size_t left_to_commit = MIN2(left_until_GC, left_until_max);

  return left_to_commit / BytesPerWord;
}

它分别计算距离最大容量和GC高水位还有多少余量,取较小值。当现有空间不够、扩展额度也不足时,元数据分配可以失败,随后进入GC及分配重试流程。

Metaspace::allocate()在分配失败后调用CollectorPolicy::satisfy_failed_metadata_allocation(),由VM_CollectForMetadataAllocation处理。启用CMS并且开启类卸载时,源码会设置请求并发收集的标志:

  if (UseConcMarkSweepGC && CMSClassUnloadingEnabled) {
    MetaspaceGC::set_should_concurrent_collect(true);
    return true;
  }

接着,JVM尝试扩展元空间并满足当前分配,为CMS并发回收留出时间。如果扩展成功,请求可以继续;如果仍然无法分配,就进入collect_as_vm_thread(Metadata GC Threshold)的回收路径,并在回收后重试。继续失败时,还会尝试扩展及最后一次回收,最终无法满足分配才报告OOM。

因此,MetaspaceSize影响的是因元数据压力启动回收的初始高水位。它并不规定“到这个值就必须发生一次停顿式Full GC”。在CMS下,先请求并发回收、扩展是否成功,以及类能否卸载,都影响后续走哪条路径。

MetaspaceSize还作为JDK8高水位调整的下界。GC后,高水位会根据committed及空闲比例调整;MaxMetaspaceSize则限制元空间的最大提交容量。设置128m,不意味着used必须涨到128MiB才启动回收,因为used和committed本来就不是同一个量。

调大堆和调大高水位,效果不同

Xmx控制Java堆,调大它不会减少反射类的生成,也不会直接抬高元空间的GC高水位。

调大MetaspaceSize可以推迟元数据压力触发的CMS收集,但也会让更多临时元数据积累。是否能减少停顿,需要同时看CMS周期、元空间扩展情况和业务延迟。每请求生成新类的代码没有改变,调参只是改变了回收时机。

6. 改成复用实例以后

一组已有JDK8参考实验比较了新建和复用实例的类加载量:

指标 每请求新建XStream 复用一个XStream
完成请求 200,000 200,000
总加载类数增量 200,660 171

复用以后,Order的构造器已经缓存,后续请求不再持续生成序列化accessor。剩余类加载主要来自初始化和其他运行开销。

在CMS环境中,这项改动减少了元空间中的临时类,以及相关的加载、卸载和回收工作。是否消除了业务系统中的Full GC,还要核对CMS日志中的触发原因和改造前后的结果。

实现上,只需把XStream的创建和配置移出请求方法:

private static final XStream XSTREAM = createXStream();

private static XStream createXStream() {
    XStream xstream = new XStream();
    xstream.alias("order", Order.class);
    return xstream;
}

public Order parse(String xml) {
    return (Order) XSTREAM.fromXML(xml);
}

也可以交给依赖注入容器管理一个长期实例。关键是让构造器缓存得以复用。

XStream允许多个线程共享已经完成配置的实例。alias、converter和注解配置应在使用前完成,请求过程中不要继续修改;开启动态注解自动发现时,以及使用自定义converter时,还需要检查相应的并发行为。具体约束见官方FAQ。

7. 升级为什么能解决问题?

XStream1.4.4创建Order对象时,会先准备一个特殊构造器。JDK为这个构造器生成一个反射类,XStream再把构造器放进当前实例的缓存。下一个请求重新创建XStream,缓存又是空的,同样的反射类就要再生成一个。

从1.4.5开始,Sun14ReflectionProvider改用Unsafe.allocateInstance(type)创建对象。它可以直接分配Order实例,跳过构造方法,不再需要为此生成序列化构造器的反射类。

版本 创建Order对象的方式 每请求新建XStream的结果
1.4.4 生成反射类,通过合成构造器创建对象 每个请求都为Order生成一个新的反射类
1.4.5 通过Unsafe直接分配对象 创建Order时不再生成这类反射类

因此,即使仍然每请求新建XStream,升级也能消除这条路径上的重复类生成,减少CMS需要卸载的临时类。参考实验中,1,000次请求生成的序列化accessor类从1,000个降到了0个。

不过,每次创建和配置XStream本身也有开销。升级后仍然建议复用完成配置的实例。

这里提到1.4.5,是为了说明创建方式从哪个版本开始改变。实际升级应选择符合业务兼容性和安全要求的版本,参阅版本历史及安全说明。

8. 在业务系统里怎样确认

先记录实际XStream版本、JDK版本和provider类名,并确认CMS的类卸载配置。日志中要同时看元数据触发原因、CMS周期、回收后的元空间用量,以及是否出现停顿式Full GC。

jstat -gc <pid> 2000
jstat -class <pid> 2000
jcmd <pid> VM.classloader_stats
jcmd <pid> VM.flags -all

检查UseConcMarkSweepGC和CMSClassUnloadingEnabled的实际值。MU、MC上升后回落,卸载类数随之增长,说明类在反复生成和清理。如果卸载很少、GC后的用量也持续升高,就应进一步检查加载器的引用、存活类数量和类卸载选项。

要确认生成的究竟是什么类,可以在测试环境短时开启-XX:+TraceClassLoading,匹配加载记录:

awk '/^\[Loaded sun\.reflect\.GeneratedSerializationConstructorAccessor[0-9]+ / {n++}
     END {print n+0}' trace.log

以下命令只统计日志中明确标为Full GC的事件;CMS并发周期的初始标记、重新标记和并发阶段应另外查看:

awk '/Full GC \(/ {
    s=$0
    sub(/^.*Full GC \(/,"",s)
    sub(/\).*$/,"",s)
    n[s]++
}
END {for (s in n) print n[s], s}' gc.log

TraceClassLoading记录的是类的加载。看到DelegatingClassLoader这个类只加载一次,并不表示只创建了一个加载器实例;实例数量要看VM.classloader_stats等工具。多个采集命令执行的时刻不同,比较数据时也要保留时间戳。

改造后,用同样的请求量再跑一次。应看到序列化accessor不再随请求数持续增加,元空间用量趋于稳定,相应的元数据GC压力下降。

附录:源码索引

JDK反射和HotSpot实现参考OpenJDK8u462源码。

源码 建议阅读位置
XStream1.4.4:XStream.java 构造器创建JVM辅助对象并选择provider;272行起为线程安全说明
1.4.4:JVM.java bestReflectionProvider与厂商判断
1.4.4:AbstractReflectionConverter.java instantiateNewInstance
1.4.4:Sun14ReflectionProvider.java 实例缓存及getMungedConstructor
1.4.5:Sun14ReflectionProvider.java newInstance改用Unsafe.allocateInstance
JDK8u462:ReflectionFactory.java 序列化构造器fast path与生成路径
JDK8u462:MethodAccessorGenerator.java 类名自增与字节码生成
JDK8u462:ClassDefiner.java 每个生成类的新DelegatingClassLoader
JDK8u462:metaspace.cpp can_expand、allowed_expansion、compute_new_size及分配失败处理
JDK8u462:CMS实现 CMS周期与类卸载
JDK8u462:vmGCOperations.cpp VM_CollectForMetadataAllocation的回收与重试分支

全文搜索

输入关键词,搜索完整正文。