快速了解JVM 虚拟机:从小白到工作实战

适用对象:Java 初学者、后端开发、准备面试、想真正理解线上问题的人。
学习目标:看完后能讲清楚 JVM 的运行原理,能看懂常见 JVM 参数,能用 JVM 思维分析工作中的性能、内存、GC、类加载问题。
推荐 Java 版本:JDK 8、JDK 11、JDK 17 都适用。本文会重点说明 JDK 8 和 JDK 11+ 的差异。


1. 先用一句话理解 JVM

JVM,全称 Java Virtual Machine,中文叫 Java 虚拟机。

一句话理解:

JVM 是 Java 程序和操作系统之间的一层运行环境,它负责加载 class 文件、管理内存、执行字节码、回收垃圾、优化代码运行速度。

你写的 Java 代码不是直接被 Windows、Linux、macOS 执行的,而是先被编译成 .class 字节码,然后交给 JVM 执行。

执行链路如下:

Java 源代码 .java
        ↓ javac 编译
字节码文件 .class
        ↓ JVM 加载并执行
机器指令
        ↓ 操作系统和 CPU 执行
程序运行结果

1.1 为什么 Java 能跨平台

Java 常说“一次编写,到处运行”,原因不是 Java 代码本身能跨平台,而是不同平台有不同版本的 JVM。

同一份 .class 字节码
        ↓
Windows JVM  -> Windows 上运行
Linux JVM    -> Linux 上运行
macOS JVM    -> macOS 上运行

辅助理解:

Java 代码就像普通话写的说明书。
JVM 就像各地的翻译人员。
Windows、Linux、macOS 就像不同语言环境。
只要每个地方都有懂普通话的翻译,说明书就能被执行。

1.2 JVM 在工作中有什么用

很多人觉得 JVM 是面试八股,但它在工作中非常有用。

常见场景:

工作问题 JVM 相关知识
服务突然 OOM 堆内存、方法区、直接内存、内存泄漏
接口突然变慢 GC 停顿、JIT 编译、线程阻塞
服务启动很慢 类加载、Spring Bean 初始化、JIT 预热
CPU 飙高 线程栈、死循环、频繁 GC、即时编译
容器内存被打爆 JVM 参数、堆外内存、容器感知
修改代码不生效 类加载器、缓存、热部署
线上偶发卡顿 Stop The World、Full GC、锁竞争

2. 学 JVM 要建立的整体地图

JVM 很大,但可以分成 6 块学习:

1. 类加载机制:class 文件如何进入 JVM
2. 运行时内存:对象、变量、方法调用放在哪里
3. 字节码执行:JVM 如何执行代码
4. 垃圾回收:不用的对象如何被清理
5. 性能调优:如何定位慢、卡、OOM、CPU 高
6. 工作实践:怎么把 JVM 知识用到项目中

一张总图:

                 Java 源代码
                    |
                    v
                 javac 编译
                    |
                    v
                 .class 文件
                    |
                    v
              类加载子系统
                    |
                    v
  ------------------------------------------------
  |              JVM 运行时数据区                 |
  |  堆 | 方法区/元空间 | 栈 | 本地方法栈 | 程序计数器 |
  ------------------------------------------------
                    |
                    v
             执行引擎解释或编译执行
                    |
                    v
             操作系统 / CPU / 内存

学习建议:

先理解对象放在哪里,再理解对象怎么创建,最后理解对象什么时候被回收。

3. JVM、JRE、JDK 的区别

很多小白容易混淆这三个概念。

名称 全称 作用 面向谁
JVM Java Virtual Machine 执行 Java 字节码 程序运行
JRE Java Runtime Environment JVM + 运行时类库 只运行 Java 程序的人
JDK Java Development Kit JRE + 开发工具 Java 开发者

关系:

JDK
 ├─ JRE
 │   ├─ JVM
 │   └─ Java 基础类库
 └─ javac、jps、jstack、jmap、jstat 等开发工具

工作建议:

  • 开发机器安装 JDK。
  • 线上服务器如果需要排查问题,也建议安装 JDK,而不是只安装 JRE。
  • 因为 jstackjmapjstatjcmd 等排查工具都在 JDK 里。

4. JVM 运行时内存区域

这是 JVM 最核心的基础。

JVM 运行时内存可以理解为 Java 程序运行时使用的一套“内存房间”。

JVM 运行时数据区
├─ 堆 Heap
├─ 方法区 Method Area / 元空间 Metaspace
├─ Java 虚拟机栈 JVM Stack
├─ 本地方法栈 Native Method Stack
└─ 程序计数器 Program Counter Register

4.1 堆 Heap

堆是 JVM 中最大的一块内存区域。

主要作用:

存放对象实例和数组。

例如:

User user = new User();
int[] nums = new int[10];

这里的 new User() 对象和 new int[10] 数组主要放在堆里。

辅助理解:

堆就像一个大型仓库。
Java 程序运行过程中创建的大部分对象,都被放进这个仓库。
垃圾回收器就像仓库管理员,定期清理没人使用的物品。

特点:

  • 线程共享。
  • JVM 启动时创建。
  • 是垃圾回收最主要管理区域。
  • 容易出现 java.lang.OutOfMemoryError: Java heap space

常见参数:

-Xms512m      # 堆初始大小
-Xmx512m      # 堆最大大小

工作建议:

线上服务通常建议 -Xms 和 -Xmx 设置成一样,避免运行过程中堆频繁扩容或收缩带来抖动。

示例:

java -Xms2g -Xmx2g -jar app.jar

4.2 方法区 Method Area 和元空间 Metaspace

