一文讲懂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. 运行时数据区(内存区域,调优核心!)

分为:程序计数器、虚拟机栈、本地方法栈、堆、方法区(元空间)

  1. 程序计数器:很小,记录当前线程执行到哪一行字节码。唯一一个没有 OOM 的区域。

程序计数器是当前线程私有的一小块内存,用来记录当前线程执行到哪一条字节码指令。

⚠️ 它是 JVM 运行时数据区里唯一一个没有规定 OOM的内存区域。

  1. 虚拟机栈(栈内存) :每个线程私有。方法调用时创建栈帧,存局部变量、方法返回值。
    • 栈溢出:StackOverflowError,递归深度太大。
  2. 本地方法栈:和虚拟机栈类似,给 native 方法(C/C++ 写的方法)使用。
  3. 堆(Heap)【调优最重要区域!】
    • 所有线程共享,所有 new 出来的对象都放在堆里
    • 堆分:新生代 + 老年代。
      • 新生代:新创建对象。分为 Eden 区 + 2 个 Survivor(S0,S1)。比例默认 Eden:S0:S1 = 8:1:1
      • 老年代:存活时间久、大对象。
    • GC 主要回收堆内存。堆满了就会 OOM java.lang.OutOfMemoryError: Java heap space
  4. 方法区(JDK8 之后叫元空间 Metaspace)
    • 存放类信息、常量、静态变量、即时编译后的代码。
    • JDK7:永久代 PermGen,放在 JVM 内存;JDK8 移除永久代,元空间放在本地操作系统内存,默认不限制,容易元空间 OOM。

简单记忆:栈存局部变量和方法调用;堆存对象;元空间存类信息。

3. 执行引擎 & GC

  • 执行引擎:解释器(逐行解释字节码)、JIT 即时编译器(热点代码编译成本地机器码,提升速度)
  • GC:垃圾回收,自动识别堆中不再使用的对象,释放内存。JVM 调优大部分工作就是 GC 调优

关于程序计数器(Program Counter Register,PC 寄存器)

1. 核心作用

Java 方法执行的时候,JVM 读取.class里的字节码,一条一条执行。

程序计数器保存下一条要执行的字节码的地址

举个例子:

java 复制代码
public void test(){
    int a = 1;
    int b = 2;
    int c = a + b;
}

编译后的字节码有多条指令:iconst_1istore_1iconst_2...... PC 寄存器记录:现在执行到第几条,下一条该执行谁。

2. 为什么是线程私有?(面试重点)

Java 是多线程,CPU 是时间片轮转。 CPU 切换线程的时候,要保存现场

  1. 线程 A 拿到 CPU,执行一部分字节码;
  2. CPU 时间片用完,线程 A 被挂起;
  3. 把 A 当前执行位置保存到 A 自己的程序计数器
  4. CPU 切换执行线程 B;
  5. 等线程 A 再次抢到 CPU,读取自己 PC 寄存器的值,从上次中断位置继续执行

每个线程都有独立 PC 寄存器,互不干扰。

3. 两种情况:Java 方法 vs native 本地方法

  1. 如果执行Java 方法 :PC 寄存器存放字节码指令地址
  2. 如果执行native 方法 (C/C++ 写的本地方法):PC 寄存器值是 undefined(未定义)

native 方法不是字节码,JVM 不记录字节码地址。

4. 关键特点(面试必背)

  1. 线程私有,线程创建时分配,线程销毁,PC 寄存器跟着销毁。
  2. ✅ 内存极小,只存指令地址。
  3. 该区域没有 OOM。JVM 规范没有要求这块内存抛出 OutOfMemoryError。

面试高频坑题:JVM 内存区域哪个不会 OOM?答案就是程序计数器

  1. ❌ 不是 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 层)

从顶层到底层:

  1. 启动类加载器(Bootstrap ClassLoader)
    • C++ 写的,JVM 内置,Java 拿不到它的对象
    • 加载 JAVA_HOME/lib 下核心类:java.lang.StringObject、ArrayList 等 rt.jar
  2. 扩展类加载器(Extension ClassLoader) Java 实现,加载JAVA_HOME/lib/ext目录下的扩展 jar 包
  3. 应用程序类加载器(Application ClassLoader,系统类加载器) 我们写的业务代码、classpath 下的类,默认由它加载
  4. 自定义类加载器 自己继承 ClassLoader 写的加载器(比如框架热部署、加密 class)

层级关系:自定义 → 应用 → 扩展 → 启动。向上委托

二、双亲委派完整流程(重点)

当应用类加载器收到 com.demo.Test 的加载请求:

  1. 应用类加载器,先不自己加载,委托父加载器(扩展类加载器)
  2. 扩展类加载器继续往上委托给启动类加载器
  3. 启动类加载器检查:能不能找到这个类?
    • ✅ 能找到:自己加载,返回。
    • ❌ 找不到:回传给子加载器(扩展)
  4. 扩展类加载器尝试查找,找不到,继续回传给应用类加载器
  5. 应用类加载器在 classpath 查找,找到则加载;找不到抛ClassNotFoundException

核心规则:向上委托,向下查找 请求往上抛;找不到的时候,从顶层往回,由下层加载器自己去找。

三、为什么要用双亲委派?两大核心目的(必背)

1. 防止核心类被篡改(沙箱安全机制)

举例子:你自己写一个java.lang.String类。 当加载这个类,会向上委托,一直到 Bootstrap。Bootstrap 已经加载了 jdk 自带的java.lang.String,直接返回 JDK 原生 String。 你自己写的 String 永远不会被加载,避免覆盖 JDK 核心类。

如果没有双亲委派:用户自定义的 java.lang.String 会替换原生类,有巨大安全漏洞。

2. 保证类的全局唯一性

同一个类,类的全限定名 + 类加载器 才是唯一标识。 保证全路径相同的类,在整个 JVM 只加载一次,避免重复加载。

