一文讲懂JVM与调优
目标:先搞懂 JVM 是什么 → JVM 核心结构 → GC 基础 → 调优思路 → 常见场景 → 高频面试题。 前提:你写 Java 代码,
new对象,代码跑在 JVM 里;JVM 就是Java 虚拟机,是一个运行在操作系统上的程序,把 Java 字节码翻译成操作系统能执行的指令,同时管理内存、垃圾回收。
一、什么是 JVM?
一句话:JVM(Java Virtual Machine)Java 虚拟机,是 Java 程序的运行环境。
Java 源码 .java → 编译成字节码 .class → JVM 加载 class,解释 / 编译执行字节码。
Java 跨平台原理:一次编译,到处运行。
不是 Java 直接跑操作系统,是JVM 屏蔽操作系统差异,不同系统有对应版本 JVM。
层级关系
【运行时数据区(JVM规范定义的5大逻辑区)】
├─ 程序计数器
├─ Java虚拟机栈
├─ 本地方法栈
├─ 堆
└─ 【方法区(逻辑)】
├─ 运行时常量池(方法区内部子区域)
├─ 类元数据(类名、字段、方法、接口信息)
├─ static静态变量
└─ JIT代码缓存
> 在JDK8 HotSpot中,上面这整个【方法区】,物理内存就放在 Metaspace(元空间)里。
类比理解
JVM 内部分三大块:
1. 类加载器 2. 运行时数据区 3.执行引擎 + GC 垃圾回收
重点:调优,本质就是调【运行时数据区】内存大小 + GC 垃圾回收器参数,减少 GC 停顿、OOM、CPU 高
1. 类加载器 ClassLoader
负责把磁盘上的.class字节码文件加载到 JVM 内存。
双亲委派模型(面试高频):加载类先交给父加载器,父加载不了自己再加载,防止核心类被篡改。
❗️调优很少动类加载。
2. 运行时数据区(内存区域,调优核心!)
分为:程序计数器、虚拟机栈、本地方法栈、堆、方法区(元空间)
- 程序计数器:很小,记录当前线程执行到哪一行字节码。唯一一个没有 OOM 的区域。
程序计数器是当前线程私有的一小块内存,用来记录当前线程执行到哪一条字节码指令。
⚠️ 它是 JVM 运行时数据区里唯一一个没有规定 OOM的内存区域。
- 虚拟机栈(栈内存):每个线程私有。方法调用时创建栈帧,存局部变量、方法返回值。
- 栈溢出:
StackOverflowError,递归深度太大。
- 栈溢出:
- 本地方法栈:和虚拟机栈类似,给 native 方法(C/C++ 写的方法)使用。
- 堆(Heap)【调优最重要区域!】
- 所有线程共享,所有 new 出来的对象都放在堆里。
- 堆分:新生代 + 老年代。
- 新生代:新创建对象。分为 Eden 区 + 2 个 Survivor(S0,S1)。比例默认 Eden:S0:S1 = 8:1:1
- 老年代:存活时间久、大对象。
- GC 主要回收堆内存。堆满了就会 OOM
java.lang.OutOfMemoryError: Java heap space
- 方法区(JDK8 之后叫元空间 Metaspace)
- 存放类信息、常量、静态变量、即时编译后的代码。
- JDK7:永久代 PermGen,放在 JVM 内存;JDK8 移除永久代,元空间放在本地操作系统内存,默认不限制,容易元空间 OOM。
简单记忆:栈存局部变量和方法调用;堆存对象;元空间存类信息。
3. 执行引擎 & GC
- 执行引擎:解释器(逐行解释字节码)、JIT 即时编译器(热点代码编译成本地机器码,提升速度)
- GC:垃圾回收,自动识别堆中不再使用的对象,释放内存。JVM 调优大部分工作就是 GC 调优
关于程序计数器(Program Counter Register,PC 寄存器)
1. 核心作用
Java 方法执行的时候,JVM 读取
.class里的字节码,一条一条执行。程序计数器保存下一条要执行的字节码的地址。
举个例子:
public void test(){ int a = 1; int b = 2; int c = a + b; }编译后的字节码有多条指令:
iconst_1、istore_1、iconst_2…… PC 寄存器记录:现在执行到第几条,下一条该执行谁。2. 为什么是线程私有?(面试重点)
Java 是多线程,CPU 是时间片轮转。 CPU 切换线程的时候,要保存现场:
- 线程 A 拿到 CPU,执行一部分字节码;
- CPU 时间片用完,线程 A 被挂起;
- 把 A 当前执行位置保存到 A 自己的程序计数器;
- CPU 切换执行线程 B;
- 等线程 A 再次抢到 CPU,读取自己 PC 寄存器的值,从上次中断位置继续执行。
每个线程都有独立 PC 寄存器,互不干扰。
3. 两种情况:Java 方法 vs native 本地方法
- 如果执行Java 方法:PC 寄存器存放字节码指令地址
- 如果执行native 方法(C/C++ 写的本地方法):PC 寄存器值是 undefined(未定义)
native 方法不是字节码,JVM 不记录字节码地址。
4. 关键特点(面试必背)
- ✅ 线程私有,线程创建时分配,线程销毁,PC 寄存器跟着销毁。
- ✅ 内存极小,只存指令地址。
- ✅ 该区域没有 OOM。JVM 规范没有要求这块内存抛出 OutOfMemoryError。
面试高频坑题:JVM 内存区域哪个不会 OOM?答案就是程序计数器。
- ❌ 不是 CPU 硬件的 PC 寄存器,是 JVM 虚拟机层面的内存,不是硬件寄存器(很多人踩坑)。
5. 和虚拟机栈的区别(容易混淆)
- 程序计数器:记录执行到哪一条指令(记录位置)
- 虚拟机栈:存栈帧,局部变量、方法返回地址、操作数栈(存方法调用的数据)
简单类比: PC 寄存器 = 看书的书签,记录读到第几行; 虚拟机栈 = 草稿纸,放这一页用到的变量、计算临时结果。
6. 面试真题
Q1:程序计数器作用?是否会 OOM?
记录当前线程下一条字节码指令地址;线程私有;不会 OOM。
Q2:执行 native 方法时程序计数器保存什么?
undefined,未定义。native 方法没有字节码。
(native 方法的实现不是用 Java 写的,是 C/C++ 写的,编译成操作系统本地机器码,不是编译成 class 字节码,所以 .class 文件里只有方法声明,没有方法体字节码。)
Q3:为什么程序计数器线程私有?
Java 多线程是 CPU 时间片调度。线程切换时,每个线程需要独立保存自己的执行位置,线程恢复后继续执行。如果共享,多个线程位置会互相覆盖。
Q4:程序计数器保存的是机器码地址吗?
不是,保存的是字节码指令地址,不是操作系统机器码。
Q5:双亲委派模型?
一句话概括
当类加载器收到加载类的请求时,先交给父加载器去尝试加载;父加载器加载不了,自己才去加载。
一、类加载器分类(4 层)
从顶层到底层:
- 启动类加载器(Bootstrap ClassLoader)
- C++ 写的,JVM 内置,Java 拿不到它的对象
- 加载
JAVA_HOME/lib下核心类:java.lang.String、Object、ArrayList 等 rt.jar- 扩展类加载器(Extension ClassLoader) Java 实现,加载
JAVA_HOME/lib/ext目录下的扩展 jar 包- 应用程序类加载器(Application ClassLoader,系统类加载器) 我们写的业务代码、classpath 下的类,默认由它加载
- 自定义类加载器 自己继承 ClassLoader 写的加载器(比如框架热部署、加密 class)
层级关系:自定义 → 应用 → 扩展 → 启动。向上委托。
二、双亲委派完整流程(重点)
当应用类加载器收到
com.demo.Test的加载请求:
- 应用类加载器,先不自己加载,委托父加载器(扩展类加载器)
- 扩展类加载器继续往上委托给启动类加载器
- 启动类加载器检查:能不能找到这个类?
- ✅ 能找到:自己加载,返回。
- ❌ 找不到:回传给子加载器(扩展)
- 扩展类加载器尝试查找,找不到,继续回传给应用类加载器
- 应用类加载器在 classpath 查找,找到则加载;找不到抛
ClassNotFoundException核心规则:向上委托,向下查找 请求往上抛;找不到的时候,从顶层往回,由下层加载器自己去找。
三、为什么要用双亲委派?两大核心目的(必背)
1. 防止核心类被篡改(沙箱安全机制)
举例子:你自己写一个
java.lang.String类。 当加载这个类,会向上委托,一直到 Bootstrap。Bootstrap 已经加载了 jdk 自带的java.lang.String,直接返回 JDK 原生 String。 你自己写的 String 永远不会被加载,避免覆盖 JDK 核心类。如果没有双亲委派:用户自定义的 java.lang.String 会替换原生类,有巨大安全漏洞。
2. 保证类的全局唯一性
同一个类,类的全限定名 + 类加载器 才是唯一标识。 保证全路径相同的类,在整个 JVM 只加载一次,避免重复加载。
四、打破双亲委派模型(面试必问)
双亲委派不是强制语法,可以打破,3 种经典场景:
- SPI(JDBC 经典例子) Driver 接口是 JDK 核心类,由 Bootstrap 加载;但是各个数据库厂商的 Driver 实现类(mysql 驱动)在 classpath,需要应用类加载器加载。 Bootstrap 加载 Driver 接口,但无法加载厂商实现类。 解决方案:线程上下文类加载器
Thread.getContextClassLoader(),拿到应用类加载器,反向加载,打破双亲委派。- 热部署 / 热加载(Tomcat、Spring devtools) Tomcat:每个 web 应用有独立类加载器。不同 war 包可以有同名类,互不干扰,需要打破双亲委派。
Tomcat 类加载器:优先自己加载,再委托父加载器。
- 自定义 ClassLoader,重写 loadClass ()
loadClass()方法里实现了双亲委派逻辑;如果重写这个方法,不调用父加载器,直接自己加载,就打破委派。注意:推荐重写
findClass(),而不是 loadClass,保留双亲委派。区分:
loadClass:实现双亲委派逻辑;findClass:真正找 class 文件。五、高频面试题
Q1:双亲委派模型的流程?
收到加载请求,先交给父加载器;父加载器无法加载,自己再尝试加载。
Q2:双亲委派的好处?
- 安全:防止 JDK 核心类被自定义类篡改;
- 保证类唯一,避免重复加载。
Q3:类加载器之间是继承关系吗?
❌ 不是继承,是组合。子加载器内部持有 parent 引用。
Q4:JDBC 为什么打破双亲委派?
Bootstrap 加载 java.sql.Driver 接口,但是 mysql 驱动实现类不在 Bootstrap 的加载路径。所以使用线程上下文类加载器,使用应用类加载器加载驱动实现类,反向。
Q5:Tomcat 如何打破双亲委派?
Tomcat 自定义 WebappClassLoader,优先自己加载 WEB-INF 下的类,再委托父加载器,和双亲委派顺序相反。实现多个 web 应用隔离,不同 war 包同名类互不影响。
Q6:怎么实现自定义类加载器,要不要重写 loadClass?
一般重写 findClass (),不要重写 loadClass ()。重写 loadClass 会直接破坏双亲委派。
六、类加载 5 个阶段
加载 → 验证 → 准备 → 解析 → 初始化
加载阶段:类加载器把 class 字节码读到内存。
易错坑
- 双亲委派只是类加载器的一种设计模式,不是 JVM 强制机制,可以打破;
- Bootstrap 类加载器没有 Java 对象,拿不到实例;
- 判定两个类是否相等:全类名 + 类加载器,缺一不可;同一个 class 文件,不同类加载器加载,是两个完全不同的类。
调优相关
程序计数器几乎不用调优,内存极小,不会成为性能瓶颈,日常 JVM 调优基本不会碰它。
Java 虚拟机栈(JVM Stack,简称虚拟机栈)
一句话总结:虚拟机栈是线程私有内存区域,每个 Java 方法执行的时候,都会在虚拟机栈里创建一块叫「栈帧 (Stack Frame)」的内存;方法调用就是栈帧入栈,方法返回就是栈帧出栈。 重点区分:虚拟机栈 ≠ 硬件 CPU 栈,是 JVM 在内存里抽象出来的栈。
1. 基础特性(面试必背)
- 线程私有:线程创建时,虚拟机栈同步创建;线程销毁,虚拟机栈跟着释放。多线程之间栈完全隔离,互不干扰。
- 生命周期和线程一致。
- 栈里存储单位:栈帧 StackFrame。一个方法对应一个栈帧。
- 内存是栈结构:后进先出 LIFO。
- 可能抛出两种异常:
StackOverflowError:栈深度超过上限(递归太深)OutOfMemoryError:动态扩展栈内存,内存不够(很少见,HotSpot 不自动扩展,主要报 StackOverflow)配置参数:
-Xss,设置单个线程的虚拟机栈大小。比如-Xss1m,每个线程栈 1MB。2. 栈帧(Stack Frame):虚拟机栈的核心
一个栈帧,代表一次方法调用。 栈帧内部包含 4 个部分:
- 局部变量表 Local Variable Table
- 操作数栈 Operand Stack
- 动态链接 Dynamic Linking
- 方法返回地址 Return Address
额外:部分实现会带上附加信息(异常表,调试信息等)
① 局部变量表 Local Variable Table
- 存放方法参数 + 方法内定义的局部变量。
- 基本类型、对象引用(不是对象本身!对象在堆,这里只存引用地址)。
- 单位是槽 slot,32 位占 1 个 slot;
long/double64 位,占用 2 个 slot。示例:
public void test(int a){ int b = 10; Object o = new Object(); }局部变量表保存:
this(实例方法默认第一个)、a、b、o 的引用。⚠️ 对象实例在堆,局部变量表只存引用地址。
② 操作数栈 Operand Stack
也是栈结构,用来做计算、传递参数。JVM 字节码指令的临时数据区。 举个简单计算:
int c = a + b;字节码流程:
- 把 a 从局部变量表压入操作数栈
- 把 b 从局部变量表压入操作数栈
iadd:弹出栈顶两个数,相加,结果压回栈顶- 弹出结果,存入局部变量表 c
没有 CPU 寄存器,JVM 字节码计算靠操作数栈完成。
③ 动态链接 Dynamic Linking
栈帧里保存指向运行时常量池的引用。 class 文件里方法调用是符号引用(字符串形式,比如
com.Test.hello())。 动态链接:在方法调用的时候,把符号引用解析成直接引用(内存地址)。1. 符号引用 vs 直接引用(核心概念)
- 符号引用(编译期写入 class 常量池) 只是字符串描述,和内存地址无关。
例子:
#5 Methodref Animal.say:()V意思:调用 Animal 类的 say 方法,编译期不知道这个方法在内存哪个位置。
- 直接引用(运行后得到) 是方法在内存里真实地址 / 句柄,可以直接跳转执行。
动态链接,就是运行时翻译:符号引用 → 直接引用。
2. 为什么栈帧里要存这个指向运行时常量池的引用?
class 文件编译完成时,所有方法调用写的全是符号引用。
当这个方法被执行、创建栈帧的时候: 栈帧带上这个指针,随时可以回到运行时常量池拿到符号引用;
遇到方法调用字节码时,JVM 去翻译这个符号引用,找到目标方法的内存地址。
3. 静态链接 和 动态链接(面试重点)
✅ 静态链接(解析调用,类加载阶段就完成)
类加载的解析阶段就把符号引用转成直接引用,运行时不再变。 对应字节码指令:
invokestatic(静态方法)、invokespecial(构造器、private 私有方法、super 父类方法)这些叫非虚方法:编译期就能确定唯一版本,运行不会变。
✅ 动态链接(分派调用,运行时才解析)
编译期无法确定到底调用哪个方法,必须等到运行时,看对象实际类型再确定。 对应指令:
invokevirtual:普通实例方法(方法重写、多态最常见)invokeinterface:接口方法调用invokedynamic:JDK7 新增,Lambda 表达式👉 Java 多态(重写)底层就是靠动态链接 + 动态分派 + 虚方法表实现。
代码例子
Animal a = new Dog(); a.say();编译后字节码是:调用
Animal.say()(符号引用)。 编译期只知道静态类型是 Animal;运行时发现对象实际类型是 Dog,通过动态链接找到 Dog 的 say () 方法入口。4. 一个容易踩坑的误区
问:动态链接 = 动态分派? ❌ 不等。
- 动态链接:栈帧的属性,是符号引用翻译成直接引用这个机制。
- 动态分派:动态链接里的查找目标方法版本的逻辑(根据对象实际类型找重写方法)。 动态分派属于动态链接的其中一种场景。
5. 面试口述精简版
动态链接是栈帧中的一个引用,指向运行时常量池。class 文件中方法调用保存的是符号引用;动态链接负责在运行期将符号引用解析为方法的直接内存地址。 静态方法、私有方法、构造器在类加载阶段就完成解析(静态链接);而实例虚方法、接口方法需要运行时解析,也就是动态链接,支撑 Java 的多态重写。
配套追问(你大概率会被问到)
- 重载是静态分派还是动态分派? → 静态分派(编译期确定)
- 重写是静态分派还是动态分派? → 动态分派(运行期确定,依赖动态链接)
- invokevirtual 底层怎么找方法? → 去对象的虚方法表 vtable 查找。
- 静态解析:编译期就能确定(static、final 方法,invokestatic)
- 动态绑定:运行期才能确定(普通实例方法,多态,invokevirtual)
④ 方法返回地址 Return Address
一句话:
保存「当前方法执行完之后,要回到调用方方法的哪个位置继续执行」,就是字节码的程序计数器地址。
方法执行完,需要回到调用方法的位置继续执行。 保存调用者方法的下一条字节码指令地址。 两种退出方式:
- 正常返回:return 指令,把返回值传给上层栈帧,栈帧出栈;
- 异常退出:抛出异常,没有返回值,也要找到返回地址。
3. 方法调用和栈帧入栈出栈演示
public void A(){ B(); } public void B(){ C(); } public void C(){ return; }执行流程:
- 调用 A → A 栈帧入栈
- A 调用 B → B 栈帧入栈
- B 调用 C → C 栈帧入栈
- C 执行 return → C 栈帧出栈,回到 B
- B 执行结束 → B 栈帧出栈,回到 A
- A 结束 → A 栈帧出栈 ✅ 同一时刻,栈顶就是当前正在执行的方法的栈帧。
4. 两个异常详解(高频考点)
StackOverflowError
虚拟机栈有最大深度限制。 典型场景:无限递归,不断新建栈帧,栈帧数量超过栈最大深度。
public void rec(){ rec(); }
-Xss设置的栈越小,能支持的递归层数越少,更容易 StackOverflow。OOM(OutOfMemoryError)
如果 JVM 支持栈动态扩容,扩容时拿不到足够内存,抛出 OOM。
HotSpot 虚拟机的虚拟机栈不支持动态扩容,所以基本只会出现 StackOverflowError,很难出现 OOM。
6. 面试真题
Q1:虚拟机栈里存放的是什么?
栈帧;栈帧包含局部变量表、操作数栈、动态链接、方法返回地址。
Q2:局部变量表里面存对象本身吗?
不存,只存对象引用;对象实例在堆内存。
Q3:-Xss 作用?
设置单个线程虚拟机栈的大小。栈越小,一个线程能支持的方法递归深度越小;同时服务器能创建更多线程。
Q4:StackOverflowError 和 OOM 在虚拟机栈的区别?
StackOverflow:栈帧太多,超过栈最大深度;HotSpot 基本不会出现虚拟机栈 OOM。
Q5:方法退出有哪两种方式?
正常 return 返回;异常抛出退出。
7. 容易踩坑的误区
❌ 误区 1:虚拟机栈存对象 ✅ 纠正:对象在堆,栈只存对象引用。 ❌ 误区 2:栈帧在线程之间共享 ✅ 纠正:虚拟机栈线程私有,栈帧只属于当前线程。 ❌ 误区 3:-Xss 是整个 JVM 所有线程共享栈大小 ✅ 纠正:每个线程单独一份 - Xss 大小。如果 - Xss=1M,开启 1000 个线程就要占用约 1000M 内存。
本地方法栈(Native Method Stack)
一句话总结:
本地方法栈也是线程私有,作用和虚拟机栈非常像,只不过虚拟机栈服务 Java 方法,本地方法栈专门用来执行 native 本地方法。
1. 基础特性
- 线程私有:和虚拟机栈、程序计数器一样,线程创建时一同分配,线程销毁,内存释放。
- 作用:为native 方法服务(C/C++ 写的本地方法)。
- HotSpot 虚拟机中,本地方法栈和 Java 虚拟机栈是合二为一的,共用一块栈内存。
- 同样会抛出异常:
StackOverflowError:栈深度超出上限OutOfMemoryError:栈内存扩容失败参数:HotSpot 没有单独的参数配置本地方法栈,由
-Xss统一控制。2. 和虚拟机栈对比
- Java 虚拟机栈:执行 Java 方法,栈帧存放 Java 方法的局部变量、操作数栈等,执行字节码。
- 本地方法栈:执行 native 方法,服务 C/C++ 本地代码,没有字节码。
结合之前程序计数器的知识点:
- 执行 Java 方法:PC 寄存器保存下一条字节码地址。
- 执行 native 方法:PC 寄存器的值是 undefined,此时 JVM 把执行交给本地方法栈,跑 C/C++ 机器码。
3. 工作流程举例
Object.wait()是 native 方法:public final native void wait(long timeout) throws InterruptedException;
- Java 代码调用 wait (),发现是 native;
- JVM 切换,使用本地方法栈执行对应的 C++ 实现;
- C++ 代码直接调用操作系统内核 API,完成线程等待;
- 本地方法执行完毕,切回 Java 虚拟机栈,继续执行后面 Java 字节码。
4. 本地方法栈的作用(为什么单独分出这个区域)
- 作为 JVM 与操作系统交互的桥梁。 很多底层能力 Java 字节码做不到:操作系统调用、硬件操作、线程调度。这些能力交给 C/C++ 实现,跑在本地方法栈。
- 保存 native 方法执行时的本地变量、返回地址(C 层面的栈帧,不是 Java 栈帧)。
注意:这里的栈帧不是 Java 的栈帧,是本地代码的栈帧。
5. 面试高频题
Q1:本地方法栈是线程私有还是共享?
线程私有。
Q2:HotSpot 中本地方法栈和虚拟机栈关系?
HotSpot 将两者合并,共用栈内存,使用 - Xss 统一设置大小。
Q3:本地方法栈里有没有 Java 字节码?
没有,native 方法没有字节码,本地方法栈执行 C/C++ 编译后的机器码。
Q4:本地方法栈会抛出什么异常?
StackOverflowError,OOM(HotSpot 下很少 OOM)。
6. JVM 运行时数据区 5 块汇总(到这里就全部讲完)
- 程序计数器:线程私有,记录下一条字节码地址;唯一不会 OOM。
- Java 虚拟机栈:线程私有,存放 Java 方法的栈帧;StackOverflow。
- 本地方法栈:线程私有,服务 native 本地方法;HotSpot 和虚拟机栈合并。
- 堆(Heap):线程共享,存放所有对象实例、数组;GC 主要回收区域。
- 方法区(Method Area):线程共享,存放类信息、常量、静态变量、即时编译后的代码。
运行时常量池是方法区里面的一部分。
✅ 记忆口诀:三私两共 线程私有:PC 寄存器、虚拟机栈、本地方法栈 线程共享:堆、方法区
JVM 堆(Heap)—— 调优主战场
一句话总结:堆是 JVM 中最大的一块内存,线程共享,所有对象实例、数组都在堆上分配,也是垃圾收集器 GC 工作的主要区域。
1. 基础特性
- 线程共享,JVM 启动时创建,生命周期贯穿整个 JVM 进程。
- 唯一目的:存放对象实例和数组。
- 会抛出异常:
java.lang.OutOfMemoryError: Java heap space当堆内存用尽,GC 之后仍然没有足够空间分配新对象,抛出堆 OOM。
- 核心参数:
-Xms:堆初始内存-Xmx:堆最大内存生产环境一般设置
-Xms = -Xmx,避免 JVM 频繁扩容缩容带来性能损耗。 例:-Xms2g -Xmx2g,固定堆大小 2G。2. 堆的分代模型(HotSpot 经典,面试必学)
设计思路:大部分对象朝生夕灭,存活时间很短;少数对象长期存活。 不同生命周期对象用不同 GC 算法回收,提升效率。 堆分为两大块:新生代(Young Generation) + 老年代(Old Generation)
新生代 Young(占堆总大小 1/3)
新生代又划分为:Eden 区 + Survivor0 (S0) + Survivor1 (S1) 默认比例 Eden:S0:S1 = 8:1:1
- Eden:绝大多数对象首次分配在这里。
- S0、S1(也叫 From、To):两个 Survivor,同一时间只有一块在用,另一块是空的。
新生代 GC 叫做 Minor GC(YGC) 流程:
- Eden 满,触发 YGC,标记 Eden 存活对象,复制到空闲 Survivor;
- 清空 Eden;
- 交换 S0/S1 角色,下一次 YGC 把存活对象复制到另一块;
- 对象每熬过一次 YGC,年龄 + 1;年龄达到阈值(默认 15),晋升到老年代。
YGC(Minor GC)清理范围:年轻代 = Eden + 当前 From Survivor,不碰 To Survivor(它是空的,作为复制目标)。
YGC不是只扫 Eden,一定会扫描 From Survivor 里的对象。
完整拆解
年轻代三块:Eden、S0、S1,同一时刻:
- Eden:大量新建对象
- From Survivor:上一次 YGC 幸存下来的对象(有数据)
- To Survivor:空,作为本次复制的目的地(无数据)
YGC 做的事情:
- 标记:遍历 Eden + From Survivor,找出里面存活对象(可达对象)
- 复制:把所有存活对象,拷贝到 To Survivor
- 清空:Eden 和 From Survivor 全部清空(里面死掉的对象直接丢弃)
- 交换角色:To → 新 From;旧 From → 新 To(变空,等待下一次 YGC)
所以: ✅ 回收死亡对象:Eden 中死亡的 + From 中死亡的 ✅ 保留存活对象:Eden 存活 + From 存活,搬家到 To ❌ To 区本来是空的,不需要扫描清理
特殊:大对象直接进老年代;Survivor 放不下存活对象 → Promotion Failure,对象直接晋升到老年代(前面实战场景)。
老年代 Old(占堆总大小 2/3)
存放长期存活的对象。 老年代 GC 叫 Major GC / Full GC
- Major GC:只回收老年代;
- Full GC:回收整个堆(新生代 + 老年代 + 元空间),STW 时间很长,线上要尽量减少 FullGC。
注意:很多时候 Major GC 发生时会连带触发 YGC,所以日常口语经常把 Major GC 直接叫 FullGC。
3. 对象分配的一般流程
- new 对象,优先分配到 Eden 区;
- Eden 填满,触发 YGC:
- 死亡对象直接回收;
- 存活对象复制到 Survivor;
- 对象在 Survivor 来回拷贝,年龄不断增长;
- 年龄达到阈值,晋升到老年代;
- 老年代空间不足,触发 FullGC。
4. GC 算法(对应分代)
- 新生代:复制算法。存活对象少,复制成本低。两块 Survivor 就是为复制算法设计。
- 老年代:标记 - 清除 / 标记 - 整理。老年代对象存活多,复制代价太大。
- 标记清除:标记垃圾,直接清除;缺点:产生内存碎片。
- 标记整理:标记垃圾,清除后把存活对象向一端压缩,消除碎片。
5. 常见调优目标(堆调优核心)
- 尽量减少 FullGC 次数;
- 降低 YGC 和 FullGC 的 STW 停顿时间;
- 避免堆 OOM;
- 减少内存碎片。
6. 面试高频题
Q1:堆是线程私有还是共享?存放什么?
线程共享;存放对象实例和数组,GC 主要区域。
Q2:-Xms 和 -Xmx 含义,生产为什么设置相等?
Xms 初始堆,Xmx 最大堆;相等避免堆动态扩容、缩容带来性能开销。
Q3:新生代区域划分,默认比例?
Eden:S0:S1 =8:1:1。
Q4:Minor GC、Major GC、Full GC 区别?
- Minor GC:新生代 GC,STW 短,频繁执行;
- Major GC:老年代 GC;
- Full GC:全堆回收,STW 时间长,尽量避免。
Q5:对象什么时候晋升到老年代?
- 年龄达到 15;
- Survivor 空间不足,Promotion Failure,直接晋升;
- 大对象直接分配到老年代(PretenureSizeThreshold)。
Q6:堆 OOM 是什么报错?
java.lang.OutOfMemoryError: Java heap space7. 易踩坑误区
❌ 误区:对象一定在堆上 ✅ 纠正:JVM 有逃逸分析。如果对象不逃逸,JIT 可以做栈上分配,对象直接分配在虚拟机栈,不进堆,栈帧销毁对象自动释放。(面试加分知识点)
❌ 误区:Survivor 两块空间可以同时使用 ✅ 纠正:永远一块用作 From,一块 To,保持一块是空的,复制算法需要。
❌ 误区:FullGC 一定先做 YGC ✅ 不一定,要看 GC 收集器。
方法区(Method Area)+ 运行时常量池 + 元空间 Metaspace
一句话总结:方法区是线程共享的内存区域,存放类的元数据信息、静态变量、常量、JIT 编译代码;JDK8 之后移除永久代,方法区的实现改成元空间(Metaspace),直接使用操作系统本地内存,不再占用堆空间。
重点:方法区是逻辑概念;永久代、元空间是它不同版本的物理实现。
1. 基础特性
- 线程共享,JVM 启动创建,JVM 关闭才释放。
- 存储内容:
- 类的元数据(Class 信息:类名、父类、接口、字段、方法信息)
- 静态变量
static- 运行时常量池
- JIT 即时编译后的代码缓存
- 异常:
java.lang.OutOfMemoryError: Metaspace(JDK8+)- 参数:
-XX:MetaspaceSize:元空间初始大小-XX:MaxMetaspaceSize:元空间最大上限,不设置的话默认无上限,会一直吃系统内存2. JDK 版本演变(面试必考)
- JDK1.6 及以前:永久代 PermGen,属于堆的一部分,GC 会在 FullGC 回收永久代;容易 PermGen OOM。
- JDK1.7:把字符串常量池从永久代移到堆。
- JDK1.8+:彻底删除永久代,方法区使用元空间 Metaspace,使用操作系统本地内存(native memory),不在 Java 堆里。
面试一句话记忆:1.8 之后,方法区实现是元空间,不在堆,用本地内存。
3. 运行时常量池(Runtime Constant Pool)
运行时常量池是方法区里面的一部分
- class 文件有一个「静态常量池」,编译期生成,存放字面量、符号引用。
- 类加载阶段,静态常量池加载进内存,变成运行时常量池。
- 作用:存放:
- 字面量:字符串常量、基本类型常量
- 符号引用:类名、方法名、字段名(后面解析阶段转为直接内存地址)
- 动态特性:运行期间也可以往里面添加常量,最典型就是
String.intern()。字符串常量池:JDK7 移入堆,不属于运行时常量池(坑点!很多面试在这里翻车)。
4. 方法区 GC(了解即可)
方法区不是永久不用回收,GC 也会回收这里的垃圾:
- 回收条件苛刻:卸载类。
- 类被卸载的全部条件(三个必须同时满足):
- 该类所有实例对象全部被回收,堆里没有这个类的对象;
- 加载这个类的类加载器已经被回收;
- 该类的
Class对象没有任何地方被引用。普通业务类很难满足,所以方法区 GC 回收频率很低;只有热部署、动态生成大量类(CGLIB、ASM)场景容易 Metaspace OOM。
5. 运行时数据区完整 5 块复盘(三私两共)
线程私有(每个线程一份)
- 程序计数器:记录字节码地址,唯一不会 OOM
- Java 虚拟机栈:Java 方法栈帧,
StackOverflowError- 本地方法栈:native 方法,HotSpot 与虚拟机栈合并
线程共享(全局一份)
- 堆 Heap:对象实例,GC 主战场,
Java heap spaceOOM- 方法区 MethodArea:类元信息、静态变量、运行时常量池;JDK8 元空间,
MetaspaceOOM6. 高频面试题
Q1:方法区存放什么?
类元信息、static 静态变量、运行时常量池、JIT 编译代码。
Q2:永久代和元空间区别?
- JDK1.8 取消永久代,方法区改用元空间;
- 永久代在堆内存,元空间使用操作系统本地内存;
- 永久代有固定上限容易 OOM;元空间默认无上限,需要手动设置 MaxMetaspaceSize。
Q3:运行时常量池在哪个区域?
JDK8:方法区。字符串常量池在堆,不要混淆。
Q4:类什么时候会被卸载?三个条件?
①类实例全部回收;②类加载器被回收;③Class 对象无引用。全部满足才会卸载。
Q5:Metaspace OOM 是什么原因?
动态生成大量 class(CGLIB、动态代理、热部署),类不断加载,很少卸载,耗尽元空间内存。
7. 容易踩坑的误区
❌ 误区 1:static 变量存在堆中 ✅ 纠正:static 变量存放在方法区(元空间),不是堆。 ❌ 误区 2:元空间属于 Java 堆 ✅ 纠正:不属于堆,是操作系统本地内存。 ❌ 误区 3:运行时常量池 = 字符串常量池 ✅ 纠正:字符串常量池 JDK7 放到堆,独立出来,不属于运行时常量池。
8. 补充:直接内存(堆外内存,不属于运行时数据区,面试常顺带问)
- 不是 JVM 运行时数据区,属于操作系统本地内存。
- NIO
ByteBuffer.allocateDirect()使用直接内存。- 不受 Xmx 限制,但受操作系统总内存限制;也会抛出 OOM。
- 优点:读写文件减少一次用户态内核态数据拷贝;缺点:分配回收代价高。
二、GC 垃圾回收基础(调优前提)
GC 三大基础算法(面试核心)
GC 目标:识别堆中死亡对象,回收内存。判断对象是否存活主流:可达性分析算法(JVM 用这个,不是引用计数!),三大回收算法是回收时采用的内存组织方式。
前置:可达性分析(先搞懂,所有 GC 算法基础)
核心思路:以一组叫 GC Roots 的对象作为起点,向下搜索引用链;
- 能走到的对象:存活对象;
- 搜索不到、无法到达的对象:垃圾对象,可以回收。
GC Roots 包含哪些(面试常考)
- 虚拟机栈(栈帧局部变量表)中引用的对象
- 本地方法栈中 JNI 引用的对象
- 方法区中静态变量引用的对象
- 方法区中常量引用的对象
- 被同步锁
synchronized持有的对象- JVM 内部引用对象(Class 对象、异常对象等)
❌ 引用计数法(Python 用,JVM 不用):对象加引用计数,引用 + 1,释放 - 1,计数 0 则回收。致命缺陷:无法解决循环引用。
1. 标记 - 清除(Mark-Sweep)
流程两步
- 标记:遍历堆,标记所有 GC Roots 可达的存活对象;
- 清除:把未标记的垃圾对象直接回收,内存放回空闲列表。
优点
- 简单,不需要移动对象。
缺点(两大痛点,面试必背)
- 内存碎片:回收后空闲内存是零散的。堆总内存足够,但没有连续大块空间,放不下大对象,提前触发 FullGC。
- 效率:标记和清除两个阶段,堆越大耗时越长。
使用场景
老年代部分收集器(CMS)底层使用标记清除。
2. 复制算法(Copying)
流程
把内存划分为两块大小相等的 A、B 区域,同一时间只用一块。
- 只在 A 区分配对象;
- A 区满,触发 GC:标记 A 区存活对象,全部复制到 B 区;
- 一次性清空 A 区;
- 交换 A、B 角色,下一次在 B 分配。
新生代就是复制算法,但不是对半分:Eden:S0:S1=8:1:1,不是 1:1。一块大 Eden,两块小的 Survivor,同一时刻只用一块 Survivor。
优点
- 没有内存碎片,存活对象连续排列;
- 只复制存活对象,清除阶段简单,速度快。
缺点
- 浪费内存,需要预留一块同等大小的空间作为复制的备用区;对半分场景,直接损失 50% 堆内存。
- 如果存活对象很多,复制开销急剧变大。
使用场景
新生代。新生代特点:绝大多数对象朝生夕灭,存活对象少,复制成本低。
3. 标记 - 整理(Mark-Compact / Mark-Compact)
流程
- 标记:和标记清除一样,标记存活对象;
- 整理(压缩):不直接清除垃圾。把所有存活对象向内存一端移动,紧密排列;
- 边界以外全部空间一次性清空。
优点
- 无内存碎片;
- 不会浪费额外内存(对比复制算法)。
缺点
- 需要移动对象,STW 停顿时间长。移动对象要修改所有指向这些对象的引用地址,开销大。
使用场景
老年代,比如 Serial Old、G1 的压缩阶段。老年代存活对象多,不适合复制算法。
三大算法横向对比表
表格
算法 流程 优点 缺点 适用区域 标记 - 清除 标记存活 → 清除垃圾 不用移动对象 产生内存碎片;效率低 老年代(CMS) 复制算法 存活对象复制到备用区,清空原区域 无碎片,回收快 额外内存开销;存活多则复制慢 新生代 标记 - 整理 标记存活 → 对象压缩移动到一端 无碎片,不浪费内存 移动对象,STW 长 老年代(Serial Old) 面试高频题
Q1:JVM 判断对象存活用什么算法?为什么不用引用计数?
可达性分析。引用计数无法处理对象循环引用问题。
Q2:新生代为什么用复制算法?老年代为什么不用复制?
新生代大部分对象很快死亡,存活对象少,复制开销小;老年代存活对象多,复制代价极高,还要预留大量内存,不合适。
Q3:标记清除和标记整理最大区别?
标记清除不移动对象,产生碎片;标记整理移动存活对象压缩内存,无碎片,但移动对象带来 STW 开销。
Q4:为什么新生代不是 1:1 划分内存?
绝大多数对象很快死亡,不需要对半划分。采用 8:1:1,Eden 占 8 份,两块 Survivor 各 1 份,只使用 1 块 Survivor 作为复制目标,内存利用率高。
补充:对象的四种引用(面试常跟着 GC 一起问)
- 强引用:
Object o = new Object();GC 永远不回收,除非引用断开。- 软引用 SoftReference:内存不足时才回收;适合做缓存。
- 弱引用 WeakReference:只要 GC 发生,就会回收;适合缓存、ThreadLocalMap。
- 虚引用 PhantomReference:最弱,无法拿到对象;仅用于收到对象被回收的通知,堆外内存释放。
记忆:强引用永不回收;软引用内存不够才回收;弱引用 GC 必回收;虚引用只做通知。
HotSpot 是什么
HotSpot 是目前默认的 Java 虚拟机实现(JVM),Oracle JDK / OpenJDK 默认使用的虚拟机。
JVM 是一套规范(《Java 虚拟机规范》);HotSpot 是这套规范的具体实现。 类比:JVM 是 “汽车设计图纸”,HotSpot 是按照图纸造出来的一台汽车。
HotSpot 名字由来
HotSpot = Hot(热点)+ Spot(点),核心特色:热点代码探测,也就是 JIT 技术。 它会在运行时找到反复执行的 “热点代码”,把字节码编译成本地机器码,直接交给 CPU 执行,大幅提速。
HotSpot 核心特性
- 解释器 + JIT 编译器混合执行模式(解释 + 编译)
- 解释器:启动快,边解释边执行字节码;
- JIT:找到热点代码,编译成机器码,后续执行更快。
两者互补:启动用解释器快速跑起来;热点代码交给 JIT 编译加速。
- 内置 GC:Serial、Parallel、CMS、G1、ZGC 这些收集器,都是 HotSpot 实现。
- 分代内存模型:新生代、老年代、元空间,前面讲的堆分代就是 HotSpot 的设计。
- 栈帧、类加载器、运行时数据区(PC 寄存器、虚拟机栈等)都是 HotSpot 实现。
其他 JVM 实现(了解即可,面试偶尔提):
- J9(IBM,现在 Eclipse OpenJ9):内存占用更小,适合容器
- Azul Zing:低延迟 GC,商业虚拟机
JIT(Just-In-Time,即时编译)是什么
一句话:JIT 是 HotSpot 内置的编译器,在程序运行期间,把反复执行的 Java 字节码,编译成 CPU 可直接执行的本地机器码。
对比:
- javac:静态编译,编译期
.java → .class(源码转字节码)- JIT:即时编译,运行期
.class字节码 → 本地机器码为什么需要 JIT?
字节码是 JVM 的中间指令,解释器逐条翻译字节码执行,速度慢。 很多代码会被反复调用(循环、高频方法),这就是热点代码。 JIT 探测到热点代码,一次性编译成机器码缓存起来;后续再次执行,直接跑机器码,不再解释,性能提升很多倍。
HotSpot 里的两个 JIT 编译器
- C1(Client 编译器)
- 简单,编译速度快;优化比较保守;编译耗时短。
- 适合客户端程序,快速启动。
- C2(Server 编译器)
- 编译慢,但是深度优化(循环展开、逃逸分析、方法内联、常量传播等),生成的机器码性能极高。
- 服务端程序默认使用。
JDK8 默认是分层编译(Tiered Compilation):C1 + C2 配合。先用 C1 快速编译拿到基础性能;热点持续走高再交给 C2 做重度优化。
热点代码的判定条件(两个满足其一)
- 方法被调用的次数达到阈值
- 循环体内代码执行次数达到阈值(循环回边计数器)
阈值可以 JVM 参数调整:
-XX:CompileThresholdJIT 的经典优化手段(面试加分项)
- 方法内联:把小方法的代码直接嵌入调用方,减少方法调用开销(最核心优化)
- 逃逸分析:判断对象是否逃逸出方法。不逃逸可以做栈上分配,对象分配在虚拟机栈,不需要 GC 回收;还有标量替换。
- 循环展开、常量传播、死代码消除
- 公共子表达式消除
⚠️ 注意:JIT 优化是运行期动态做的,代码第一次跑没有优化,所以应用刚启动的时候慢,预热一段时间变快,就是 JIT 在起作用。
解释执行 / JIT 编译 / AOT 对比(面试容易混淆)
- 解释执行:字节码逐条解释执行;启动快,执行慢;不占额外 CPU 做编译。
- JIT 即时编译:运行时探测热点,编译成机器码;启动中等,越跑越快;会占用 CPU 做编译(JIT 编译线程)。
- AOT(提前编译,JDK9+):运行前直接把字节码编译成本地机器码;启动快;没有运行时编译开销;但是缺少运行时信息,优化弱于 JIT。
面试高频题
Q1:HotSpot 是什么?
HotSpot 是 OpenJDK/OracleJDK 默认的 JVM 实现,核心特点是热点探测,解释器 + JIT 混合执行。
Q2:JIT 是什么?JIT 和 javac 有什么区别?
JIT 是即时编译器,运行时把字节码编译为机器码;javac 是把 java 源码编译成 class 字节码,属于编译期。
Q3:HotSpot 为什么采用解释器 + JIT 的混合模式,不用纯 JIT?
纯 JIT 需要全部代码编译完才能执行,启动很慢;纯解释执行性能差。混合模式兼顾启动速度 + 运行性能。启动阶段解释执行,热点代码交给 JIT 编译优化。
Q4:JIT 有哪些经典优化?逃逸分析、方法内联。
方法内联、逃逸分析(栈上分配、标量替换)、循环优化、常量传播、死代码消除。
Q5:什么是逃逸分析?栈上分配的前提?
逃逸分析分析对象是否逃出方法 / 线程。对象不逃逸,则可以栈上分配,对象存虚拟机栈,方法结束自动释放,减轻 GC 压力。
小坑提醒
❌ 误区:JIT 编译发生在类加载阶段 ✅ 纠正:类加载只是加载 class 字节码;JIT 是运行时,代码跑了很多次之后才触发编译。
❌ 误区:所有代码都会被 JIT 编译 ✅ 纠正:只有热点代码才会被 JIT 编译;只执行一次的代码永远解释执行。
1. 怎么判断对象是垃圾?
可达性分析算法(现代 JVM 默认) 以 GC Roots 作为起点,沿着引用链遍历。如果对象无法到达 GC Roots,就是垃圾。 GC Roots 包含:栈里引用的对象、静态变量对象、本地方法引用对象等。
旧版本:引用计数法,无法解决循环引用问题,JVM 不用。
2. GC 分类
- Minor GC(新生代 GC):Eden 满了触发,清理新生代,速度快,停顿短。对象熬过一次 Minor GC,年龄 + 1,年龄到阈值(默认 15)晋升到老年代。
- Major GC(老年代 GC):清理老年代,通常伴随 Minor GC,停顿时间长。
- Full GC:全堆(新生代 + 老年代 + 元空间)一起回收,STW(Stop The World)时间最长,尽量避免!
STW:垃圾回收时,暂停所有用户线程,只让 GC 线程工作。STW 时间就是业务停顿时间,调优核心目标:降低 STW。
3. 垃圾回收器(重点,不同 JDK 版本默认不一样)
| 回收器 | 适用场景 | 特点 |
|---|---|---|
| Serial | 客户端、单核 | 单线程 GC,STW 长 |
| ParallelGC(JDK8 默认) | 多核,追求吞吐量 | 多线程 GC,优先最大化运行代码时间,牺牲停顿 |
| CMS | JDK8 可选,JDK9 废弃 | 并发标记清除,低停顿;内存碎片,无法处理浮动垃圾 |
| G1(JDK9 默认) | 大堆,低停顿 | 堆划分为多个 Region,可预测停顿,兼顾吞吐量和延迟 |
| ZGC(JDK11+) | 超大堆,超低延迟 | 几乎毫秒级 STW,适合高并发低延迟系统 |
| Shenandoah | JDK12+ | 和 ZGC 类似,低延迟 |
两个指标:
- 吞吐量:用户代码运行时间 / (用户代码 + GC 时间)。批处理、离线任务看重吞吐量。ParallelGC。
- 停顿时间:GC 时 STW 暂停业务的时长。互联网在线业务(接口)看重低停顿。G1/ZGC。
三、JVM 调优是什么?调哪些参数?
JVM 调优不是上来就改参数!调优是定位问题,然后合理设置参数,解决 OOM、长时间 GC、CPU 高。 原则:先监控,再分析,最后调参;不要凭经验瞎调。
1. 核心 JVM 参数(JDK8)
堆内存
-Xms 初始堆大小
-Xmx 最大堆大小
# 生产一般设置 Xms=Xmx,避免堆扩容带来开销
例:-Xms4g -Xmx4g
-Xmn 新生代大小(一般不直接指定,改用比例)
-XX:NewRatio 老年代/新生代比例,默认2,老年代是新生代2倍
-XX:SurvivorRatio Eden:S = 8:1,默认8
-XX:MaxTenuringThreshold 对象晋升老年代年龄,默认15
元空间 JDK8
-XX:MetaspaceSize=256m 初始元空间
-XX:MaxMetaspaceSize=256m 最大元空间,防止无限涨占用宿主机内存
GC 相关
# ParallelGC
-XX:+UseParallelGC
# G1
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 目标最大停顿时间(只是目标,不能保证绝对)
# CMS
-XX:+UseConcMarkSweepGC
日志(排查问题必备!上线必须打开 GC 日志)
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:gc.log
OOM 自动 dump 堆快照
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/xxx/heap.hprof
当 OOM 的时候自动导出堆文件,用 MAT 工具分析哪个对象占内存。
2. JVM 调优完整标准流程(面试必考)
- 明确业务目标:是高并发在线业务(低延迟)还是离线批处理(高吞吐),可接受 GC 停顿多少。
- 监控采集数据: 指标:堆内存使用、GC 次数、GC 耗时、FullGC 次数、CPU、线程数。 工具:
jps、jstat、jstack、jmap、Arthas、Prometheus+Grafana。 - 分析日志 / 堆快照:定位问题,是内存泄漏?堆太小?大对象太多?频繁晋升老年代?
- 制定调参方案,小流量灰度测试。
- 对比调优前后指标,验证效果;不行回滚,反复迭代。
重点:调优不是一次性工作,是持续观测迭代。
四、常用 JVM 排查工具(零基础看懂)
- jps:查看 java 进程 pid
jps -l
- jstat:实时看 GC 统计信息
jstat -gc pid 1000 # 每1000ms打印一次GC数据
# S0 S1 E O M 新生代老年代元空间使用率;YGC YGCT FGC FGCT 次数和总耗时
- jstack:打印线程栈。排查死锁、线程阻塞、CPU 飙升。
jstack pid
- jmap:堆内存查看,导出 dump 文件
jmap -dump:format=b,file=heap.hprof pid
- MAT:分析 hprof 堆快照,找内存泄漏对象。
- Arthas(阿里):线上诊断神器,不用重启应用,看方法耗时、内存、线程。
五、实际业务场景 & 问题案例
场景 1:接口服务频繁 FullGC,接口超时(最常见)
现象:jstat 看到 FGC 不断上涨,每次 FGC 停顿几秒,接口超时。 根因常见几种:
- 内存泄漏:集合 List/Map 长期持有对象引用,对象无法被 GC,老年代慢慢填满。
例如:静态 List 不断 add 元素,没有 remove;线程池无限任务,对象被线程引用。 排查:dump 堆,MAT 看最大对象,定位代码。解决:修复泄漏代码,调参没用!内存泄漏改 JVM 参数治标不治本。
- 短生命周期大对象直接进老年代
代码里循环 new 很大的 byte [] 数组,超过阈值直接进老年代,老年代快速满触发 FullGC。 参数:
-XX:PretenureSizeThreshold超过这个大小对象直接进老年代。 解决:优化代码,不要循环创建超大对象;调整阈值。
- 对象晋升过快,大量短期对象涌到老年代。
Survivor 区太小,Minor GC 后存活对象放不下,直接晋升老年代。老年代被大量短命对象占满,频繁 Major/FullGC。 方案:调整新生代大小,调整 Survivor 比例,调整晋升年龄。
场景 2:OOM java.lang.OutOfMemoryError: Java heap space
堆内存不够。两种情况:
- 内存泄漏:代码 bug,对象无法回收。优先查代码。
- 确实业务需要更大内存:加大 Xmx,但不能无限大,堆越大 FullGC STW 时间越长。
场景 3:元空间 OOM Metaspace
原因:动态生成类(CGLIB、反射、热部署),类不断加载不释放。 解决:设置 MaxMetaspaceSize,排查动态类生成代码。
场景 4:CPU 很高,业务很慢
排查步骤:
- top 找到 java 进程 pid
top -H -p pid找到占用 CPU 最高的线程 id- jstack 导出线程栈,把线程 id 转 16 进制,匹配栈信息 两种典型:
- 大量 GC 线程占用 CPU:GC 疯狂执行,内存不足 / 内存泄漏。
- 业务线程死循环:代码死循环。
六、高频面试题(零基础可以直接背 + 理解)
Q1:JVM 内存区域划分,哪些线程共享哪些私有?
私有:程序计数器、虚拟机栈、本地方法栈。 共享:堆、方法区(元空间)。
Q2:JDK7 和 JDK8 元空间区别?
JDK7:方法区是永久代 PermGen,在 JVM 堆内存里,容易 OOM。 JDK8:废除永久代,改为元空间 Metaspace,使用操作系统本地内存,默认上限无限制,一般手动设置 MaxMetaspaceSize 防止占满服务器内存。
Q3:CMS 和 G1 区别?什么时候选 G1?
CMS:标记清除,会产生内存碎片;只能在老年代使用;并发收集低停顿;JDK9 废弃。 G1:把堆切分成多个 Region,新生代老年代不再物理隔离;可设置预期停顿时间;整理内存碎片;适合堆大于 4G、需要低延迟的线上服务。
线上高并发服务 JDK8,堆比较大,优先 G1。
Q4:什么是 STW?哪些 GC 会 STW?
STW Stop The World,GC 的时候暂停所有用户业务线程。 所有 GC 都有 STW,只是时间长短。Minor GC 有 STW;FullGC STW 最长。CMS 并发阶段不用 STW,但初始标记、重新标记阶段依然 STW。G1、ZGC 也是部分阶段 STW。
Q5:Minor GC、Major GC、FullGC 区别,FullGC 为什么要避免?
Minor GC:新生代 Eden 满触发,回收新生代,STW 短。 Major GC:老年代 GC,通常伴随 Minor GC。 FullGC:新生代 + 老年代 + 元空间全部回收,STW 时间非常长,业务接口大量超时,线上尽量减少 FullGC。
Q6:对象晋升到老年代的条件?
- 对象年龄达到 MaxTenuringThreshold(默认 15),熬过多次 Minor GC 晋升。
- Survivor 空间放不下本次 Minor GC 存活对象,剩余对象直接晋升老年代。
- 对象大小超过 PretenureSizeThreshold,创建时直接分配到老年代。
Q7:JVM 调优步骤,你线上怎么调优?
- 确定业务指标(吞吐量 / 延迟);
- 监控:jstat、Arthas,采集 GC 日志;
- 出现 FullGC/OOM,dump 堆快照,MAT 分析,区分是内存泄漏还是参数不合理;
- 定位代码问题优先修复;
- 调整 JVM 参数,灰度发布;
- 持续监控对比指标,迭代。
Q8:内存泄漏和内存溢出 OOM 区别?
内存泄漏:对象不再使用,但是 GC 无法回收,持续占用内存。泄漏累积,最终导致 OOM。 OOM:内存不够,无法分配新对象。
内存泄漏是原因之一;OOM 是结果。内存泄漏改 JVM 参数没用,必须修复代码。
Q9:可达性分析,GC Roots 有哪些?
GC Roots:栈帧本地变量引用对象;静态变量引用;本地方法 JNI 引用对象;Class 对象。
Q10:Xms 和 Xmx 为什么生产环境设置相等?
Xms 初始堆,Xmx 最大堆。如果不等,堆内存不够时 JVM 会扩容堆,扩容过程消耗性能,会触发 GC。设置相等,JVM 启动就申请好固定内存,避免运行时扩容开销。
七、零基础学习路线建议
- 先吃透:运行时数据区,堆结构,GC Roots,STW。
- 掌握 GC 回收器选型差异。
- 学会 jstat 看 GC 日志,看懂 YGC、FGC。
- 练习:本地模拟 OOM,生成 dump 文件,MAT 分析。
- 再看调优案例,不要一上来啃 ZGC 底层。
八、常见误区
❌ 误区 1:JVM 调优就是把 - Xmx 调越大越好。堆越大,FullGC 停顿时间越长。 ❌ 误区 2:遇到 FullGC 上来改参数。优先排查代码内存泄漏,代码问题调参没用。 ❌ 误区 3:G1 一定比 ParallelGC 好。离线批处理任务,追求吞吐量 ParallelGC 更合适。
JVM 调优 & 问题排查工具大全
按类别分成:JDK 自带命令行工具(基础必备)、可视化分析工具、线上诊断工具、监控系统,附带用途、常用命令、适用场景,面试也常考。
前置说明:JDK 的工具都在
$JAVA_HOME/bin目录下。 区分:jps/jstat/jstack/jmap/jhat 是基础四件套,面试必问。
一、JDK 自带命令行工具(最常用,线上服务器一般没有图形界面,只能用这组)
1. jps(Java Virtual Machine Process Status Tool)
作用:查看 Java 进程 PID、主类名,相当于 Java 版ps
jps # 列出java进程pid + 简短类名
jps -l # 完整主类全限定名(最常用)
jps -v # 查看进程启动的JVM参数
jps -m # 查看main方法入参
先拿 PID,后面所有工具都需要 PID。
2. jstat(JVM Statistics Monitoring Tool)⭐GC 排查首选
作用:实时采集 JVM 运行时数据,GC 次数、内存占用、GC 耗时,线上看 GC 首选。
# 每1000ms输出一次GC统计,持续打印
jstat -gc 进程PID 1000
输出字段含义:
- S0C S1C S0U S1U:Survivor0/1 容量、已使用
- EC EU:Eden 容量、已使用
- OC OU:老年代容量、已使用
- MC MU:元空间容量、已使用
- YGC YGCT:Young GC 总次数、Young GC 总耗时
- FGC FGCT:Full GC 总次数、Full GC 总耗时
- GCT:全部 GC 总耗时
其他常用选项:
jstat -gccapacity pid:查看各区内存容量jstat -gcutil pid:百分比形式展示各区使用率(推荐)jstat -gcnew pid:只看新生代 GC
面试重点:排查频繁 FullGC,第一反应就是 jstat 持续观察 FGC 是否持续上涨
3. jstack(Java Stack Trace)⭐线程问题神器
作用:打印线程快照(线程栈),排查:死锁、CPU 飙升、线程阻塞、死循环、线程池堆积
jstack 进程PID
# 导出到文件
jstack pid > thread.txt
能看到:
- 所有线程状态:RUNNABLE、BLOCKED、WAITING
- 线程锁信息,自动检测死锁
Found one Java-level deadlock
CPU 高排查套路:top 找到 java 进程 → top -H 找到高消耗线程 → 线程 ID 转 16 进制 → jstack 匹配栈信息
4. jmap(Memory Map for Java)⭐堆内存快照工具
作用:查看堆配置、对象统计、导出 dump 堆快照(hprof 文件),用于 OOM、内存泄漏分析
# 查看堆概要信息,GC回收器、堆各区大小
jmap -heap pid
# 查看堆中对象统计:类名、实例数量、占用内存大小
jmap -histo pid
# 导出堆dump快照(核心!OOM分析用)
jmap -dump:format=b,file=heap.hprof pid
⚠️ 注意:执行jmap -dump会触发 STW!生产不要随便在线上高峰期执行。
替代方案:JVM 启动参数
-XX:+HeapDumpOnOutOfMemoryError,OOM 时自动 dump,不手动触发。
5. jhat(Java Heap Analysis Tool)
作用:JDK 自带简易 hprof 分析工具
jhat heap.hprof
缺点:功能弱,分析大 dump 文件很慢,生产几乎不用,一般导出 hprof 到本地用 MAT 分析。面试知道有这个工具就行。
6. jinfo(Configuration Info for Java)
作用:查看 / 动态修改 JVM 部分参数
# 查看全部JVM参数
jinfo -flags pid
# 查看某个参数值
jinfo -flag MaxHeapSize pid
# 部分参数支持运行时开启(比如GC日志,JDK8部分支持)
jinfo -flag +PrintGCDetails pid
注意:很多参数不支持运行时修改。
二、可视化分析工具(本地分析 dump、看 GC,电脑端使用)
1. MAT(Memory Analyzer Tool)⭐内存泄漏分析首选,面试高频
独立软件(Eclipse 出品),专门分析 hprof 堆 dump 文件。 核心能力:
- 自动计算内存泄漏可疑报告 Leak Suspects
- 找出大对象、支配树,看谁持有对象引用导致无法 GC
- 直方图、对象引用链,快速定位代码哪里持续持有对象
工作流程:线上 dump → 下载 hprof 到本地 → MAT 打开分析。
2. JVisualVM(VisualVM)
JDK 自带可视化工具,jvisualvm 命令启动。 功能:远程 / 本地监控 JVM,实时看堆、线程、GC,抽样对象,也可以加载 hprof。 优点:开箱即用;缺点:大 dump 文件分析性能不如 MAT。
3. JConsole
JDK 自带图形化监控,jconsole,比较老,功能简单,了解即可。
三、线上诊断工具(不用重启应用,阿里 Arthas 最火)
Arthas(阿尔萨斯)⭐线上排查神器,面试加分项
阿里开源 Java 诊断工具,安装简单,attach 到运行中的 Java 进程。 核心能力:
dashboard:实时看线程、内存、GC、CPU 面板thread:查看线程栈,找死锁、高 CPU 线程(替代 jstack)heapdump:导出堆快照(替代 jmap)watch:方法入参、返回值、异常监控,不用改代码重启trace:追踪方法调用耗时,定位慢接口ognl:线上执行表达式,查看对象属性
优势:不需要重启应用,轻量;很多互联网公司线上常备。
其他同类
- Greys:和 Arthas 同类,早期线上诊断工具
- BTrace:字节码追踪,可以在运行时植入埋点,限制较多
四、GC 日志分析工具(专门解析 gc.log)
GC 日志本身是文本,量大肉眼很难看,用工具可视化:
- GCViewer:开源,导入 gc.log,画图,统计 GC 停顿、吞吐量
- GCEasy:网页版,上传 gc.log,自动生成报告,适合快速看 GC 瓶颈
五、监控大盘(持续观测,提前发现问题,属于常态化调优)
不是临时排查工具,用于长期监控告警:
- Prometheus + Grafana + Micrometer / SpringBoot Actuator
- SkyWalking / Pinpoint:APM 全链路监控,自动采集 JVM 指标、GC、接口耗时
作用:持续监控堆使用率、YGC/FGC 次数、GC 耗时,提前发现 GC 恶化,而不是等到故障发生再排查。
工具选型总结(工作中怎么选)
表格
| 遇到问题 | 首选工具 |
|---|---|
| 想看看 GC 情况,有没有频繁 FullGC | jstat |
| CPU 飙升、线程卡死、死锁 | jstack / Arthas thread |
| OOM、怀疑内存泄漏 | jmap 导出 dump → MAT 分析 |
| 线上想看方法耗时、不重启应用 | Arthas |
| 长期监控 JVM 指标,告警 | SkyWalking / Prometheus+Grafana |
| 本地简单看 JVM 状态 | JVisualVM |
✅ 面试高频问题(这块配套面试题)
Q1:jmap -dump 有什么风险?
执行 dump 的时候会 STW,业务暂停。大堆(比如 8G、16G)dump 会耗时很久,生产高峰期禁止执行。优先配置 OOM 自动 dump。
Q2:jstack 作用?CPU 高排查步骤?
- top 找到 java 进程 PID
- top -H -p pid 列出进程下所有线程,拿到占用 CPU 最高线程 ID(十进制)
- printf "% x" 线程 ID 转 16 进制
- jstack pid,在输出里搜索 16 进制线程 ID,找到对应代码栈定位死循环。
Q3:MAT 是干嘛的?dump 文件分析主要看什么?
MAT 分析堆快照,找内存泄漏。重点看 Leak Suspects 泄漏报告、支配树,找到 GC Roots 引用链,定位无法回收的对象。
Q4:Arthas 和 JDK 自带工具对比?
JDK 工具是基础,不需要额外部署;Arthas 可以动态观测方法入参、追踪耗时,能力更强,但需要把 Arthas attach 到进程。
Q5:jstat 和 GC 日志区别?
jstat 实时采样看 GC 统计;GC 日志是完整记录每一次 GC 事件(时间、原因、各区域内存变化、STW 时长),做精细调优必须打开 GC 日志。
实操演示:写一段内存泄漏代码 + 使用 jps/jstat/jmap/jstack 完整排查
环境:JDK8,直接复制运行,会模拟内存泄漏,慢慢填满老年代,触发 FullGC。 原理:静态集合一直持有对象引用,对象永远不会被 GC 回收,不断创建对象往 List 里塞。
1. 内存泄漏 Demo 代码
import java.util.ArrayList;
import java.util.List;
public class MemoryLeakDemo {
// static静态List,属于类,GC Roots!里面的对象永远不会被回收
private static List<Object> leakList = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
while (true) {
// 循环创建对象,不断加入静态List
byte[] data = new byte[1024 * 100]; // 每个对象约100KB
leakList.add(data);
Thread.sleep(10);
}
}
}
启动时加上 JVM 参数(限制堆大小,快速复现)
-Xms200m -Xmx200m -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap.hprof
启动之后,程序会持续往静态 List 塞对象,慢慢填满堆,不断 FullGC,最后抛出 OOM 并自动生成 heap.hprof。
2. 全套命令实操步骤
① jps:找到 Java 进程 PID
新开终端执行
jps -l
输出示例:
12345 MemoryLeakDemo
12345 就是 PID,下面所有命令替换成你的 PID。
② jstat:持续观察 GC,看 YGC、FGC 上涨
jstat -gc 12345 1000
每 1 秒打印一行 GC 数据
S0C S1C EC OC MC YGC YGCT FGC FGCT GCT
6656.0 6656.0 53248.0 133120.0 4864.0 10 0.050 2 0.200 0.250
字段重点看:
- YGC:新生代 GC 次数,不断上涨
- FGC:FullGC 次数,持续上涨 → 危险信号
- OU:老年代已使用内存,持续接近 OC 老年代总容量
现象:对象不断晋升到老年代,老年代被静态 List 持有的对象占满,频繁 FullGC,直到 OOM。
③ jstack:查看线程栈(这个 demo 主线程是 while 循环)
jstack 12345 > thread.txt
打开 thread.txt,可以看到 main 线程在 sleep 循环。
如果是 CPU 高场景,这里就能找到死循环代码栈;本 demo 是内存泄漏,CPU 不高。
④ jmap:查看堆信息 & 手动导出 dump
# 查看堆概要,GC回收器、新生代老年代大小
jmap -heap 12345
# 查看对象直方图,统计对象数量和占用内存
jmap -histo 12345 > histo.txt
打开 histo.txt,会看到[B byte 数组实例数量巨大,占用绝大多数内存。
[B在 JVM 里代表 byte [] 数组。
手动导出 dump(⚠️会 STW)
jmap -dump:format=b,file=heap-manual.hprof 12345
推荐优先使用启动参数
HeapDumpOnOutOfMemoryError自动 dump,避免手动 dump 线上 STW。
3. MAT 分析 heap.hprof(定位泄漏)
- 把 heap.hprof 导入 MAT
- 打开 Leak Suspects(泄漏可疑报告)
- MAT 会直接提示:
java.util.ArrayList占用大量内存 - 点开支配树,看 GC Roots 引用链:
MemoryLeakDemo.leakList静态变量持有 ArrayList 引用,所有 byte 数组无法被回收。 ✅ 定位到代码:静态 List 持续 add 对象,没有 remove,内存泄漏。
4. 现象总结(对应前面讲的理论)
- 静态变量属于 GC Roots,引用的对象永远可达,无法 GC;
- 大量 byte 对象不断创建,Eden 满触发 Minor GC;存活对象晋升到老年代;
- 老年代内存持续上涨,触发 FullGC;FullGC 无法回收任何对象;
- 反复 FullGC 后堆耗尽,抛出
java.lang.OutOfMemoryError: Java heap space。
5. 面试延伸提问
Q:这个案例为什么 FullGC 回收不掉对象?
因为对象被静态 List 引用,静态变量属于 GC Roots,对象可达,GC 不会回收。内存泄漏。
Q:如果把static去掉,List 定义在 main 方法内部还会泄漏吗?
不会。方法内局部变量,循环迭代,下一次循环 data 引用会断开,对象变成垃圾,可以被 GC 回收。
本地模拟内存泄漏,复现 FullGC & OOM + jstat、MAT 完整实战
环境:JDK8,IDEA /javac 命令运行均可。 目标:亲手复现内存泄漏,观察 GC 指标变化,最后用 MAT 定位泄漏点。
1. 泄漏代码(直接复制)
import java.util.ArrayList;
import java.util.List;
/**
* 模拟内存泄漏:静态集合持续持有对象引用
* static 变量属于类,是GC Roots,里面的对象永远无法被GC回收
*/
public class MemoryLeakDemo {
// 静态List,全局唯一,不会被销毁
private static final List<byte[]> leakContainer = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
System.out.println("程序开始运行,持续创建对象加入静态List");
while (true) {
// 每个数组 100KB
byte[] data = new byte[1024 * 100];
leakContainer.add(data);
Thread.sleep(10);
}
}
}
启动 JVM 参数(必须带上,限制堆大小,快速复现)
-Xms200m
-Xmx200m
-XX:+PrintGCDetails
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=heap.hprof
参数说明:
-Xms200m -Xmx200m:初始堆 = 最大堆,固定 200M,避免堆扩容-XX:+PrintGCDetails:打印详细 GC 日志-XX:+HeapDumpOnOutOfMemoryError:OOM 发生时,自动生成堆快照-XX:HeapDumpPath=heap.hprof:dump 文件保存路径
运行现象:程序跑一会儿,GC 越来越频繁,FGC 不断上涨,最后抛出
java.lang.OutOfMemoryError: Java heap space,并且在项目目录生成heap.hprof。
2. 配套命令实战(新开终端执行)
① jps 拿到进程 PID
jps -l
输出示例:
7890 MemoryLeakDemo
7890 就是 PID,下面所有命令替换成你自己的 PID。
② jstat 实时观察 GC(核心!)
jstat -gc 7890 1000
每 1000ms 打印一行 GC 统计:
S0C S1C EC OC MC YGC YGCT FGC FGCT GCT
6656.0 6656.0 53248.0 133120.0 4864.0 12 0.061 3 0.280 0.341
6656.0 6656.0 53248.0 133120.0 4864.0 14 0.072 5 0.472 0.544
重点观察指标变化:
OU:老年代已使用内存,持续上涨;YGC:新生代 GC 次数缓慢增加;FGC持续上涨!这是内存泄漏最典型特征
正常程序:FGC 基本不变;一旦 FGC 持续涨,立刻警惕内存泄漏。
③ jmap 查看堆信息 & 对象直方图
# 查看堆配置,GC回收器、新生代老年代容量
jmap -heap 7890
# 查看堆内对象统计,输出到文件
jmap -histo 7890 > histo.txt
打开 histo.txt,会看到大量 [B(byte 数组),占用绝大部分内存。
④ jstack(本案例 CPU 不高,演示用法)
jstack 7890 > thread.txt
打开 thread.txt,main 线程处于 sleep 循环,没有死锁。
如果是CPU 飙升场景,jstack 就是用来定位死循环代码栈。
⚠️ 注意:
jmap -dump:format=b,file=manual.hprof 7890手动 dump 会触发 STW,生产环境不要高峰期执行,优先使用 OOM 自动 dump。
3. MAT 分析 heap.hprof,定位内存泄漏
MAT(Memory Analyzer Tool)下载:Eclipse MAT,独立工具,无需安装 Eclipse。 操作步骤:
- File → Open Heap Dump,选中
heap.hprof; - 弹出窗口选择:Leak Suspects Report(泄漏可疑报告);
- 报告首页直接给出怀疑点:
java.util.ArrayList占用大量内存; - 点开详情,查看支配树(Dominator Tree):
- 找到大量
byte[]对象; - 查看引用链:
MemoryLeakDemo.leakContainer(静态变量)持有 ArrayList; - 静态变量属于 GC Roots,对象可达,GC 永远回收不掉。
- 找到大量
✅ 根因定位:静态集合不断 add 对象,没有 remove,产生内存泄漏。
验证实验(修改代码对比,加深理解)
把 static 去掉,List 定义在 main 方法里面:
public static void main(String[] args) throws InterruptedException {
// 非静态,方法内局部变量
List<byte[]> leakContainer = new ArrayList<>();
while (true) {
byte[] data = new byte[1024 * 100];
leakContainer.add(data);
Thread.sleep(10);
}
}
此时不会内存泄漏:循环每次 add,上一轮对象引用失效,Minor GC 可以回收;
jstat观察 FGC 基本不增长,不会 OOM。
小思考题:如果还是 static List,但循环里每次 add 之后都 remove (0),还会泄漏吗?
4. 面试配套思考题(这个案例常被拿来提问)
- 为什么静态集合会造成内存泄漏?
static 变量属于类,是 GC Roots,只要类没有卸载,引用一直存在,对象无法被可达性分析判定为垃圾。
- FGC 不断上涨一定是内存泄漏吗?
不一定。还有可能:大对象直接进老年代、对象晋升过快、堆太小。但 FGC 持续上涨 + FullGC 后老年代内存几乎不下降,高度怀疑内存泄漏。
- OOM 自动 dump 和 jmap 手动 dump 区别?
OOM 自动 dump:发生 OOM 那一刻抓取快照,STW 时间短,推荐;手动 jmap dump 会 STW,大堆 dump 耗时久,线上风险高。
场景:GC 日志实战教学:逐行解读本次内存泄漏 Demo 的 gc.log
使用上面
MemoryLeakDemo+ JDK8,启动参数不变:
-Xms200m -Xmx200m -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap.hprof
把日志输出到文件,可以追加
-Xloggc:gc.log
日志样例(YGC 新生代 GC)
[GC (Allocation Failure) [PSYoungGen: 54272K->4928K(59904K)] 54272K->18528K(184832K), 0.012345 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
逐段拆解
[GC (Allocation Failure)]
- GC:这次是Minor GC(新生代 GC)
- Allocation Failure:分配失败,Eden 区满了,new 对象放不下,触发 YGC
其他 GC 原因:Promotion Failure(晋升失败,Survivor 放不下存活对象)
[PSYoungGen: 54272K->4928K(59904K)]
- PSYoungGen:ParallelGC 的新生代(JDK8 默认回收器)
54272K:GC 前新生代占用4928K:GC 后新生代占用(59904K):新生代总容量
54272K->18528K(184832K)
- 整个堆:GC 前 54272K → GC 后 18528K;堆总大小 184832K
重点:GC 后堆内存没有大量下降,说明大量对象存活,晋升到老年代
0.012345 secs:本次 GC 耗时(STW 停顿)[Times: user=0.03 sys=0.00, real=0.01 secs]
- user:GC 线程 CPU 时间
- sys:内核 CPU 时间
- real:实际业务停顿时间(STW 时长,我们最关心)
FullGC 日志样例(泄漏一段时间后出现)
[Full GC (Ergonomics) [PSYoungGen: 4896K->0K(59904K)] [ParOldGen: 123000K->122000K(124928K)] 127896K->122000K(184832K), [Metaspace: 3456K->3456K(1056768K)], 0.234567 secs] [Times: user=0.72 sys=0.01, real=0.24 secs]
逐段解读:
Full GC (Ergonomics):FullGC,JVM 自动判断需要整堆回收PSYoungGen:4896K->0K:新生代全部清空ParOldGen:123000K->122000K✅ 核心特征:老年代 GC 前后几乎没释放内存! FullGC 做完,老年代内存几乎不变,说明对象全部被 GC Roots 持有,无法回收,典型内存泄漏。
如果是正常 FullGC,老年代内存会明显下降。
- Metaspace:元空间信息
0.234567 secs:FullGC 停顿时间,远大于 YGC
日志阅读总结(面试考点)
- 先看是
GC还是Full GC; - 看触发原因;
- 看 GC 前后内存变化;
- 看 real 时间,就是 STW 停顿;
- 老年代 FullGC 后内存下降很少 → 内存泄漏嫌疑。
小问题:Allocation Failure 是什么? 答:Eden 空间不足,分配新对象失败,触发 Minor GC。
场景:【大对象直接进入老年代】,复现频繁 FullGC
原理
JVM 参数 -XX:PretenureSizeThreshold:超过这个阈值的对象,创建时直接分配到老年代,不经过 Eden 和 Survivor
⚠️ 这个参数只对 Serial、ParNew 生效;ParallelGC 不识别这个参数,所以我们要切换 GC 回收器。
代码
public class BigObjectDemo {
public static void main(String[] args) throws InterruptedException {
while (true) {
// 每个对象 800KB
byte[] bigArr = new byte[1024 * 800];
// 没有static,方法内局部变量,循环结束引用失效
Thread.sleep(50);
}
}
}
JVM 启动参数
-Xms200m
-Xmx200m
-XX:+UseParNewGC
-XX:PretenureSizeThreshold=1024*500
-XX:+PrintGCDetails
-Xloggc:gc-big.log
参数说明:
-XX:+UseParNewGC使用 ParNew 新生代回收器,才能生效 PretenureSizeThreshold-XX:PretenureSizeThreshold=1024*500:大于 500KB 对象直接进老年代 我们 new 的数组 800KB >500KB,每次创建直接放老年代!
现象
- 对象没有 static 持有,单个对象用完就可以回收;
- 但是每次创建直接扔到老年代,老年代内存快速填满;
- 触发频繁 FullGC;
- FullGC 之后内存会释放(和上面内存泄漏不一样!)
✅ 和上面内存泄漏案例对比(面试高频) 内存泄漏:FullGC 之后老年代内存几乎不降 大对象场景:FullGC 之后老年代内存明显下降,对象可以回收,只是不断把对象塞到老年代,反复触发 FullGC
jstat 观察这个案例
jstat -gc pid 1000
你会看到:
- YGC 次数很少;
- FGC 快速上涨;
- OU(老年代使用)反复冲高,FullGC 后回落。
排查思路 & 解决方案
根因
业务循环不断创建超过阈值的大对象,直接分配到老年代,老年代快速占满,频繁 FullGC。
解决办法(二选一)
- 代码优化(优先):复用大数组,用池化,不要循环不停 new 大 byte 数组。
- 调参:调高
PretenureSizeThreshold,让大对象先走新生代 MinorGC。
面试题:大对象直接进老年代有什么问题? 答:老年代回收代价大(FullGC,STW 长),大量短命大对象直接进老年代,会造成频繁 FullGC,业务卡顿。
对比两个案例的差异(重点,面试经常问)
表格
| 场景 | FullGC 后老年代内存 | FGC 特点 | 根因 |
|---|---|---|---|
| 静态集合内存泄漏 | 几乎不下降 | FGC 持续上涨,每次回收释放极少 | 对象被 GC Roots 永久持有,无法回收 |
| 短命大对象直接入老年代 | 明显下降 | FGC 反复冲高回落 | 对象可以回收,但是直接分配到老年代,快速填满老年代 |
配套思考题
- PretenureSizeThreshold 在 ParallelGC 下无效,这个你记住,面试很爱挖坑;
- 上面大对象 Demo,如果去掉 PretenureSizeThreshold,会发生什么?
800K 对象进入 Eden,MinorGC 后到 Survivor,经过几次 YGC 晋升老年代,FGC 不会那么频繁。
场景 :对象晋升过快(Survivor 区太小)导致频繁 FullGC
原理回顾
新生代 = Eden + S0 + S1,默认比例 Eden:S0:S1 = 8:1:1。 Minor GC 流程:
- Eden 满触发 YGC,存活对象复制到空闲的 Survivor;
- 如果Survivor 空间放不下本次存活对象,这些对象不看年龄,直接晋升到老年代;
- 大量短命对象一次性涌进老年代,老年代被快速占满 → 触发频繁 FullGC。
重点区分:
- 大对象场景:创建时直接进老年代
- 本场景:对象本来要放 Survivor,Survivor 装不下被迫晋升,也叫「晋升失败 Promotion Failure」
Demo 代码
public class SurvivorPromoteDemo {
public static void main(String[] args) throws InterruptedException {
while (true) {
// 每个对象 200KB
byte[] data = new byte[1024 * 200];
Thread.sleep(2);
}
}
}
逻辑:循环快速创建大量中等大小短命对象,对象用完就失效,但同一时间存活对象总量很大。
JVM 启动参数(刻意缩小 Survivor,制造晋升失败)
-Xms200m
-Xmx200m
-Xmn100m # 新生代总大小100M,默认8:1:1 → Eden=80M,S0=10M,S1=10M
-XX:SurvivorRatio=8
-XX:+PrintGCDetails
-Xloggc:gc-promote.log
新生代 100M:Eden=80M,S0=10M,S1=10M 当一次 YGC 后存活对象 >10M,Survivor 放不下,超出部分直接晋升到老年代
现象
- Eden 快速被填满,频繁 YGC;
- 某次 Minor GC 存活对象超过 Survivor (10M) → Promotion Failure(晋升失败);
- 大量短期对象直接进入老年代;
- 老年代持续上涨,触发 FullGC;
- FullGC 之后内存可以回落(对象都是短命的,可以被回收)。
GC 日志关键字段
[GC (Allocation Failure) [PSYoungGen: 81920K->12500K(92160K)] 81920K->35000K(204800K), 0.03 secs]
本次 YGC 后存活对象 12500K > Survivor 的 10M,放不下,多出的 2.5M 直接进老年代。 日志里会看到触发原因:Promotion Failure。
面试考点:Promotion Failure 是什么? Minor GC 后存活对象 > Survivor 容量,无法放入 Survivor,对象被迫晋升到老年代。这是线上非常常见的 FullGC 诱因。
jstat 观察特征
jstat -gc pid 1000
- YGC 上涨很快;
- OU(老年代使用)慢慢爬升;
- FGC 慢慢上涨;
- FullGC 之后 OU 明显下降,说明对象可以回收。
根因
Survivor 区太小,一次 MinorGC 后的存活对象放不下,大量短命对象提前涌入老年代,老年代被撑满,频繁 FullGC。
调优方案(两种思路)
方案 1:调大 Survivor 区域 减小 SurvivorRatio,比如改成 -XX:SurvivorRatio=4 Eden:S0:S1 =4:1:1。新生代 100M 时,Eden≈66M,S0=16.7M,S1=16.7M。 Survivor 空间变大,能容纳更多存活对象,减少被迫晋升。
方案 2:调小单次存活对象数量(代码层面,优先)
- 控制并发创建对象速率;
- 分批处理,避免瞬间大批量对象同时存活。
❌ 不推荐:单纯加大老年代。只是延缓 FullGC,不能解决根源,只是把问题延后。
三个案例横向对比(面试必背,高频对比题)
表格
| 场景 | FullGC 后老年代内存 | FGC 特点 | GC 日志关键字 | 根因 |
|---|---|---|---|---|
| 静态集合内存泄漏 | 几乎不降 | FGC 持续上涨,回收几乎无效 | Full GC (Ergonomics) | 对象被 GC Roots 永久持有,无法回收 |
| 短命大对象直接入老年代 | 明显下降 | FGC 快速上涨,反复冲高回落 | PretenureSizeThreshold | 对象太大,创建直接分配到老年代 |
| Survivor 过小,晋升失败 | 明显下降 | YGC 很多,慢慢带出 FGC | Promotion Failure | YGC 存活对象 > Survivor 容量,被迫晋升到老年代 |
面试真题
Q:Promotion Failure 会有什么后果? A:本次 YGC 存活对象放不下 Survivor,大量对象直接晋升到老年代,老年代快速占满,触发 FullGC,STW 变长,接口超时。
Q:为什么不能单纯调大 Xmx 解决 Promotion Failure? A:治标不治本,只是推迟 FullGC 发生。大量短命对象持续涌入老年代,堆越大,后续 FullGC 停顿时间更长。优先调整新生代 / Survivor。
Q:对象晋升到老年代的几个条件?
- 对象年龄达到 MaxTenuringThreshold(默认 15);
- Survivor 放不下存活对象,直接晋升(Promotion Failure);
- 对象大小超过 PretenureSizeThreshold,创建直接进老年代。
JVM 常见调优场景(除了晋升失败,一共 6 个高频场景,包含现象、根因、排查手段、解决方案、面试考点)
前置区分: 一类是内存相关 GC 问题(最常遇到);一类是非内存类 JVM 问题(线程、元空间、JIT 等)
场景 1:元空间 Metaspace OOM(JDK8+)
现象
报错:java.lang.OutOfMemoryError: Metaspace jstat 查看 MC/MU:元空间使用率持续涨,触发 Metaspace FullGC,最后 OOM。
根因
元空间存放类信息、常量、方法。
- 动态生成大量类:CGLIB、ASM、反射、动态代理、Groovy 脚本
- Spring 热部署、Tomcat 频繁 reload、应用不停发布
JDK8 元空间默认使用操作系统本地内存,不设上限,会吃光服务器内存
排查
- Arthas
sc查看加载类数量,看类是不是持续增加 - dump 后 MAT 查看 classloader
解决方案
- 设置元空间上限:
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m - 代码层面:减少动态类生成,关闭开发环境热部署(生产不要热部署)
面试题:永久代和元空间区别?为什么元空间容易 OOM?
场景 2:大堆下 FullGC 停顿时间过长(低延迟业务,如接口服务)
现象
FGC 次数不多,但单次 FullGC 停顿几十秒,接口大批量超时。
比如 Xmx=16G,老年代满了,一次 FullGC 要扫描 16G 堆,STW 很久。
根因
ParallelGC 适合吞吐量,大堆使用 ParallelGC,FullGC STW 随堆变大线性变长。
排查
GC 日志看 FullGC 的 real 时间,几十秒级别。
解决方案
- 更换 GC 器:G1 / ZGC,降低单次停顿
- 拆应用,拆分实例,降低单实例堆大小(不要单个 JVM 堆拉到 16G 以上)
面试考点:堆越大,FullGC 停顿越长,线上服务堆不要无限加大。
场景 3:GC 并发模式失败(CMS 特有,JDK8 CMS)
CMS 已经在 JDK9 废弃,但面试经常考
现象
CMS 运行中出现 Concurrent Mode Failure,直接退化成 Serial Old,触发长时间 STW FullGC。
根因
CMS 并发标记阶段,业务线程继续创建对象,老年代剩余空间不够存放新对象; CMS 预留空间不足,并发标记还没做完,老年代提前占满,并发失败。
排查
GC 日志看到 Concurrent Mode Failure
解决方案
- 调高 CMS 预留内存:
-XX:CMSInitiatingOccupancyFraction(触发 CMS 的老年代占用阈值) - 调大老年代,减少对象晋升
- 推荐直接替换为 G1,规避 CMS 碎片 + 并发失败问题
附加:CMS 另一个问题:内存碎片。老年代多次 GC 后产生大量不连续碎片,有足够总内存,但放不下大对象,触发 FullGC。
场景 4:线程栈溢出 StackOverflowError
现象
抛出 java.lang.StackOverflowError,不是 OOM。
根因
虚拟机栈(线程私有)默认栈大小一般 1M。递归深度太大、无限递归。
注意:这不是堆的问题,是线程栈。每个线程单独分配栈内存。
排查
jstack 直接看栈,会看到重复的方法调用栈(递归)
解决方案
- 优先改代码:终止递归,改成循环(首选!)
- 调参:
-Xss修改线程栈大小(不推荐,栈越大,服务器能创建的线程总数越少)
面试:StackOverflow 和 OOM 区别;Xss 参数作用。
场景 5:线程过多,创建线程 OOM unable to create new native thread
现象
不是堆 OOM,报错:java.lang.OutOfMemoryError: unable to create new native thread
根因
JVM 创建线程,需要向操作系统申请本地内存。线程数量太多,操作系统达到最大线程限制,无法新建线程。
线程栈 Xss 越大,单个线程占用内存越多,能创建的线程越少。
排查
jstack 看线程数量;top 看进程虚拟内存。 常见原因:
- 代码裸写 new Thread (),无界创建线程
- 线程池没有设置核心 / 最大线程数,无界队列
解决方案
- 修复代码:使用 ThreadPoolExecutor,设置合理核心线程、最大线程,拒绝策略
- 调高操作系统最大线程数(linux 系统参数);或者调小 - Xss 减少每个线程内存占用
面试高频:
unable to create new native thread和堆 OOM 的区别,很多人混淆。
场景 6:JIT 编译问题(冷启动、预热慢)
现象
应用刚启动时接口很慢,跑一段时间性能变好。
根因
JVM 执行引擎:解释器先解释执行代码;多次调用的热点代码,JIT 编译成本地机器码。 刚启动代码没被 JIT 编译,解释执行性能差。
排查
可以开启 JIT 日志;看启动初期接口耗时。
解决方案
- 上线前预热:启动后先跑一遍核心接口,完成 JIT 编译,再接入流量
- 调整 JIT 参数(一般很少调,业务优化为主)
汇总:全部 7 大类调优场景清单(包含前面 3 个)
- 内存泄漏(静态集合持有对象)
- 短命大对象直接进老年代
- Survivor 太小 → Promotion Failure 对象晋升过快
- 元空间 Metaspace OOM
- 大堆 FullGC 停顿时间过长
- CMS 并发失败 / CMS 内存碎片
- 栈溢出、无法创建新线程(非堆内存问题)
面试对比思考题(可以自测)
Q1:unable to create new native thread 是堆 OOM 吗?为什么?
不是。是操作系统本地内存不够分配线程栈,和 Java 堆 Heap 无关。
Q2:CMS Concurrent Mode Failure 是什么,怎么解决? Q3:Metaspace OOM 一般是什么代码导致?
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)