方法区用于存放类相关信息,例如:

  • 类名。
  • 方法信息。
  • 字段信息。
  • 常量。
  • 静态变量相关信息。
  • JIT 编译后的代码缓存相关内容。

JDK 8 之前,HotSpot JVM 使用永久代 PermGen 实现方法区。

JDK 8 及以后,永久代被移除,改为元空间 Metaspace。

差异:

版本 实现方式 内存位置
JDK 7 及以前 永久代 PermGen JVM 堆内存的一部分或紧邻堆实现
JDK 8 及以后 元空间 Metaspace 本地内存 Native Memory

辅助理解:

堆里放的是一个个对象。
方法区或元空间里放的是对象所属类的说明书。

User user = new User();

User 对象本身主要在堆里。
User 这个类的结构信息在方法区或元空间里。

常见参数:

JDK 8 及以后:

-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

常见错误:

java.lang.OutOfMemoryError: Metaspace

常见原因:

  • 动态生成了大量类,例如 CGLIB、Javassist、ByteBuddy。
  • 应用频繁热部署,旧类加载器无法释放。
  • 元空间上限设置太小。

4.3 Java 虚拟机栈 JVM Stack

每个线程都有自己的 Java 虚拟机栈。

主要作用:

保存方法调用过程中的局部变量、操作数栈、返回地址等信息。

每调用一个方法,JVM 就会创建一个栈帧 Stack Frame。

方法调用结束后,对应栈帧出栈。

示例代码:

public void a() {
    int x = 1;
    b();
}

public void b() {
    int y = 2;
    c();
}

public void c() {
    int z = 3;
}

执行过程:

调用 a():a 栈帧入栈
调用 b():b 栈帧入栈
调用 c():c 栈帧入栈
c() 执行完:c 栈帧出栈
b() 执行完:b 栈帧出栈
a() 执行完:a 栈帧出栈

辅助理解:

栈就像一摞盘子。
最后放上去的盘子,最先拿下来。
方法调用也是这样,最后调用的方法最先返回。

常见错误:

java.lang.StackOverflowError

常见原因:

  • 无限递归。
  • 方法调用层级过深。
  • 单个线程栈设置太小。

示例:

public class StackOverflowDemo {
    public static void main(String[] args) {
        test();
    }

    private static void test() {
        test();
    }
}

常见参数:

-Xss1m

说明:

  • -Xss 用于设置每个线程的栈大小。
  • 栈越大,单个线程能支持的方法调用深度越深。
  • 栈越大,同样内存下能创建的线程数越少。

工作启发:

如果服务创建大量线程,不能只看堆内存。
每个线程都会占用独立栈内存,线程过多也可能导致内存被打满。

4.4 本地方法栈 Native Method Stack

本地方法栈服务于 Native 方法。

Native 方法通常是用 C、C++ 等语言实现的方法。

例如:

public native int hashCode();

工作中一般不需要深入操作本地方法栈,但要知道:

  • Java 底层很多功能会调用 Native 方法。
  • NIO、压缩、加密、网络、文件操作可能涉及本地代码。
  • 本地内存问题不一定体现在 Java 堆里。

4.5 程序计数器 Program Counter Register

程序计数器是一块很小的线程私有内存。

主要作用:

记录当前线程执行到哪一条字节码指令。

辅助理解:

程序计数器像书签。
线程被 CPU 暂停后,下次恢复执行时,需要知道上次读到了哪一页。

特点:

  • 线程私有。
  • 占用很小。
  • JVM 规范中唯一一个不会发生 OutOfMemoryError 的区域。

5. 对象是怎么创建出来的

当你写下:

User user = new User();

JVM 大致会做这些事:

1. 检查 User 类是否已经被加载。
2. 如果没有加载,先触发类加载。
3. 在堆中为 User 对象分配内存。
4. 将对象内存初始化为零值。
5. 设置对象头信息。
6. 执行构造方法。
7. 将对象引用赋值给变量 user。

5.1 对象内存布局

HotSpot JVM 中,一个普通对象大致由三部分组成:

对象头 Header
实例数据 Instance Data
对齐填充 Padding
部分 内容 说明
对象头 Mark Word、类型指针等 存储锁状态、GC 年龄、哈希码、类元信息指针
实例数据 成员变量值 对象真正保存的数据
对齐填充 补齐字节 JVM 对象大小通常按 8 字节对齐

辅助理解:

对象头像快递面单,记录这个包裹的基础信息。
实例数据像快递箱里的真实商品。
对齐填充像箱子里为了填满空间放的泡沫。

5.2 对象引用是什么

代码:

User user = new User();

这里的 user 不是对象本身,而是对象引用。

可以理解为:

对象在堆仓库里。
user 是一张写着仓库地址的小纸条。

所以当你写:

User user1 = new User();
User user2 = user1;

含义是:

user1 和 user2 两张纸条指向同一个对象。

这也是为什么修改 user2 的属性,user1 看到的也会变化。


6. 类加载机制

Java 类不是一开始全部加载进 JVM 的,而是在需要时才加载。

类加载过程:

加载 Loading
  ↓
验证 Verification
  ↓
准备 Preparation
  ↓
解析 Resolution
  ↓
初始化 Initialization
  ↓
使用 Using
  ↓
卸载 Unloading

其中验证、准备、解析统称为连接 Linking。

6.1 加载 Loading

加载阶段做三件事:

1. 通过类的全限定名获取 class 字节码。
2. 将字节码表示的静态存储结构转换为方法区运行时数据结构。
3. 在堆中生成一个 Class 对象,作为访问类元信息的入口。

来源可以是:

  • 本地 .class 文件。
  • Jar 包。
  • 网络下载。
  • 动态代理运行时生成。