四、打破双亲委派模型(面试必问)

双亲委派不是强制语法,可以打破,3 种经典场景:

  1. SPI(JDBC 经典例子) Driver 接口是 JDK 核心类,由 Bootstrap 加载;但是各个数据库厂商的 Driver 实现类(mysql 驱动)在 classpath,需要应用类加载器加载。 Bootstrap 加载 Driver 接口,但无法加载厂商实现类。 解决方案:线程上下文类加载器Thread.getContextClassLoader(),拿到应用类加载器,反向加载,打破双亲委派。
  2. 热部署 / 热加载(Tomcat、Spring devtools) Tomcat:每个 web 应用有独立类加载器。不同 war 包可以有同名类,互不干扰,需要打破双亲委派。

Tomcat 类加载器:优先自己加载,再委托父加载器。

  1. 自定义 ClassLoader,重写 loadClass () loadClass()方法里实现了双亲委派逻辑;如果重写这个方法,不调用父加载器,直接自己加载,就打破委派。

注意:推荐重写findClass(),而不是 loadClass,保留双亲委派。
区分:loadClass:实现双亲委派逻辑;findClass:真正找 class 文件。

五、高频面试题

Q1:双亲委派模型的流程?

收到加载请求,先交给父加载器;父加载器无法加载,自己再尝试加载。

Q2:双亲委派的好处?

  1. 安全:防止 JDK 核心类被自定义类篡改;
  2. 保证类唯一,避免重复加载。

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 字节码读到内存。

易错坑

  1. 双亲委派只是类加载器的一种设计模式,不是 JVM 强制机制,可以打破;
  2. Bootstrap 类加载器没有 Java 对象,拿不到实例;
  3. 判定两个类是否相等:全类名 + 类加载器,缺一不可;同一个 class 文件,不同类加载器加载,是两个完全不同的类。

调优相关

程序计数器几乎不用调优,内存极小,不会成为性能瓶颈,日常 JVM 调优基本不会碰它。

Java 虚拟机栈(JVM Stack,简称虚拟机栈)

一句话总结:虚拟机栈是线程私有内存区域,每个 Java 方法执行的时候,都会在虚拟机栈里创建一块叫「栈帧 (Stack Frame)」的内存;方法调用就是栈帧入栈,方法返回就是栈帧出栈。 重点区分:虚拟机栈 ≠ 硬件 CPU 栈,是 JVM 在内存里抽象出来的栈。

1. 基础特性(面试必背)

  1. 线程私有:线程创建时,虚拟机栈同步创建;线程销毁,虚拟机栈跟着释放。多线程之间栈完全隔离,互不干扰。
  2. 生命周期和线程一致。
  3. 栈里存储单位:栈帧 StackFrame。一个方法对应一个栈帧。
  4. 内存是栈结构:后进先出 LIFO
  5. 可能抛出两种异常:
    • StackOverflowError:栈深度超过上限(递归太深)
    • OutOfMemoryError:动态扩展栈内存,内存不够(很少见,HotSpot 不自动扩展,主要报 StackOverflow)

配置参数:-Xss,设置单个线程的虚拟机栈大小 。比如 -Xss1m,每个线程栈 1MB。

2. 栈帧(Stack Frame):虚拟机栈的核心

一个栈帧,代表一次方法调用。 栈帧内部包含 4 个部分:

  1. 局部变量表 Local Variable Table
  2. 操作数栈 Operand Stack
  3. 动态链接 Dynamic Linking
  4. 方法返回地址 Return Address

额外:部分实现会带上附加信息(异常表,调试信息等)

① 局部变量表 Local Variable Table

  • 存放方法参数 + 方法内定义的局部变量
  • 基本类型、对象引用(不是对象本身!对象在堆,这里只存引用地址)。
  • 单位是槽 slot ,32 位占 1 个 slot;long/double 64 位,占用 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; 字节码流程:

  1. 把 a 从局部变量表压入操作数栈
  2. 把 b 从局部变量表压入操作数栈
  3. iadd:弹出栈顶两个数,相加,结果压回栈顶
  4. 弹出结果,存入局部变量表 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 多态(重写)底层就是靠动态链接 + 动态分派 + 虚方法表实现。

代码例子

java 复制代码
Animal a = new Dog();
a.say();

编译后字节码是:调用Animal.say()(符号引用)。 编译期只知道静态类型是 Animal;运行时发现对象实际类型是 Dog,通过动态链接找到 Dog 的 say () 方法入口。

4. 一个容易踩坑的误区

问:动态链接 = 动态分派? ❌ 不等。

  • 动态链接 :栈帧的属性,是符号引用翻译成直接引用这个机制。
  • 动态分派 :动态链接里的查找目标方法版本的逻辑(根据对象实际类型找重写方法)。 动态分派属于动态链接的其中一种场景。

5. 面试口述精简版

动态链接是栈帧中的一个引用,指向运行时常量池。class 文件中方法调用保存的是符号引用;动态链接负责在运行期将符号引用解析为方法的直接内存地址。 静态方法、私有方法、构造器在类加载阶段就完成解析(静态链接);而实例虚方法、接口方法需要运行时解析,也就是动态链接,支撑 Java 的多态重写。

配套追问(你大概率会被问到)

  1. 重载是静态分派还是动态分派? → 静态分派(编译期确定)
  2. 重写是静态分派还是动态分派? → 动态分派(运行期确定,依赖动态链接)
  3. invokevirtual 底层怎么找方法? → 去对象的虚方法表 vtable 查找。
  • 静态解析:编译期就能确定(static、final 方法,invokestatic)
  • 动态绑定:运行期才能确定(普通实例方法,多态,invokevirtual)

④ 方法返回地址 Return Address

一句话:

保存「当前方法执行完之后,要回到调用方方法的哪个位置继续执行」,就是字节码的程序计数器地址。

