目标:先搞懂 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里的字节码,一条一条执行。

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

举个例子:

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 多态(重写)底层就是靠动态链接 + 动态分派 + 虚方法表实现。

代码例子

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. 方法调用和栈帧入栈出栈演示

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

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

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,优先最大化运行代码时间,牺牲停顿
CMSJDK8 可选,JDK9 废弃并发标记清除,低停顿;内存碎片,无法处理浮动垃圾
G1(JDK9 默认)大堆,低停顿堆划分为多个 Region,可预测停顿,兼顾吞吐量和延迟
ZGC(JDK11+)超大堆,超低延迟几乎毫秒级 STW,适合高并发低延迟系统
ShenandoahJDK12+和 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
  1. jstat:实时看 GC 统计信息
jstat -gc pid 1000  # 每1000ms打印一次GC数据
# S0 S1 E O M 新生代老年代元空间使用率;YGC YGCT FGC FGCT 次数和总耗时
  1. jstack:打印线程栈。排查死锁、线程阻塞、CPU 飙升。
jstack pid
  1. jmap:堆内存查看,导出 dump 文件
jmap -dump:format=b,file=heap.hprof pid
  1. MAT:分析 hprof 堆快照,找内存泄漏对象。
  2. 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 情况,有没有频繁 FullGCjstat
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 代码

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. 泄漏代码(直接复制)

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 方法里面:

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 回收器。

代码

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 很多,慢慢带出 FGCPromotion FailureYGC 存活对象 > 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 一般是什么代码导致?

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