6.2 验证 Verification

验证 class 文件是否合法、安全。

例如:

  • 文件格式是否正确。
  • 字节码是否符合 JVM 规范。
  • 是否会危害 JVM 安全。

辅助理解:

验证阶段就像安检。
不是所有 class 文件都能直接进入 JVM,必须先确认它安全合法。

6.3 准备 Preparation

准备阶段为类变量分配内存并设置初始零值。

注意,这里是类变量,也就是 static 变量。

示例:

public class Demo {
    public static int count = 10;
}

准备阶段:

count = 0

初始化阶段:

count = 10

6.4 解析 Resolution

解析阶段将符号引用转换为直接引用。

小白理解即可:

符号引用像“我要找 com.example.User 这个人”。
直接引用像“这个人就在内存地址 xxx”。

6.5 初始化 Initialization

初始化阶段才真正执行类构造器 <clinit>()

它会执行:

  • 静态变量赋值。
  • 静态代码块。

示例:

public class Demo {
    public static int count = 10;

    static {
        count = 20;
    }
}

最终 count20

6.6 类什么时候会初始化

常见触发时机:

  • new 一个对象。
  • 访问类的静态变量。
  • 调用类的静态方法。
  • 使用反射访问类。
  • 初始化子类时,如果父类还没初始化,会先初始化父类。
  • JVM 启动时执行 main 方法所在类。

示例:

public class User {
    static {
        System.out.println("User init");
    }
}

public class Main {
    public static void main(String[] args) {
        new User();
    }
}

执行 new User() 时,会触发 User 类初始化。


7. 类加载器和双亲委派模型

类加载器 ClassLoader 负责把 class 文件加载到 JVM 中。

常见类加载器:

类加载器 作用
Bootstrap ClassLoader 加载 Java 核心类库,例如 java.lang.String
Extension ClassLoader JDK 8 中加载扩展类库
Platform ClassLoader JDK 9+ 中替代部分扩展类加载职责
Application ClassLoader 加载应用 classpath 下的业务类和依赖 Jar
Custom ClassLoader 自定义类加载器,例如 Tomcat、插件框架

7.1 双亲委派是什么

双亲委派流程:

应用类加载器收到加载请求
        ↓
先交给父加载器
        ↓
父加载器继续往上交
        ↓
最顶层父加载器尝试加载
        ↓
父加载器加载不了,再由子加载器加载

辅助理解:

员工遇到问题先上报主管。
主管解决不了再交回员工自己处理。

7.2 为什么要双亲委派

主要目的:

保护 Java 核心类库,避免核心类被随意替换。

例如你自己写一个类:

package java.lang;

public class String {
}

如果没有双亲委派,你可能替换掉 JDK 自带的 java.lang.String,这会造成严重安全问题。

有双亲委派后,加载 java.lang.String 会优先交给 Bootstrap ClassLoader,由它加载 JDK 自带版本。

7.3 工作中的类加载问题

常见问题:

问题 可能原因
ClassNotFoundException classpath 中没有对应类
NoClassDefFoundError 编译时有,运行时没有,或类初始化失败
NoSuchMethodError 依赖版本冲突,运行时加载了旧版本类
ClassCastException 同名类被不同类加载器加载

工作启发:

遇到奇怪的类错误,不要只看代码。要看运行时到底加载了哪个 Jar、哪个版本、哪个 ClassLoader。

排查命令示例:

java -verbose:class -jar app.jar

或在 Spring Boot 中查看依赖树:

mvn dependency:tree

8. 字节码和执行引擎

Java 编译后生成的是字节码,不是机器码。

8.1 什么是字节码

字节码是 JVM 能识别的一套指令。

示例 Java 代码:

public int add(int a, int b) {
    return a + b;
}

可以使用下面命令查看字节码:

javac Demo.java
javap -c Demo

可能看到类似:

0: iload_1
1: iload_2
2: iadd
3: ireturn

简单理解:

字节码 含义
iload_1 加载第 1 个 int 参数
iload_2 加载第 2 个 int 参数
iadd 执行 int 加法
ireturn 返回 int 结果

8.2 解释执行和即时编译

JVM 执行字节码有两种主要方式:

方式 说明 特点
解释执行 一条一条解释字节码 启动快,长期运行性能一般
JIT 编译 将热点代码编译成本地机器码 启动预热后性能高

辅助理解:

解释执行像同声传译,听一句翻译一句。
JIT 编译像把常用文章提前翻译好,下次直接读译文。

8.3 什么是热点代码

热点代码就是被频繁执行的代码,例如:

  • 高频接口中的核心方法。
  • 循环体中的代码。
  • 被大量请求调用的方法。

JVM 会统计代码执行次数,当达到一定阈值后,JIT 会把热点代码编译成本地机器码,提高执行速度。

工作启发:

Java 服务刚启动时可能性能没有达到最佳,因为热点代码还没有被 JIT 充分编译。
这就是所谓的预热。

8.4 为什么线上服务刚启动会有抖动

可能原因:

  • Spring 容器刚初始化完成,缓存还没热起来。
  • JIT 还没有编译热点代码。
  • 数据库连接池、Redis 连接池刚建立。
  • 类还在陆续加载。

工作建议:

  • 重要服务上线后先做健康检查。
  • 流量切入不要瞬间打满。
  • 可以使用灰度发布,让服务逐步承接流量。

9. 垃圾回收 GC 基础

GC,全称 Garbage Collection,垃圾回收。

它负责清理不再使用的对象,释放内存。

9.1 什么对象是垃圾

简单说:

如果一个对象再也无法被程序访问,它就是垃圾。

示例:

public void test() {
    User user = new User();
    user = null;
}

user = null 后,如果没有其他引用指向这个对象,它就可能成为垃圾。

9.2 引用计数法

引用计数法思路:

对象被引用一次,计数加 1。
引用失效一次,计数减 1。
计数为 0,就认为是垃圾。

缺点:无法解决循环引用。

示例:

class A {
    B b;
}

class B {
    A a;
}

如果 A 引用 B,B 又引用 A,但外部已经无法访问它们,引用计数仍然不为 0。

所以主流 JVM 不是主要依靠引用计数判断对象是否存活。

9.3 可达性分析法

HotSpot JVM 主要使用可达性分析。

核心思路:

从一组 GC Roots 出发,沿着引用链往下找。
能找到的对象是存活对象。
找不到的对象就是可回收对象。

辅助理解:

GC Roots 像树根。
对象引用像树枝。
能从树根连到的叶子还活着。
连不到树根的枝叶就是枯枝,可以剪掉。

常见 GC Roots:

  • 虚拟机栈中引用的对象。
  • 方法区中静态变量引用的对象。
  • 方法区中常量引用的对象。
  • Native 方法引用的对象。
  • 活跃线程对象。

10. Java 对象引用类型

Java 中引用不只有普通引用,还可以分成强、软、弱、虚四种。

引用类型 回收时机 常见用途
强引用 Strong Reference 只要还被强引用,就不会被回收 普通对象引用
软引用 Soft Reference 内存不足时可能回收 缓存
弱引用 Weak Reference 下一次 GC 时回收 ThreadLocalMap 的 key
虚引用 Phantom Reference 无法直接获取对象,用于回收通知 直接内存释放跟踪

10.1 强引用

最常见的引用:

User user = new User();

只要 user 还指向对象,对象就不会被回收。

10.2 软引用

软引用适合做缓存:

SoftReference<byte[]> ref = new SoftReference<>(new byte[1024 * 1024]);

当内存不足时,软引用对象可能被回收。

10.3 弱引用

弱引用只要发生 GC,就可能被回收。

WeakReference<User> ref = new WeakReference<>(new User());

工作重点:

ThreadLocalMap 的 key 使用弱引用,但 value 是强引用。
如果线程长期不结束,又不 remove,仍可能导致内存泄漏。

ThreadLocal 正确使用方式:

private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();

public void handle() {
    try {
        USER_ID.set("10001");
        // 业务处理
    } finally {
        USER_ID.remove();
    }
}

11. 堆内存分代模型

多数垃圾回收器基于分代思想。

核心经验:

大多数对象朝生夕死,少数对象长期存活。

堆通常可以分为:

堆 Heap
├─ 新生代 Young Generation
│   ├─ Eden 区
│   ├─ Survivor From 区
│   └─ Survivor To 区
└─ 老年代 Old Generation

11.1 新生代

新对象大多数先进入 Eden 区。

当 Eden 区满了,会触发 Minor GC。

存活下来的对象会进入 Survivor 区。

对象在 Survivor 区多次熬过 GC 后,会晋升到老年代。

11.2 老年代

老年代存放生命周期较长的对象。

例如:

  • Spring 单例 Bean。
  • 本地缓存。
  • 线程池对象。
  • 长期存在的连接池对象。
  • 大对象。

老年代空间不足时,可能触发 Major GC 或 Full GC。

11.3 Eden 和 Survivor 的关系

默认情况下,新生代比例常见为:

Eden : From Survivor : To Survivor = 8 : 1 : 1

辅助理解:

Eden 像新员工试用区。
Survivor 像观察区。
老年代像正式长期员工区。

对象如果每次 GC 都还活着,说明它可能是长期对象,于是逐渐晋升到老年代。


12. 常见 GC 类型

12.1 Minor GC

Minor GC 主要回收新生代。

特点:

  • 触发频繁。
  • 速度相对较快。
  • 会发生 Stop The World。

12.2 Major GC

Major GC 通常指老年代 GC。

不同资料中定义略有差异,有些地方会把 Major GC 和 Full GC 混用。

12.3 Full GC

Full GC 通常会回收整个堆以及方法区或元空间相关资源。

特点:

  • 停顿时间较长。
  • 对线上服务影响较大。
  • 频繁 Full GC 是严重性能问题信号。

工作启发:

Minor GC 频繁不一定严重,要看停顿时间和回收效果。
Full GC 频繁通常需要重点排查。

13. Stop The World

Stop The World,简称 STW。

意思是:

GC 执行某些阶段时,会暂停所有用户线程,等 GC 完成后再恢复。

辅助理解:

餐厅要大扫除时,先暂停接客。
服务员和厨师都停下来,清洁人员打扫完,再继续营业。

为什么需要 STW:

如果程序一边修改对象引用关系,GC 一边判断对象是否存活,结果可能不准确。

工作影响:

  • 接口响应时间突然变长。
  • 服务短时间无响应。
  • 心跳超时。
  • RPC 调用超时。

工作建议:

排查接口偶发慢请求时,一定要结合 GC 日志看是否有 STW 停顿。

14. 垃圾回收算法

14.1 标记-清除 Mark-Sweep

步骤:

1. 标记存活对象。
2. 清除未标记对象。

优点:

  • 思路简单。

缺点:

  • 会产生内存碎片。
  • 清理后内存不连续。

辅助理解:

像在教室里把没人用的座位清出来,但空位分散在各处。

14.2 标记-复制 Mark-Copy

步骤:

1. 将内存分成两块。
2. 只使用其中一块。
3. GC 时把存活对象复制到另一块。
4. 清空原来的整块内存。