方法执行完,需要回到调用方法的位置继续执行。 保存调用者方法的下一条字节码指令地址。 两种退出方式:

  1. 正常返回:return 指令,把返回值传给上层栈帧,栈帧出栈;
  2. 异常退出:抛出异常,没有返回值,也要找到返回地址。

3. 方法调用和栈帧入栈出栈演示

java 复制代码
public void A(){
    B();
}
public void B(){
    C();
}
public void C(){
    return;
}

执行流程:

  1. 调用 A → A 栈帧入栈
  2. A 调用 B → B 栈帧入栈
  3. B 调用 C → C 栈帧入栈
  4. C 执行 return → C 栈帧出栈,回到 B
  5. B 执行结束 → B 栈帧出栈,回到 A
  6. A 结束 → A 栈帧出栈 ✅ 同一时刻,栈顶就是当前正在执行的方法的栈帧

4. 两个异常详解(高频考点)

StackOverflowError

虚拟机栈有最大深度限制。 典型场景:无限递归,不断新建栈帧,栈帧数量超过栈最大深度。

java 复制代码
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. 基础特性

  1. 线程私有:和虚拟机栈、程序计数器一样,线程创建时一同分配,线程销毁,内存释放。
  2. 作用:为native 方法服务(C/C++ 写的本地方法)。
  3. HotSpot 虚拟机中,本地方法栈和 Java 虚拟机栈是合二为一的,共用一块栈内存。
  4. 同样会抛出异常:
    • 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;
  1. Java 代码调用 wait (),发现是 native;
  2. JVM 切换,使用本地方法栈执行对应的 C++ 实现;
  3. C++ 代码直接调用操作系统内核 API,完成线程等待;
  4. 本地方法执行完毕,切回 Java 虚拟机栈,继续执行后面 Java 字节码。

4. 本地方法栈的作用(为什么单独分出这个区域)

  1. 作为 JVM 与操作系统交互的桥梁。 很多底层能力 Java 字节码做不到:操作系统调用、硬件操作、线程调度。这些能力交给 C/C++ 实现,跑在本地方法栈。
  2. 保存 native 方法执行时的本地变量、返回地址(C 层面的栈帧,不是 Java 栈帧)。

注意:这里的栈帧不是 Java 的栈帧,是本地代码的栈帧。

5. 面试高频题

Q1:本地方法栈是线程私有还是共享?

线程私有。

Q2:HotSpot 中本地方法栈和虚拟机栈关系?

HotSpot 将两者合并,共用栈内存,使用 - Xss 统一设置大小。

Q3:本地方法栈里有没有 Java 字节码?

没有,native 方法没有字节码,本地方法栈执行 C/C++ 编译后的机器码。

Q4:本地方法栈会抛出什么异常?

StackOverflowError,OOM(HotSpot 下很少 OOM)。

6. JVM 运行时数据区 5 块汇总(到这里就全部讲完)

  1. 程序计数器:线程私有,记录下一条字节码地址;唯一不会 OOM。
  2. Java 虚拟机栈:线程私有,存放 Java 方法的栈帧;StackOverflow。
  3. 本地方法栈:线程私有,服务 native 本地方法;HotSpot 和虚拟机栈合并。
  4. 堆(Heap)线程共享,存放所有对象实例、数组;GC 主要回收区域。
  5. 方法区(Method Area)线程共享,存放类信息、常量、静态变量、即时编译后的代码。

运行时常量池是方法区里面的一部分

✅ 记忆口诀:三私两共 线程私有:PC 寄存器、虚拟机栈、本地方法栈 线程共享:堆、方法区

JVM 堆(Heap)------ 调优主战场

一句话总结:堆是 JVM 中最大的一块内存,线程共享,所有对象实例、数组都在堆上分配,也是垃圾收集器 GC 工作的主要区域。

1. 基础特性

  1. 线程共享,JVM 启动时创建,生命周期贯穿整个 JVM 进程。
  2. 唯一目的:存放对象实例和数组。
  3. 会抛出异常:java.lang.OutOfMemoryError: Java heap space

当堆内存用尽,GC 之后仍然没有足够空间分配新对象,抛出堆 OOM。

  1. 核心参数:
  • -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) 流程:

  1. Eden 满,触发 YGC,标记 Eden 存活对象,复制到空闲 Survivor;
  2. 清空 Eden;
  3. 交换 S0/S1 角色,下一次 YGC 把存活对象复制到另一块;
  4. 对象每熬过一次 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 做的事情:

  1. 标记 :遍历 Eden + From Survivor,找出里面存活对象(可达对象)
  2. 复制:把所有存活对象,拷贝到 To Survivor
  3. 清空:Eden 和 From Survivor 全部清空(里面死掉的对象直接丢弃)
  4. 交换角色: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. 对象分配的一般流程

  1. new 对象,优先分配到 Eden 区;
  2. Eden 填满,触发 YGC:
    • 死亡对象直接回收;
    • 存活对象复制到 Survivor;
  3. 对象在 Survivor 来回拷贝,年龄不断增长;
  4. 年龄达到阈值,晋升到老年代;
  5. 老年代空间不足,触发 FullGC。

4. GC 算法(对应分代)

  • 新生代:复制算法。存活对象少,复制成本低。两块 Survivor 就是为复制算法设计。
  • 老年代:标记 - 清除 / 标记 - 整理 。老年代对象存活多,复制代价太大。
    • 标记清除:标记垃圾,直接清除;缺点:产生内存碎片。
    • 标记整理:标记垃圾,清除后把存活对象向一端压缩,消除碎片。

5. 常见调优目标(堆调优核心)

  1. 尽量减少 FullGC 次数;
  2. 降低 YGC 和 FullGC 的 STW 停顿时间;
  3. 避免堆 OOM;
  4. 减少内存碎片。

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:对象什么时候晋升到老年代?

  1. 年龄达到 15;
  2. Survivor 空间不足,Promotion Failure,直接晋升;
  3. 大对象直接分配到老年代(PretenureSizeThreshold)。

