JVM:类加载、内存、GC 与性能诊断
本文从 JVM 整体架构出发,深入剖析类加载机制、对象内存布局、垃圾回收算法(分代 GC / G1 / ZGC)、即时编译原理,以及 synchronized 锁升级的底层实现,并给出生产环境的诊断工具使用指南。
目录
| 章节 | 说明 |
|---|---|
| JVM 整体架构 | 运行时数据区、执行引擎 |
| 类加载机制 | 加载→链接→初始化、双亲委派 |
| 对象内存布局 | 对象头、压缩指针、字段重排列 |
| 垃圾回收基础 | 可达性分析、三种回收方式、安全点 |
| 分代垃圾回收 | 新生代 Minor GC、老年代、卡表 |
| 垃圾回收器 | Serial/Parallel/CMS/G1/ZGC |
| 即时编译(JIT) | C1/C2、分层编译、OSR |
| synchronized 底层实现 | 偏向锁→轻量级锁→重量级锁 |
| JVM 内存模型 | Happens-Before、内存屏障 |
| JVM 诊断工具 | jps/jstat/jmap/jstack/jcmd |
| JVM 调优实践 | GC 参数、堆大小、线程栈 |
JVM 整体架构
┌─────────────────────────────────────────────────────────────┐
│ Java 源代码 │
│ ↓ javac │
│ 字节码 (.class) │
└─────────────────────────────────────────────────────────────┘
↓ 类加载
┌─────────────────────────────────────────────────────────────┐
│ JVM 运行时 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 运行时数据区 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │ │
│ │ │方法区 │ │ 堆 │ │ 线程私有区域 │ │ │
│ │ │(元空间) │ │(GC 管理) │ │ 虚拟机栈/本地方法栈 │ │ │
│ │ │类信息/常量│ │对象实例 │ │ 程序计数器 │ │ │
│ │ └──────────┘ └──────────┘ └──────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 执行引擎 │ │
│ │ 解释器 → C1 编译器 → C2 编译器(分层编译) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
运行时数据区
| 区域 | 线程 | 内容 | OOM 风险 |
|---|---|---|---|
| 堆(Heap) | 共享 | 所有对象实例、数组 | 是(最常见) |
| 方法区(元空间) | 共享 | 类信息、常量池、静态变量 | 是(类加载过多) |
| 虚拟机栈 | 私有 | 栈帧(局部变量表、操作数栈、动态链接) | StackOverflow |
| 本地方法栈 | 私有 | Native 方法栈帧 | StackOverflow |
| 程序计数器 | 私有 | 当前执行字节码行号 | 无 |
类加载机制
三个阶段
flowchart LR
A["加载<br/>查找字节流<br/>创建 Class 对象"] --> B["链接"]
B --> B1["验证<br/>约束检查"]
B --> B2["准备<br/>静态字段分配内存"]
B --> B3["解析<br/>符号引用→实际引用"]
B1 & B2 & B3 --> C["初始化<br/>执行 <clinit>"]
加载:通过类加载器查找字节流(.class 文件、网络、动态生成),创建 Class 对象。
链接:
- 验证:确保字节码满足 JVM 约束(格式、语义、字节码、符号引用验证)
- 准备:为静态字段分配内存并赋零值(不是代码中的初始值)
- 解析:将符号引用替换为直接引用(内存地址)
初始化:执行 <clinit> 方法(静态代码块 + 静态字段赋值),JVM 保证线程安全,且只执行一次。
触发初始化的时机
new指令新建对象- 调用静态方法
- 访问静态字段(非 final 常量)
- 子类初始化触发父类初始化
- 反射调用
- JVM 启动时的主类
// 利用类初始化的线程安全性实现懒加载单例
public class Singleton {
private Singleton() {}
private static class LazyHolder {
// 只有调用 getInstance() 时才触发 LazyHolder 的初始化
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
双亲委派模型
每个类加载器收到加载请求时,先委派给父加载器,父加载器无法完成时才自己尝试。
graph TD
A["Bootstrap ClassLoader<br/>C++ 实现,加载 java.base 等核心类"] --> B["Platform ClassLoader<br/>Java 9+,加载 Java SE 平台模块"]
B --> C["Application ClassLoader<br/>加载 classpath 下的应用类"]
C --> D["自定义 ClassLoader"]
意义:
- 防止核心类被覆盖(如自己写一个
java.lang.String不会生效) - 同一类由不同 ClassLoader 加载会得到两个不同的 Class 对象(可用于实现热部署、插件隔离)
对象内存布局
对象头(Object Header)
每个 Java 对象的内存开头是对象头,包含:
| 字段 | 大小 | 内容 |
|---|---|---|
| Mark Word | 64 bit | 哈希码、GC 年龄、锁状态标志 |
| 类型指针 | 64 bit(未压缩)/ 32 bit(压缩) | 指向类元数据 |
| 数组长度 | 32 bit(仅数组) | 数组大小 |
开启压缩指针(默认):类型指针从 64 bit 压缩为 32 bit,对象头从 16 字节降至 12 字节。
# 压缩指针相关参数
-XX:+UseCompressedOops # 开启(默认)
-XX:ObjectAlignmentInBytes=8 # 内存对齐粒度(默认 8 字节)
堆超过 32GB 时,压缩指针自动关闭(32 bit × 8 字节对齐 = 32GB 寻址空间)。
字段重排列
JVM 会对字段进行重排列以满足内存对齐,减少填充浪费:
# 示例:启用压缩指针时,class B extends A 的内存布局
OFFSET SIZE TYPE
0 4 (object header - mark word 低 32 bit)
4 4 (object header - mark word 高 32 bit)
8 4 (object header - 类型指针)
12 4 int A.i ← JVM 将 int 字段提前填充 4 字节空缺
16 8 long A.l
24 8 long B.l
32 4 int B.i
36 4 (padding)
内存浪费分析
// Integer 对象的内存占用
// 对象头:12 字节(压缩指针)
// int value:4 字节
// padding:0 字节(刚好 16 字节对齐)
// 总计:16 字节,而 int 本身只有 4 字节
// 额外开销高达 300%!
Integer i = 42; // 16 字节 vs 基本类型 int 的 4 字节
垃圾回收基础
可达性分析
JVM 以一系列 GC Roots 为起点,遍历所有可达对象(标记为存活),未被遍历到的对象即为垃圾。
GC Roots 包括:
- 虚拟机栈中引用的对象(局部变量)
- 已加载类的静态变量
- JNI handles
- 已启动且未停止的 Java 线程
可达性分析解决了引用计数法无法处理循环引用的问题。
三种回收方式
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 清除(Sweep) | 标记死亡对象,记入空闲列表 | 简单 | 内存碎片、分配效率低 |
| 压缩(Compact) | 存活对象移至内存起始位置 | 无碎片,指针碰撞分配 | 移动对象开销大 |
| 复制(Copy) | 内存分两半,存活对象复制到另一半 | 无碎片,分配快 | 空间利用率只有 50% |
现代 GC 综合三种方式:新生代用复制(大部分对象死亡,复制代价低),老年代用标记-压缩。
Stop-the-World 与安全点
GC 标记阶段需要暂停所有应用线程(Stop-the-World),防止标记过程中对象引用被修改。
JVM 通过**安全点(Safepoint)**机制实现 STW:应用线程在安全点检测到 GC 请求后主动挂起。安全点通常位于:
- 方法返回前
- 循环回边(非计数循环)
- 异常抛出处
分代垃圾回收
分代假设
大部分对象"朝生夕死",少部分对象长期存活。基于此,JVM 将堆分为新生代和老年代,针对不同代使用不同 GC 算法。
堆结构
堆
├── 新生代(Young Generation)
│ ├── Eden 区(新对象分配,约 80%)
│ ├── Survivor From(S0)
│ └── Survivor To(S1,始终保持空)
└── 老年代(Old Generation)
Minor GC(新生代 GC)
触发条件:Eden 区空间耗尽。
执行过程:
- 标记 Eden + S0(From)中的存活对象
- 将存活对象复制到 S1(To)
- 交换 S0 和 S1 的角色
- 复制次数超过阈值(默认 15)的对象晋升老年代
-XX:MaxTenuringThreshold=15 # 晋升老年代的年龄阈值
-XX:SurvivorRatio=8 # Eden:Survivor = 8:1:1
-XX:TargetSurvivorRatio=50 # Survivor 使用率超过 50% 时提前晋升
TLAB(Thread-Local Allocation Buffer):每个线程预先申请一块私有内存区域,对象分配通过指针碰撞完成,无需加锁,大幅提升分配效率。
卡表(Card Table)
Minor GC 只扫描新生代,但老年代对象可能引用新生代对象。为避免全堆扫描,HotSpot 引入卡表:
- 将整个堆划分为 512 字节的"卡"
- 维护卡表数组,标记可能含有跨代引用的"脏卡"
- Minor GC 时只扫描脏卡,而非整个老年代
写屏障:每次引用赋值时,JIT 插入一条指令将对应卡标记为脏:
CARD_TABLE[address >> 9] = DIRTY; // 相当于 address / 512
Full GC(老年代 GC)
触发条件:老年代空间不足、显式调用 System.gc()、晋升失败等。
Full GC 代价极高(全堆扫描 + 长时间 STW),应尽量避免。
垃圾回收器
垃圾回收器概览
| 回收器 | 适用代 | 算法 | 特点 |
|---|---|---|---|
| Serial | 新生代 | 标记-复制 | 单线程,Client 模式默认 |
| ParNew | 新生代 | 标记-复制 | 多线程版 Serial,配合 CMS |
| Parallel Scavenge | 新生代 | 标记-复制 | 注重吞吐率 |
| Serial Old | 老年代 | 标记-压缩 | 单线程 |
| Parallel Old | 老年代 | 标记-压缩 | 多线程,注重吞吐率 |
| CMS | 老年代 | 标记-清除 | 并发收集,低延迟(Java 9 废弃) |
| G1 | 全堆 | 标记-压缩 | 可预测停顿,Java 9+ 默认 |
| ZGC | 全堆 | 标记-压缩 | 停顿 < 10ms(Java 11+) |
G1(Garbage First)
G1 打破了传统分代的固定边界,将堆划分为大量等大小的 Region(默认 1~32MB),每个 Region 可动态充当 Eden/Survivor/Old/Humongous(大对象)。
flowchart TD
A["G1 GC 周期"] --> B["Young GC<br/>回收 Eden + Survivor Region"]
A --> C["Mixed GC<br/>回收 Young + 部分 Old Region"]
A --> D["Full GC<br/>退化兜底,尽量避免"]
style D fill:#fcc,stroke:#c00
G1 的核心优势:
- 可设置最大停顿时间目标(
-XX:MaxGCPauseMillis=200) - 优先回收垃圾最多的 Region(Garbage First 的由来)
- 并发标记阶段与应用线程同时运行
关键内部机制:
- RSet(记忆集):每个 Region 维护一个 Hash Table,记录"哪些其他 Region 引用了我"(points-into)。YGC 时只扫描年轻代的 RSet,避免全量扫描老年代。
- SATB(快照标记):基于三色标记,通过 pre-write barrier 记录被替换的旧引用,防止并发标记时漏标白对象。代价是产生少量 float garbage。
- 停顿预测模型:基于衰减标准偏差,用历史数据预测本次 GC 耗时,动态决定纳入 CSet 的 Region 数量以满足停顿目标。
Mixed GC 关键触发参数:
| 参数 | 说明 | 默认值 |
|---|---|---|
InitiatingHeapOccupancyPercent | 触发并发标记的堆占用率 | 45% |
G1HeapWastePercent | 垃圾占比阈值,达到才触发 Mixed GC | 5% |
G1MixedGCLiveThresholdPercent | 老年代 Region 存活率上限,超过则不纳入 CSet | 85% |
G1MixedGCCountTarget | 一次并发标记后最多执行 Mixed GC 次数 | 8 |
# G1 常用参数
-XX:+UseG1GC # 启用 G1(Java 9+ 默认)
-XX:MaxGCPauseMillis=200 # 目标最大停顿时间(毫秒)
-XX:G1HeapRegionSize=4m # Region 大小(1~32MB,2 的幂次)
-XX:G1NewSizePercent=5 # 新生代最小占比
-XX:G1MaxNewSizePercent=60 # 新生代最大占比
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的堆占用率
ZGC(Java 11+)
ZGC 的目标是将 GC 停顿控制在 10ms 以内,且停顿时间不随堆大小增长(支持 8MB~4TB 堆)。
核心技术:
- 染色指针(Colored Pointers):将对象存活元数据存储在指针第 42~45 位,无需访问对象头即可获取 GC 状态
- 读屏障:应用线程读取堆引用时自动触发,将旧地址更新为新地址,保障并发转移正确性
ZGC vs G1 对比:
| 维度 | G1 | ZGC |
|---|---|---|
| 转移阶段 | 完全 STW | 并发执行 |
| STW 次数 | 多次 | 仅 3 次(初始标记、再标记、初始转移) |
| 停顿随堆增长 | 是 | 否 |
| 吞吐量 | 高 | 略低(读屏障开销) |
| 适用场景 | 通用 | 低延迟优先 |
美团实践经验(TP999 下降 18%~74%):
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 流量突增时内存分配阻塞 | 自适应触发 GC 不及时 | -XX:ZCollectionInterval=5(固定间隔触发) |
| 压测时吞吐瓶颈 | 并发回收线程不足 | 增大 -XX:ConcGCThreads |
| 单次 GC 停顿 30ms | 上万个 ClassLoader 导致 GC Roots 过大 | 复用单一 ClassLoader |
⚠️ ZGC 适用场景:低延迟服务(TP999 < 200ms 收益最大)。吞吐优先场景建议保留 G1,单代设计 + 读屏障会降低吞吐量。
-XX:+UseZGC # 启用 ZGC(Java 15+ 正式版)
-Xmx16g -Xms16g # 建议固定堆大小,避免扩缩容
-XX:ZCollectionInterval=5 # 固定 GC 间隔(秒),防止流量突增时分配阻塞
-XX:ConcGCThreads=4 # 并发 GC 线程数(默认 CPU 核数/4)
方法调用机制
五种字节码调用指令
| 指令 | 用途 | 分派时机 |
|---|---|---|
invokestatic | 静态方法 | 编译期确定(非虚方法) |
invokespecial | 构造器 <init>、私有方法、super() | 编译期确定(非虚方法) |
invokevirtual | 所有虚方法(含 final,虽然 final 不可重写) | 运行时多态分派 |
invokeinterface | 接口方法 | 运行时确定实现类 |
invokedynamic | 动态调用(Lambda、Groovy 等),分派逻辑由用户引导方法决定 | 运行时动态解析 |
非虚方法(编译期确定):
invokestatic+invokespecial调用的方法,以及final方法(虽用invokevirtual)。 虚方法:其余所有方法,运行时通过动态分派(vtable)确定实际调用版本。
解析 vs 分派
- 解析:类加载阶段将符号引用转为直接引用,仅适用于非虚方法(调用目标编译期可知且运行期不变)
- 静态分派:编译期根据静态类型选择方法版本(方法重载的实现基础)
- 动态分派:运行时根据对象实际类型确定方法版本(方法重写/多态的实现基础),
invokevirtual每次都要在 vtable 中查找
即时编译(JIT)
分层编译
Java 8+ 默认开启分层编译(-XX:+TieredCompilation),结合 C1 的快速编译和 C2 的深度优化:
| 层次 | 执行方式 | 特点 |
|---|---|---|
| 0 | 解释执行 | 启动快,无编译开销 |
| 1 | C1,无 profiling | 快速编译,适合简单方法 |
| 2 | C1,部分 profiling | 收集调用次数、循环次数 |
| 3 | C1,全量 profiling | 收集分支预测、类型信息 |
| 4 | C2,深度优化 | 内联、逃逸分析、循环展开等 |
典型编译路径:
解释执行(0) → C1 全量 profiling(3) → C2 深度优化(4)
C2 代码比 C1 代码性能高 30% 以上。
触发条件
方法调用次数 + 循环回边次数超过阈值时触发编译。阈值随编译队列长度动态调整(队列越长,阈值越高,防止 C2 过载)。
OSR(On-Stack-Replacement)
OSR 允许在方法执行过程中(而非方法入口)切换到编译后的代码,专门解决"单次调用但包含热循环"的场景:
// 这种方法只会被调用一次,但循环是热点
// OSR 会在循环执行到一定次数后,直接替换栈帧,切换到编译代码
public static void main(String[] args) {
long sum = 0;
for (int i = 0; i < 1_000_000_000; i++) { // 热循环
sum += i;
}
System.out.println(sum);
}
主要优化技术
| 优化 | 说明 |
|---|---|
| 方法内联 | 将被调用方法的代码直接嵌入调用处,消除调用开销 |
| 逃逸分析 | 判断对象是否逃逸出方法/线程,若不逃逸则栈上分配或标量替换 |
| 循环展开 | 减少循环控制指令,提升指令级并行度 |
| 锁消除 | 逃逸分析证明锁不会被多线程竞争时,消除加锁操作 |
| 锁粗化 | 将多次连续的加锁合并为一次,减少加锁次数 |
-XX:+PrintCompilation # 打印 JIT 编译情况
-XX:+PrintInlining # 打印内联决策
-XX:CompileThreshold=10000 # 触发 C2 的调用次数阈值(关闭分层编译时有效)
synchronized 底层实现
字节码层面
// synchronized 代码块
public void foo(Object lock) {
synchronized (lock) {
lock.hashCode();
}
}
// 编译为:monitorenter ... monitorexit(正常路径)
// ... monitorexit(异常路径)
// JVM 保证两条路径都能解锁
锁状态与 Mark Word
对象头的 Mark Word 最后几位标识锁状态:
| 状态 | 标志位 | Mark Word 内容 |
|---|---|---|
| 无锁 | 01 | 哈希码、GC 年龄 |
| 偏向锁 | 101 | 线程 ID、epoch、GC 年龄 |
| 轻量级锁 | 00 | 指向栈帧中锁记录的指针 |
| 重量级锁 | 10 | 指向 Monitor 对象的指针 |
| GC 标记 | 11 | GC 算法使用 |
锁升级详解
偏向锁(针对单线程反复获取同一把锁):
- 首次加锁:CAS 将线程 ID 写入 Mark Word,设置标志位为 101
- 后续加锁:检查 Mark Word 中的线程 ID 是否匹配,匹配则直接返回(零 CAS)
- 撤销代价高:需要等待安全点,遍历所有线程栈
轻量级锁(针对多线程在不同时间段获取同一把锁):
- 加锁:在当前线程栈帧中分配锁记录,CAS 将 Mark Word 替换为锁记录地址
- 解锁:CAS 将 Mark Word 还原
- 竞争失败:膨胀为重量级锁
重量级锁(针对多线程同时竞争):
- 依赖操作系统 mutex(pthread_mutex)
- 阻塞/唤醒需要从用户态切换到内核态,开销极大
- 引入自适应自旋:在阻塞前先自旋,根据历史成功率动态调整自旋次数
sequenceDiagram
participant T1 as 线程 T1
participant T2 as 线程 T2
participant Lock as 锁对象
T1->>Lock: 首次加锁(CAS 设置偏向锁)
T1->>Lock: 再次加锁(检查线程 ID 匹配,直接返回)
T2->>Lock: 请求加锁(线程 ID 不匹配)
Note over Lock: 撤销偏向锁,升级为轻量级锁
T2->>Lock: CAS 替换 Mark Word
T1->>Lock: 也尝试 CAS(竞争)
Note over Lock: CAS 失败,升级为重量级锁
T1->>Lock: 阻塞等待
JVM 内存模型
Happens-Before 与内存屏障
JVM 通过内存屏障实现 Happens-Before 规则,禁止特定的指令重排序:
| 屏障类型 | 禁止重排序 | 对应场景 |
|---|---|---|
| LoadLoad | 读操作不能被重排到另一读之前 | volatile 读后的普通读 |
| StoreStore | 写操作不能被重排到另一写之前 | volatile 写前的普通写 |
| LoadStore | 读操作不能被重排到写之前 | volatile 读后的普通写 |
| StoreLoad | 写操作不能被重排到读之前 | volatile 写后的普通读(开销最大) |
在 x86_64 架构上,只有 StoreLoad 屏障需要真正插入指令(lock add [rsp], 0),其他三种是空操作。
volatile 字段的底层实现
- 写操作后插入 StoreLoad 屏障(强制刷新写缓存至主内存)
- 读操作前插入 LoadLoad 屏障(从主内存读取最新值)
- volatile 字段不能分配到寄存器(每次都直接读写内存)
数据竞争示例
int a = 0, b = 0;
// 线程 1
void method1() {
int r2 = a; // 读 a
b = 1; // 写 b
}
// 线程 2
void method2() {
int r1 = b; // 读 b
a = 2; // 写 a
}
// 没有同步时,(r1, r2) 可能出现 (1, 2)!
// 解决:将 b 声明为 volatile,利用 volatile 的 Happens-Before 规则
volatile int b = 0;
// 现在 b=1 HB r1=b,再加上程序顺序规则和传递性,r2=a HB a=2
// 因此 (1, 2) 不可能出现
JVM 诊断工具
jps:查看 Java 进程
jps -mlv
# 输出:PID 主类名 main参数 JVM参数
# 18331 com.example.Foo Hello World -Xmx512m
jstat:GC 统计
# 每隔 1 秒打印一次 GC 数据,共打印 10 次
jstat -gc <pid> 1s 10
# 关键列说明
# S0C/S1C:Survivor 0/1 容量
# S0U/S1U:Survivor 0/1 已用
# EC/EU:Eden 容量/已用
# OC/OU:老年代容量/已用
# YGC/YGCT:Minor GC 次数/总时间
# FGC/FGCT:Full GC 次数/总时间
# GCT:GC 总时间
# 判断是否内存泄漏:长时间观察 OU(老年代已用)是否持续上涨
jmap:堆分析
# 查看堆中对象统计(按内存从多到少排序)
jmap -histo <pid> | head -30
# 导出堆快照(用 MAT/VisualVM 分析)
jmap -dump:live,format=b,file=heap.hprof <pid>
# 也可以通过 JVM 参数在 OOM 时自动 dump
# -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof
jstack:线程分析
# 打印所有线程的栈轨迹
jstack <pid>
# 常见线程状态
# RUNNABLE:正在执行(可能是 CPU 密集或死循环)
# BLOCKED:等待 synchronized 锁
# WAITING:等待 Object.wait() / LockSupport.park()
# TIMED_WAITING:有超时的等待
# TERMINATED:已结束
# jstack 会自动检测并打印死锁信息
# Found one Java-level deadlock:
jinfo:查看/修改 JVM 参数
# 查看当前 JVM 参数
jinfo <pid>
# 动态修改 manageable 参数(无需重启)
jinfo -flag +HeapDumpAfterFullGC <pid>
jinfo -flag HeapDumpPath=/tmp <pid>
jcmd:瑞士军刀
# 列出所有可用命令
jcmd <pid> help
# 常用子命令
jcmd <pid> GC.heap_info # 堆信息
jcmd <pid> GC.class_histogram # 对象统计
jcmd <pid> GC.heap_dump /tmp/heap.hprof # 导出堆
jcmd <pid> Thread.print # 线程栈(等同 jstack)
jcmd <pid> VM.flags # JVM 参数
jcmd <pid> VM.uptime # 运行时间
JVM 调优实践
堆大小设置
-Xms4g # 初始堆大小
-Xmx4g # 最大堆大小(建议与 Xms 相同,避免扩缩容开销)
-Xmn1g # 新生代大小(G1 下不建议手动设置)
-XX:MetaspaceSize=256m # 元空间初始大小
-XX:MaxMetaspaceSize=512m # 元空间最大大小
GC 选择建议
| 场景 | 推荐 GC | 关键参数 |
|---|---|---|
| 低延迟(< 200ms) | G1 | -XX:MaxGCPauseMillis=100 |
| 极低延迟(< 10ms) | ZGC(Java 15+) | -XX:+UseZGC |
| 高吞吐(批处理) | Parallel GC | -XX:+UseParallelGC |
| 堆 < 4GB 的小应用 | G1 或 Serial | 默认即可 |
常见 GC 问题排查
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| Full GC 频繁 | 老年代空间不足、大对象直接晋升 | 增大堆、调整晋升阈值 |
| Minor GC 后存活对象多 | Survivor 区太小,对象提前晋升老年代 | 增大新生代或 Survivor 比例 |
| GC 停顿过长 | 堆太大、GC 线程数不足 | 换 G1/ZGC,调整 MaxGCPauseMillis |
| 元空间 OOM | 动态生成大量类 | 增大 MaxMetaspaceSize,检查类加载泄漏 |
| 堆 OOM | 内存泄漏或堆确实不够 | jmap -dump 后用 MAT 分析 |
常用 GC 日志参数
# Java 9+ 统一日志格式
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
# Java 8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/gc.log
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20m
性能调优 Checklist
- [ ] 确认 GC 停顿时间是否满足 SLA(
jstat -gc观察 GCT 占比) - [ ] 确认老年代增长趋势(是否内存泄漏)
- [ ] 确认 Full GC 频率(超过 1 次/小时需关注)
- [ ] 确认堆大小是否合理(OU 稳定后应 < OC 的 70%)
- [ ] 确认元空间大小是否设置上限(防止无限增长)
- [ ] 确认线程池配置(线程数、队列大小、拒绝策略)
- [ ] 确认是否有大对象(> G1HeapRegionSize/2)直接进老年代
参考资料
- Java SE 21 虚拟机规范
- HotSpot 虚拟机垃圾回收调优指南
- G1 GC 官方教程
- 《深入理解 Java 虚拟机》(周志明著)
评论 (0)