优点:

  • 没有碎片。
  • 回收效率高。

缺点:

  • 可用内存变少。
  • 存活对象多时复制成本高。

适合:

  • 新生代,因为大多数新对象很快死亡,存活对象少。

14.3 标记-整理 Mark-Compact

步骤:

1. 标记存活对象。
2. 将存活对象向一端移动。
3. 清理边界外的内存。

优点:

  • 没有内存碎片。
  • 适合老年代。

缺点:

  • 移动对象成本较高。

辅助理解:

像整理书架,把还要的书都靠左排整齐,右边空出来整块空间。

15. 常见垃圾回收器

不同 JDK 版本默认 GC 不一样。

GC 特点 常见版本
Serial GC 单线程,简单 客户端、小内存场景
Parallel GC 吞吐量优先 JDK 8 默认常见选择
CMS GC 低停顿,已逐步淘汰 JDK 8 常见,JDK 14 移除
G1 GC 平衡吞吐和停顿,可预测停顿 JDK 9+ 默认
ZGC 超低停顿 JDK 11+ 可用,JDK 15 生产可用
Shenandoah 超低停顿 部分 OpenJDK 版本

15.1 Parallel GC

特点:

  • 多线程 GC。
  • 吞吐量优先。
  • 适合后台任务、批处理。

参数:

-XX:+UseParallelGC

15.2 CMS GC

CMS 全称 Concurrent Mark Sweep。

特点:

  • 目标是减少老年代回收停顿。
  • 使用标记-清除算法。
  • 会产生内存碎片。
  • JDK 9 开始标记废弃,JDK 14 移除。

参数:

-XX:+UseConcMarkSweepGC

15.3 G1 GC

G1 全称 Garbage First。

它不再简单把堆切成连续的新生代和老年代,而是拆成很多 Region。

特点:

  • 适合大堆内存。
  • 可以设置期望最大停顿时间。
  • JDK 9 以后默认 GC。
  • 工作中非常常见。

参数:

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200

辅助理解:

传统 GC 像按整片城区清理垃圾。
G1 像把城市分成很多小区,优先清理垃圾最多、收益最高的小区。

15.4 ZGC

ZGC 目标是超低停顿。

特点:

  • 停顿时间通常非常短。
  • 适合大内存、低延迟服务。
  • 对 JDK 版本要求较高。

参数:

-XX:+UseZGC

工作建议:

不要盲目追新 GC。
如果系统稳定、延迟可接受,优先保守。
如果明确有大堆低延迟需求,再评估 G1、ZGC 等方案。

16. JVM 常用参数

16.1 堆内存参数

-Xms2g
-Xmx2g

说明:

  • -Xms:初始堆大小。
  • -Xmx:最大堆大小。

建议:

线上服务通常设置为一样,例如 -Xms2g -Xmx2g。

16.2 栈内存参数

-Xss1m

说明:

  • 每个线程的栈大小。

注意:

线程越多,栈总占用越大。

16.3 元空间参数

-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

说明:

  • MetaspaceSize:触发元空间 GC 的初始阈值。
  • MaxMetaspaceSize:元空间最大大小。

16.4 GC 日志参数

JDK 8:

-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/data/logs/app-gc.log

JDK 9+:

-Xlog:gc*:file=/data/logs/app-gc.log:time,uptime,level,tags:filecount=10,filesize=100M

工作建议:

生产环境建议一定开启 GC 日志。
没有 GC 日志,排查卡顿和内存问题会非常被动。

16.5 OOM 自动 Dump 参数

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump

说明:

  • 发生 OOM 时自动导出堆快照。
  • 后续可以用 MAT、JProfiler、VisualVM 分析。

工作建议:

线上服务建议配置 OOM dump,但 dump 目录要有足够磁盘空间。

16.6 推荐基础启动参数模板

JDK 8 示例:

java \
  -Xms2g \
  -Xmx2g \
  -Xss1m \
  -XX:MetaspaceSize=256m \
  -XX:MaxMetaspaceSize=512m \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/data/dump \
  -XX:+PrintGCDetails \
  -XX:+PrintGCDateStamps \
  -Xloggc:/data/logs/app-gc.log \
  -jar app.jar

JDK 11+ 示例:

java \
  -Xms2g \
  -Xmx2g \
  -Xss1m \
  -XX:MetaspaceSize=256m \
  -XX:MaxMetaspaceSize=512m \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/data/dump \
  -Xlog:gc*:file=/data/logs/app-gc.log:time,uptime,level,tags:filecount=10,filesize=100M \
  -jar app.jar

17. JVM 常用排查工具

17.1 jps:查看 Java 进程

jps -l

示例输出:

12345 com.example.Application

12345 就是 Java 进程 PID。

17.2 jinfo:查看 JVM 参数

jinfo -flags 12345

作用:

  • 查看进程启动参数。
  • 确认线上实际参数是否和配置一致。

17.3 jstat:查看 GC 情况

jstat -gcutil 12345 1000 10

含义:

  • 每 1 秒打印一次。
  • 总共打印 10 次。

重点关注:

字段 含义
S0、S1 Survivor 使用率
E Eden 使用率
O Old 老年代使用率
M Metaspace 使用率
YGC Young GC 次数
YGCT Young GC 总耗时
FGC Full GC 次数
FGCT Full GC 总耗时
GCT GC 总耗时

工作判断:

如果 FGC 持续增加,且 Old 区使用率降不下来,要重点怀疑内存泄漏或堆太小。

17.4 jstack:查看线程栈

jstack 12345 > /tmp/jstack.txt