Q6:堆 OOM 是什么报错? java.lang.OutOfMemoryError: Java heap space

7. 易踩坑误区

❌ 误区:对象一定在堆上 ✅ 纠正:JVM 有逃逸分析 。如果对象不逃逸,JIT 可以做栈上分配,对象直接分配在虚拟机栈,不进堆,栈帧销毁对象自动释放。(面试加分知识点)

❌ 误区:Survivor 两块空间可以同时使用 ✅ 纠正:永远一块用作 From,一块 To,保持一块是空的,复制算法需要。

❌ 误区:FullGC 一定先做 YGC ✅ 不一定,要看 GC 收集器。

方法区(Method Area)+ 运行时常量池 + 元空间 Metaspace

一句话总结:方法区是线程共享的内存区域,存放类的元数据信息、静态变量、常量、JIT 编译代码;JDK8 之后移除永久代,方法区的实现改成元空间(Metaspace),直接使用操作系统本地内存,不再占用堆空间。

重点:方法区是逻辑概念;永久代、元空间是它不同版本的物理实现。

1. 基础特性

  1. 线程共享,JVM 启动创建,JVM 关闭才释放。
  2. 存储内容:
    • 类的元数据(Class 信息:类名、父类、接口、字段、方法信息)
    • 静态变量 static
    • 运行时常量池
    • JIT 即时编译后的代码缓存
  3. 异常:java.lang.OutOfMemoryError: Metaspace(JDK8+)
  4. 参数:
    • -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)

运行时常量池是方法区里面的一部分

  1. class 文件有一个「静态常量池」,编译期生成,存放字面量、符号引用。
  2. 类加载阶段,静态常量池加载进内存,变成运行时常量池
  3. 作用:存放:
    • 字面量:字符串常量、基本类型常量
    • 符号引用:类名、方法名、字段名(后面解析阶段转为直接内存地址)
  4. 动态特性:运行期间也可以往里面添加常量,最典型就是 String.intern()

字符串常量池:JDK7 移入堆,不属于运行时常量池(坑点!很多面试在这里翻车)。

4. 方法区 GC(了解即可)

方法区不是永久不用回收,GC 也会回收这里的垃圾:

  • 回收条件苛刻:卸载类。
  • 类被卸载的全部条件(三个必须同时满足):
  1. 该类所有实例对象全部被回收,堆里没有这个类的对象;
  2. 加载这个类的类加载器已经被回收;
  3. 该类的Class对象没有任何地方被引用。

普通业务类很难满足,所以方法区 GC 回收频率很低;只有热部署、动态生成大量类(CGLIB、ASM)场景容易 Metaspace OOM。

5. 运行时数据区完整 5 块复盘(三私两共)

线程私有(每个线程一份)

  1. 程序计数器:记录字节码地址,唯一不会 OOM
  2. Java 虚拟机栈:Java 方法栈帧,StackOverflowError
  3. 本地方法栈:native 方法,HotSpot 与虚拟机栈合并

线程共享(全局一份)

  1. 堆 Heap:对象实例,GC 主战场,Java heap space OOM
  2. 方法区 MethodArea:类元信息、静态变量、运行时常量池;JDK8 元空间,Metaspace OOM

6. 高频面试题

Q1:方法区存放什么?

类元信息、static 静态变量、运行时常量池、JIT 编译代码。

Q2:永久代和元空间区别?

  1. JDK1.8 取消永久代,方法区改用元空间;
  2. 永久代在堆内存,元空间使用操作系统本地内存;
  3. 永久代有固定上限容易 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 包含哪些(面试常考)

  1. 虚拟机栈(栈帧局部变量表)中引用的对象
  2. 本地方法栈中 JNI 引用的对象
  3. 方法区中静态变量引用的对象
  4. 方法区中常量引用的对象
  5. 被同步锁synchronized持有的对象
  6. JVM 内部引用对象(Class 对象、异常对象等)

❌ 引用计数法(Python 用,JVM 不用):对象加引用计数,引用 + 1,释放 - 1,计数 0 则回收。致命缺陷:无法解决循环引用


1. 标记 - 清除(Mark-Sweep)

流程两步

  1. 标记:遍历堆,标记所有 GC Roots 可达的存活对象;
  2. 清除:把未标记的垃圾对象直接回收,内存放回空闲列表。

优点

  • 简单,不需要移动对象。

缺点(两大痛点,面试必背)

  1. 内存碎片:回收后空闲内存是零散的。堆总内存足够,但没有连续大块空间,放不下大对象,提前触发 FullGC。
  2. 效率:标记和清除两个阶段,堆越大耗时越长。

使用场景

老年代部分收集器(CMS)底层使用标记清除。

2. 复制算法(Copying)

流程

把内存划分为两块大小相等的 A、B 区域,同一时间只用一块。

  1. 只在 A 区分配对象;
  2. A 区满,触发 GC:标记 A 区存活对象,全部复制到 B 区
  3. 一次性清空 A 区;
  4. 交换 A、B 角色,下一次在 B 分配。

新生代就是复制算法,但不是对半分:Eden:S0:S1=8:1:1,不是 1:1。一块大 Eden,两块小的 Survivor,同一时刻只用一块 Survivor。

优点

  1. 没有内存碎片,存活对象连续排列;
  2. 只复制存活对象,清除阶段简单,速度快。

缺点

  1. 浪费内存,需要预留一块同等大小的空间作为复制的备用区;对半分场景,直接损失 50% 堆内存。
  2. 如果存活对象很多,复制开销急剧变大。

使用场景

新生代。新生代特点:绝大多数对象朝生夕灭,存活对象少,复制成本低。

3. 标记 - 整理(Mark-Compact / Mark-Compact)

