JVM 技术文档:从入门到精通
这份文档解决什么问题?
你可能在下面这几种情况里:
- 刚接触 Java,听到 JVM、GC、JMM 就犯怵
- 八股文背了不少,但面试官一问"为什么这样设计"就卡住
- 线上出了问题,只会猜"是不是 GC 的问题"
这份文档想做的事情很简单:
- 先搞清楚 JVM 到底在干什么
- 把零散的知识点串成一条线
- 面试的时候能讲出道理,而不是背定义
阅读顺序
JVM是什么
内存结构
对象创建与GC
JMM与并发
调优与排障
面试题串讲
文字版:
- 别上来就记参数,先把整体结构弄明白
- 理解了"结构 → 机制 → 现象"这个链条,调优就不是玄学了
- 面试官喜欢问"为什么不用另一个方案",这里会覆盖这类思路
第一章:JVM、JDK、JRE 是什么
1.1 先说清楚三者的关系
很多人上来就背 "JVM 是 Java Virtual Machine 的缩写",但面试官更想听你自己理解后的表述。
- JVM(Java 虚拟机) :运行
.class字节码的虚拟计算机。你可以把它想成"软件模拟出来的 CPU + 内存 + 指令集"。注意:JVM 是规范,不是某一个具体产品。 - JRE(Java 运行环境) :跑 Java 程序需要的东西。里面包含 JVM 和核心类库(
java.lang.*、java.util.*等)。打个比方:JRE 就像一个搭好的舞台,你的程序上去就能演。 - JDK(Java 开发工具包) :要写代码、编译、调试就得装这个。它在 JRE 基础上加了
javac编译器、jdb调试器、jvisualvm性能分析工具这些。
1.2 为什么要有 JVM?
你可能想过:Java 为什么不直接编译成机器码,像 C++ 那样?
原因就一句话:一次编译,到处运行。
流程是这样的:
.java源码
javac编译器
.class字节码
平台无关的中间表示
Windows上的JVM
Linux上的JVM
Mac上的JVM
执行
执行
执行
文字版:
javac把.java编译成.class字节码(中间语言,不是任何平台的机器码)- 不同操作系统装各自的 JVM
- 各自的 JVM 把字节码翻译成本地指令然后执行
这样做的好处:
- 跨平台 :同一份
.class在 Windows/Linux/Mac 上都能跑 - 安全:JVM 会做字节码验证,防止恶意代码搞破坏
- 省心:自带 GC,不用手动管理内存
- 还能优化:JIT 编译器会在运行时对热点代码做优化
1.3 用生活例子帮助记忆
为了好记,我用餐厅打个比方:
| 组件 | 类比 | 说明 |
|---|---|---|
| .java 代码 | 菜谱 | 你写的业务逻辑 |
| javac 编译器 | 翻译官 | 把菜谱翻译成标准格式 |
| .class 字节码 | 标准化菜谱 | 任何厨师都能看懂 |
| JVM | 厨师 | 按菜谱做菜 |
| 不同 OS 的 JVM | 不同店的厨师 | Windows 厨师、Linux 厨师、Mac 厨师 |
| JRE | 搭好的厨房+基础调料 | 有灶台、锅碗瓢盆、盐酱油 |
| JDK | 完整厨房+工具间 | 除了 JRE,还有刀具、烤箱这些 |
1.4 面试常被问到的问题
问:JVM 到底是规范还是实现?
怎么答:
- JVM 首先是一份规范(《Java 虚拟机规范》定义了字节码格式、指令集、内存模型这些)
- 具体的实现有:HotSpot(Oracle 主推,用得最多)、OpenJ9(IBM 开源,内存占用低)、Zing(Azul 出品,延迟低)、JRockit(Oracle 收购后并入 HotSpot)
- 面试时可以主动说:"我主要熟悉 HotSpot,它用的是解释 + JIT 混合模式..."
问:Java 真的完全跨平台吗?
怎么答(展示你思考过这个问题):
- 跨的部分 :
.class文件层面确实跨平台 - 不完全跨的部分 :
- JNI 调本地 C/C++ 库时,不同平台要分别编译
.so/.dll - 文件路径分隔符不一样(Windows 用
\,Linux 用/) - socket API 在不同 OS 上行为有细微差别
- 线程调度依赖操作系统的策略
- JNI 调本地 C/C++ 库时,不同平台要分别编译
- 结论:大部分情况跨平台,但涉及底层交互时要注意差异
问:JDK、JRE、JVM 的包含关系?
怎么答(画图):
JDK(最大的)
├── JRE
│ ├── JVM
│ └── 核心类库(rt.jar, charset.jar 等)
└── 开发工具(javac, jdb, jvisualvm, jstat 等)
- JDK 8 及之前:JDK 包含 JRE,JRE 包含 JVM
- JDK 9+ 模块化后结构有变化,但逻辑关系没变
- 实际开发中建议直接装 JDK ,就算只运行程序也需要
jcmd、jmap这些排障工具
1.5 常见误解
误解:装了 JRE 就能开发
实际情况:
- 没有
javac,没法编译.java文件 - 缺少
jcmd、jmap、jstack这些诊断工具,线上出问题时会很被动 - 用不了
jshell(JDK 9+ 的 REPL 工具)
建议:
- 开发环境:装 JDK(推荐 JDK 8 LTS 或 JDK 17/21 LTS)
- 生产环境:可以只装 JRE(省磁盘空间),但要确保运维团队有 JDK 工具链
- Docker 镜像可以用
eclipse-temurin:17-jre作为运行时镜像(体积小)
误解:所有 JVM 实现都一样
实际情况:
- HotSpot:最成熟,社区活跃,适合大多数场景
- OpenJ9:内存占用极低,适合容器化/微服务(Quarkus 默认用的就是这个)
- Zing/Azul:付费产品,GC 停顿可以控制在微秒级,适合金融交易系统
- Android ART:专门给移动端优化的,跟标准 JVM 差别较大
选型参考:
场景 推荐实现
普通 Web 服务 HotSpot(稳定、生态好)
容器化微服务(内存敏感) OpenJ9 或 HotSpot + GraalVM Native Image
超低延迟交易系统 Zing 或 ZGC(HotSpot)
Android 应用 ART(非标准 JVM)
1.6 怎么选 JDK 版本?
这个问题面试和工作都可能遇到:
| 版本 | 状态 | 适用场景 | 注意事项 |
|---|---|---|---|
| JDK 8 | LTS(支持到 2030) | 传统企业、老系统、Spring Boot 2.x | 语法旧,没有模块化、Record 这些特性 |
| JDK 11 | LTS(支持到 2032) | 过渡版本,云原生应用 | 没什么新语法糖,主要是内部优化 |
| JDK 17 | LTS(支持到 2029) | 当前主流选择,Spring Boot 3.x 要求 | 有 Sealed Classes、Pattern Matching 等 |
| JDK 21 | 最新 LTS(支持到 2031) | 新项目、想尝鲜 | Virtual Threads(协程)、Record Patterns |
我的建议(基于实际经验):
- 2024 年起新项目:优先 JDK 17(生态成熟,Spring 3.x 官方支持)
- 学习/实验性质的项目:可以试试 JDK 21(体验 Virtual Threads)
- 维护老系统:JDK 8(别轻易升级,风险大)
1.7 小结
到这里你应该能说清楚:
- JVM/JDK/JRE 三者什么关系、有什么区别
- 为什么要有 JVM(跨平台、安全、GC、动态优化)
- JVM 是规范,HotSpot/OpenJ9 是实现
- 怎么根据场景选 JDK 版本
如果这四点你能用自己的话讲出来,第一章就过关了。接下来看第二章,JVM 内存到底是怎么划分的。
第二章:运行时数据区
2.1 先看全貌
很多人一上来就背"堆、栈、方法区",但不知道它们在 JVM 进程里的位置和作用。先把地图画出来。
线程共享区域
线程私有区域
线程
PC程序计数器
线程私有
Java虚拟机栈
线程私有
本地方法栈
线程私有
进程共享
堆 Heap
方法区/元空间
Metaspace
直接内存
Direct Memory
垃圾回收GC
重点区域
文字版(防止 Mermaid 渲染失败时参考):
- 线程私有区 :每个线程一份,服务于线程执行
- PC 记录当前执行到哪条字节码
- Java 虚拟机栈存方法的局部变量、操作数栈
- 本地方法栈存 Native 方法(C/C++ 实现)的调用栈
- 线程共享区 :所有线程共用,存对象和类元数据
- 堆:对象实例、数组(GC 主要管这里)
- 元空间:类信息、常量池、静态变量(JDK 8 后从永久代移到本地内存)
- 直接内存:NIO 用的堆外内存(GC 不直接管这里)
- 关键点 :GC 处理堆和部分元空间,不会回收栈帧
2.2 每块内存放什么
程序计数器(Program Counter Register)
| 属性 | 说明 |
|---|---|
| 是否线程私有 | 是,每个线程独立 |
| 存储内容 | 当前线程下一条字节码指令的位置 |
| 大小 | 能放下一个 returnAddress 或指针就行(很小) |
| 会 OOM 吗 | 基本不会(唯一一个不会 OOM 的区域) |
为什么需要它?
- 多线程环境下 CPU 会频繁切换线程。切换回来后得知道"上次执行到哪了",程序计数器就是记这个位置的
- 如果当前执行的是 Native 方法,计数器值是空(Undefined)
面试可能追问:
- 为什么线程私有?→ 多线程并发时各线程执行位置不同,必须独立保存
- 执行 Native 方法时计数器是什么?→ Undefined(未定义)
Java 虚拟机栈(Java Virtual Machine Stack)
| 属性 | 说明 |
|---|---|
| 是否线程私有 | 是 |
| 存储内容 | 栈帧(Stack Frame),每次方法调用对应一个栈帧 |
| 栈帧包含 | 局部变量表、操作数栈、动态链接、返回地址 |
| 常见异常 | StackOverflowError(栈太深)、OutOfMemoryError(栈无法扩展) |
栈帧内部长什么样:
一个栈帧
局部变量表
Slot数组
存方法参数和局部变量
操作数栈
LIFO结构
存放中间计算结果
动态链接
运行时常量池引用
绑定方法实现
返回地址
方法结束后回到调用者的位置
文字版:
- 局部变量表:编译期确定大小,用 Slot 存储。boolean/byte/short/char/int/reference 占 1 个 Slot,long/double 占 2 个
- 操作数栈 :后进先出,放操作数和中间结果(比如
i++会先压栈、取值、加 1、再赋值) - 动态链接:把符号引用转成直接引用(运行时绑定具体方法)
- 返回地址:记录方法执行完后回到调用者的哪条指令
示例:
java
public int add(int a, int b) {
int c = a + b; // 操作数栈: push a, push b, add, store c
return c; // 返回地址: 回到调用者的下一条指令
}
面试常见问题:
- 栈大小能调吗?→ 可以,用
-Xss参数(如-Xss1m设成 1MB) - 递归太深为什么会 StackOverflow?→ 每次递归压一个新栈帧,超了容量就溢出
- 局部变量表编译期确定吗?→ 是的,
javap -verbose能看到 LocalVariableTable
本地方法栈(Native Method Stack)
| 属性 | 说明 |
|---|---|
| 是否线程私有 | 是 |
| 干嘛的 | 给 Native 方法(JNI 调用的 C/C++ 函数)服务 |
| 实现差异 | HotSpot 把它和虚拟机栈合并了 |
| 常见异常 | 同虚拟机栈:StackOverflowError / OOM |
什么时候用到?
- 调
Object.hashCode()(某些实现依赖 Native) - 用
Unsafe操作内存 - 调第三方 C/C++ 库(比如 Netty 零拷贝底层)
堆(Heap)------ GC 的主战场
| 属性 | 说明 |
|---|---|
| 是否线程共享 | 所有线程共享 |
| 存什么 | 对象实例、数组(几乎所有对象都在这里分配) |
| 分代结构 | 新生代(Eden + Survivor×2)+ 老年代(Tenured) |
| 常见异常 | OutOfMemoryError: Java heap space |
堆是怎么分代的(这个很重要!):
堆Heap
新生代YoungGen
Eden区
8/10
新对象出生地
Survivor0
1/10
From区
Survivor1
1/10
To区
老年代OldGen
长期存活对象
大对象直接进来
文字版:
- 新生代(Young Generation) :占堆的 1/3(可用
-XX:NewRatio调)- Eden 区:新对象首先分配在这里(通过 TLAB 快速分配)
- Survivor 0/1:经过 Minor GC 还活的对象复制到这里,两个区轮流当 From/To
- 默认比例 8:1:1 (Eden:S0:S1),可用
-XX:SurvivorRatio改
- 老年代(Old Generation) :占堆的 2/3
- 存活了很多次的 GC 还活着的对象
- 大对象可能直接进来(用
-XX:PretenureSizeThreshold设阈值)
- 晋升过程:Eden → Survivor → (年龄到 15 或动态阈值)→ Old
为什么这么设计?(面试常问)
- 绝大多数对象"朝生夕死"(98% 对象熬不过第一轮 GC)
- 分代之后新生代能用复制算法 (快但费空间),老年代用标记整理(慢但省空间)
- 不用每次都扫描整个堆,GC 效率高
方法区 / 元空间(Metaspace)
| 属性 | JDK 7 及之前 | JDK 8+ |
|---|---|---|
| 名称 | 永久代(PermGen) | 元空间(Metaspace) |
| 位置 | 在堆内(受 -Xmx 限制) |
本地内存(Native Memory) |
| 默认上限 | 固定大小(约 82MB) | 无上限(受物理内存限制) |
| 存什么 | 类信息、常量池、静态变量、JIT 代码缓存 | 同左(字符串常量池移到堆了) |
| OOM 时报什么 | OutOfMemoryError: PermGen space |
OutOfMemoryError: Metaspace |
| 调参方式 | -XX:MaxPermSize |
-XX:MaxMetaspaceSize |
元空间存什么?
- 类的完整结构(父类、接口、字段、方法、构造器)
- 运行时常量池(字面量、符号引用)
- 静态变量(
static final修饰的常量) - JIT 编译后的代码缓存
踩坑案例:
java
// 场景:动态生成大量类(Spring 动态代理、CGLIB、ASM 字节码增强)
// 结果:Metaspace OOM
for (int i = 0; i < 100_000; i++) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Target.class);
enhancer.create(); // 每次循环都在元空间生成新类
}
解决办法:
- 限制动态类的数量(用缓存池)
- 加
-XX:MaxMetaspaceSize=256m - 排查 ClassLoader 泄漏(ClassLoader 回收不了导致类卸载不了)
直接内存(Direct Memory)
| 属性 | 说明 |
|---|---|
| 是否线程共享 | 是 |
| 在哪 | 堆外(JVM 堆不管这里) |
| 干嘛用 | NIO(Channel、Buffer)、Netty 零拷贝 |
| 怎么分配 | Unsafe.allocateMemory() 或 ByteBuffer.allocateDirect() |
| 怎么回收 | GC 不直接管,靠 Cleaner + PhantomReference |
| OOM 时报什么 | OutOfMemoryError: Direct buffer memory |
为什么要用它?
- 避免 Java 堆和 Native 堆之间来回拷贝数据(零拷贝)
- 提升 I/O 性能(特别是网络编程、文件读写)
- 但分配和回收成本高,容易泄漏
典型用法:Netty 的 PooledByteBufAllocator
java
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT); // 堆外内存池
2.3 栈 vs 堆
| 维度 | 栈(Stack) | 堆(Heap) |
|---|---|---|
| 归谁 | 线程私有 | 所有线程共享 |
| 存什么 | 基本类型、对象引用 | 对象实例本身 |
| 生命周期 | 方法结束就销毁 | GC 决定什么时候回收 |
| 分配速度 | 很快(挪一下指针就行) | 较慢(要找空闲空间、还要同步) |
| 大小 | 通常 512KB ~ 2MB(可调) | 通常几百 MB 到几十 GB |
| 报什么错 | StackOverflowError / OOM |
Java heap space / GC Overhead |
| 线程安全吗 | 天然安全(私有的) | 要自己控制(共享的) |
一句话总结:
栈是方法的临时工位,用完就撤;堆是公司的仓库,东西多了要定期清理(GC)。
2.4 示例 1:为什么会 StackOverflowError
java
public class StackDemo {
static int depth = 0;
public static void main(String[] args) {
try {
recurse();
} catch (StackOverflowError e) {
System.out.println("栈深度:" + depth); // 一般 5000~10000(取决于 -Xss)
e.printStackTrace();
}
}
static void recurse() {
depth++;
recurse(); // 无限递归,每次调用都压新栈帧
}
}
怎么回事:
- 每次
recurse()都在虚拟机栈压一个新栈帧 - 栈帧里有局部变量表(int depth)、操作数栈、返回地址
- 栈深度超限(默认 1MB 栈空间 ÷ 每个栈帧约 100~200 字节 ≈ 几千到一万层)就抛异常
- Java 不支持尾递归优化(TCO),别指望编译器帮你
怎么调:
bash
# 加大到 2MB
java -Xss2m StackDemo
# 减小到 256KB(更容易复现问题)
java -Xss256k StackDemo
2.5 示例 2:为什么会 Java heap space
java
import java.util.*;
public class HeapDemo {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
try {
while (true) {
list.add(new byte[1024 * 1024]); // 每次分配 1MB
if (list.size() % 100 == 0) {
System.out.println("已分配:" + list.size() + "MB");
}
}
} catch (OutOfMemoryError e) {
System.out.println("最终分配了:" + list.size() + "MB");
}
}
}
怎么回事:
new byte[1024*1024]在堆上分配 1MB 数组list.add()让对象被强引用(reachable),GC 回收不了- 堆空间不够且 GC 释放不出足够内存时就报错
- 就算 catch 了 OOM,后续代码也可能异常(堆已经很紧张了)
怎么排查:
bash
# 启动时加参数,自动 Dump
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/oom.hprof HeapDemo
# 用 Eclipse MAT 打开 oom.hprof
# 看 Dominator Tree,找到占用最大的对象
2.6 面试追问
问:为什么程序计数器是线程私有的?
怎么答:
- CPU 时间片轮转,线程频繁中断和恢复
- 恢复执行时得知道"上次到哪了"
- 多个线程共享一个计数器的话,切换后就乱了
加分项:
- 这也是多线程调试难的原因之一:每个线程有独立的执行流
- 分析
jstack日志时能看到每个线程的栈顶帧(对应计数器的位置)
问:JDK 8 为什么去掉永久代换成元空间?
主要原因:
- 永久代大小不好调:固定大小容易 OOM,设太大又浪费
- GC 效率低:永久代只在 Full GC 时才扫,影响性能
- 类的数量不可预测:Groovy、Scala、动态代理、反射导致类数量不确定
元空间的好处:
- 用本地内存,只受物理内存限制(不再受
-Xmx管) - 可以自动扩容(也能设上限
-XX:MaxMetaspaceSize) - 碎片化问题少一些(用的是 native 内存分配器)
延伸:
- 字符串常量池从永久代移到了堆里(JDK 7 开始移,JDK 8 完成)
- 这样 Full GC 时不用扫常量池了,停顿时间短一些
问:对象一定在堆上分配吗?
理论上:
- Java 规范要求对象在堆上分配
- 但 JIT 编译器可以做**逃逸分析(Escape Analysis)**优化:
- 标量替换(Scalar Replacement):对象拆成基本类型,直接在栈上分配
- 栈上分配(Stack Allocation):没逃逸的对象直接放栈上(方法结束就销毁)
验证:
java
public void test() {
User user = new User(); // user 没逃逸(没被外部引用)
user.setName("test");
System.out.println(user.getName());
}
// JIT 可能优化成:
// int nameHash = hash("test");
// println(nameHash);
// User 对象根本不会被创建!
怎么看有没有发生逃逸分析优化:
bash
# 开启 JIT 日志
java -XX:+PrintEliminationAllocations -XX:+DoEscapeAnalysis MyApp
# 关闭逃逸分析做对比
java -XX:-DoEscapeAnalysis MyApp
2.7 常见坑
坑:只调 -Xmx 就能解决所有 OOM?
真实案例:
现象:服务经常重启,日志显示 OOM
操作:运维把 -Xmx 从 2G 调到 4G
结果:还是 OOM,这次报的是 Metaspace!
原因:框架用了大量动态代理(MyBatis + Spring AOP),类加载太多
修正:加 -XX:MaxMetaspaceSize=512m + 升级框架版本
教训:
- OOM 有多种类型,先看清是哪种
- OOM 类型速查表:
| OOM 信息 | 第一怀疑方向 | 排查命令 |
|---|---|---|
Java heap space |
堆泄漏/大对象 | jmap -histo → MAT 分析 |
Metaspace |
类加载器泄漏/动态生成类过多 | jcmd <pid> VM.classloader_stats |
Direct buffer memory |
NIO/Netty Buffer 没释放 | jcmd <pid> VM.native_memory detail |
unable to create new native thread |
线程爆炸 / Xss 太大 | jstack <pid> 统计线程数 |
GC overhead limit exceeded |
GC 耗时超过 98% | jstat -gcutil 看 GC 时间占比 |
坑:-Xms 和 -Xmx 设成不一样能省内存?
实际情况:
-Xms(初始堆)<-Xmx(最大堆)会导致:- 启动时堆小,对象多了触发多次扩容(Resize)
- 每次扩容都要 Stop-The-World(STW),请求会抖动
- 堆使用率下降时缩容也会 STW
建议:
bash
# 生产环境:锁定堆大小,避免抖动
-Xms4g -Xmx4g
# 容器环境(K8s):用比例配置
-XX:MaxRAMPercentage=75.0
2.8 本章小结
到这里第二章的核心内容就讲完了。你应该能做到:
画出 JVM 运行时数据区的组成(线程私有 vs 共享)
说清每块区域的作用、存什么、报什么错
理解分代设计的理由(新生代 vs 老年代的比例、晋升规则)
区分栈和堆的差异(生命周期、分配速度、线程安全性)
复现并解释 StackOverflowError 和 Java heap space
回答追问:程序计数器为什么私有、元空间为什么替代永久代、逃逸分析
如果这六点你能用自己的话说清楚,内存模型这块就扎实了。接下来看第三章,JVM 怎么加载和管理类。
第三章:类加载机制
3.1 类加载的生命周期
很多人知道"双亲委派",但不清楚类加载的完整过程。这一节把整个过程拆开来讲。
加载 Loading
验证 Verification
准备 Preparation
解析 Resolution
初始化 Initialization
使用 Using
卸载 Unloading
文字版:
- 加载(Loading):通过类的全限定名拿到二进制字节流(文件、ZIP 包、网络、运行时生成都可以),转成方法区的运行时数据结构,并在堆中生成 Class 对象作为入口
- 验证(Verification) :检查
.class字节流是否符合 JVM 规范(文件格式、元数据、字节码、符号引用)。这是 JVM 安全的第一道防线 - 准备(Preparation) :给静态变量分配内存并设初始值 (一般是零值:
int为 0,boolean为false,reference为null) - 解析(Resolution) :把常量池里的符号引用 替换成直接引用 (比如把
com/example/User解析成实际内存指针) - 初始化(Initialization) :执行
<clinit>方法(静态代码块和静态变量的显式赋值)。这是类加载最后一步,也是真正执行你写的代码的地方 - 使用(Using):正常用类,创建实例、调方法
- 卸载(Unloading):类不再被引用且 ClassLoader 可回收时卸载(条件很苛刻)
准备阶段 vs 初始化阶段的区别:
java
public class Demo {
static int a = 10; // 准备阶段:a=0(默认值);初始化阶段:a=10(真正赋值)
static final int b = 20; // 准备阶段:b=20(常量直接赋值)
static {
System.out.println("静态代码块执行"); // 初始化阶段才执行
}
}
3.2 双亲委派保护什么?
一句话 :双亲委派保证的是 安全 + 一致性。
什么是双亲委派?
类加载器收到加载请求时,不会自己立刻加载,而是委托给父加载器去完成。一层层往上委托,直到最顶层的启动类加载器(Bootstrap ClassLoader)。父加载器能加载就由父加载器返回;父加载器在其搜索范围内找不到这个类,子加载器才会尝试自己加载。
委托
委托
找不到?
找不到?
应用程序类加载器
AppClassLoader
扩展类加载器
ExtClassLoader
启动类加载器
BootstrapClassLoader
文字版:
- 应用程序类加载器(AppClassLoader)负责加载用户 classpath 下的类
- 它先委托给扩展类加载器(ExtClassLoader,加载 jre/lib/ext 目录)
- ExtClassLoader 再委托给启动类加载器(BootstrapClassLoader,C++ 实现,加载核心类库如
java.lang.*) - 只有所有父加载器都加载不了时,发起请求的加载器才自己加载
为什么需要双亲委派?
假设没有双亲委派会发生什么:
java
// 用户自定义了一个 java.lang.String 类
package java.lang;
public class String {
public String() {
System.out.println("我是恶意的String!");
// 这里可以植入恶意代码
}
}
// 如果没有双亲委派,用户的 String 会替代 JDK 原生的 String
// 所有依赖 String 的代码都会受到影响!
双亲委派的保护作用:
- 安全 :核心 API 不能被篡改(
java.lang.String、java.lang.Object必须由 Bootstrap 加载) - 一致:同一个类在整个 JVM 中只加载一次(避免重复加载导致的类型转换异常)
- 清晰:明确类的加载来源,方便排查问题
类加载器的层次
| 加载器 | 加载范围 | 说明 |
|---|---|---|
| Bootstrap ClassLoader | %JAVA_HOME%/lib(核心类库) |
C++ 实现,是所有加载器的"根" |
| Extension ClassLoader | %JAVA_HOME%/lib/ext |
JDK 9 后改叫 Platform ClassLoader |
| Application ClassLoader | 用户 ClassPath | 也叫 System ClassLoader,加载我们自己写的类 |
| Custom ClassLoader | 自定义路径 | 继承 ClassLoader 实现的自定义加载器 |
3.3 示例:静态初始化顺序(经典面试题)
java
class Parent {
static {
System.out.println("1. Parent init");
}
public Parent() {
System.out.println("2. Parent 构造方法");
}
}
class Child extends Parent {
static {
System.out.println("3. Child init");
}
public Child() {
// super() 会隐式调用
System.out.println("4. Child 构造方法");
}
}
public class InitDemo {
static {
System.out.println("5. Main 类静态代码块");
}
public static void main(String[] args) {
System.out.println("6. main 方法开始");
new Child();
}
}
输出:
5. Main 类静态代码块
6. main 方法开始
1. Parent init
3. Child init
2. Parent 构造方法
4. Child 构造方法
执行过程:
- JVM 启动,先加载并初始化 InitDemo(main 所在类)→ 输出 5
- 进入
main→ 输出 6 - 执行
new Child():- Child 未加载 → 触发 Child 的加载
- Child 继承 Parent → 先加载 Parent
- Parent 加载完后先执行 Parent 的
<clinit>→ 输出 1 - 再执行 Child 的
<clinit>→ 输出 3 - 分配内存,调 Parent 构造方法 → 输出 2
- 调 Child 构造方法 → 输出 4
记忆口诀:
父静 > 子静 > 父构 > 子构
3.4 示例:同名类为什么不能强转
这是个经典面试题,考察对类身份的理解。
结论 :类身份 = 类加载器 + 全限定类名
即使两个类的包名、类名一样,只要类加载器不同,JVM 就认为它们是两个不同的类!
java
import java.io.*;
import java.lang.reflect.*;
public class ClassIdentityDemo {
public static void main(String[] args) throws Exception {
String str1 = "hello"; // AppClassLoader 加载
CustomClassLoader loader = new CustomClassLoader();
Object obj = loader.loadClass("java.lang.String").newInstance();
// String str2 = (String) obj; // ❌ ClassCastException!
System.out.println(str1.getClass().getClassLoader()); // null(Bootstrap)
System.out.println(obj.getClass().getClassLoader()); // com.example.CustomClassLoader
System.out.println(str1.getClass().getName()); // java.lang.String
System.out.println(obj.getClass().getName()); // java.lang.String
}
}
class CustomClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException("演示用,未实现");
}
}
为什么:
- JVM 内部用
(ClassLoader, ClassName)二元组标识一个类 str1的身份是(null, java.lang.String)(null 表示 Bootstrap)obj的身份是(CustomClassLoader, java.lang.String)- 身份不同,所以不能强转
实际用途:
- Tomcat 的 WebAppClassLoader:不同 Web 应用的类互相隔离(同名类也不会冲突)
- OSGi 框架:每个 Bundle 有自己的 ClassLoader,模块化隔离
- 热部署:重新部署时用新的 ClassLoader,旧版本可以优雅卸载
3.5 面试追问
问:哪些场景会打破双亲委派?
举三个例子:
场景 1:SPI(Service Provider Interface)
java
// JDBC 的 DriverManager.getConnection()
// 接口在 rt.jar(Bootstrap 加载)
// 实现在第三方 jar 包(mysql-connector-java)
// 严格遵循双亲委派的话,Bootstrap 无法加载第三方实现
// 解决方案:线程上下文类加载器(Thread Context ClassLoader)
Connection conn = DriverManager.getConnection(url);
场景 2:Tomcat 热部署
- Tomcat 为了隔离 Web 应用打破了双亲委派
- 每个 WebApp有自己的 WebAppClassLoader
- 优先加载自己的类,找不到再委托给父加载器
- 这样不同应用的同名类不会冲突
场景 3:OSGi 模块化框架
- 定义了复杂的类加载规则
- 支持同一类的多个版本共存
- 完全打破了传统双亲委派模型
延伸:
- SPI 打破双亲委派的本质是什么?→ 用 ThreadContextClassLoader,让父加载器委托子加载器
- Tomcat 为什么要打破?→ Web 应用隔离、热部署需求、避免 Jar 包冲突
问:什么时候触发类初始化?
主动使用(会触发初始化):
new创建实例- 读或设一个类的非 final 静态字段(编译时常量除外)
- 调静态方法
- 反射调用(
Class.forName()) - 初始化该类的子类(父类先初始化)
- JVM 启动时 main 方法所在类
被动使用(不触发初始化):
- 通过子类引用父类的静态字段(只初始化父类)
- 通过数组引用类(
User[] arr = new User[10]不会触发 User 初始化) - 引用编译时常量 (
static final基本类型或 String) Class.forName(className, false, loader)第二个参数传 false(只加载不初始化)
验证:
java
class Parent {
static final String CONSTANT = "常量"; // 编译时常量
static final Random rand = new Random(); // 非编译时常量
static {
System.out.println("Parent 初始化");
}
}
public class PassiveRefDemo {
public static void main(String[] args) {
System.out.println(Parent.CONSTANT); // 不触发初始化
System.out.println(Parent.rand); // 触发初始化
Parent[] parents = new Parent[10]; // 不触发初始化
}
}
3.6 常见坑
误区:Class.forName() 只加载不初始化
实际情况:
java
// 这个写法会触发初始化!(第二个参数默认 true)
Class.forName("com.example.MyClass");
// 只加载不初始化的正确写法
Class.forName("com.example.MyClass", false, loader);
后果:
- 静态代码提前执行,可能有副作用(数据库连接池初始化、日志配置覆盖)
- 框架开发时容易踩坑(Spring 懒加载 vs 饿加载策略)
建议:
- 只是获取 Class 元信息的话,用
forName(name, false, loader) - 确实需要初始化的话,确保初始化顺序可控(不要有循环依赖)
坑:ClassLoader 泄漏导致 Metaspace OOM
案例:
现象:服务跑几天后 Metaspace 持续增长,最终 OOM
排查:jcmd VM.classloader_stats 显示 ClassLoader 数量一直在增
原因:用了动态代理(CGLIB/Spring AOP),代理类缓存无限增长
修复:限制缓存大小 + 定期清理无用代理类
预防:
- 用 WeakReference 缓存动态生成的类
- 监控 ClassLoader 数量和元空间使用率
- 别在热点路径反复创建新的 ClassLoader
3.7 本章小结
到这里第三章就讲完了。你应该能做到:
画出类加载生命周期(加载→验证→准备→解析→初始化→使用→卸载)
解释双亲委派的作用(安全+一致)以及打破的场景(SPI/Tomcat/OSGi)
写出静态初始化顺序(父静>子静>父构>子构)
解释同名类不能强转的原因(类身份=ClassLoader+ClassName)
区分主动使用和被动使用(哪些情况触发初始化)
解决 ClassLoader 泄漏问题(监控、缓存、清理)
如果这六点你都掌握了,类加载机制这块就扎实了。接下来看第四章,new 一个对象背后发生了什么。
第四章:对象创建与内存分配(new 背后的事)
4.1 new 的完整流程
写下 new Object() 这行简单代码时,JVM 其实经历了一系列步骤:
否
是
new Object()
类已加载?
且已初始化?
先做类加载与初始化
(Loading -> Linking -> Initialization)
分配内存
字段置零
(默认值初始化)
设置对象头
(Mark Word + 类型指针)
执行 方法
(构造方法)
返回引用
文字版:
- 类加载检查:类还没加载的话,先触发类加载机制(回看第三章)
- 分配内存:在堆中给对象划一块连续空间(指针碰撞 or 空闲列表)
- 字段置零:除对象头外的内存都初始化为零值(保证字段不赋值也能直接用)
- 设置对象头:填 Mark Word(哈希码、GC 年龄、锁状态)和类型指针(指向方法区的类元数据)
- 执行构造方法 :按程序员意愿初始化对象(
<init>方法) - 返回引用:把对象在堆中的地址压入操作数栈
4.2 为什么分配通常很快?
很多同学以为 new 很慢,其实 JVM 做了大量优化后,对象分配一般只需要 10~50 纳秒。
优化 1:TLAB(Thread Local Allocation Buffer)
问题 :堆是共享的,多个线程同时 new 对象时要加锁,竞争激烈怎么办?
办法:给每个线程在 Eden 区预分配一小块私有缓冲区(TLAB),线程优先从自己的 TLAB 分配,只有 TLAB 用完或分配大对象时才需要同步
Eden区 (共享) TLAB2 (私有) 线程2 TLAB1 (私有) 线程1 Eden区 (共享) TLAB2 (私有) 线程2 TLAB1 (私有) 线程1 TLAB1满了 new Object() (快速,无锁) new Object() (快速,无锁) 申请新的TLAB (偶尔同步)
启用方式 :默认开启(JDK 6+),用 -XX:-UseTLAB 关闭
优化 2:指针碰撞(Bump The Pointer)
前提 :堆内存规整(没有碎片,通常用 Serial/ParNew 这种 Copying 收集器时)
做法:维护一个指针指向 Eden 区末尾,分配对象就是把指针向后挪对象大小的距离
分配前:
[已使用......][______空闲______↑指针]
分配后:
[已使用......][新对象][__空闲__↑指针]
时间复杂度:O(1),很快!
对比:堆不规整的话(比如 CMS 这种 Mark-Sweep 收集器),要用"空闲列表"(Free List)找合适的内存块,慢一些
优化 3:逃逸分析(Escape Analysis)+ 标量替换
逃逸分析做什么:JIT 编译器分析对象的作用域,判断对象是否"逃逸"出方法或线程
- 未逃逸 :只在方法内使用 → 可能做栈上分配 或标量替换
- 已逃逸:被外部引用(返回、赋值给成员变量、传入其他方法)→ 只能在堆上分配
标量替换示例:
java
public int sum() {
Point p = new Point(1, 2); // p 未逃逸
return p.getX() + p.getY();
}
// JIT 可能优化成:
public int sum() {
int x = 1; // 栈上分配
int y = 2;
return x + y;
// Point 对象根本没有被创建!
}
查看是否开启了逃逸分析:
bash
java -XX:+PrintEscapeAnalysis -XX:+EliminateAllocations MyApp
4.3 示例:短命对象太多导致 Young GC 频繁
这是个常见的生产问题,很多人遇到过但不知道根因。
java
public class TempObjectDemo {
public static void main(String[] args) {
for (int i = 0; i < 10_000_000; i++) {
String s = "order-" + i + "-" + System.nanoTime();
// s 很快变成垃圾,生成速度过高的话 YGC 会很频繁
process(s);
}
}
static void process(String s) {
// 业务逻辑...
}
}
问题:
- 循环体每次迭代都创建新的 String(字符串拼接会产生 StringBuilder 和临时 char[])
- 这些对象都是"朝生夕死"
- Eden 区快满时触发 Minor GC
- 如果对象生成速度 > GC 回收速度,YGC 会非常频繁
现象:
GC 日志:
- YGC 频率:每秒几十次甚至上百次
- 单次停顿:10~30ms(看起来不长)
- 但累积效应:P99 RT 从 50ms 抖到 200ms+
解决方案:
java
// 方案1:复用 StringBuilder(减少临时对象)
StringBuilder sb = new StringBuilder(64); // 提前指定容量
for (int i = 0; i < 10_000_000; i++) {
sb.setLength(0); // 清空而不是新建
sb.append("order-").append(i).append("-").append(System.nanoTime());
process(sb.toString());
}
// 方案2:对象池化(对象创建成本高时用)
ObjectPool<StringBuilder> pool = ...;
for (...) {
StringBuilder sb = pool.borrowObject();
try {
sb.setLength(0);
// 使用 sb
} finally {
pool.returnObject(sb);
}
}
// 方案3:调整新生代大小(治标)
// -Xmn2g -XX:SurvivorRatio=8
选型:
- 优先:优化代码(减少不必要的对象创建)------ 最有效
- 其次:对象池化 ------ 适合创建成本高的对象(数据库连接、线程)
- 最后:调 JVM 参数 ------ 应急用,治标不治本
4.4 对象头的结构(面试常考)
每个 Java 对象在内存中有这三部分:
┌─────────────────────────────┐
│ 对象头 (Header) │ ← Mark Word (8 bytes on 64-bit)
│ │ ← 类型指针 ( Klass Pointer, 4/8 bytes )
│ │ ← 数组长度 (仅数组对象, 4 bytes)
├─────────────────────────────┤
│ 实例数据 (Instance Data) │ ← 各字段的值(可能有 Padding)
├─────────────────────────────┤
│ 对齐填充 (Padding) │ ← 保证对象大小是 8 字节的整数倍
└─────────────────────────────┘
Mark Word 存什么(64 位 JVM):
| 锁状态 | 25 bit | 31 bit | 1 bit | 4 bit | 1 bit | 2 bit |
|---|---|---|---|---|---|---|
| unused | identity_hashcode | age | biased_lock | lock | 01 (无锁) | |
| 偏向锁 | thread_id (54) | epoch | 01 | |||
| 轻量级锁 | ptr_to_lock_record (62) | 00 | ||||
| 重量级锁 | ptr_to_monitor (62) | 10 | ||||
| GC 标记 | empty (62) | 11 |
为什么对象头要存这么多?
- 哈希码 :
hashCode()/identityHashCode()的值(第一次调用时写入) - GC 分代年龄:对象在 Survivor 区熬过的 GC 次数(到阈值就晋升老年代)
- 锁状态标志:支持 synchronized 的锁升级(无锁→偏向锁→轻量级锁→重量级锁)
- 类型指针:指向方法区的类元数据,确定对象的类型
4.5 面试追问
问:对象一定在堆上吗?
回答:
- Java 规范要求对象在堆上分配
- 但 JIT 优化后可能出现:
- 栈上分配(Stack Allocation):未逃逸的小对象直接放栈上
- 标量替换(Scalar Replacement):对象拆成基本类型,根本不创建对象
- TLAB 快速通道:虽然在堆上,但在线程私有缓冲区里分配,几乎无竞争
验证:
bash
java -XX:+PrintEliminationAllocations MyApp
# 对比开启/关闭逃逸分析的差异
java -XX:+DoEscapeAnalysis MyApp # 开启(默认)
java -XX:-DoEscapeAnalysis MyApp # 关闭
问:大对象为什么可能直接进老年代?
原因:避免在 Eden 和 Survivor 之间来回复制(复制算法成本高)
参数:
bash
# 设置大对象阈值(单位:字节)
# 超过这个值的对象直接在老年代分配
-XX:PretenureSizeThreshold=3145728 # 3MB
# 注意:对 Serial 和 ParNew 有效
# CMS 要用 -XX:CMSSizeThreshold
适用场景:
- 大数组(
byte[] buffer = new byte[4 * 1024 * 1024]) - 大字符串(读整个文件到一个 String)
- 长生命周期的缓存对象
注意:大对象太多的话老年代很快就满了,触发 Full GC 或 OOM
4.6 常见坑
误区:CPU 不高就没性能问题
案例:
现象:订单接口 CPU 60%,RT 从 120ms 周期性抖到 900ms
误判:CPU 不高应该没问题吧?
真相:高频 Young GC 导致短暂停顿累积
证据:
- jstat -gcutil 显示 YGC 每秒 20+ 次
- 每次 YGC 停顿 15~25ms
- 高峰期请求正好撞上 GC 停顿,P99 劣化严重
原因:热点路径创建了大量短命对象(日志序列化、JSON 转换)
修复:优化代码 + 调新生代大小 + 升级到 G1
教训:
- 不能只看 CPU,还要关注 GC 次数、停顿时间、分配速率
- P99 比 P50 重要(用户体验取决于尾部延迟)
- 用 JFR(Java Flight Recorder) 全面采集性能数据
坑:忽略对象对齐填充的内存浪费
示例:
java
class A {
boolean a; // 1 byte
}
// 实际占用:16 bytes(Mark Word 8 + 指针 4 + 填充 4)
class B {
boolean a; // 1 byte
boolean b; // 1 byte
}
// 实际占用:还是 16 bytes(两个字段 + 14 bytes 填充)
class C {
boolean a; // 1 byte
int b; // 4 bytes
}
// 实际占用:16 bytes(紧凑排列,无需额外填充)
启示:
-
合理安排字段顺序可以减少浪费(按大小降序排)
-
对象数量巨大时(百万级),浪费会累积成显著开销
-
用 JOL(Java Object Layout)工具查看对象布局:
xml<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.17</version> </dependency>
4.7 本章小结
到这里第四章就讲完了。你应该能做到:
描述 new 的完整流程(类加载检查→分配内存→字段置零→设置对象头→执行构造方法)
解释 TLAB、指针碰撞、逃逸分析三种优化及其适用场景
分析短命对象导致 YGC 频繁的问题并给出解决方案
画出对象头的结构(Mark Word、类型指针、数组长度)
回答追问:对象一定在堆上吗?大对象为什么直接进老年代?
识别性能假象:CPU 不高不代表没 GC 问题
如果这六点你都掌握了,对象创建这块就扎实了。接下来看第五章,垃圾回收机制。
第五章:垃圾回收(GC)
5.1 什么是"垃圾"
主流 JVM 用可达性分析:从 GC Roots 出发,走不到的对象才能回收。
GC Roots
obj1
obj2
obj3孤岛
obj4孤岛
文字版:孤岛对象互相引用也没用,跟 Roots 断开就能回收。
5.2 三种基础算法
- 标记-清除(Mark-Sweep):直观,但有碎片;适用于老年代
- 复制(Copying):适合新生代,快但费额外空间(浪费一半);无碎片
- 标记-整理(Mark-Compact):减少碎片,但移动对象有成本;适用于老年代
怎么选:
- 新生代:98% 对象朝生夕死 → 复制算法(快,费空间可以接受)
- 老年代:存活率高、没额外空间 → 标记整理(或标记清除 + 分配列表)
5.3 为什么要分代?
因为大多数对象"朝生夕死",少数长期存活。
分代之后新生代可以高频快回收,老年代低频重回收。
分代假说:
- 弱分代假说:绝大多数对象朝生夕死
- 强分代假说:熬过越多次 GC 的对象越难消亡
- 跨代引用假说:跨代引用比同代引用少得多
5.4 收集器对比(面试版)
| 收集器 | 目标 | 适用场景 | 特点 |
|---|---|---|---|
| Serial | 简单、低资源占用 | 小内存、单核或客户端 | 单线程,STW 长 |
| Parallel | 高吞吐 | 批处理、离线任务 | 多线程并行,追求吞吐量 |
| G1 | 吞吐和停顿平衡 | 大多数在线服务的默认选择 | Region 化,可预测停顿 |
| ZGC | 极低停顿 | 超大堆、低延迟敏感 | < 10ms 停顿(JDK 15+ 正式商用) |
| Shenandoah | 低停顿 | 发行版支持需评估 | 类似 ZGC,Red Hat 主导 |
注:CMS 在 JDK 14 中已移除,面试时可以作为"历史方案 + 缺点"来回答。
5.5 G1 回收周期(常考)
Young GC
(Eden满触发)
并发标记开始
(Initiating Heap Occupancy)
并发标记
(Concurrent Marking)
Remark STW
(Final Marking)
筛选高收益Region
(Live Data Counting)
Mixed GC
(回收新生代+部分老年代)
文字版:
- G1 把堆拆成多个 Region(通常 2048 个,每个 1~32MB),不再固定连续的新生代/老年代布局
- Mixed GC 回收"新生代 + 部分老年代高收益 Region"(回收收益最高的 Region 优先)
MaxGCPauseMillis是目标,不是硬性承诺(JVM 尽力而为,但不能保证)
G1 常用参数:
bash
-XX:+UseG1GC # 启用 G1
-XX:MaxGCPauseMillis=200 # 目标停顿 200ms
-XX:InitiatingHeapOccupancyPercent=45 # 堆占用 45% 时触发并发标记
-XX:G1HeapRegionSize=16m # Region 大小(2/4/8/16/32m)
-XX:G1ReservePercent=15 # 预留内存防 to-space overflow
5.6 GC 日志怎么看
关注四个指标:
| 指标 | 含义 | 正常 | 异常信号 |
|---|---|---|---|
| Allocation Rate | 每秒分配的字节数 | 取决于业务 | 突然飙升可能是 bug |
| GC Frequency | YGC/FGC 频率 | YGC 几秒~几分钟一次 | YGC 每秒几十次 = 有问题 |
| Pause Time | 单次 GC 停顿时长 | YGC < 50ms, FGC < 500ms | 经常超目标值 |
| Reclaimed Bytes | 每次回收的内存量 | 应该有明显回收 | 回收量很少 = 泄漏 |
注意:YGC 短但非常频繁的话,P99 仍然可能抖动(累积效应)。
工具:
- GCViewer:可视化 GC 日志
- GCEasy:在线分析(上传日志即可)
- JFR(Java Flight Recorder):JDK 自带的飞行记录仪(推荐)
5.7 面试追问
问:Full GC 不多但接口还是很慢?
回答方向:
- 高频 Young GC:单次停顿短但频率高,累积效应明显
- 对象分配速率太高:热点路径创建了太多临时对象(JSON 序列化、字符串拼接)
- 非 GC 因素:锁竞争、I/O 等待、数据库慢查询、网络延迟
- 混合因素:GC 导致的 STW 与其他等待叠加
排查思路:
- 看 GC 日志:
jstat -gcutil <pid> 1000 - 看分配速率:
jstat -gc <pid> - 看线程栈:
jstack <pid>是否有锁等待 - 看 I/O:
iostat -x 1是否磁盘瓶颈
问:G1 和 ZGC 怎么选?
决策树:
堆大小 < 4GB?
├─ 是 → G1 或 Parallel(ZGC 杀鸡用牛刀)
└─ 否 →
停顿要求 < 10ms?
├─ 是 → ZGC(JDK 21+ 推荐)
└─ 否 → G1(稳妥选择)
G1 的优势 :生态成熟,资料丰富,可预测停顿(MaxGCPauseMillis),适合大多数在线服务
ZGC 的优势:停顿极短(< 10ms,与堆大小无关),支持 TB 级大堆,适合低延迟场景(金融交易、实时竞价)
ZGC 的劣势:吞吐量略低于 G1(约 5~15%),相对较新,生产案例较少,需要较新的 JDK(推荐 21 LTS)
5.8 常见坑
- 误区:追求"GC 次数越少越好"
- 后果:可能牺牲吞吐或占用过多内存(Full GC 虽少但单次停顿长)
- 修正:围绕 SLO(比如 P99 < 200ms)做平衡,不追单指标最优
第六章:JMM 与并发语义
6.1 JMM 管什么?
JMM 主要约束多线程下的三件事:
- 可见性(Visibility):一个线程改了共享变量,另一个线程能不能立即看到
- 原子性(Atomicity):一个或多个操作是不是不可分割(要么全成功,要么全失败)
- 有序性(Ordering):程序执行的顺序是不是按代码写的顺序来
为什么需要 JMM?
- 不同硬件架构有不同的内存模型(x86 强一致性,ARM 弱一致性)
- 编译器和 CPU 都会做指令重排序优化
- JMM 屏蔽底层差异,提供一致的并发语义
6.2 volatile 能做什么、不能做什么
能做的:
- 保证可见性(写主内存 + 让其他线程缓存失效)
- 禁止特定类型的指令重排序(Memory Barrier)
不能做的:
- 不保证复合操作的原子性(比如
count++是读-改-写三步操作)
示例:volatile 保不住 count++:
java
class Counter {
volatile int count = 0;
void inc() {
count++; // 实际是:1.读取count 2.count+1 3.写回count
// 不是原子操作!
}
}
// 多线程并发调用 inc() 时,count 结果可能小于预期
什么时候用 volatile:
- 状态标志位 :
volatile boolean running = true;(停止标志) - 单写多读:一个线程写,多个线程读(比如配置项)
- DCL 双重检查锁定 :配合
synchronized使用
6.3 happens-before 规则(面试必会)
happens-before 定义了操作 A 的结果对操作 B 可见的规则。
8 条规则:
| 规则 | 含义 | 示例 |
|---|---|---|
| 程序次序规则 | 同一线程中,前面的操作 hb 后面的 | a=1; b=a+1; → a=1 hb b=a+1 |
| 监视器锁规则 | unlock hb 后续同一把锁的 lock | synchronized 块内的修改对下次进入的线程可见 |
| volatile 规则 | volatile 写 hb 后续的 volatile 读 | volatile 变量的修改立即可见 |
| 线程启动规则 | Thread.start() hb 该线程的任何操作 | 主线程设置的属性对新线程可见 |
| 线程终止规则 | 线程的所有操作 hb 其他线程检测到它终止 | join() 后能看到线程内的修改 |
| 线程中断规则 | interrupt() hb 被中断线程检测到中断 | isInterrupted() 能看到 interrupt() 调用 |
| 对象终结规则 | 构造函数执行完毕 hb finalize() | finalize() 能看到构造函数内的赋值 |
| 传递性 | A hb B, B hb C → A hb C | 传递闭包 |
记忆技巧:不需要死记硬背,理解每条的工程含义就行。
6.4 经典 DCL 单例为什么必须加 volatile
java
public class Singleton {
private static volatile Singleton INSTANCE; // 必须加 volatile!
private Singleton() {}
public static Singleton getInstance() {
if (INSTANCE == null) { // 第一次检查(无锁)
synchronized (Singleton.class) { // 加锁
if (INSTANCE == null) { // 第二次检查(锁内)
INSTANCE = new Singleton(); // ⚠️ 非原子操作
}
}
}
return INSTANCE;
}
}
为什么 new Singleton() 不是原子的?
实际步骤:
1. 分配内存空间
2. 初始化对象(调用构造方法)
3. 将引用指向内存地址(INSTANCE != null)
如果 2 和 3 重排序:
1. 分配内存空间
3. 将引用指向内存地址(INSTANCE != null,但对象没初始化!)
2. 初始化对象
此时其他线程看到 INSTANCE != null,拿到的是"半初始化对象"!
volatile 的作用:禁止步骤 2 和 3 的重排序,保证对象完全初始化后才对其他线程可见。
6.5 面试追问
问:synchronized 和 ReentrantLock 怎么选?
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 内置(monitorenter/monitorexit) | API 层面(CAS + AQS) |
| 锁释放 | 自动释放(代码块结束或异常) | 必须在 finally 里手动 unlock() |
| 可中断 | 不行 | lockInterruptibly() |
| 超时 | 不支持 | tryLock(timeout) |
| 公平性 | 非公平 | 可选公平/非公平 |
| 条件队列 | 单一(wait/notify) | 多个 Condition(精确唤醒) |
| 性能 | JDK 6+ 做了锁优化(偏向/轻量/重量),差距很小 | 略优(但差距可忽略) |
选型建议:
- 简单互斥 :优先
synchronized(简洁、不容易出错) - 需要高级功能 (可中断、超时、多条件、公平锁):选
ReentrantLock - 读多写少 :考虑
ReadWriteLock(如ReentrantReadWriteLock)
问:什么叫"安全发布对象"?
定义:对象的引用对其他线程可见时,该对象的所有字段也同时正确初始化完成。
安全发布的方式:
- 静态初始化器 :
public static final Holder holder = new Holder();(JLS 保证) - 通过 volatile :
volatile Holder holder = new Holder(); - 通过 final 字段 :
private final int value;(final 语义保证) - 通过锁 :在
synchronized块内初始化并发布 - 通过并发容器 :
ConcurrentHashMap.put(key, new Value()) - 通过 Future :
Future<Value> f = executor.submit(task); f.get();
不安全的例子:
java
public class UnsafePublish {
private Holder holder; // 非 volatile,非 final
public void initialize() {
holder = new Holder(42); // 其他线程可能看到 holder!=null 但 value=0
}
}
6.6 常见坑
- 误区 :共享变量全部改成
volatile - 后果 :复合操作仍然不安全(
count++),还可能导致伪共享(False Sharing)影响性能 - 修正 :先判断需不需要原子复合语义,再决定用
synchronized/Atomic*/LongAdder/volatile
第七章:JVM 调优
7.1 标准调优步骤
- 定目标 :比如
P99 < 200ms、吞吐 > 3w QPS、FGC 频率 < 1次/小时 - 采证据 :GC 日志(
-Xlog:gc*)、JFR、线程栈(jstack)、系统指标(top/iostat) - 建假设:是分配速率的问题?还是锁竞争?还是 I/O 瓶颈?
- 小步验证:一次只改 1~2 个关键参数,对比前后指标
核心原则 :没有银弹,只有权衡。
7.2 一个实战案例
场景:订单接口 RT 周期性从 120ms 抖到 900ms。
- 现象:CPU 60% 左右,不算满
- 数据:YGC 很频繁(每秒 20+ 次),每次停顿 20~40ms
- 根因:热点路径创建了大量临时对象(JSON 序列化、日志格式化)
- 处理 :
- 优化代码:复用 StringBuilder、用对象池
- 调参数:增大新生代(
-Xmn2g)、降低 IHOP(-XX:InitiatingHeapOccupancyPercent=35) - 复测:YGC 频率降到每分钟几次,P99 恢复稳定
- 结果:RT P99 从 900ms 降到 150ms,满足 SLO
7.3 G1 参数示例
bash
-server
-Xms4g -Xmx4g # 锁定堆大小,避免扩缩容抖动
-XX:+UseG1GC # 使用 G1
-XX:MaxGCPauseMillis=200 # 目标停顿 200ms
-XX:InitiatingHeapOccupancyPercent=35 # 堆占用 35% 时触发并发标记
-XX:ParallelRefProcEnabled # 并行处理 Reference 对象
-XX:+HeapDumpOnOutOfMemoryError # OOM 时自动 Dump
-XX:HeapDumpPath=/data/dump # Dump 保存路径
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags # GC 日志
7.4 参数边界
-
-Xms和-Xmx建议一致:减少堆扩缩容带来的 STW 抖动 -
别把机器内存全给堆:要留给元空间、线程栈、直接内存、Native 内存(建议堆占物理内存的 60~75%)
-
容器环境(K8s/Docker):用比例配置
bash# 推荐 -XX:MaxRAMPercentage=75.0 # 避免(容器内存变化时要手动改) -Xms4g -Xmx4g
7.5 面试追问
问:调优第一步是什么?
回答:
- 先定 SLO(Service Level Objective):没有目标谈不上优化(比如 P99 < 200ms、QPS > 1w)
- 建立基线:优化前采集基线数据(GC 日志、JFR、APM 指标)
- 拒绝玄学:不凭感觉调参,每次改动都要有数据支撑
问:怎么证明参数有效?
回答:
- 同压测模型对比:相同的流量模式、数据量、并发度
- 多维度指标:不只看平均 RT,要看 P95/P99、GC 次数、停顿时间分布、CPU 利用率
- 长时间验证:至少观察 24~72 小时(排除周期性波动)
- 可回滚:保留原始参数配置,确保能快速回滚
7.6 常见坑
- 误区:复制网上"最优参数模板"直接上线
- 后果:业务特征不同(读多写少 vs 写多读少、小对象 vs 大对象),可能适得其反
- 修正 :按"业务类型 + 堆大小 + 延迟目标"定制参数,参考但不盲从网上的配置
第八章:故障排查 SOP
8.1 先止血,再定位
能
不能
是
否
是
否
故障发生
RT高/CPU高/OOM/频繁FGC
能登录机器吗?
采集现场数据
jcmd/jstat/jstack/top/iostat
紧急止血
限流/降级/扩容/回滚
GC异常明显?
FGC频繁/OOM
分析GC日志和HeapDump
MAT/Eclipse Memory Analyzer
分析线程栈
锁竞争/死锁/CPU热点
定位根因
制定修复方案
小步修复并回归验证
指标恢复?
复盘总结
更新SOP
回滚或升级处理
8.2 OOM 快速索引
| OOM 类型 | 第一怀疑点 | 第一步动作 | 排查命令 |
|---|---|---|---|
Java heap space |
堆泄漏/突发流量 | 立刻 Dump 堆看 Dominator Tree | jcmd <pid> GC.heap_dump + MAT |
Metaspace |
类加载器泄漏/动态类太多 | 看 ClassLoader 数量趋势 | jcmd <pid> VM.classloader_stats |
Direct buffer memory |
NIO/Netty Buffer 没释放 | 查 Buffer 生命周期 | jcmd <pid> VM.native_memory detail |
unable to create new native thread |
线程爆炸 / Xss 太大 | 统计线程数,审计线程池 | jstack <pid> | grep -c "java.lang.ThreadState" |
8.3 常用命令
bash
# 1) 查看进程
jps -l
# 2) GC 概览(每秒刷新)
jstat -gcutil <pid> 1000
# 3) 导出线程栈(分析死锁、锁等待、CPU热点)
jstack -l <pid> > /tmp/jstack.txt
# 4) 查看类直方图(找出占用内存最多的类)
jcmd <pid> GC.class_histogram
# 5) 堆转储
jcmd <pid> GC.heap_dump /tmp/heap.hprof
# 6) 本地内存摘要(排查直接内存问题)
jcmd <pid> VM.native_memory summary
# 7) 查看 JVM 参数
jcmd <pid> VM.flags
# 8) 查看 System Properties
jcmd <pid> system.properties
8.4 案例:ThreadLocal 泄漏
错误写法(线程池场景常见):
java
private static final ThreadLocal<byte[]> LOCAL = new ThreadLocal<>();
public void handle() {
LOCAL.set(new byte[1024 * 1024]); // 每次 set 一个 1MB 数组
// 业务逻辑...
// 忘记 remove()
// 线程池里线程会被复用,value 一直挂在 Thread 上
// 结果:线程数 × 1MB 的内存泄漏
}
正确写法:
java
public void handle() {
try {
LOCAL.set(new byte[1024 * 1024]);
// 业务逻辑...
} finally {
LOCAL.remove(); // 必须在 finally 中 remove!
}
}
为什么线程池场景特别危险?
- 线程池中的线程是复用的,不会销毁
- 不
remove()的话,ThreadLocal 的 value 会一直存在 - 随请求积累,内存持续增长,最终 OOM
怎么检测:
bash
# 1) Dump 堆
jcmd <pid> GC.heap_dump /tmp/heap.hprof
# 2) 用 MAT 分析
# 查询:select * from java.lang.ThreadLocal
# 检查 entry 数量是否异常大
第九章:面试冲刺
9.1 你要能串起来的知识链
- 内存结构:哪些区线程私有,哪些共享,对应什么异常
- 类加载:双亲委派作用、破坏场景、类型身份判定
- 对象分配:TLAB、晋升、对象头、分配热点
- GC:算法权衡、G1 周期、为什么会抖动
- JMM :happens-before、
volatile边界、DCL - 排障:OOM/RT 抖动时的证据链思路
9.2 高频题速答
Q1:Minor GC 和 Full GC 区别?
- 答:作用域和停顿代价不同。Minor GC 只针对新生代(快,毫秒级);Full GC 通常范围更广(新生代+老年代+元空间),停顿更长(几百毫秒到秒级)。
Q2:Full GC 不多但接口还是慢?
- 答:高频 Young GC(累积停顿)、对象分配速率高、锁竞争、I/O 抖动都可能导致慢。综合分析 GC 日志、线程栈、系统指标。
Q3:volatile 能替代锁吗?
- 答:仅限单写多读或状态发布场景(停止标志、配置更新);复合读写(如
count++)仍需锁/CAS/Atomic*。
Q4:怎么判断内存泄漏?
- 答:看趋势而非瞬时值;Full GC 后堆占用仍持续上升且无法回落到基线,结合 Dominator Tree 找到 GC Root 到泄漏对象的引用链。
9.3 面试官深挖时怎么展开
当面试官追问细节时,展示你的工程思维:
- 给出场景约束:吞吐优先还是延迟优先?堆多大?QPS 多少?
- 给出证据来源:你怎么得出这个结论的?(GC 日志截图、JFR 数据、火焰图)
- 给出方案权衡:收益、代价、风险、怎么回滚
这样能把你和"只会背定义"的候选人区分开。