常用于排查:

  • CPU 高。
  • 死锁。
  • 线程阻塞。
  • 请求卡死。

17.5 jmap:导出堆快照

jmap -dump:format=b,file=/tmp/heap.hprof 12345

注意:

导出大堆快照可能导致服务短暂停顿,生产环境要谨慎操作。

17.6 jcmd:综合诊断工具

查看可用命令:

jcmd 12345 help

查看 JVM 参数:

jcmd 12345 VM.flags

触发堆 dump:

jcmd 12345 GC.heap_dump /tmp/heap.hprof

查看线程:

jcmd 12345 Thread.print

工作建议:

JDK 8 以后,jcmd 很强大,很多场景可以优先使用 jcmd。

18. 线上 CPU 飙高怎么排查

这是 JVM 工作实战中非常常见的问题。

18.1 排查步骤

第 1 步:找到 CPU 高的 Java 进程。

top

假设 Java 进程 PID 是 12345

第 2 步:查看进程内哪个线程 CPU 高。

top -Hp 12345

假设线程 ID 是 12370

第 3 步:将线程 ID 转成 16 进制。

printf '%x\n' 12370

假设输出:

3052

第 4 步:导出线程栈。

jstack 12345 > /tmp/jstack.txt

第 5 步:在 jstack 文件中搜索 3052

grep -n "3052" /tmp/jstack.txt

找到对应线程栈后,看它正在执行什么代码。

18.2 常见原因

原因 表现
死循环 某个线程一直 RUNNABLE
正则回溯 CPU 高,栈里有 Pattern 相关方法
JSON 大对象序列化 栈里有 Jackson、Fastjson、Gson
频繁 GC GC 线程占 CPU,jstat 中 GC 次数猛增
加密压缩 栈里有 Cipher、Zip、GZIP 等

工作启发:

CPU 高不要先猜代码问题。
先定位是哪个线程高,再看线程栈,最后对应到代码。

19. 线上 OOM 怎么排查

OOM,全称 OutOfMemoryError。

不同 OOM 对应不同内存区域。

19.1 常见 OOM 类型

错误 含义 常见原因
Java heap space 堆内存不足 对象太多、内存泄漏、堆太小
GC overhead limit exceeded GC 花太多时间但回收很少 堆接近耗尽
Metaspace 元空间不足 类太多、动态代理过多、热部署泄漏
Direct buffer memory 直接内存不足 NIO、Netty、堆外内存未释放
unable to create new native thread 无法创建线程 线程太多、系统资源不足

19.2 排查思路

1. 看 OOM 报错类型。
2. 找 JVM 启动参数。
3. 找 GC 日志。
4. 找 heap dump。
5. 分析哪些对象占用最多。
6. 判断是正常业务量增长,还是对象无法释放。

19.3 堆 OOM 排查

建议启动参数提前配置:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump

发生 OOM 后,用 MAT 分析 .hprof 文件。

MAT 下载地址:

https://www.eclipse.org/mat/downloads.php

重点看:

  • Leak Suspects Report。
  • Dominator Tree。
  • 大对象列表。
  • 哪些集合对象非常大,例如 ArrayListHashMap

工作启发:

堆 OOM 不一定是堆太小,很多时候是对象被错误地长期引用,导致 GC 无法回收。

19.4 线程 OOM 排查

错误:

java.lang.OutOfMemoryError: unable to create new native thread

常见原因:

  • 无限制创建线程。
  • 线程池参数不合理。
  • 每个请求创建新线程。
  • 系统 ulimit 限制太低。

排查命令:

ps -eLf | grep java | wc -l
ulimit -u

工作建议:

业务代码不要随手 new Thread。
统一使用线程池,并设置合理的核心线程数、最大线程数和队列长度。

20. 内存泄漏怎么理解

内存泄漏不是对象丢了,而是对象不该继续存在,却仍然被引用,导致 GC 无法回收。

辅助理解:

你已经不需要某个快递箱了,但它还被登记在仓库系统里。
仓库管理员以为它还有用,就不会清理。

20.1 常见内存泄漏场景

场景 问题
静态集合缓存 数据只增不删
ThreadLocal 未 remove 线程池线程长期存活,value 无法释放
监听器未注销 对象被事件源长期引用
连接未关闭 数据库、Redis、HTTP 连接泄漏
大对象放入 Session 用户多时内存暴涨
本地缓存无上限 HashMap、ConcurrentHashMap 不断增长

20.2 静态集合泄漏示例

错误示例:

public class CacheDemo {
    private static final Map<String, Object> CACHE = new HashMap<>();

    public void put(String key, Object value) {
        CACHE.put(key, value);
    }
}

如果这个 Map 只增不删,最终可能 OOM。

改进建议:

  • 设置最大容量。
  • 设置过期时间。
  • 使用 Caffeine、Guava Cache、Redis 等成熟方案。

Caffeine 示例:

Cache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .build();

20.3 ThreadLocal 泄漏示例

错误示例:

private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();

public void handle(User user) {
    CURRENT_USER.set(user);
    doSomething();
}

正确示例:

private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();

public void handle(User user) {
    try {
        CURRENT_USER.set(user);
        doSomething();
    } finally {
        CURRENT_USER.remove();
    }
}

21. GC 日志怎么看

GC 日志是排查 JVM 问题的关键证据。

21.1 JDK 8 GC 日志示例

2026-07-14T10:00:00.123+0800: 12.345: [GC pause (G1 Evacuation Pause) (young), 0.0256789 secs]
   [Eden: 512.0M(512.0M)->0.0B(480.0M) Survivors: 32.0M->64.0M Heap: 1.2G(2.0G)->800.0M(2.0G)]