流程

  1. 标记:和标记清除一样,标记存活对象;
  2. 整理(压缩)不直接清除垃圾。把所有存活对象向内存一端移动,紧密排列;
  3. 边界以外全部空间一次性清空。

优点

  1. 无内存碎片
  2. 不会浪费额外内存(对比复制算法)。

缺点

  1. 需要移动对象,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 一起问)

  1. 强引用Object o = new Object();GC 永远不回收,除非引用断开。
  2. 软引用 SoftReference:内存不足时才回收;适合做缓存。
  3. 弱引用 WeakReference:只要 GC 发生,就会回收;适合缓存、ThreadLocalMap。
  4. 虚引用 PhantomReference:最弱,无法拿到对象;仅用于收到对象被回收的通知,堆外内存释放。

记忆:强引用永不回收;软引用内存不够才回收;弱引用 GC 必回收;虚引用只做通知。

HotSpot 是什么

HotSpot 是目前默认的 Java 虚拟机实现(JVM),Oracle JDK / OpenJDK 默认使用的虚拟机。

JVM 是一套规范 (《Java 虚拟机规范》);HotSpot 是这套规范的具体实现。 类比:JVM 是 "汽车设计图纸",HotSpot 是按照图纸造出来的一台汽车。

HotSpot 名字由来

HotSpot = Hot(热点)+ Spot(点),核心特色:热点代码探测,也就是 JIT 技术。 它会在运行时找到反复执行的 "热点代码",把字节码编译成本地机器码,直接交给 CPU 执行,大幅提速。

HotSpot 核心特性

  1. 解释器 + JIT 编译器混合执行模式(解释 + 编译)

    • 解释器:启动快,边解释边执行字节码;
    • JIT:找到热点代码,编译成机器码,后续执行更快。

    两者互补:启动用解释器快速跑起来;热点代码交给 JIT 编译加速。

  2. 内置 GC:Serial、Parallel、CMS、G1、ZGC 这些收集器,都是 HotSpot 实现。

  3. 分代内存模型:新生代、老年代、元空间,前面讲的堆分代就是 HotSpot 的设计。

  4. 栈帧、类加载器、运行时数据区(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 编译器

  1. C1(Client 编译器)
    • 简单,编译速度快;优化比较保守;编译耗时短。
    • 适合客户端程序,快速启动。
  2. C2(Server 编译器)
    • 编译慢,但是深度优化(循环展开、逃逸分析、方法内联、常量传播等),生成的机器码性能极高。
    • 服务端程序默认使用。

JDK8 默认是分层编译(Tiered Compilation):C1 + C2 配合。先用 C1 快速编译拿到基础性能;热点持续走高再交给 C2 做重度优化。

热点代码的判定条件(两个满足其一)

  1. 方法被调用的次数达到阈值
  2. 循环体内代码执行次数达到阈值(循环回边计数器)

阈值可以 JVM 参数调整:-XX:CompileThreshold

JIT 的经典优化手段(面试加分项)

  1. 方法内联:把小方法的代码直接嵌入调用方,减少方法调用开销(最核心优化)
  2. 逃逸分析 :判断对象是否逃逸出方法。不逃逸可以做栈上分配,对象分配在虚拟机栈,不需要 GC 回收;还有标量替换。
  3. 循环展开、常量传播、死代码消除
  4. 公共子表达式消除

⚠️ 注意:JIT 优化是运行期动态做的,代码第一次跑没有优化,所以应用刚启动的时候慢,预热一段时间变快,就是 JIT 在起作用。

解释执行 / JIT 编译 / AOT 对比(面试容易混淆)

  1. 解释执行:字节码逐条解释执行;启动快,执行慢;不占额外 CPU 做编译。
  2. JIT 即时编译:运行时探测热点,编译成机器码;启动中等,越跑越快;会占用 CPU 做编译(JIT 编译线程)。
  3. 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 分类

  1. Minor GC(新生代 GC):Eden 满了触发,清理新生代,速度快,停顿短。对象熬过一次 Minor GC,年龄 + 1,年龄到阈值(默认 15)晋升到老年代。
  2. Major GC(老年代 GC):清理老年代,通常伴随 Minor GC,停顿时间长。
  3. 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 调优完整标准流程(面试必考)

  1. 明确业务目标:是高并发在线业务(低延迟)还是离线批处理(高吞吐),可接受 GC 停顿多少。
  2. 监控采集数据 : 指标:堆内存使用、GC 次数、GC 耗时、FullGC 次数、CPU、线程数。 工具:jpsjstatjstackjmap、Arthas、Prometheus+Grafana。
  3. 分析日志 / 堆快照:定位问题,是内存泄漏?堆太小?大对象太多?频繁晋升老年代?
  4. 制定调参方案,小流量灰度测试。
  5. 对比调优前后指标,验证效果;不行回滚,反复迭代。

重点:调优不是一次性工作,是持续观测迭代。

四、常用 JVM 排查工具(零基础看懂)

  1. jps:查看 java 进程 pid

    jps -l

  2. jstat:实时看 GC 统计信息

    jstat -gc pid 1000 # 每1000ms打印一次GC数据

    S0 S1 E O M 新生代老年代元空间使用率;YGC YGCT FGC FGCT 次数和总耗时

  3. jstack:打印线程栈。排查死锁、线程阻塞、CPU 飙升。

    jstack pid

  4. jmap:堆内存查看,导出 dump 文件

    jmap -dump:format=b,file=heap.hprof pid

  5. MAT:分析 hprof 堆快照,找内存泄漏对象。

  6. Arthas(阿里):线上诊断神器,不用重启应用,看方法耗时、内存、线程。

五、实际业务场景 & 问题案例

场景 1:接口服务频繁 FullGC,接口超时(最常见)

现象:jstat 看到 FGC 不断上涨,每次 FGC 停顿几秒,接口超时。 根因常见几种:

  1. 内存泄漏:集合 List/Map 长期持有对象引用,对象无法被 GC,老年代慢慢填满。

例如:静态 List 不断 add 元素,没有 remove;线程池无限任务,对象被线程引用。 排查:dump 堆,MAT 看最大对象,定位代码。解决:修复泄漏代码,调参没用!内存泄漏改 JVM 参数治标不治本。

  1. 短生命周期大对象直接进老年代

代码里循环 new 很大的 byte \[\] 数组,超过阈值直接进老年代,老年代快速满触发 FullGC。 参数:-XX:PretenureSizeThreshold 超过这个大小对象直接进老年代。 解决:优化代码,不要循环创建超大对象;调整阈值。

  1. 对象晋升过快,大量短期对象涌到老年代。

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 很高,业务很慢

排查步骤:

  1. top 找到 java 进程 pid
  2. top -H -p pid 找到占用 CPU 最高的线程 id
  3. 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:对象晋升到老年代的条件?

  1. 对象年龄达到 MaxTenuringThreshold(默认 15),熬过多次 Minor GC 晋升。
  2. Survivor 空间放不下本次 Minor GC 存活对象,剩余对象直接晋升老年代。
  3. 对象大小超过 PretenureSizeThreshold,创建时直接分配到老年代。

Q7:JVM 调优步骤,你线上怎么调优?

  1. 确定业务指标(吞吐量 / 延迟);
  2. 监控:jstat、Arthas,采集 GC 日志;
  3. 出现 FullGC/OOM,dump 堆快照,MAT 分析,区分是内存泄漏还是参数不合理;
  4. 定位代码问题优先修复;
  5. 调整 JVM 参数,灰度发布;
  6. 持续监控对比指标,迭代。

Q8:内存泄漏和内存溢出 OOM 区别?

内存泄漏:对象不再使用,但是 GC 无法回收,持续占用内存。泄漏累积,最终导致 OOM。 OOM:内存不够,无法分配新对象。

内存泄漏是原因之一;OOM 是结果。内存泄漏改 JVM 参数没用,必须修复代码。

Q9:可达性分析,GC Roots 有哪些?

GC Roots:栈帧本地变量引用对象;静态变量引用;本地方法 JNI 引用对象;Class 对象。

Q10:Xms 和 Xmx 为什么生产环境设置相等?

Xms 初始堆,Xmx 最大堆。如果不等,堆内存不够时 JVM 会扩容堆,扩容过程消耗性能,会触发 GC。设置相等,JVM 启动就申请好固定内存,避免运行时扩容开销。

七、零基础学习路线建议

  1. 先吃透:运行时数据区,堆结构,GC Roots,STW。
  2. 掌握 GC 回收器选型差异。
  3. 学会 jstat 看 GC 日志,看懂 YGC、FGC。
  4. 练习:本地模拟 OOM,生成 dump 文件,MAT 分析。
  5. 再看调优案例,不要一上来啃 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

能看到:

  1. 所有线程状态:RUNNABLE、BLOCKED、WAITING
  2. 线程锁信息,自动检测死锁 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 进程。 核心能力:

  1. dashboard:实时看线程、内存、GC、CPU 面板
  2. thread:查看线程栈,找死锁、高 CPU 线程(替代 jstack)
  3. heapdump:导出堆快照(替代 jmap)
  4. watch:方法入参、返回值、异常监控,不用改代码重启
  5. trace:追踪方法调用耗时,定位慢接口
  6. ognl:线上执行表达式,查看对象属性

优势:不需要重启应用,轻量;很多互联网公司线上常备。

其他同类

  • Greys:和 Arthas 同类,早期线上诊断工具
  • BTrace:字节码追踪,可以在运行时植入埋点,限制较多

四、GC 日志分析工具(专门解析 gc.log)

GC 日志本身是文本,量大肉眼很难看,用工具可视化:

  1. GCViewer:开源,导入 gc.log,画图,统计 GC 停顿、吞吐量
  2. 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 高排查步骤?

  1. top 找到 java 进程 PID
  2. top -H -p pid 列出进程下所有线程,拿到占用 CPU 最高线程 ID(十进制)
  3. printf "% x" 线程 ID 转 16 进制
  4. 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 代码

java 复制代码
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(定位泄漏)

  1. 把 heap.hprof 导入 MAT
  2. 打开 Leak Suspects(泄漏可疑报告)
  3. MAT 会直接提示:java.util.ArrayList占用大量内存
  4. 点开支配树,看 GC Roots 引用链:MemoryLeakDemo.leakList静态变量持有 ArrayList 引用,所有 byte 数组无法被回收。 ✅ 定位到代码:静态 List 持续 add 对象,没有 remove,内存泄漏。

4. 现象总结(对应前面讲的理论)

  1. 静态变量属于 GC Roots,引用的对象永远可达,无法 GC;
  2. 大量 byte 对象不断创建,Eden 满触发 Minor GC;存活对象晋升到老年代;
  3. 老年代内存持续上涨,触发 FullGC;FullGC 无法回收任何对象;
  4. 反复 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. 泄漏代码(直接复制)

java 复制代码
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

重点观察指标变化:

  1. OU:老年代已使用内存,持续上涨;
  2. YGC:新生代 GC 次数缓慢增加;
  3. 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。 操作步骤:

  1. File → Open Heap Dump,选中 heap.hprof
  2. 弹出窗口选择:Leak Suspects Report(泄漏可疑报告)
  3. 报告首页直接给出怀疑点:java.util.ArrayList占用大量内存;
  4. 点开详情,查看支配树(Dominator Tree)
    • 找到大量 byte[] 对象;
    • 查看引用链:MemoryLeakDemo.leakContainer(静态变量)持有 ArrayList;
    • 静态变量属于 GC Roots,对象可达,GC 永远回收不掉。

✅ 根因定位:静态集合不断 add 对象,没有 remove,产生内存泄漏。

验证实验(修改代码对比,加深理解)

把 static 去掉,List 定义在 main 方法里面:

java 复制代码
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. 面试配套思考题(这个案例常被拿来提问)

  1. 为什么静态集合会造成内存泄漏?

static 变量属于类,是 GC Roots,只要类没有卸载,引用一直存在,对象无法被可达性分析判定为垃圾。

  1. FGC 不断上涨一定是内存泄漏吗?

不一定。还有可能:大对象直接进老年代、对象晋升过快、堆太小。但 FGC 持续上涨 + FullGC 后老年代内存几乎不下降,高度怀疑内存泄漏

  1. 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]