重点看:

内容 说明
GC 类型 Young GC、Mixed GC、Full GC
停顿时间 例如 0.025 秒
GC 前后堆变化 是否回收了大量内存
Old 区变化 老年代是否持续上涨
Full GC 次数 是否频繁

21.2 如何判断 GC 是否异常

重点不是只看次数,而是看:

1. 每次 GC 停顿多久。
2. GC 后内存有没有明显下降。
3. Full GC 是否频繁。
4. 老年代是否持续上涨不回落。
5. GC 是否和接口慢请求时间吻合。

21.3 推荐 GC 日志分析工具

工具 链接 说明
GCeasy https://gceasy.io/ 在线 GC 日志分析
GCViewer https://github.com/chewiebug/GCViewer 本地 GC 日志查看工具
IBM GCMV https://www.ibm.com/support/pages/ibm-monitoring-and-diagnostic-tools-garbage-collection-and-memory-visualizer-gcmv GC 和内存可视化工具

注意:

如果 GC 日志包含敏感路径、机器名或业务信息,上传在线工具前要先脱敏。

22. JVM 在 Spring Boot 工作中的启发

22.1 不要无限制使用本地缓存

很多服务为了提高性能,会把数据放进本地 Map。

问题是:

本地缓存就是 JVM 堆内存的一部分。
缓存越大,堆压力越大,GC 压力越大。

建议:

  • 使用有上限的缓存。
  • 设置过期时间。
  • 大数据量缓存放 Redis。
  • 不要把用户级大对象长期放本地内存。

22.2 接口返回大对象会增加 GC 压力

例如一次查询 10 万条数据并返回:

List<Order> orders = orderMapper.selectAll();
return orders;

问题:

  • 大量 Order 对象进入堆。
  • JSON 序列化还会产生额外临时对象。
  • 网络传输慢。
  • 客户端也可能扛不住。

建议:

  • 分页查询。
  • 限制最大导出量。
  • 大文件异步导出。
  • 流式处理。

22.3 线程池不是越大越好

线程池过大可能导致:

  • 上下文切换变多。
  • 栈内存占用变大。
  • 下游数据库、Redis 被打爆。
  • 请求排队和超时更严重。

建议:

线程池大小要结合 CPU 核数、任务类型、下游能力、响应时间目标一起设置。

22.4 日志也会影响 JVM

大量日志可能导致:

  • 字符串对象大量创建。
  • 磁盘 IO 压力变大。
  • 异步日志队列堆积。
  • 日志参数拼接带来额外开销。

错误示例:

log.debug("user info: " + JSON.toJSONString(user));

更好写法:

if (log.isDebugEnabled()) {
    log.debug("user info: {}", JSON.toJSONString(user));
}

22.5 大事务会增加内存和 GC 压力

大事务中可能持有大量对象、连接、锁。

建议:

  • 控制事务范围。
  • 批量处理分批提交。
  • 不要在事务中做远程调用。
  • 不要在事务中做长时间文件处理。

23. JVM 和容器 Docker / Kubernetes

现在很多 Java 服务运行在 Docker 或 Kubernetes 中。

23.1 容器中为什么容易 OOMKilled

容器有内存限制,例如 2G。

但 JVM 使用的内存不只有堆:

JVM 总内存 ≈ 堆 + 元空间 + 线程栈 + 直接内存 + Code Cache + JVM 自身开销 + Native 内存

如果你设置:

-Xmx2g

而容器限制也是 2G,那么很容易被容器杀掉。

23.2 容器内存参数建议

假设容器限制 2G,不建议堆直接设置 2G。

可以考虑:

-Xms1g -Xmx1g

或使用百分比参数:

-XX:InitialRAMPercentage=50.0
-XX:MaxRAMPercentage=70.0

工作建议:

容器内存一定要给堆外内存留余量。
不要让 -Xmx 等于容器 memory limit。

24. 一套实用 JVM 排查流程

24.1 接口变慢

排查顺序:

1. 看应用日志,确认慢请求时间点。
2. 看 GC 日志,确认是否有长时间 STW。
3. 看 CPU 是否飙高。
4. 看线程栈是否有锁等待、IO 等待、数据库等待。
5. 看下游依赖是否变慢。
6. 看数据库慢 SQL。

24.2 内存持续上涨

排查顺序:

1. jstat 看 Old 区是否持续上涨。
2. 看 Full GC 后 Old 区是否下降。
3. 如果不下降,怀疑内存泄漏。
4. 导出 heap dump。
5. 用 MAT 看大对象和引用链。
6. 定位代码中的长期引用。

24.3 服务频繁重启

排查顺序:

1. 看应用日志是否有 OOM。
2. 看容器事件是否 OOMKilled。
3. 看机器 dmesg 是否被系统杀掉。
4. 看 JVM 参数和容器内存限制。
5. 看健康检查是否过短导致误杀。

Kubernetes 查看命令:

kubectl describe pod pod-name
kubectl logs pod-name --previous

25. 面试和工作都要会的 JVM 问题

25.1 JVM 内存区域有哪些

回答框架:

JVM 运行时内存主要包括堆、方法区或元空间、虚拟机栈、本地方法栈、程序计数器。
堆和方法区是线程共享的,栈、本地方法栈、程序计数器是线程私有的。
堆主要放对象实例,是 GC 重点管理区域。
栈保存方法调用的栈帧。
元空间保存类元信息。

25.2 对象什么时候会被回收

回答框架:

HotSpot 主要通过可达性分析判断对象是否存活。
从 GC Roots 出发,沿引用链能到达的对象是存活对象。
无法到达的对象可能被回收。
GC Roots 包括栈中引用、静态变量引用、常量引用、Native 引用等。

25.3 什么是 Full GC,为什么要关注

回答框架:

Full GC 通常会对整个堆以及方法区或元空间相关资源进行回收。
它通常停顿时间较长,会造成应用线程暂停,也就是 STW。
如果线上频繁 Full GC,可能导致接口超时、吞吐下降、服务不可用。
需要结合 GC 日志、jstat、heap dump 分析原因。

25.4 什么是双亲委派

回答框架:

类加载器加载类时,会先把请求交给父加载器,父加载器无法加载时才由子加载器加载。
这样可以保证 Java 核心类库优先由 Bootstrap ClassLoader 加载,避免核心类被应用代码篡改。

25.5 怎么排查 CPU 飙高

回答框架:

先用 top 找到 CPU 高的 Java 进程,再用 top -Hp 找到进程内 CPU 高的线程。
把线程 ID 转为 16 进制,然后用 jstack 导出线程栈,在栈中搜索对应 nid。
最后根据线程正在执行的方法定位业务代码或 GC、锁竞争等问题。

26. JVM 学习路线

26.1 第一阶段:基础理解

目标:能讲清楚 JVM 是什么、内存怎么分、对象放哪里。

学习内容:

  • JVM、JRE、JDK 区别。
  • 堆、栈、元空间、程序计数器。
  • 对象创建过程。
  • 类加载流程。

26.2 第二阶段:GC 理解

目标:能看懂 GC 和常见参数。

学习内容:

  • 可达性分析。
  • 新生代、老年代。
  • Minor GC、Full GC。
  • G1、CMS、ZGC。
  • GC 日志。

26.3 第三阶段:工具实战

目标:能排查线上问题。

学习内容:

  • jps。
  • jstat。
  • jstack。
  • jmap。
  • jcmd。
  • MAT。
  • GC 日志分析工具。

26.4 第四阶段:结合项目优化

目标:能把 JVM 思维用于日常开发。

学习内容:

  • 合理设置缓存。
  • 控制对象创建。
  • 控制线程池。
  • 优化大查询和大响应。
  • 容器内 JVM 参数。
  • 建立线上排查流程。

27. 推荐学习资料

27.1 官方资料

资料 链接
Java 官方文档 https://docs.oracle.com/en/java/
HotSpot VM 参数 https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
OpenJDK https://openjdk.org/
G1 GC 官方调优指南 https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc_tuning.html

27.2 推荐书籍

书籍 说明
《深入理解 Java 虚拟机》 JVM 中文经典书,适合系统学习
《Java 性能权威指南》 偏性能优化和实践
《Java 并发编程实战》 理解线程、并发、锁,对 JVM 排查很有帮助

27.3 推荐工具

工具 用途
VisualVM 本地 JVM 监控和分析
JConsole JDK 自带监控工具
MAT 分析 heap dump
Arthas 在线诊断 Java 应用
GCeasy 分析 GC 日志

Arthas 官方地址:

https://arthas.aliyun.com/

28. 小白实践任务

只看文档容易忘,建议做下面练习。

28.1 查看字节码

写一个 Demo.java

public class Demo {
    public int add(int a, int b) {
        return a + b;
    }
}

执行:

javac Demo.java
javap -c Demo

目标:理解 Java 会先编译成字节码。

28.2 制造 StackOverflowError

public class StackDemo {
    public static void main(String[] args) {
        call();
    }

    private static void call() {
        call();
    }
}

目标:理解方法调用和栈。

28.3 制造堆 OOM

import java.util.ArrayList;
import java.util.List;

public class HeapOomDemo {
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        while (true) {
            list.add(new byte[1024 * 1024]);
        }
    }
}

启动:

java -Xms64m -Xmx64m HeapOomDemo

目标:理解堆内存不足。

28.4 使用 jstat 观察 GC

运行 Java 程序后执行:

jps -l
jstat -gcutil 进程PID 1000

目标:观察 Eden、Old、YGC、FGC 的变化。

28.5 使用 jstack 查看线程

jstack 进程PID > /tmp/jstack.txt

目标:理解线程栈可以看到程序卡在哪里。


29. 一句话总结 JVM

如果只记住一段话,记住这个:

JVM 负责把 class 字节码加载到内存中,并通过执行引擎运行代码。
运行过程中,对象主要分配在堆里,方法调用在线程栈里,类信息在元空间里。
垃圾回收器会根据可达性分析清理不用的对象,但 GC 可能带来 Stop The World 停顿。
工作中学习 JVM,不是为了背概念,而是为了能排查 OOM、CPU 高、接口卡顿、类加载冲突和容器内存问题。

30. 工作中的 JVM 检查清单

新项目上线前建议检查:

  • 是否设置了合理的 -Xms-Xmx
  • 是否开启了 GC 日志。
  • 是否配置了 OOM 自动 heap dump。
  • dump 目录是否有足够磁盘空间。
  • 容器内 -Xmx 是否小于 memory limit,并给堆外留余量。
  • 本地缓存是否有最大容量和过期时间。
  • ThreadLocal 是否在 finally 中 remove。
  • 线程池是否有边界。
  • 大查询是否分页。
  • 大文件是否流式处理。
  • 是否有监控 JVM 堆、非堆、线程数、GC 次数、GC 停顿。

线上问题发生时建议保留:

  • 应用日志。
  • GC 日志。
  • JVM 启动参数。
  • jstack 文件。
  • jstat 输出。
  • heap dump。
  • 容器事件或系统日志。

这些证据越完整,问题越容易定位。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