逐段拆解

  1. [GC (Allocation Failure)]
  • GC:这次是Minor GC(新生代 GC)
  • Allocation Failure:分配失败,Eden 区满了,new 对象放不下,触发 YGC

其他 GC 原因:Promotion Failure(晋升失败,Survivor 放不下存活对象)

  1. [PSYoungGen: 54272K->4928K(59904K)]
  • PSYoungGen:ParallelGC 的新生代(JDK8 默认回收器)
  • 54272K:GC 前新生代占用
  • 4928K:GC 后新生代占用
  • (59904K):新生代总容量
  1. 54272K->18528K(184832K)
  • 整个堆:GC 前 54272K → GC 后 18528K;堆总大小 184832K

重点:GC 后堆内存没有大量下降,说明大量对象存活,晋升到老年代

  1. 0.012345 secs:本次 GC 耗时(STW 停顿)
  2. [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]

逐段解读:

  1. Full GC (Ergonomics):FullGC,JVM 自动判断需要整堆回收
  2. PSYoungGen:4896K->0K:新生代全部清空
  3. ParOldGen:123000K->122000K核心特征:老年代 GC 前后几乎没释放内存! FullGC 做完,老年代内存几乎不变,说明对象全部被 GC Roots 持有,无法回收,典型内存泄漏

如果是正常 FullGC,老年代内存会明显下降。

  1. Metaspace:元空间信息
  2. 0.234567 secs:FullGC 停顿时间,远大于 YGC

日志阅读总结(面试考点)

  1. 先看是GC还是Full GC
  2. 看触发原因;
  3. 看 GC 前后内存变化;
  4. 看 real 时间,就是 STW 停顿;
  5. 老年代 FullGC 后内存下降很少 → 内存泄漏嫌疑。

小问题:Allocation Failure 是什么? 答:Eden 空间不足,分配新对象失败,触发 Minor GC。

场景:【大对象直接进入老年代】,复现频繁 FullGC

原理

JVM 参数 -XX:PretenureSizeThreshold超过这个阈值的对象,创建时直接分配到老年代,不经过 Eden 和 Survivor

⚠️ 这个参数只对 Serial、ParNew 生效;ParallelGC 不识别这个参数,所以我们要切换 GC 回收器。

代码

java 复制代码
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

参数说明:

  1. -XX:+UseParNewGC 使用 ParNew 新生代回收器,才能生效 PretenureSizeThreshold
  2. -XX:PretenureSizeThreshold=1024*500:大于 500KB 对象直接进老年代 我们 new 的数组 800KB >500KB,每次创建直接放老年代!

现象

  • 对象没有 static 持有,单个对象用完就可以回收;
  • 但是每次创建直接扔到老年代,老年代内存快速填满;
  • 触发频繁 FullGC
  • FullGC 之后内存会释放(和上面内存泄漏不一样!)

✅ 和上面内存泄漏案例对比(面试高频) 内存泄漏:FullGC 之后老年代内存几乎不降 大对象场景:FullGC 之后老年代内存明显下降,对象可以回收,只是不断把对象塞到老年代,反复触发 FullGC

jstat 观察这个案例

复制代码
jstat -gc pid 1000

你会看到:

  1. YGC 次数很少;
  2. FGC 快速上涨
  3. OU(老年代使用)反复冲高,FullGC 后回落。

排查思路 & 解决方案

根因

业务循环不断创建超过阈值的大对象,直接分配到老年代,老年代快速占满,频繁 FullGC。

解决办法(二选一)

  1. 代码优化(优先):复用大数组,用池化,不要循环不停 new 大 byte 数组。
  2. 调参:调高PretenureSizeThreshold,让大对象先走新生代 MinorGC。

面试题:大对象直接进老年代有什么问题? 答:老年代回收代价大(FullGC,STW 长),大量短命大对象直接进老年代,会造成频繁 FullGC,业务卡顿。

对比两个案例的差异(重点,面试经常问)

表格

场景 FullGC 后老年代内存 FGC 特点 根因
静态集合内存泄漏 几乎不下降 FGC 持续上涨,每次回收释放极少 对象被 GC Roots 永久持有,无法回收
短命大对象直接入老年代 明显下降 FGC 反复冲高回落 对象可以回收,但是直接分配到老年代,快速填满老年代

配套思考题

  1. PretenureSizeThreshold 在 ParallelGC 下无效,这个你记住,面试很爱挖坑;
  2. 上面大对象 Demo,如果去掉 PretenureSizeThreshold,会发生什么?

800K 对象进入 Eden,MinorGC 后到 Survivor,经过几次 YGC 晋升老年代,FGC 不会那么频繁。

场景 :对象晋升过快(Survivor 区太小)导致频繁 FullGC

原理回顾

新生代 = Eden + S0 + S1,默认比例 Eden:S0:S1 = 8:1:1。 Minor GC 流程:

  1. Eden 满触发 YGC,存活对象复制到空闲的 Survivor;
  2. 如果Survivor 空间放不下本次存活对象 ,这些对象不看年龄,直接晋升到老年代
  3. 大量短命对象一次性涌进老年代,老年代被快速占满 → 触发频繁 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 放不下,超出部分直接晋升到老年代

现象

  1. Eden 快速被填满,频繁 YGC;
  2. 某次 Minor GC 存活对象超过 Survivor (10M) → Promotion Failure(晋升失败)
  3. 大量短期对象直接进入老年代;
  4. 老年代持续上涨,触发 FullGC;
  5. 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:对象晋升到老年代的几个条件?

  1. 对象年龄达到 MaxTenuringThreshold(默认 15);
  2. Survivor 放不下存活对象,直接晋升(Promotion Failure);
  3. 对象大小超过 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 元空间默认使用操作系统本地内存,不设上限,会吃光服务器内存

排查

  1. Arthas sc 查看加载类数量,看类是不是持续增加
  2. dump 后 MAT 查看 classloader

解决方案

  1. 设置元空间上限:-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m
  2. 代码层面:减少动态类生成,关闭开发环境热部署(生产不要热部署)

面试题:永久代和元空间区别?为什么元空间容易 OOM?


场景 2:大堆下 FullGC 停顿时间过长(低延迟业务,如接口服务)

现象

FGC 次数不多,但单次 FullGC 停顿几十秒,接口大批量超时。

比如 Xmx=16G,老年代满了,一次 FullGC 要扫描 16G 堆,STW 很久。

根因

ParallelGC 适合吞吐量,大堆使用 ParallelGC,FullGC STW 随堆变大线性变长。

排查

GC 日志看 FullGC 的 real 时间,几十秒级别。

解决方案

  1. 更换 GC 器:G1 / ZGC,降低单次停顿
  2. 拆应用,拆分实例,降低单实例堆大小(不要单个 JVM 堆拉到 16G 以上)

面试考点:堆越大,FullGC 停顿越长,线上服务堆不要无限加大。


场景 3:GC 并发模式失败(CMS 特有,JDK8 CMS)

CMS 已经在 JDK9 废弃,但面试经常考

现象

CMS 运行中出现 Concurrent Mode Failure,直接退化成 Serial Old,触发长时间 STW FullGC。

根因

CMS 并发标记阶段,业务线程继续创建对象,老年代剩余空间不够存放新对象; CMS 预留空间不足,并发标记还没做完,老年代提前占满,并发失败。

排查

GC 日志看到 Concurrent Mode Failure

解决方案

  1. 调高 CMS 预留内存:-XX:CMSInitiatingOccupancyFraction(触发 CMS 的老年代占用阈值)
  2. 调大老年代,减少对象晋升
  3. 推荐直接替换为 G1,规避 CMS 碎片 + 并发失败问题

附加:CMS 另一个问题:内存碎片。老年代多次 GC 后产生大量不连续碎片,有足够总内存,但放不下大对象,触发 FullGC。


场景 4:线程栈溢出 StackOverflowError

现象

抛出 java.lang.StackOverflowError,不是 OOM。

根因

虚拟机栈(线程私有)默认栈大小一般 1M。递归深度太大、无限递归。

注意:这不是堆的问题,是线程栈。每个线程单独分配栈内存。

排查

jstack 直接看栈,会看到重复的方法调用栈(递归)

解决方案

  1. 优先改代码:终止递归,改成循环(首选!)
  2. 调参:-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 (),无界创建线程
  • 线程池没有设置核心 / 最大线程数,无界队列

解决方案

  1. 修复代码:使用 ThreadPoolExecutor,设置合理核心线程、最大线程,拒绝策略
  2. 调高操作系统最大线程数(linux 系统参数);或者调小 - Xss 减少每个线程内存占用

面试高频:unable to create new native thread 和堆 OOM 的区别,很多人混淆。


场景 6:JIT 编译问题(冷启动、预热慢)

现象

应用刚启动时接口很慢,跑一段时间性能变好。

根因

JVM 执行引擎:解释器先解释执行代码;多次调用的热点代码,JIT 编译成本地机器码。 刚启动代码没被 JIT 编译,解释执行性能差。

排查

可以开启 JIT 日志;看启动初期接口耗时。

解决方案

  1. 上线前预热:启动后先跑一遍核心接口,完成 JIT 编译,再接入流量
  2. 调整 JIT 参数(一般很少调,业务优化为主)

汇总:全部 7 大类调优场景清单(包含前面 3 个)

  1. 内存泄漏(静态集合持有对象)
  2. 短命大对象直接进老年代
  3. Survivor 太小 → Promotion Failure 对象晋升过快
  4. 元空间 Metaspace OOM
  5. 大堆 FullGC 停顿时间过长
  6. CMS 并发失败 / CMS 内存碎片
  7. 栈溢出、无法创建新线程(非堆内存问题)

面试对比思考题(可以自测)

Q1:unable to create new native thread 是堆 OOM 吗?为什么?

不是。是操作系统本地内存不够分配线程栈,和 Java 堆 Heap 无关。

Q2:CMS Concurrent Mode Failure 是什么,怎么解决? Q3:Metaspace OOM 一般是什么代码导致?

相关推荐
步行cgn1 小时前
Spring Boot 指定数据来源详解
spring boot·后端·python
土司大王1 小时前
LeetCode 79 单词搜索:Java 回溯模板、网格 DFS 与剪枝优化
java·算法·leetcode·深度优先
白远山1 小时前
智慧场馆解决方案小程序系统开发实战与架构设计指南
java·开发语言·小程序·架构
ECT-OS-JiuHuaShan1 小时前
哲学是迭代学,数学是拓扑学
开发语言·人工智能·学习·算法·机器学习·php·拓扑学
All for pursuit.1 小时前
【数组-5】560.和为K的子数组
数据结构·c++·算法·leetcode
一个有温度的技术博主1 小时前
深入理解 Spring Boot 自动装配
java·spring boot·后端
快乐非自愿1 小时前
低代码落地实战:场景拆解+企业数字化转型完整框架
人工智能·低代码·架构
IT_陈寒2 小时前
我TM竟然被Java的空指针坑了第三次!
前端·人工智能·后端
一心同学2 小时前
Hermes架构拆解之Gateway
架构·gateway·agent·hermes