一、JVM 基础概念
JVM 是什么?它的作用是什么?
1. JVM 是什么?
JVM 全称 Java Virtual Machine ,即 Java 虚拟机 。
它是一个运行 Java 字节码的虚拟计算机 ,负责将 .class 文件中的 字节码 转换为 机器指令,并在不同操作系统上运行。
简单来说:
- Java 源代码 (
.java) → 编译器(javac) → 字节码文件(.class) → JVM 执行。 - JVM 是 Java 的核心,保证了 一次编写,处处运行(Write Once, Run Anywhere)。
2. JVM 的作用
JVM 在 Java 运行过程中承担了很多重要职责,可以总结为以下几个方面:
(1)跨平台运行
- Java 程序编译后生成的是 字节码,而不是直接的机器码。
- 不同平台只需要安装对应的 JVM,就能运行同样的字节码文件。
- 这就是 Java 的 平台无关性。
(2)内存管理
- JVM 会自动管理内存,包括:
- 内存分配(对象创建时分配内存)
- 垃圾回收(GC 自动回收不再使用的对象)
- 这让开发者不必手动释放内存,减少内存泄漏风险。
(3)类加载机制
- JVM 通过 类加载器(ClassLoader) 动态加载
.class文件到内存。 - 支持按需加载、延迟加载、热替换等功能。
(4)字节码执行
- JVM 内部有一个 执行引擎(Execution Engine) ,负责解释执行字节码或通过 JIT(Just-In-Time)编译优化为机器码,提高运行效率。
(5)安全性
- JVM 会在类加载和字节码执行时进行 安全检查,防止恶意代码破坏系统。
- 例如:字节码验证、访问权限控制、沙箱机制。
3. JVM 的主要组成结构
JVM 内部可以分为几个核心模块:
| 模块 | 作用 |
|---|---|
| 类加载器(ClassLoader) | 负责加载 .class 文件到内存 |
| 运行时数据区(Runtime Data Area) | JVM 的内存结构,存放类信息、对象、方法等 |
| 执行引擎(Execution Engine) | 解释执行字节码或通过 JIT 编译为机器码 |
| 本地方法接口(JNI) | 调用本地操作系统方法或 C/C++ 库 |
| 垃圾回收器(GC) | 自动回收不再使用的对象内存 |
4. JVM 内存结构(运行时数据区)
JVM 在运行时会划分不同的内存区域:
| 内存区域 | 作用 |
|---|---|
| 方法区(Method Area) | 存放类信息、常量、静态变量、JIT 编译后的代码 |
| 堆(Heap) | 存放所有对象实例,是 GC 的主要管理区域 |
| 虚拟机栈(VM Stack) | 存放方法调用的局部变量、操作数栈等 |
| 本地方法栈(Native Method Stack) | 为本地方法服务 |
| 程序计数器(PC Register) | 记录当前线程执行的字节码行号 |
5. JVM 的工作流程
- 编译阶段 :Java 源代码 → 编译器 → 字节码文件(
.class) - 类加载阶段:类加载器将字节码加载到 JVM 内存
- 执行阶段 :
- 解释执行字节码(Interpreter)
- 或 JIT 编译为机器码(提高性能)
- 内存管理与垃圾回收:自动分配与回收对象内存
6. 总结
- JVM 是 Java 的核心运行环境,负责字节码执行、内存管理、类加载、安全检查等。
- 它的最大作用是:
- 跨平台运行(一次编写,处处运行)
- 自动内存管理(GC)
- 安全性保障
- 性能优化(JIT 编译)
JVM、JRE、JDK 的区别?
1. 三者的定义
JVM(Java Virtual Machine)
- Java 虚拟机,是 Java 程序的运行环境。
- 负责将 字节码(.class 文件) 转换为机器码并执行。
- 提供 跨平台能力 、内存管理(GC) 、安全检查 等功能。
- 核心作用:运行 Java 程序。
JRE(Java Runtime Environment)
- Java 运行时环境 ,包含 JVM + 核心类库(Java API)。
- 提供运行 Java 程序所需的全部环境,但不包含编译工具。
- 核心作用:让你可以运行 Java 程序,但不能编译。
JDK(Java Development Kit)
- Java 开发工具包 ,包含 JRE + 开发工具(javac 编译器、调试工具、文档生成工具等)。
- 用于开发、编译、调试 Java 程序。
- 核心作用:让你可以开发和运行 Java 程序。
2. 三者的关系
可以用一个简单的包含关系来理解:
JDK = JRE + 开发工具 JRE = JVM + 核心类库
3. 对比表格
| 名称 | 全称 | 主要组成 | 作用 | 是否能运行 Java 程序 | 是否能开发 Java 程序 |
|---|---|---|---|---|---|
| JVM | Java Virtual Machine | 虚拟机执行引擎 | 运行字节码 | ✅ | ❌ |
| JRE | Java Runtime Environment | JVM + 核心类库 | 提供运行环境 | ✅ | ❌ |
| JDK | Java Development Kit | JRE + 开发工具 | 开发、编译、运行 Java 程序 | ✅ | ✅ |
4. 举例理解
- JVM:相当于一台虚拟的计算机,只能执行 Java 字节码。
- JRE:相当于这台虚拟计算机 + 必备的系统文件,让它能正常运行 Java 程序。
- JDK:相当于这台虚拟计算机 + 系统文件 + 开发工具,让你可以写代码、编译、运行。
5. 面试高频总结
- JVM:运行 Java 程序的虚拟机。
- JRE:运行 Java 程序的环境(包含 JVM 和类库)。
- JDK:开发 Java 程序的工具包(包含 JRE 和开发工具)。
- 关系:JDK ⊃ JRE ⊃ JVM。
Java 程序的执行过程是什么?
1. Java 程序执行的整体流程
Java 程序的执行过程可以分为 编译阶段 和 运行阶段 两大部分:
-
编译阶段 (由
javac完成)- 将 Java 源代码文件 (
.java)编译成 字节码文件 (.class)。 - 字节码是一种 平台无关的中间代码,不能直接在操作系统上运行。
- 将 Java 源代码文件 (
-
运行阶段(由 JVM 完成)
- JVM 通过 类加载器 将
.class文件加载到内存。 - 字节码验证器 检查字节码的安全性。
- 执行引擎(解释器 / JIT 编译器)将字节码转换为机器码并执行。
- JVM 负责 内存分配 和 垃圾回收(GC)。
- JVM 通过 类加载器 将
2. 详细执行步骤
① 编写源代码
- 开发者使用 Java 语言编写
.java文件。
② 编译成字节码
-
使用
javac编译器:bashjavac HelloWorld.java -
生成
.class文件(字节码)。
③ 类加载(Class Loading)
- JVM 的 类加载器(ClassLoader) 将
.class文件加载到内存。 - 类加载过程分为:
- 加载(Loading) :读取
.class文件。 - 链接(Linking):验证字节码、准备静态变量、解析符号引用。
- 初始化(Initialization):执行类的静态初始化块和静态变量赋值。
- 加载(Loading) :读取
④ 字节码验证
- JVM 的 字节码验证器 检查代码的合法性和安全性,防止恶意代码破坏系统。
⑤ 执行引擎运行字节码
- 解释器(Interpreter):逐行解释字节码为机器码,启动快但运行慢。
- JIT 编译器(Just-In-Time Compiler):将热点代码编译为机器码,提高运行速度。
⑥ 内存管理与垃圾回收
- JVM 自动分配对象内存(在堆中)。
- GC 自动回收不再使用的对象,避免内存泄漏。
3. Java 程序执行流程图
[编写源代码] → [javac 编译] → [字节码文件 .class] ↓ [类加载器加载类] → [字节码验证] → [执行引擎运行字节码] ↓ [机器码执行] → [内存管理 & 垃圾回收]
4. 总结
- 编译阶段:Java 源代码 → 字节码(平台无关)
- 运行阶段:JVM 将字节码 → 机器码(平台相关)
- 核心环节:
- 类加载器(加载字节码)
- 字节码验证器(安全检查)
- 执行引擎(解释执行 / JIT 编译)
- 内存管理 & GC(自动管理内存)
JVM 的跨平台原理是什么?
1. 先说结论
JVM 的跨平台原理 就是:
Java 源代码先编译成平台无关的字节码(Bytecode),再由不同平台的 JVM 将字节码翻译成对应平台的机器码执行。
这样,Java 程序只需要编写一次,就能在不同操作系统上运行(Write Once, Run Anywhere)。
2. 原理拆解
跨平台的关键在于 字节码 + 各平台的 JVM 实现。
① 编译成字节码(平台无关)
- Java 源代码(
.java)通过 javac 编译器 编译成 字节码文件(.class)。 - 字节码是一种 中间语言,不依赖具体的硬件和操作系统。
② JVM 解释执行字节码(平台相关)
- 不同操作系统(Windows、Linux、macOS)都有各自的 JVM 实现。
- JVM 会将字节码 解释执行 或 JIT 编译成该平台的机器码。
- 因为 JVM 屏蔽了底层差异,所以同一个字节码文件可以在不同平台运行。
3. 跨平台的核心机制
- 统一的字节码格式
- Java 编译器生成的
.class文件在任何平台上都是相同的。
- Java 编译器生成的
- 不同平台的 JVM 实现
- 每个平台的 JVM 都能识别字节码,并将其转换为该平台的机器码。
- Java API 的平台适配
- Java 标准库(Java API)对底层系统调用进行了封装,开发者不需要关心操作系统差异。
4. 流程图
Java 源代码 (.java) ↓ javac 编译 字节码文件 (.class) ←—— 平台无关 ↓ JVM(不同平台实现) 机器码(平台相关) → 执行程序
5. 举例说明
假设你写了一个 HelloWorld.java:
- 在 Windows 上:用 Windows 版 JVM 运行
.class文件 → 转成 Windows 机器码 → 执行。 - 在 Linux 上:用 Linux 版 JVM 运行同一个
.class文件 → 转成 Linux 机器码 → 执行。 - 源代码和字节码完全相同,只是 JVM 的底层实现不同。
6. 面试高频总结
- 跨平台的本质:字节码统一,JVM 适配不同平台。
- 一句话回答:Java 通过将源代码编译成平台无关的字节码,由不同平台的 JVM 将字节码转换为平台相关的机器码,从而实现跨平台运行。
JVM 的主要组成部分有哪些?
1. JVM 的主要组成部分
JVM 的核心结构可以分为 类加载子系统、运行时数据区、执行引擎、垃圾回收系统、JNI 接口 五大模块。
① 类加载子系统(Class Loader Subsystem)
- 作用 :负责将
.class字节码文件加载到 JVM 内存中。 - 过程 :
- 加载(Loading):读取字节码文件。
- 链接(Linking):验证字节码、准备静态变量、解析符号引用。
- 初始化(Initialization):执行类的静态初始化块和静态变量赋值。
- 特点:支持按需加载、延迟加载、热替换。
② 运行时数据区(Runtime Data Area)
- JVM 在运行时会划分不同的内存区域,用来存储类信息、对象、方法调用等数据。
| 内存区域 | 作用 |
|---|---|
| 方法区(Method Area) | 存放类信息、常量、静态变量、JIT 编译后的代码 |
| 堆(Heap) | 存放所有对象实例,是 GC 的主要管理区域 |
| 虚拟机栈(VM Stack) | 存放方法调用的局部变量、操作数栈等 |
| 本地方法栈(Native Method Stack) | 为本地方法服务 |
| 程序计数器(PC Register) | 记录当前线程执行的字节码行号 |
③ 执行引擎(Execution Engine)
- 作用:负责执行字节码。
- 组成 :
- 解释器(Interpreter):逐行解释字节码为机器码,启动快但运行慢。
- JIT 编译器(Just-In-Time Compiler):将热点代码编译为机器码,提高运行速度。
- 特点:解释器启动快,JIT 编译器运行快,二者结合使用。
④ 垃圾回收系统(Garbage Collector, GC)
- 作用:自动回收不再使用的对象内存。
- 特点 :
- 避免内存泄漏。
- 常见算法:标记-清除、标记-整理、复制算法、分代收集。
- 好处:开发者无需手动释放内存。
⑤ 本地方法接口(Java Native Interface, JNI)
- 作用:让 Java 调用本地操作系统方法或 C/C++ 库。
- 特点 :
- 提供与底层平台交互的能力。
- 常用于调用系统 API 或高性能的本地库。
2. JVM 组成结构图
┌───────────────────────────────┐ │ 类加载子系统 │ ├───────────────────────────────┤ │ 运行时数据区(内存) │ │ ┌───────────────┐ │ │ │ 方法区 │ │ │ │ 堆 │ │ │ │ 虚拟机栈 │ │ │ │ 本地方法栈 │ │ │ │ 程序计数器 │ │ │ └───────────────┘ │ ├───────────────────────────────┤ │ 执行引擎 │ │ ┌───────────────┐ │ │ │ 解释器 │ │ │ │ JIT 编译器 │ │ │ └───────────────┘ │ ├───────────────────────────────┤ │ 垃圾回收系统 │ ├───────────────────────────────┤ │ 本地方法接口 │ └───────────────────────────────┘
3. 面试高频总结
- 类加载子系统 :加载
.class文件到内存。 - 运行时数据区:JVM 的内存结构(方法区、堆、栈、PC 寄存器、本地方法栈)。
- 执行引擎:解释执行字节码或 JIT 编译为机器码。
- 垃圾回收系统:自动回收内存。
- JNI 接口:调用本地方法。
JVM 的运行时数据区有哪些?
1. 什么是运行时数据区?
- 运行时数据区(Runtime Data Area) 是 JVM 在运行 Java 程序时分配的内存区域,用于存储类信息、对象、方法调用、局部变量等数据。
- 这些区域有的 线程共享 ,有的 线程私有。
2. JVM 运行时数据区的组成
JVM 运行时数据区主要包括以下几个部分:
| 内存区域 | 线程共享/私有 | 主要作用 |
|---|---|---|
| 方法区(Method Area) | 共享 | 存放类信息、常量、静态变量、JIT 编译后的代码 |
| 堆(Heap) | 共享 | 存放所有对象实例,是 GC 的主要管理区域 |
| 虚拟机栈(VM Stack) | 私有 | 存放方法调用的局部变量、操作数栈、方法出口等 |
| 本地方法栈(Native Method Stack) | 私有 | 为本地方法(C/C++)服务 |
| 程序计数器(PC Register) | 私有 | 记录当前线程执行的字节码行号 |
① 方法区(Method Area)
- 线程共享。
- 存放:
- 类的结构信息(类名、父类、接口、字段、方法等)
- 运行时常量池(字符串常量、符号引用等)
- 静态变量
- JIT 编译后的代码
- 在 JDK 8 之前,方法区的实现是 永久代(PermGen) ;JDK 8 之后改为 元空间(Metaspace),存储在本地内存中。
② 堆(Heap)
- 线程共享。
- 存放所有对象实例和数组。
- 是 垃圾回收(GC) 的主要区域。
- 一般分为:
- 新生代(Young Generation):Eden 区 + Survivor 区(S0、S1)
- 老年代(Old Generation)
- GC 主要在堆中进行。
③ 虚拟机栈(VM Stack)
- 线程私有。
- 每个方法在执行时会创建一个 栈帧(Stack Frame) ,用于存储:
- 局部变量表(方法参数、局部变量)
- 操作数栈
- 动态链接
- 方法返回地址
- 方法执行结束后,栈帧被销毁。
④ 本地方法栈(Native Method Stack)
- 线程私有。
- 为 JVM 调用的本地方法(Native Method)服务。
- 存储本地方法的局部变量、操作数栈等。
⑤ 程序计数器(PC Register)
- 线程私有。
- 记录当前线程执行的字节码指令地址(行号)。
- 如果执行的是本地方法,PC 寄存器的值是 未定义(Undefined)。
3. 运行时数据区结构图
┌───────────────────────────────┐ │ 线程共享(所有线程可见) │ │ ┌─────────────────────────┐ │ │ │ 方法区(Method Area) │ │ │ │ 堆(Heap) │ │ │ └─────────────────────────┘ │ ├───────────────────────────────┤ │ 线程私有(每个线程独立) │ │ ┌─────────────────────────┐ │ │ │ 虚拟机栈(VM Stack) │ │ │ │ 本地方法栈(Native Stack)│ │ │ │ 程序计数器(PC Register)│ │ │ └─────────────────────────┘ │ └───────────────────────────────┘
4. 面试高频总结
- 线程共享:方法区、堆。
- 线程私有:虚拟机栈、本地方法栈、程序计数器。
- GC 主要作用于堆,方法区也可能被回收(如废弃常量、无用类)。
- JDK 8 之后:永久代被移除,方法区由元空间实现。
JVM 的内存模型与 Java 内存模型(JMM)的区别?
1. JVM 内存模型
- 定义 :描述 JVM 在运行 Java 程序时的内存结构划分。
- 关注点:程序运行时,数据在 JVM 内部是如何存储和管理的。
- 核心内容 :
- 方法区(Method Area)
- 堆(Heap)
- 虚拟机栈(VM Stack)
- 本地方法栈(Native Method Stack)
- 程序计数器(PC Register)
- 用途:用于理解对象存储位置、GC 作用范围、内存分配策略等。
2. Java 内存模型(JMM)
- 定义 :描述 Java 多线程程序中,线程如何通过内存进行交互。
- 关注点:线程之间如何共享变量、如何保证可见性、有序性和原子性。
- 核心内容 :
- 主内存(Main Memory):存储所有共享变量。
- 工作内存(Working Memory):每个线程的私有内存,存储主内存变量的副本。
- 内存交互规则 :定义了变量在主内存和工作内存之间的读写操作(如
read、load、store、write)。
- 用途 :用于理解并发编程中的内存可见性问题、
volatile、synchronized、final等关键字的作用。
3. 核心区别
| 对比项 | JVM 内存模型 | Java 内存模型(JMM) |
|---|---|---|
| 定义 | JVM 运行时的内存结构划分 | 多线程间的内存交互规范 |
| 关注点 | 程序运行时数据的存储位置和管理 | 线程间共享变量的可见性、有序性、原子性 |
| 范围 | JVM 内部实现细节 | Java 语言层面的并发语义 |
| 主要内容 | 方法区、堆、虚拟机栈、本地方法栈、PC 寄存器 | 主内存、工作内存、内存交互规则 |
| 应用场景 | GC 调优、内存溢出分析、对象分配优化 | 并发编程、线程安全、内存可见性问题 |
| 是否与硬件相关 | 偏向 JVM 实现,与硬件关系不大 | 与 CPU 缓存、内存一致性模型密切相关 |
4. 形象理解
- JVM 内存模型:像是 JVM 的"房间布局图",告诉你数据在 JVM 内部的哪个房间(区域)存放。
- Java 内存模型(JMM):像是"快递传输规则",告诉你不同线程之间如何把数据从一个房间送到另一个房间,并保证送到的数据是最新的。
5. 面试高频总结
- JVM 内存模型:运行时内存结构(方法区、堆、栈等)。
- JMM:并发编程的内存交互规则(主内存、工作内存、可见性、有序性、原子性)。
- 一句话区分:JVM 内存模型是"存储结构",JMM 是"访问规则"。
JVM 的指令集是基于栈还是寄存器?为什么?
1. 什么是寄存器?
- 寄存器(Register) 是 CPU 内部 的一种 速度最快的存储单元。
- 它用来 暂时存放 CPU 正在处理的数据、指令地址等信息。
- 因为寄存器在 CPU 内部,所以访问速度比内存(RAM)快很多。
2. 寄存器的特点
- 位置:在 CPU 芯片内部。
- 容量:很小(通常只有几十到几百个寄存器,每个寄存器大小一般是 32 位或 64 位)。
- 速度:最快的存储器,比缓存(Cache)和内存(RAM)都快。
- 用途:存放运算过程中的数据、指令地址、状态信息等。
3. 寄存器的主要类型
类型 作用 通用寄存器 存放运算数据(如加法、乘法的操作数) 地址寄存器 存放内存地址 指令寄存器(IR) 存放当前正在执行的指令 程序计数器(PC) 存放下一条要执行的指令地址 状态寄存器 存放运算结果的状态(如进位、溢出、零标志)
4. 为什么寄存器很快?
- 寄存器直接在 CPU 内部,数据不需要通过总线传输。
- 访问寄存器的延迟几乎为 0 个 CPU 时钟周期,而访问内存可能需要几十甚至上百个时钟周期。
5. 形象类比
你可以把 寄存器 想象成:
- CPU 的手里正在握着的工具(随时可用,速度最快)。
- 而 内存 就像是旁边的工具箱,需要伸手去拿,速度会慢一些。
✅ 一句话总结 :
寄存器是 CPU 内部的超高速存储单元,用来暂存正在处理的数据和指令地址,速度比内存快很多,但数量很少。
JVM 的指令集是 基于栈(Stack-Based) 的,而不是基于寄存器的。
1. 结论
- JVM 指令集是基于栈的(Stack-Based Instruction Set)。
- 这意味着 JVM 的大部分指令操作数都来自 操作数栈,而不是 CPU 寄存器。
2. 为什么选择基于栈?
① 跨平台性
- 不同 CPU 架构的寄存器数量和名称都不同(x86、ARM、RISC-V 等)。
- 如果 JVM 基于寄存器,那么字节码就会依赖具体硬件,失去跨平台能力。
- 基于栈的指令集不依赖硬件寄存器,字节码可以在任何平台的 JVM 上运行。
② 字节码更紧凑
- 基于栈的指令集不需要显式指定操作数位置(寄存器编号),只需从栈顶取值。
- 这样字节码更短、更紧凑,便于传输和存储。
③ 实现简单
- 栈结构简单,指令只需操作栈顶元素。
- JVM 的解释器实现更容易,不需要复杂的寄存器分配算法。
④ 安全性
- 栈操作天然有边界检查(栈溢出检测),更容易保证字节码的安全性。
- 这符合 JVM 的设计目标:安全、可移植、可验证。
3. 基于栈 vs 基于寄存器的对比
| 特性 | 基于栈(JVM) | 基于寄存器(如 x86) |
|---|---|---|
| 跨平台性 | 高,不依赖硬件寄存器 | 低,依赖具体 CPU 架构 |
| 指令长度 | 短,紧凑 | 长,需要指定寄存器编号 |
| 实现复杂度 | 低,操作栈顶即可 | 高,需要寄存器分配和管理 |
| 执行速度 | 相对慢(多一次栈操作) | 快(直接寄存器操作) |
| 安全性 | 高,易于验证字节码 | 较低,需额外安全检查 |
4. 示例
假设我们要计算 $1 + 2$:
基于栈的字节码(JVM):
iconst_1 // 压入常量 1 iconst_2 // 压入常量 2 iadd // 弹出两个数相加,结果压回栈顶
基于寄存器的指令(x86):
mov eax, 1 // 将 1 放入寄存器 eax add eax, 2 // eax 加 2
JVM 不需要关心寄存器名称,只操作栈顶数据,因此更容易跨平台。
5. 面试高频总结
- JVM 指令集是基于栈的,不是基于寄存器。
- 原因 :
- 跨平台性(不依赖硬件寄存器)
- 字节码更紧凑
- 实现简单
- 安全性高
- 缺点:执行速度比寄存器架构稍慢,但可以通过 JIT 编译优化。
JVM 的启动过程是怎样的?
1. JVM 启动的整体流程
当你在命令行执行
bash
java MyClass
时,JVM 会经历以下几个阶段:
- 启动类加载器加载启动类
- 初始化运行时数据区
- 加载并初始化主类(包含
main方法的类) - 执行
main方法 - 程序运行直到结束或被终止
2. 详细步骤
① 启动类加载器(Bootstrap ClassLoader)启动
- JVM 由操作系统加载到内存后,会先启动 Bootstrap ClassLoader。
- 这个类加载器负责加载 核心类库 (
rt.jar或模块化后的java.base)。 - 包括:
java.lang.*(如Object、String、Thread)java.util.*- 其他 JVM 必需的基础类。
② 初始化运行时数据区
- JVM 会创建并初始化 运行时数据区 :
- 方法区(存放类信息、常量、静态变量)
- 堆(存放对象实例)
- 虚拟机栈(每个线程一个)
- 本地方法栈
- 程序计数器
- 这一步相当于为程序运行准备好"内存环境"。
③ 加载并初始化主类
- JVM 使用 应用类加载器(Application ClassLoader) 加载你指定的主类(
MyClass)。 - 类加载过程:
- 加载(Loading) :读取
.class文件字节码。 - 链接(Linking) :
- 验证(Verify):检查字节码合法性。
- 准备(Prepare):为静态变量分配内存。
- 解析(Resolve):将符号引用替换为直接引用。
- 初始化(Initialization):执行静态初始化块和静态变量赋值。
- 加载(Loading) :读取
④ 执行 main 方法
- JVM 创建一个主线程(Main Thread)。
- 主线程的虚拟机栈中压入
main方法的栈帧。 - 执行引擎(解释器 + JIT 编译器)开始执行字节码。
⑤ 程序运行与结束
- 程序运行过程中:
- JVM 负责内存分配和垃圾回收。
- 多线程由 JVM 调度。
- 当
main方法执行完毕,且所有非守护线程结束时,JVM 退出。
3. JVM 启动过程示意图
[操作系统加载 JVM] ↓ [Bootstrap ClassLoader 加载核心类库] ↓ [初始化运行时数据区] ↓ [Application ClassLoader 加载主类] ↓ [类加载过程:加载 → 链接 → 初始化] ↓ [创建主线程,执行 main 方法] ↓ [程序运行,GC 管理内存] ↓ [所有非守护线程结束 → JVM 退出]
4. 面试高频总结
- 启动类加载器先加载核心类库。
- 运行时数据区初始化,为程序运行准备内存。
- 主类加载经过加载、链接、初始化三个阶段。
- 执行引擎负责运行字节码。
- JVM 退出条件:所有非守护线程结束。
JVM 的双亲委派模型是什么?
1. 什么是双亲委派模型?
- 双亲委派模型(Parent Delegation Model) 是 JVM 类加载器的一种工作机制。
- 核心思想 :
当一个类加载器接到类加载请求时,它不会自己先加载 ,而是先把请求交给父类加载器去处理,父类加载器如果能加载,就直接返回;如果不能加载,才由当前加载器自己去加载。
2. 类加载器层次结构
JVM 中的类加载器有一个层级关系:
Bootstrap ClassLoader(启动类加载器) ↑ ExtClassLoader(扩展类加载器) ↑ AppClassLoader(应用类加载器) ↑ 自定义类加载器(Custom ClassLoader)
- Bootstrap ClassLoader
- 由 C++ 实现(不是 Java 类),负责加载 核心类库 (
java.lang.*、java.util.*等)。
- 由 C++ 实现(不是 Java 类),负责加载 核心类库 (
- ExtClassLoader
- 加载
JAVA_HOME/lib/ext目录下的扩展类库。
- 加载
- AppClassLoader
- 加载应用程序的
classpath下的类。
- 加载应用程序的
- 自定义类加载器
- 由开发者自己实现,通常继承
ClassLoader。
- 由开发者自己实现,通常继承
3. 工作流程
双亲委派模型的类加载流程如下:
- 收到类加载请求。
- 先委托父类加载器去加载。
- 父类加载器继续向上委托,直到 Bootstrap ClassLoader。
- 如果父类加载器能加载,直接返回类。
- 如果父类加载器不能加载,由当前加载器自己加载。
4. 为什么要用双亲委派?
① 避免类的重复加载
- 保证同一个类在 JVM 中只有一个版本(由同一个类加载器加载)。
- 例如:
java.lang.String始终由 Bootstrap ClassLoader 加载,避免被恶意替换。
② 保证核心类的安全性
- 防止用户自己写一个
java.lang.Object或java.lang.String来替换系统核心类。
③ 提高加载效率
- 父类加载器通常已经加载过很多类,直接复用可以减少重复工作。
5. 示例
假设我们要加载 java.lang.String:
- AppClassLoader 收到请求 → 委托 ExtClassLoader。
- ExtClassLoader 收到请求 → 委托 Bootstrap ClassLoader。
- Bootstrap ClassLoader 在核心类库中找到
String.class→ 返回。 - AppClassLoader 直接使用父类加载器返回的类。
6. 面试高频总结
- 定义:类加载器收到请求先委托父类加载器,父类加载器无法加载时才自己加载。
- 好处 :
- 避免类重复加载
- 保证核心类安全
- 提高加载效率
- 缺点 :
- 不灵活,某些情况下需要打破双亲委派(如 SPI、热部署、插件机制)。
二、类加载机制
类加载的过程包括哪几个阶段?
加载、验证、准备、解析、初始化分别做什么?
什么是双亲委派模型?为什么要使用它?
如何打破双亲委派模型?
1. 为什么要打破双亲委派模型?
双亲委派模型的好处是安全、避免重复加载,但它也有一个限制:
如果父类加载器已经加载了某个类,子类加载器就无法加载同名类。
在一些场景下,我们希望由子类加载器优先加载,而不是父类加载器,例如:
- SPI(Service Provider Interface)机制
父类加载器加载接口,子类加载器加载实现类。 - 热部署 / 热替换
需要重新加载同名类。 - 插件化系统
不同插件可能有相同类名,但需要隔离加载。
2. 打破双亲委派的常见方法
方法 1:自定义类加载器并重写 loadClass 方法
- 默认
ClassLoader的loadClass方法是先委托父类加载器。 - 如果我们不调用
super.loadClass,而是直接调用findClass,就可以让当前类加载器优先加载。
示例代码:
java
public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 1. 核心类库还是走父类加载器,保证安全
if (name.startsWith("java.")) {
return super.loadClass(name, resolve);
}
// 2. 自己先尝试加载
try {
byte[] data = loadClassData(name); // 从文件或网络读取字节码
Class<?> clazz = defineClass(name, data, 0, data.length);
if (resolve) {
resolveClass(clazz);
}
return clazz;
} catch (Exception e) {
// 3. 如果自己加载失败,再交给父类加载器
return super.loadClass(name, resolve);
}
}
private byte[] loadClassData(String name) {
// 读取字节码的逻辑
return new byte[0];
}
}
关键点:
- 不直接调用
super.loadClass,而是先自己加载。 - 核心类库(
java.*)依然交给父类加载器,避免安全问题。
方法 2:线程上下文类加载器(Thread Context ClassLoader)
- JDK 提供了
Thread.currentThread().setContextClassLoader()方法,可以临时替换当前线程的类加载器。 - 常用于 SPI 机制 :
- 父类加载器加载接口。
- 子类加载器(通过线程上下文类加载器)加载实现类。
示例:
java
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(new MyClassLoader());
方法 3:OSGi / 模块化框架
- OSGi 等模块化框架会为每个模块创建独立的类加载器,并且不遵循传统的双亲委派 ,而是通过模块依赖关系来决定类加载顺序。
- 这种方式可以让不同模块加载同名类而互不干扰。
3. 典型应用场景
| 场景 | 打破方式 | 原因 |
|---|---|---|
| SPI 机制 | 线程上下文类加载器 | 父类加载器加载接口,子类加载器加载实现类 |
| 热部署 | 自定义类加载器 | 重新加载同名类 |
| 插件化系统 | 模块化类加载器(OSGi) | 不同插件隔离加载 |
| Tomcat / Web 容器 | WebAppClassLoader | 每个 Web 应用独立类加载器,避免类冲突 |
4. 面试高频总结
- 打破双亲委派的核心:让子类加载器优先加载,而不是父类加载器。
- 常用方法 :
- 重写
loadClass(不调用super.loadClass) - 线程上下文类加载器(SPI)
- 模块化类加载器(OSGi、Tomcat)
- 重写
- 注意安全性:核心类库仍应由 Bootstrap ClassLoader 加载。
类加载器的种类有哪些?
JVM 中的类加载器种类 ,这个知识点在面试和调优中都很常见,而且和 双亲委派模型 、打破双亲委派 等问题紧密相关。
1. JVM 内置的类加载器种类
在 Java 8 及之前(非模块化),JVM 主要有三种内置类加载器:
| 类加载器 | 作用 | 加载范围 |
|---|---|---|
| Bootstrap ClassLoader(启动类加载器) | JVM 最顶层的类加载器,由 C++ 实现,不是 Java 类 | 加载 核心类库 (JAVA_HOME/lib 下的 rt.jar 或模块化后的 java.base) |
| Extension ClassLoader(扩展类加载器) | Java 实现,父类是 Bootstrap ClassLoader | 加载 JAVA_HOME/lib/ext 目录下的类库 |
| Application ClassLoader(系统类加载器) | Java 实现,父类是 Extension ClassLoader | 加载应用程序 classpath 下的类 |
2. Java 9 之后的变化(模块化系统)
- Java 9 引入 模块化(Jigsaw) ,类加载器结构有所调整:
- Bootstrap ClassLoader 仍然存在,加载
java.base模块。 - Platform ClassLoader 取代了 Extension ClassLoader,加载平台模块(如
java.sql、java.xml)。 - Application ClassLoader 仍然负责加载应用模块和类路径下的类。
- Bootstrap ClassLoader 仍然存在,加载
3. 自定义类加载器
除了 JVM 内置的类加载器,我们还可以自己实现类加载器:
- 继承
ClassLoader或URLClassLoader。 - 通过重写
findClass或loadClass方法实现自定义加载逻辑。 - 常用于:
- 热部署(重新加载同名类)
- 插件化系统(不同插件隔离)
- 网络加载类(从远程服务器加载字节码)
4. 类加载器的层次关系(Java 8 之前)
Bootstrap ClassLoader(启动类加载器) ↑ Extension ClassLoader(扩展类加载器) ↑ Application ClassLoader(系统类加载器) ↑ Custom ClassLoader(自定义类加载器)
5. 面试高频总结
- Java 8 及之前 :
- Bootstrap ClassLoader(核心类库)
- Extension ClassLoader(扩展类库)
- Application ClassLoader(应用类)
- Custom ClassLoader(自定义)
- Java 9 及之后 :
- Bootstrap ClassLoader
- Platform ClassLoader(平台模块)
- Application ClassLoader
- Custom ClassLoader
- 关系:双亲委派模型形成树状结构,父类加载器优先加载。
Bootstrap ClassLoader、ExtClassLoader、AppClassLoader 的区别?
1. Bootstrap ClassLoader
- 作用 :负责加载 Java 核心类库 ,例如
rt.jar中的类(java.lang.*、java.util.*等)。 - 加载路径 :
%JAVA_HOME%/lib目录下的.jar和.class文件。 - 实现方式 :由 JVM 内部的 C/C++ 代码实现,不是 Java 类,因此在 Java 中无法直接获取它的对象。
- 特点 :
- 是所有类加载器的顶层父加载器。
- 在 Java 中调用
getParent()时,如果父加载器是它,会返回null。 - 主要保证核心类的安全性,防止被用户代码替换。
2. ExtClassLoader(扩展类加载器)
- 作用 :负责加载 扩展类库 ,例如
%JAVA_HOME%/lib/ext目录下的.jar文件。 - 加载路径 :
%JAVA_HOME%/lib/ext或由系统属性java.ext.dirs指定的路径。 - 实现方式 :由 Java 编写,类名通常是
sun.misc.Launcher$ExtClassLoader。 - 特点 :
- 父加载器 是 Bootstrap ClassLoader。
- 用于加载非核心但属于 JDK 扩展的类库。
- 在双亲委派机制下,如果核心类加载器无法加载某个类,才会由它来加载。
3. AppClassLoader(系统类加载器)
- 作用 :负责加载 应用程序类路径(classpath)下的类和资源。
- 加载路径 :由系统属性
java.class.path指定的路径(通常是我们项目的bin、target/classes目录以及依赖的 jar)。 - 实现方式 :由 Java 编写,类名通常是
sun.misc.Launcher$AppClassLoader。 - 特点 :
- 父加载器 是 ExtClassLoader。
- 是我们在 Java 程序中最常用的类加载器。
- 调用
ClassLoader.getSystemClassLoader()返回的就是它。
4. 三者关系(双亲委派机制)
它们之间的关系可以用一条链表示:
Bootstrap ClassLoader ↑ ExtClassLoader ↑ AppClassLoader
双亲委派机制:
- 当一个类加载器接到加载请求时,先委托给父加载器。
- 父加载器如果能加载,就直接返回。
- 父加载器不能加载时,才由当前加载器自己尝试加载。
5. 对比表格
| 类加载器 | 主要作用 | 加载路径 | 实现方式 | 父加载器 |
|---|---|---|---|---|
| Bootstrap ClassLoader | 加载 Java 核心类库 | %JAVA_HOME%/lib |
JVM 内部 C/C++ 实现 | 无(返回 null) |
| ExtClassLoader | 加载扩展类库 | %JAVA_HOME%/lib/ext 或 java.ext.dirs |
Java 实现 | Bootstrap ClassLoader |
| AppClassLoader | 加载应用程序类路径下的类 | java.class.path |
Java 实现 | ExtClassLoader |
✅ 总结:
- Bootstrap ClassLoader → 核心类库。
- ExtClassLoader → JDK 扩展类库。
- AppClassLoader → 应用程序类和第三方依赖。
- 它们遵循 双亲委派机制,保证核心类的安全性和类加载的有序性。
自定义类加载器的实现方法?
1. 自定义类加载器的实现步骤
在 Java 中,自定义类加载器通常有两种方式:
- 继承
ClassLoader(推荐) - 继承
URLClassLoader(适合加载指定路径的 jar/目录)
核心方法:
findClass(String name)
负责根据类名找到字节码并返回Class对象。defineClass(String name, byte[] b, int off, int len)
将字节数组转换为Class对象。loadClass(String name)
继承自ClassLoader,实现了双亲委派机制,一般不重写,除非要改变加载逻辑。
2. 实现思路
- 读取
.class文件的字节码(可以从文件系统、网络、加密文件等来源)。 - 调用
defineClass()将字节码转换为Class对象。 - 使用
loadClass()或findClass()来加载类。
3. 示例代码
下面是一个简单的自定义类加载器,从指定目录加载 .class 文件:
java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
public class MyClassLoader extends ClassLoader {
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 将类名转换为文件路径
String fileName = classPath + "/" + name.replace('.', '/') + ".class";
byte[] classBytes = Files.readAllBytes(Paths.get(fileName));
// 将字节码转换为 Class 对象
return defineClass(name, classBytes, 0, classBytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("无法加载类: " + name, e);
}
}
public static void main(String[] args) throws Exception {
// 假设 Hello.class 存放在 D:/classes 目录
MyClassLoader loader = new MyClassLoader("D:/classes");
// 使用自定义类加载器加载类
Class<?> clazz = loader.loadClass("com.example.Hello");
// 创建对象并调用方法
Object obj = clazz.getDeclaredConstructor().newInstance();
clazz.getMethod("sayHello").invoke(obj);
}
}
4. 运行流程
- 编译目标类 (例如
Hello.java)并放到D:/classes/com/example/Hello.class。 - 运行
MyClassLoader,它会:- 先走双亲委派机制,父加载器找不到时调用
findClass()。 - 从指定目录读取字节码。
- 调用
defineClass()转换为Class对象。
- 先走双亲委派机制,父加载器找不到时调用
- 通过反射调用方法。
5. 注意事项
- 如果要打破双亲委派机制 (例如热加载、插件系统),可以重写
loadClass()方法。 - 自定义类加载器可以实现类隔离(不同加载器加载同名类会被视为不同类)。
- 在实际项目中,可以结合加密/解密来保护字节码安全。
类加载器之间的关系是什么?
类的主动引用与被动引用的区别?
什么时候会触发类的初始化?
三、JVM 内存结构
JVM 的运行时数据区有哪些? 程序计数器的作用是什么? Java 虚拟机栈的作用是什么? 本地方法栈的作用是什么? 堆的作用是什么? 方法区(元空间)的作用是什么? 运行时常量池是什么?
1. JVM 运行时数据区概览
Java 虚拟机在运行 Java 程序时,会将内存划分为几个不同的区域,每个区域有不同的用途。主要包括:
- 程序计数器(Program Counter Register)
- Java 虚拟机栈(Java Virtual Machine Stack)
- 本地方法栈(Native Method Stack)
- 堆(Heap)
- 方法区(Method Area / 元空间 Metaspace)
- 运行时常量池(Runtime Constant Pool)
它们的关系可以简单表示为:
┌─────────────────────────────┐
│ 程序计数器 │
│ Java 虚拟机栈 │
│ 本地方法栈 │
│ 堆 │
│ 方法区(元空间) │
│ 运行时常量池 │
└─────────────────────────────┘
2. 各部分详细说明
(1) 程序计数器
- 作用:记录当前线程正在执行的字节码指令的地址(行号)。
- 特点 :
- 每个线程都有一个独立的程序计数器(线程私有)。
- 在执行 Java 方法时,计数器记录的是字节码指令地址;执行 Native 方法时,计数器值为 undefined。
- 用途 :
- 支持线程切换后能恢复到正确的执行位置。
- 是实现 多线程 的关键。
(2) Java 虚拟机栈
- 作用 :存储 Java 方法执行时的栈帧(Stack Frame)。
- 栈帧内容 :
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)
- 动态链接(Dynamic Linking)
- 方法返回地址(Return Address)
- 特点 :
- 线程私有。
- 生命周期与线程相同。
- 用途 :
- 方法调用时创建栈帧,方法结束时销毁栈帧。
- 用于存储方法的局部变量和操作数。
(3) 本地方法栈
- 作用 :为 JVM 调用 Native 方法(非 Java 方法)服务。
- 特点 :
- 线程私有。
- 存储的是 Native 方法的执行状态。
- 用途 :
- 当 Java 调用 C/C++ 编写的本地方法时,本地方法栈会保存相关信息。
(4) 堆
- 作用 :存储对象实例 和数组。
- 特点 :
- 所有线程共享。
- 是垃圾回收(GC)的主要区域。
- 用途 :
- 创建对象时,内存分配在堆中。
- GC 会回收不再使用的对象。
(5) 方法区(元空间 Metaspace)
- 作用:存储类的结构信息(类元数据)、常量、静态变量、JIT 编译后的代码等。
- 特点 :
- 所有线程共享。
- JDK 8 之前方法区在堆中(永久代 PermGen),JDK 8 之后改为 元空间,使用本地内存。
- 用途 :
- 保存类的元信息(类名、方法、字段等)。
- 存储静态变量和常量。
(6) 运行时常量池
- 作用 :存储类加载后解析的字面量 和符号引用。
- 特点 :
- 属于方法区的一部分。
- 每个类都有自己的运行时常量池。
- 用途 :
- 支持动态链接。
- 存储编译期生成的常量(如字符串字面量、final 常量)。
3. 对比表格
| 区域 | 线程共享 | 主要存储内容 | 生命周期 |
|---|---|---|---|
| 程序计数器 | 否 | 当前线程执行的字节码指令地址 | 与线程相同 |
| Java 虚拟机栈 | 否 | 方法栈帧(局部变量、操作数栈等) | 与线程相同 |
| 本地方法栈 | 否 | Native 方法调用信息 | 与线程相同 |
| 堆 | 是 | 对象实例、数组 | JVM 启动到关闭 |
| 方法区 / 元空间 | 是 | 类元数据、静态变量、JIT 代码 | JVM 启动到关闭 |
| 运行时常量池 | 是 | 字面量、符号引用 | JVM 启动到关闭 |
✅ 总结:
- 程序计数器 → 记录当前线程执行位置。
- Java 虚拟机栈 → 存储方法调用的局部变量和操作数。
- 本地方法栈 → 支持 Native 方法执行。
- 堆 → 存储对象实例,GC 主要区域。
- 方法区 / 元空间 → 存储类元数据、静态变量。
- 运行时常量池 → 存储字面量和符号引用,支持动态链接。
直接内存是什么?
1. 什么是直接内存
直接内存 并不是 JVM 运行时数据区的一部分,它是操作系统的内存 ,由 Java 程序通过 Native 方法直接向操作系统申请的内存空间。
它的分配和回收不受 JVM 堆的限制,而是由 操作系统 管理。
在 Java 中,直接内存通常通过 java.nio 包中的 ByteBuffer.allocateDirect() 来申请。
2. 直接内存的特点
- 不在 JVM 堆中,因此不会被 GC 直接管理。
- 由 操作系统分配和释放。
- 访问速度比普通堆内存快(尤其在 I/O 场景),因为它可以减少数据在 JVM 堆和操作系统内核缓冲区之间的复制。
- 容量受限于系统内存和
-XX:MaxDirectMemorySize参数。
3. 为什么需要直接内存
在传统 I/O 中,数据需要:
- 从磁盘读到操作系统内核缓冲区。
- 再从内核缓冲区复制到 JVM 堆内存。
- 程序处理数据。
这种双重拷贝 会降低性能。
直接内存可以让 JVM 直接使用操作系统的内存缓冲区,减少一次复制,提高效率。
4. 使用场景
- 高性能 I/O(NIO)
- 网络通信框架(Netty、MINA)
- 大数据处理(Hadoop、Spark)
- 需要频繁与操作系统交互的场景
5. 示例代码
java
import java.nio.ByteBuffer;
public class DirectMemoryDemo {
public static void main(String[] args) {
// 分配 100MB 直接内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 100);
System.out.println("直接内存容量: " + buffer.capacity() / 1024 / 1024 + "MB");
// 写入数据
for (int i = 0; i < buffer.capacity(); i++) {
buffer.put((byte) i);
}
// 读取数据
buffer.flip();
while (buffer.hasRemaining()) {
buffer.get();
}
}
}
说明:
allocateDirect()会向操作系统申请内存。- 释放由 JVM 的 Cleaner 机制触发,但不是立即释放。
6. 注意事项
- 内存溢出风险 :直接内存不受堆大小限制,但受
-XX:MaxDirectMemorySize控制,超出会抛出OutOfMemoryError。 - 释放延迟:虽然不在堆中,但释放依赖 GC 触发 Cleaner,可能会延迟。
- 调优参数 :
-XX:MaxDirectMemorySize:最大直接内存大小。- 如果不设置,默认与堆大小相同。
✅ 总结:
- 直接内存是操作系统分配的内存,不在 JVM 堆中。
- 主要用于 高性能 I/O,减少数据复制,提高速度。
- 需要注意内存管理和释放延迟问题。
堆与栈的区别?
1. 概念区别
-
堆(Heap)
- 存储对象实例 和数组。
- 所有线程共享。
- 由 **垃圾回收器(GC)**管理。
-
栈(Stack)
- 在 JVM 中指 Java 虚拟机栈 ,存储方法调用的栈帧(局部变量、操作数等)。
- 每个线程独有(线程私有)。
- 生命周期与线程相同,方法结束时栈帧自动销毁。
2. 详细对比表
| 对比项 | 堆(Heap) | 栈(Stack) |
|---|---|---|
| 存储内容 | 对象实例、数组 | 方法栈帧(局部变量、操作数栈、返回地址等) |
| 线程共享 | 是 | 否(线程私有) |
| 生命周期 | JVM 启动到关闭 | 与线程相同 |
| 管理方式 | 由 GC 自动回收 | 方法结束时自动释放 |
| 访问速度 | 相对较慢(需 GC 管理) | 快(直接内存地址访问) |
| 内存分配 | 动态分配(运行时创建对象) | 静态分配(方法调用时分配栈帧) |
| 溢出错误 | OutOfMemoryError(堆空间不足) |
StackOverflowError(栈深度过大) |
| 作用 | 存储程序运行中需要长期存在的对象 | 存储方法调用过程中的临时数据 |
3. 运行过程中的关系
-
当你在 Java 中写:
javaPerson p = new Person();new Person()创建的对象存放在 堆 中。- 变量
p存放在 栈 的局部变量表中,保存的是对象在堆中的引用地址。
-
方法调用时:
- JVM 在 栈 中为该方法创建一个栈帧。
- 栈帧中可能包含指向堆中对象的引用。
4. 图示关系
线程1栈(私有) 线程2栈(私有) ┌─────────────┐ ┌─────────────┐ │ 栈帧:方法A │ │ 栈帧:方法X │ │ 局部变量p → ──────→ 堆(共享) └─────────────┘ └─────────────┘ ┌─────────────┐ │ Person对象 │ └─────────────┘
- 栈:存放方法调用信息和对象引用。
- 堆:存放对象本身,所有线程共享。
✅ 总结:
- 堆 → 存储对象实例,线程共享,GC 管理,生命周期长。
- 栈 → 存储方法调用信息和局部变量,线程私有,方法结束自动释放,生命周期短。
- 栈中保存的是对象引用,对象本身在堆中。
对象的创建过程是什么?
1. 概览
在 Java 中,当我们写下:
java
Person p = new Person();
JVM 会经历 类加载 → 内存分配 → 初始化 → 引用赋值 等多个阶段,最终得到一个可用的对象。
2. 详细过程
(1) 类加载检查
- 当第一次使用某个类时,JVM 会检查该类是否已经被加载、链接和初始化。
- 如果没有:
- 加载(Loading) :通过类加载器读取
.class文件字节码。 - 链接(Linking) :
- 验证(Verify):检查字节码合法性。
- 准备(Prepare):为静态变量分配内存并赋默认值。
- 解析(Resolve):将符号引用替换为直接引用。
- 初始化(Initialization) :执行类的
<clinit>方法(静态初始化块、静态变量赋值)。
- 加载(Loading) :通过类加载器读取
(2) 分配内存
- JVM 在 堆 中为新对象分配内存空间,大小由类的实例变量决定。
- 分配方式:
- 指针碰撞(Pointer Bump):如果堆是规整的,直接移动指针分配。
- 空闲列表(Free List):如果堆有碎片,查找可用块分配。
- 线程安全:
- 使用 CAS + 失败重试 或 TLAB(Thread Local Allocation Buffer) 来保证并发安全。
(3) 设置默认值
- 分配的内存会先清零(保证对象的实例变量有默认值)。
- 例如:
int→0boolean→falseObject→null
(4) 执行 <init> 构造方法
- JVM 调用对象的构造方法,执行类中定义的初始化逻辑。
- 过程:
- 调用父类构造方法(
super())。 - 按声明顺序初始化实例变量。
- 执行构造方法体中的代码。
- 调用父类构造方法(
(5) 返回对象引用
- 构造完成后,JVM 将对象的引用地址返回给调用者。
- 引用存放在 栈 的局部变量表中,对象本身在 堆 中。
3. 流程图
源码:new Person() ↓ 类加载检查(ClassLoader) ↓ 堆内存分配(Heap) ↓ 默认值初始化(0/null/false) ↓ 执行构造方法 <init> ↓ 返回对象引用(存放在栈中)
4. 对象创建的 5 个关键步骤总结
- 类加载检查 → 确保类已加载、链接、初始化。
- 分配内存 → 在堆中为对象分配空间。
- 默认值初始化 → 保证实例变量有默认值。
- 执行构造方法 → 完成实例变量赋值和初始化逻辑。
- 返回引用 → 将对象地址赋给变量。
✅ 总结:
- 对象创建不仅仅是
new,背后涉及 类加载机制 、内存分配策略 、初始化流程。 - 堆中存放对象本身,栈中存放对象引用。
- JVM 会通过 TLAB 和 CAS 保证多线程下的分配效率与安全。
四、垃圾回收(GC)机制
什么是垃圾回收?为什么需要垃圾回收? 如何判断对象是否可回收?
1. 什么是垃圾回收(GC)
垃圾回收 是指 JVM 自动释放不再被使用的对象所占用的内存空间的过程。
它的主要目标是:
- 回收无用对象,释放堆内存。
- 避免内存泄漏(Memory Leak)。
- 提高内存利用率。
在 Java 中,内存管理由 JVM 自动完成,程序员不需要手动释放对象(不像 C/C++ 需要 free() 或 delete)。
2. 为什么需要垃圾回收
(1) 防止内存泄漏
- 如果对象不再使用但仍占用内存,会导致堆空间不足,最终抛出
OutOfMemoryError。
(2) 提高开发效率
- 程序员无需手动管理内存,减少错误风险。
(3) 保证程序稳定性
- 自动释放无用对象,避免因内存不足导致程序崩溃。
3. 如何判断对象是否可回收
JVM 主要通过**可达性分析(Reachability Analysis)**来判断对象是否可回收。
(1) 可达性分析算法
- 从一组称为 GC Roots 的对象出发,沿着引用链向下搜索。
- 如果某个对象不可从 GC Roots 到达 ,则认为它是不可达对象,可以被回收。
GC Roots 常见类型:
- 虚拟机栈中引用的对象(局部变量)。
- 方法区中类的静态引用对象。
- 方法区中常量引用对象。
- 本地方法栈中 JNI 引用的对象。
(2) 引用类型与回收
Java 中的引用类型会影响 GC 判断:
- 强引用(Strong Reference):不会被回收。
- 软引用(Soft Reference):内存不足时回收。
- 弱引用(Weak Reference):下一次 GC 必回收。
- 虚引用(Phantom Reference):仅用于跟踪对象回收状态。
(3) 对象的"死亡"过程
- 第一次标记:对象不可达。
- 是否覆盖
finalize()方法 :- 如果覆盖且未执行过,则放入 F-Queue 队列,等待执行
finalize()。 - 执行后,如果对象重新与 GC Roots 建立联系,则"复活"。
- 如果覆盖且未执行过,则放入 F-Queue 队列,等待执行
- 第二次标记:如果仍不可达,则回收。
4. 流程图
GC Roots ↓ 可达性分析 ↓ 不可达对象 ↓ 是否执行 finalize() ↓ 复活? → 是:继续存活 否:回收
5. 总结表格
| 问题 | 说明 |
|---|---|
| 什么是 GC | 自动释放不再使用的对象所占内存 |
| 为什么需要 GC | 防止内存泄漏、提高开发效率、保证稳定性 |
| 判断可回收的方式 | 可达性分析算法,从 GC Roots 出发 |
| GC Roots 类型 | 栈中引用、静态引用、常量引用、JNI 引用 |
| 引用类型影响 | 强引用不回收,软引用内存不足时回收,弱引用必回收,虚引用仅跟踪 |
✅ 总结:
- 垃圾回收是 JVM 自动管理内存的核心机制。
- 可达性分析是判断对象是否可回收的主要算法。
- 引用类型会影响对象的回收时机。
- 对象在被回收前可能会通过
finalize()方法"复活"。
引用计数法与可达性分析的区别?
1. 引用计数法(Reference Counting)
(1) 原理
- 在对象中维护一个引用计数器。
- 每当有一个地方引用它时,计数器 +1。
- 每当引用失效时,计数器 -1。
- 当计数器为 0 时,说明对象不再被使用,可以回收。
(2) 优点
- 实现简单,判断效率高(计数为 0 立即回收)。
- 回收过程分散进行,不会出现长时间的停顿(Stop-The-World)。
(3) 缺点
-
无法处理循环引用 :
javaclass Node { Node next; } Node a = new Node(); Node b = new Node(); a.next = b; b.next = a; // 循环引用 a = null; b = null; // 计数器不为 0,无法回收 -
需要额外的存储空间维护计数器。
-
每次引用变化都要更新计数器,性能开销大。
(4) 在 JVM 中的应用
- 主流 JVM(HotSpot)不使用引用计数法作为主要 GC 判断算法,但在一些场景(如 Python、Objective-C、COM 组件)中仍被使用。
2. 可达性分析(Reachability Analysis)
(1) 原理
- 从一组称为 GC Roots 的对象出发,沿着引用链向下搜索。
- 如果某个对象不可从 GC Roots 到达,则认为它是不可达对象,可以回收。
(2) GC Roots 常见类型
- 虚拟机栈中引用的对象(局部变量)。
- 方法区中类的静态引用对象。
- 方法区中常量引用对象。
- 本地方法栈中 JNI 引用的对象。
(3) 优点
- 能正确处理循环引用问题。
- 更适合现代 GC 算法(分代收集、标记-清除、标记-整理等)。
(4) 缺点
- 需要从 GC Roots 遍历整个引用图,耗时比引用计数法长。
- 在执行分析时,通常需要 Stop-The-World 暂停用户线程。
(5) 在 JVM 中的应用
- HotSpot JVM 采用可达性分析作为主要的 GC 判定算法。
3. 对比表
| 对比项 | 引用计数法 | 可达性分析 |
|---|---|---|
| 原理 | 维护引用计数,计数为 0 即回收 | 从 GC Roots 出发,遍历引用链 |
| 循环引用 | 无法处理 | 可以处理 |
| 实现复杂度 | 简单 | 相对复杂 |
| 性能 | 引用变化时立即更新计数,开销大 | 需要遍历对象图,耗时集中 |
| 回收时机 | 计数为 0 立即回收 | GC 触发时统一回收 |
| 是否需要 STW | 不需要 | 通常需要 |
| JVM 应用 | 不作为主要算法 | HotSpot 主要使用 |
4. 图示理解
引用计数法循环引用问题
a → b ↑ ↓ └───┘ a 和 b 互相引用,即使外部引用断开,计数器仍 > 0,无法回收
可达性分析
GC Roots ↓ 对象A → 对象B → 对象C ↘ 对象D 不可达对象:从 GC Roots 无法到达的对象(即使它们互相引用)
5. 总结
- 引用计数法:简单高效,但无法解决循环引用问题,因此不适合作为 JVM 的主要 GC 判定算法。
- 可达性分析:能解决循环引用问题,是现代 JVM(如 HotSpot)的主流选择,但需要 STW 暂停用户线程。
强引用、软引用、弱引用、虚引用的区别?
1. 强引用(Strong Reference)
概念
- Java 中最常见的引用类型。
- 只要对象有强引用存在,GC 永远不会回收它。
- 即使内存不足,也不会回收,宁愿抛出
OutOfMemoryError。
特点
-
默认的引用类型,例如:
javaObject obj = new Object(); -
对象生命周期与引用变量一致。
示例
java
Object obj = new Object(); // 强引用
obj = null; // 断开引用,GC 才能回收
适用场景
- 普通对象的引用,确保对象在使用期间不被回收。
2. 软引用(Soft Reference)
概念
- 在内存不足时,GC 会回收软引用指向的对象。
- 适合做缓存,在内存充足时保留对象,内存紧张时释放。
特点
- 比强引用更容易被回收。
- 不会导致
OutOfMemoryError(除非内存不足且软引用对象已被回收)。
示例
java
import java.lang.ref.SoftReference;
public class SoftRefDemo {
public static void main(String[] args) {
SoftReference<byte[]> softRef = new SoftReference<>(new byte[1024 * 1024 * 10]); // 10MB
System.out.println("Before GC: " + softRef.get());
System.gc(); // 触发 GC
System.out.println("After GC: " + softRef.get()); // 可能仍存在
// 模拟内存不足
try {
byte[] bigData = new byte[1024 * 1024 * 100]; // 100MB
} catch (Throwable e) {
System.out.println("After OOM: " + softRef.get()); // 可能为 null
}
}
}
适用场景
- 图片缓存、数据缓存等,内存不足时自动释放。
3. 弱引用(Weak Reference)
概念
- GC 发现弱引用对象后立即回收,不管内存是否充足。
- 生命周期非常短。
特点
- 适合存储一些不重要的对象,随时可能被回收。
- 常用于 WeakHashMap(键使用弱引用)。
示例
java
import java.lang.ref.WeakReference;
public class WeakRefDemo {
public static void main(String[] args) {
WeakReference<Object> weakRef = new WeakReference<>(new Object());
System.out.println("Before GC: " + weakRef.get());
System.gc(); // 触发 GC
System.out.println("After GC: " + weakRef.get()); // 一定为 null
}
}
适用场景
- 缓存中存放不重要数据。
- 避免内存泄漏(如监听器、回调对象)。
4. 虚引用(Phantom Reference)
概念
- 最弱的引用类型,对象几乎不能通过虚引用访问。
- 主要用于跟踪对象被回收的状态。
- 必须与 引用队列(ReferenceQueue) 配合使用。
特点
get()方法永远返回null。- 用于在对象被 GC 回收前执行一些清理操作(类似
finalize())。
示例
java
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
public class PhantomRefDemo {
public static void main(String[] args) {
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);
System.out.println("phantomRef.get(): " + phantomRef.get()); // 永远为 null
System.gc();
System.out.println("From queue: " + queue.poll()); // 对象被回收后,引用进入队列
}
}
适用场景
- 管理堆外内存(如直接内存)。
- 在对象回收前做资源释放。
5. 对比表
| 引用类型 | GC 回收时机 | 是否可通过 get() 获取对象 |
适用场景 |
|---|---|---|---|
| 强引用 | 永不回收(除非断开引用) | 是 | 普通对象引用 |
| 软引用 | 内存不足时回收 | 是 | 缓存(图片、数据) |
| 弱引用 | 下一次 GC 必回收 | 是 | 不重要数据、避免内存泄漏 |
| 虚引用 | 回收前进入引用队列 | 否 | 跟踪对象回收、资源释放 |
✅ 总结:
- 强引用:最常用,保证对象不被回收。
- 软引用:适合缓存,内存不足时释放。
- 弱引用:随时可能被回收,适合存放不重要数据。
- 虚引用:用于跟踪对象回收状态,不能直接访问对象。
垃圾回收的主要算法有哪些?
1. 垃圾回收的主要算法概览
在 JVM 中,常见的 GC 算法主要有:
- 标记-清除(Mark-Sweep)
- 复制(Copying)
- 标记-整理(Mark-Compact)
- 分代收集(Generational Collection)
2. 标记-清除(Mark-Sweep)算法
原理
- 第一阶段:标记
从 GC Roots 出发,标记所有可达对象。 - 第二阶段:清除
遍历堆内存,回收未被标记的对象。
流程
GC Roots → 可达性分析 → 标记存活对象 → 清除未标记对象
优点
- 实现简单。
- 不需要移动对象。
缺点
- 内存碎片化:清除后内存不连续,可能导致大对象无法分配。
- 回收速度较慢。
示例
java
// 模拟标记-清除
List<Object> heap = new ArrayList<>();
Object live = new Object(); // 存活对象
heap.add(live);
heap.add(new Object()); // 垃圾对象
// 标记阶段
Set<Object> marked = new HashSet<>();
marked.add(live);
// 清除阶段
heap.removeIf(obj -> !marked.contains(obj));
3. 复制(Copying)算法
原理
- 将内存分为两块(From 和 To)。
- GC 时只遍历 From 区,将存活对象复制到 To 区,然后清空 From 区。
流程
From 区 → 标记存活对象 → 复制到 To 区 → 清空 From 区 → 交换角色
优点
- 无碎片化:复制后内存连续。
- 分配速度快(指针碰撞)。
缺点
- 需要额外的内存空间(只能使用一半内存)。
- 复制成本高(如果存活对象多)。
示例
java
List<Object> from = new ArrayList<>();
List<Object> to = new ArrayList<>();
Object live = new Object();
from.add(live);
from.add(new Object()); // 垃圾
// 复制阶段
for (Object obj : from) {
if (obj == live) {
to.add(obj);
}
}
from.clear(); // 清空 From
4. 标记-整理(Mark-Compact)算法
原理
- 类似标记-清除,但在清除阶段会移动存活对象,让它们在内存中连续排列。
- 解决了标记-清除的碎片化问题。
流程
GC Roots → 标记存活对象 → 移动存活对象 → 清理剩余空间
优点
- 无碎片化。
- 不需要额外内存空间。
缺点
- 移动对象成本高(需要更新引用)。
示例
java
List<Object> heap = new ArrayList<>();
Object live1 = new Object();
Object live2 = new Object();
heap.add(live1);
heap.add(new Object()); // 垃圾
heap.add(live2);
// 标记阶段
List<Object> marked = Arrays.asList(live1, live2);
// 整理阶段
heap.clear();
heap.addAll(marked); // 移动到连续空间
5. 分代收集(Generational Collection)算法
原理
- 根据对象生命周期的不同,将堆分为:
- 新生代(Young Generation) :对象存活率低,使用复制算法。
- 老年代(Old Generation) :对象存活率高,使用标记-整理算法。
- 新生代 GC 称为 Minor GC ,老年代 GC 称为 Major GC 或 Full GC。
流程
新生代 → 复制算法 → 存活对象晋升到老年代 老年代 → 标记-整理算法
优点
- 针对不同对象生命周期优化回收策略。
- 提高 GC 效率。
缺点
- 需要维护多块内存区域。
- 老年代 GC 停顿时间长。
示例
java
List<Object> youngGen = new ArrayList<>();
List<Object> oldGen = new ArrayList<>();
Object shortLife = new Object();
Object longLife = new Object();
youngGen.add(shortLife);
youngGen.add(longLife);
// Minor GC
List<Object> survivors = new ArrayList<>();
survivors.add(longLife); // 存活对象晋升
oldGen.addAll(survivors);
youngGen.clear();
6. 对比表
| 算法 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 标记-清除 | 标记存活对象,清除未标记对象 | 实现简单 | 内存碎片化 | 老年代(存活率高) |
| 复制 | 存活对象复制到另一块内存 | 无碎片化,分配快 | 占用双倍内存 | 新生代(存活率低) |
| 标记-整理 | 标记存活对象并移动 | 无碎片化 | 移动成本高 | 老年代 |
| 分代收集 | 新生代复制,老年代整理 | 高效,针对性强 | 老年代停顿长 | JVM 默认策略 |
✅ 总结:
- 标记-清除:简单但有碎片。
- 复制:无碎片但浪费空间。
- 标记-整理:无碎片但移动成本高。
- 分代收集:结合多种算法,效率最高,是 JVM 默认选择。
标记-清除、标记-整理、复制算法的原理?
1. 标记-清除(Mark-Sweep)算法
核心原理
- 标记阶段(Mark)
- 从 GC Roots 出发,使用可达性分析遍历对象引用链。
- 将所有可达对象打上"存活"标记。
- 清除阶段(Sweep)
- 遍历整个堆空间,回收所有未被标记的对象。
- 清除只是将内存标记为空闲,不会移动存活对象。
内存变化示意
[存活][垃圾][存活][垃圾][垃圾] → 清除垃圾 → [存活][空闲][存活][空闲][空闲]
存活对象位置不变,空闲内存分散,容易产生碎片。
优点
- 实现简单。
- 不需要移动对象,引用地址不变。
缺点
- 内存碎片化:清除后空闲内存不连续,可能导致大对象无法分配。
- 清除阶段需要遍历整个堆,速度较慢。
JVM 应用场景
- 老年代(Old Generation)中对象存活率高,移动对象成本大,因此常用标记-清除。
2. 标记-整理(Mark-Compact)算法
核心原理
- 标记阶段(Mark)
- 与标记-清除相同,从 GC Roots 出发标记存活对象。
- 整理阶段(Compact)
- 将所有存活对象向一端移动,使内存连续。
- 更新所有引用地址(因为对象位置发生变化)。
内存变化示意
[存活][垃圾][存活][垃圾][垃圾] → 整理 → [存活][存活][空闲][空闲][空闲]
存活对象被压缩到一端,空闲内存连续,避免碎片化。
优点
- 无碎片化:内存连续,分配效率高。
- 适合存活率高的区域(如老年代)。
缺点
- 移动对象需要更新引用,成本高。
- GC 停顿时间长(Stop-The-World)。
JVM 应用场景
- 老年代(Old Generation)在 Full GC 时常用标记-整理,保证内存连续性。
3. 复制(Copying)算法
核心原理
- 内存分区
- 将内存分为两块等大小的区域:From 区 和 To 区。
- 复制阶段
- GC 时只遍历 From 区,将存活对象复制到 To 区。
- 清空 From 区,交换两区角色。
内存变化示意
From: [存活][垃圾][存活][垃圾] To: [] → 复制 → [存活][存活] From 清空 → 交换角色
复制后内存连续,无碎片。
优点
- 无碎片化:复制后内存连续。
- 分配速度快(指针碰撞)。
- 只处理存活对象,效率高(适合存活率低的区域)。
缺点
- 需要双倍内存空间(只能使用一半)。
- 存活对象多时复制成本高。
JVM 应用场景
- 新生代(Young Generation)对象存活率低,复制算法效率高。
- HotSpot JVM 的 Minor GC 默认使用复制算法。
4. 三种算法对比表
| 算法 | 内存碎片 | 是否移动对象 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 标记-清除 | 有 | 否 | 老年代 | 实现简单 | 碎片化严重 |
| 标记-整理 | 无 | 是 | 老年代 | 内存连续 | 移动成本高 |
| 复制 | 无 | 是 | 新生代 | 分配快 | 占用双倍内存 |
5. JVM 为什么分代选择不同算法
- 新生代 :对象生命周期短,存活率低 → 复制算法(只复制少量存活对象,效率高)。
- 老年代 :对象生命周期长,存活率高 → 标记-清除 或 标记-整理(减少移动对象的成本,保证内存连续性)。
✅ 总结:
- 标记-清除:简单但有碎片,适合老年代。
- 标记-整理:无碎片但移动成本高,适合老年代 Full GC。
- 复制:无碎片且分配快,适合新生代。
分代收集的原理是什么?
1. 为什么需要分代收集
在 JVM 中,所有对象都存放在堆内存中,但不同对象的生命周期差异很大:
- 短命对象:如方法中的局部变量、临时计算结果,可能在几毫秒内就失效。
- 长命对象:如缓存、单例对象、线程池,可能在整个程序运行期间都存在。
如果对所有对象使用同一种 GC 算法:
- 短命对象会频繁创建和销毁,导致 GC 频繁触发。
- 长命对象每次 GC 都要扫描,浪费时间。
分代收集的核心思想:
根据对象的生命周期长短,将堆内存划分为不同区域,并针对不同区域选择最合适的 GC 算法。
2. 堆内存的分代结构
JVM 将堆分为两个主要区域:
- 新生代(Young Generation)
- 存放新创建的对象。
- 对象存活率低(大部分很快就会被回收)。
- 进一步细分为:
- Eden 区:新对象首先分配在这里。
- Survivor 区 :分为 S0 和 S1 两个区,用于保存上一次 GC 存活的对象。
- 老年代(Old Generation)
- 存放生命周期长的对象。
- 对象存活率高。
- 主要存放从新生代晋升过来的对象。
内存布局示意
堆内存 ├── 新生代 (Young) │ ├── Eden │ ├── Survivor 0 (S0) │ └── Survivor 1 (S1) └── 老年代 (Old)
3. 分代收集的工作机制
(1) 对象创建
- 新对象优先分配在 Eden 区。
- 如果 Eden 空间不足,触发 Minor GC。
(2) Minor GC(新生代垃圾回收)
- 使用 复制算法 :
- 从 GC Roots 出发,标记 Eden 和当前 Survivor 区的存活对象。
- 将存活对象复制到另一个 Survivor 区。
- 对象的**年龄(Age)**加 1。
- 如果对象年龄超过阈值(默认 15),晋升到老年代。
- 清空 Eden 和原 Survivor 区。
(3) Major GC / Full GC(老年代垃圾回收)
- 老年代对象存活率高,使用 标记-整理算法 :
- 标记存活对象。
- 移动存活对象到内存一端,保证连续性。
- 清理剩余空间。
- Full GC 会同时回收新生代和老年代。
(4) 晋升规则
- 对象在 Survivor 区经历多次 Minor GC 后,年龄超过阈值 → 晋升到老年代。
- 大对象(如大数组)可能直接进入老年代(避免复制开销)。
4. GC 类型总结
| GC 类型 | 作用范围 | 触发条件 | 使用算法 |
|---|---|---|---|
| Minor GC | 新生代 | Eden 空间不足 | 复制算法 |
| Major GC | 老年代 | 老年代空间不足 | 标记-整理 |
| Full GC | 新生代 + 老年代 | 多种情况(如 System.gc()) | 新生代复制 + 老年代标记-整理 |
5. 分代收集的优点
- 高效:新生代对象存活率低,复制算法只处理少量存活对象,速度快。
- 减少停顿:老年代 GC 不会频繁触发。
- 内存利用率高:不同区域使用最适合的算法。
6. 分代收集的缺点
- 老年代 GC(Major GC)停顿时间长。
- Survivor 区大小需要合理配置,否则晋升过快导致老年代压力大。
- 大对象直接进入老年代可能引发 Full GC。
7. JVM 中的实现细节(HotSpot)
- 默认分代比例:新生代约占堆的 1/3,老年代约占 2/3。
- Eden:Survivor 默认比例为 8:1:1。
- 晋升阈值 :对象年龄超过 15(可通过
-XX:MaxTenuringThreshold调整)。 - 大对象直接进入老年代 :可通过
-XX:PretenureSizeThreshold控制。
8. 分代收集流程图
对象创建 → Eden Eden 满 → Minor GC → 存活对象 → Survivor 对象年龄增加 → 超过阈值 → 晋升到老年代 老年代满 → Major GC / Full GC
9. 示例代码
java
public class GCDemo {
public static void main(String[] args) {
for (int i = 0; i < 10000; i++) {
byte[] data = new byte[1024 * 1024]; // 1MB
}
System.gc(); // 可能触发 Full GC
}
}
在这个例子中,大量短命对象会在 Eden 中被快速回收,少量存活对象晋升到老年代。
✅ 总结:
- 分代收集是 JVM GC 的核心策略,通过将堆分为新生代和老年代,分别使用不同算法(复制、标记-整理)来提高效率。
- 新生代 → 复制算法(存活率低,速度快)。
- 老年代 → 标记-整理算法(存活率高,避免碎片)。
- 这种设计充分利用了对象生命周期的差异,减少了 GC 的总开销。
新生代、老年代的 GC 策略?
1. 新生代(Young Generation)GC 策略
1.1 内存结构
新生代主要存放新创建的对象 ,生命周期短,存活率低。
它被进一步划分为:
- Eden 区:新对象首先分配在这里。
- Survivor 0 (S0) 和 Survivor 1 (S1):两个幸存区,交替使用,用于保存上一次 GC 存活的对象。
默认比例 :Eden : S0 : S1 = 8 : 1 : 1
(可通过 -XX:SurvivorRatio 调整)
1.2 GC 类型
- Minor GC(新生代 GC)
- 只回收新生代,不影响老年代。
- 触发频率高,但速度快(因为存活对象少)。
1.3 回收算法
复制算法(Copying):
- 从 GC Roots 出发,标记 Eden 和当前 Survivor 区的存活对象。
- 将存活对象复制到另一个 Survivor 区。
- 对象的**年龄(Age)**加 1。
- 如果对象年龄超过阈值(默认 15,可通过
-XX:MaxTenuringThreshold调整),晋升到老年代。 - 清空 Eden 和原 Survivor 区。
1.4 触发条件
- Eden 区满时触发 Minor GC。
- Survivor 区不足时,部分对象会直接晋升到老年代(称为晋升失败)。
1.5 优缺点
优点:
- 存活对象少,复制成本低。
- 内存整理后连续,分配速度快(指针碰撞)。
缺点:
- 需要额外 Survivor 区空间。
- Survivor 区过小会导致对象过早晋升到老年代,增加老年代压力。
1.6 JVM 调优参数
-XX:NewRatio:新生代与老年代的比例(默认 1:2)。-XX:SurvivorRatio:Eden 与 Survivor 的比例(默认 8:1:1)。-XX:MaxTenuringThreshold:对象晋升老年代的年龄阈值(默认 15)。
2. 老年代(Old Generation)GC 策略
2.1 内存结构
- 存放生命周期长、存活率高的对象。
- 包括:
- 从新生代晋升的对象。
- 大对象(超过
-XX:PretenureSizeThreshold阈值)可能直接进入老年代。
2.2 GC 类型
- Major GC(只回收老年代)
- Full GC(回收新生代 + 老年代 + 方法区)
- 触发频率低,但停顿时间长。
2.3 回收算法
标记-整理(Mark-Compact):
- 标记阶段:从 GC Roots 出发,标记所有存活对象。
- 整理阶段:将存活对象移动到内存一端,保持内存连续。
- 清理剩余空间。
有些老年代 GC(如 CMS)使用标记-清除(Mark-Sweep),但会产生碎片,需要配合整理阶段。
2.4 触发条件
- 老年代空间不足时触发 Major GC。
- 新生代晋升对象时,老年代空间不足会触发 Full GC。
- 调用
System.gc()(默认会触发 Full GC,可用-XX:+DisableExplicitGC禁用)。
2.5 优缺点
优点:
- 适合存活率高的区域,减少对象复制成本。
- 内存整理后连续,避免碎片化。
缺点:
- 停顿时间长(Stop-The-World)。
- 移动对象需要更新引用,成本高。
2.6 JVM 调优参数
-XX:NewRatio:新生代与老年代比例。-XX:PretenureSizeThreshold:大对象直接进入老年代的阈值。-XX:CMSInitiatingOccupancyFraction:CMS GC 触发的老年代使用率阈值。
3. 新生代 vs 老年代 GC 策略对比
| 对比项 | 新生代 GC(Minor GC) | 老年代 GC(Major/Full GC) |
|---|---|---|
| 触发条件 | Eden 满 | 老年代满 / 晋升失败 |
| 算法 | 复制算法 | 标记-整理(或标记-清除) |
| 存活率 | 低 | 高 |
| 速度 | 快 | 慢 |
| 停顿时间 | 短 | 长 |
| 频率 | 高 | 低 |
| 适用对象 | 新创建、短命对象 | 长期存活对象、大对象 |
4. GC 流程示意
对象创建 → Eden Eden 满 → Minor GC → 存活对象 → Survivor 对象年龄增加 → 超过阈值 → 晋升到老年代 老年代满 → Major GC / Full GC
5. 总结
- 新生代 GC(Minor GC) :频繁触发,速度快,使用复制算法,适合短命对象。
- 老年代 GC(Major/Full GC) :触发频率低,停顿时间长,使用标记-整理算法,适合长期存活对象。
- 调优关键:合理分配新生代与老年代比例,避免对象过早晋升,减少 Full GC 频率。
Minor GC 与 Full GC 的区别?
1. 定义
Minor GC(新生代 GC)
- **只回收新生代(Young Generation)**的垃圾对象。
- 新生代包括 Eden 区 和两个 Survivor 区(S0、S1)。
- 触发频率高,但速度快,因为新生代对象存活率低。
Full GC(全堆 GC)
- **回收整个堆(新生代 + 老年代 + 方法区/元空间)**的垃圾对象。
- 触发频率低,但停顿时间长,因为需要扫描和回收更多对象。
- Full GC 通常包含一次 Major GC(老年代 GC)+ 新生代 GC。
2. 触发条件
| GC 类型 | 触发条件 |
|---|---|
| Minor GC | Eden 区满时触发 |
| Full GC | 老年代满;方法区/元空间满;调用 System.gc()(默认);晋升失败(新生代对象晋升老年代时空间不足) |
3. 回收范围
| GC 类型 | 回收范围 |
|---|---|
| Minor GC | 仅新生代(Eden + 一个 Survivor 区) |
| Full GC | 新生代 + 老年代 + 方法区(元空间) |
4. 使用算法
| GC 类型 | 常用算法 |
|---|---|
| Minor GC | 复制算法(Copying):存活对象少,复制成本低,内存整理后连续 |
| Full GC | 新生代:复制算法;老年代:标记-整理(Mark-Compact)或标记-清除(Mark-Sweep) |
5. 执行流程
Minor GC 流程
- 从 GC Roots 出发,标记 Eden 和当前 Survivor 区的存活对象。
- 将存活对象复制到另一个 Survivor 区(或晋升到老年代)。
- 清空 Eden 和原 Survivor 区。
- 对象年龄(Age)+1,超过阈值(默认 15)晋升到老年代。
Full GC 流程
- 回收新生代(复制算法)。
- 回收老年代(标记-整理或标记-清除)。
- 回收方法区/元空间(卸载无用类、常量池等)。
- 可能伴随类卸载、软引用/弱引用/虚引用的清理。
6. 性能影响
| 对比项 | Minor GC | Full GC |
|---|---|---|
| 触发频率 | 高 | 低 |
| 停顿时间 | 短(毫秒级) | 长(可能秒级) |
| 回收范围 | 新生代 | 整个堆 |
| 存活率 | 低 | 高 |
| 执行速度 | 快 | 慢 |
| 影响 | 轻微卡顿 | 明显卡顿,可能影响吞吐量 |
7. JVM 调优建议
减少 Full GC 频率
- 增大新生代比例(
-XX:NewRatio),减少对象过早晋升。 - 调整 Survivor 区比例(
-XX:SurvivorRatio),减少晋升失败。 - 增大老年代容量(
-Xmx/-Xms)。 - 避免频繁调用
System.gc()(可用-XX:+DisableExplicitGC禁用)。 - 优化代码,减少大对象直接进入老年代(
-XX:PretenureSizeThreshold)。
优化 Minor GC
- 保证 Survivor 区足够大,减少晋升到老年代的对象数量。
- 调整
-XX:MaxTenuringThreshold,延迟对象晋升。
8. 对比总结表
| 对比项 | Minor GC | Full GC |
|---|---|---|
| 回收范围 | 新生代 | 新生代 + 老年代 + 方法区 |
| 触发条件 | Eden 满 | 老年代满、元空间满、晋升失败、System.gc() |
| 算法 | 复制算法 | 新生代复制 + 老年代标记-整理/清除 |
| 存活率 | 低 | 高 |
| 速度 | 快 | 慢 |
| 停顿时间 | 短 | 长 |
| 频率 | 高 | 低 |
| 影响 | 轻微卡顿 | 明显卡顿 |
9. 形象类比
- Minor GC:像清理桌面上的垃圾纸屑(快、频繁)。
- Full GC:像打扫整个房子(慢、彻底、频率低)。
✅ 总结:
- Minor GC:频繁、快、影响小,主要清理短命对象。
- Full GC:少见、慢、影响大,清理整个堆和方法区。
- 调优目标:减少 Full GC 频率,让大部分垃圾在 Minor GC 阶段就被清理掉。
常见的垃圾收集器有哪些?
1. 垃圾收集器分类
在 HotSpot JVM 中,垃圾收集器主要分为两类:
- 新生代收集器 (Young Generation GC)
- Serial GC
- ParNew GC
- Parallel Scavenge GC
- 老年代收集器 (Old Generation GC)
- Serial Old GC
- Parallel Old GC
- CMS(Concurrent Mark Sweep)GC
- G1(Garbage First)GC
新生代收集器通常使用复制算法 ,老年代收集器通常使用标记-整理 或标记-清除算法。
2. 新生代收集器
2.1 Serial GC
- 原理 :单线程执行 GC,Stop-The-World ,使用复制算法回收新生代。
- 执行流程 :
- 停止所有用户线程。
- 从 GC Roots 出发,标记 Eden 和 Survivor 区的存活对象。
- 将存活对象复制到另一个 Survivor 区或晋升到老年代。
- 清空 Eden 和原 Survivor 区。
- 优点 :
- 实现简单,单线程无线程切换开销。
- 在小堆内存环境下效率高。
- 缺点 :
- 停顿时间长,不适合多核 CPU。
- 适用场景 :
- 单核 CPU 或客户端应用(如桌面程序)。
2.2 ParNew GC
- 原理 :Serial GC 的多线程版本,使用复制算法回收新生代。
- 执行流程 :
- 多线程并行标记存活对象。
- 并行复制到 Survivor 区或晋升到老年代。
- 清空 Eden。
- 优点 :
- 利用多核 CPU 提高新生代回收速度。
- 可与 CMS 搭配使用(CMS 只能配合 ParNew 作为新生代收集器)。
- 缺点 :
- 多线程会有额外的同步开销。
- 适用场景 :
- 多核服务器,低延迟需求。
2.3 Parallel Scavenge GC
- 原理 :多线程新生代收集器,使用复制算法 ,但目标是高吞吐量(Throughput)。
- 执行流程 :
- 多线程并行标记存活对象。
- 并行复制到 Survivor 区或晋升到老年代。
- 清空 Eden。
- 优点 :
- 吞吐量高(运行用户代码时间 / 总时间)。
- 可自动调节新生代大小和晋升阈值(自适应调节策略)。
- 缺点 :
- 停顿时间可能较长,不适合低延迟场景。
- 适用场景 :
- 后台计算任务、批处理系统。
3. 老年代收集器
3.1 Serial Old GC
- 原理 :单线程老年代收集器,使用标记-整理算法。
- 执行流程 :
- 停止所有用户线程。
- 标记老年代存活对象。
- 移动存活对象到内存一端。
- 清理剩余空间。
- 优点 :
- 实现简单。
- 缺点 :
- 停顿时间长。
- 适用场景 :
- 单核 CPU,或与 Serial GC 搭配使用。
3.2 Parallel Old GC
- 原理 :Parallel Scavenge 的老年代版本,使用标记-整理算法。
- 执行流程 :
- 多线程并行标记老年代存活对象。
- 并行移动存活对象。
- 清理剩余空间。
- 优点 :
- 吞吐量高。
- 与 Parallel Scavenge 搭配使用,适合高吞吐量场景。
- 缺点 :
- 停顿时间较长。
- 适用场景 :
- 大数据计算、后台任务。
3.3 CMS(Concurrent Mark Sweep)GC
- 原理 :老年代收集器,使用标记-清除算法 ,目标是低停顿。
- 执行流程 :
- 初始标记(Stop-The-World):标记 GC Roots 直接引用的对象。
- 并发标记:与用户线程并发执行,标记所有可达对象。
- 重新标记(Stop-The-World):修正并发标记阶段的变动。
- 并发清除:与用户线程并发执行,清理未标记对象。
- 优点 :
- 停顿时间短,适合低延迟场景。
- 缺点 :
- 会产生内存碎片。
- 并发阶段会占用 CPU 资源。
- 适用场景 :
- Web 服务器、低延迟应用。
3.4 G1(Garbage First)GC
- 原理 :面向服务端的低延迟收集器,将堆划分为多个Region,同时回收新生代和老年代。
- 执行流程 :
- 将堆划分为多个 Region(新生代、老年代混合分布)。
- 优先回收垃圾最多的 Region(Garbage First)。
- 使用复制算法在 Region 内移动对象,避免碎片化。
- 支持并发标记和清理。
- 优点 :
- 可预测停顿时间(
-XX:MaxGCPauseMillis)。 - 无碎片化。
- 可预测停顿时间(
- 缺点 :
- 实现复杂,调优难度大。
- 适用场景 :
- 大堆内存、低延迟、高吞吐量并重的应用。
4. 收集器对比表
| 收集器 | 代别 | 算法 | 并行/并发 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|---|
| Serial GC | 新生代 | 复制 | 单线程 | 简单高效(小堆) | 停顿长 | 单核、小堆 |
| ParNew GC | 新生代 | 复制 | 并行 | 多核利用率高 | 同步开销 | 多核低延迟 |
| Parallel Scavenge | 新生代 | 复制 | 并行 | 高吞吐量 | 停顿长 | 后台计算 |
| Serial Old | 老年代 | 标记-整理 | 单线程 | 简单 | 停顿长 | 单核 |
| Parallel Old | 老年代 | 标记-整理 | 并行 | 高吞吐量 | 停顿长 | 大数据计算 |
| CMS | 老年代 | 标记-清除 | 并发 | 停顿短 | 碎片化 | Web 服务 |
| G1 | 混合 | 复制+标记整理 | 并发 | 可预测停顿 | 调优复杂 | 大堆低延迟 |
✅ 总结:
- 吞吐量优先:Parallel Scavenge + Parallel Old。
- 低延迟优先:ParNew + CMS,或直接使用 G1。
- 小堆单核:Serial GC + Serial Old。
垃圾收集器(Garbage Collector)和垃圾回收算法(Garbage Collection Algorithm)之间的关系
1. 概念区分
垃圾回收算法
- 定义 :一种理论方法,用于决定如何识别和回收内存中不再使用的对象。
- 作用 :描述回收的策略和步骤,不关心具体实现。
- 常见算法 :
- 标记-清除(Mark-Sweep)
- 标记-整理(Mark-Compact)
- 复制(Copying)
- 分代收集(Generational Collection)(其实是策略,内部结合多种算法)
垃圾收集器
- 定义 :垃圾回收算法的具体实现,运行在 JVM 中的一个模块。
- 作用 :根据选定的算法和策略,结合硬件和应用场景,执行垃圾回收的实际操作。
- 常见收集器 :
- Serial GC
- ParNew GC
- Parallel Scavenge GC
- CMS GC
- G1 GC
2. 区别
| 对比项 | 垃圾回收算法 | 垃圾收集器 |
|---|---|---|
| 性质 | 理论策略 | 实际实现 |
| 关注点 | 如何回收(步骤、逻辑) | 如何执行(线程数、并发、优化) |
| 抽象层级 | 高层(概念) | 低层(代码实现) |
| 是否依赖硬件 | 不直接依赖 | 会根据 CPU 核数、内存大小优化 |
| 示例 | 标记-清除、复制算法 | CMS、G1、Parallel Scavenge |
3. 联系
- 垃圾收集器是垃圾回收算法的实现者。
- 一个收集器可能只实现一种算法,也可能组合多种算法。
- 收集器会根据**代(新生代/老年代)**选择不同算法:
- 新生代 → 复制算法(存活率低,复制快)
- 老年代 → 标记-整理 或标记-清除(存活率高,减少移动)
4. 举例说明
以 G1 GC 为例:
- 算法层面 :
- 新生代 Region → 复制算法
- 老年代 Region → 标记-整理算法
- 并发标记阶段 → 可达性分析
- 收集器层面 :
- 将堆划分为多个 Region。
- 预测停顿时间(
-XX:MaxGCPauseMillis)。 - 优先回收垃圾最多的 Region(Garbage First 策略)。
- 并发执行标记和清理,减少停顿。
5. 总结关系
- 算法 是方法论,定义了"怎么做"。
- 收集器 是工程实现,定义了"怎么高效地做"。
- 收集器会根据不同代别、硬件条件、应用需求,选择或组合不同算法来实现垃圾回收。
✅ 一句话总结:
垃圾回收算法是理论,垃圾收集器是实践;收集器是算法的执行者,并会在实际运行中结合多线程、并发、分代等优化策略,让算法在不同场景下高效运行。
Serial、Parallel、CMS、G1 的特点与适用场景?
1. Serial GC
原理
- 单线程垃圾收集器。
- 新生代使用复制算法 ,老年代使用标记-整理算法。
- GC 时会触发 Stop-The-World(STW),暂停所有用户线程。
执行流程
- 停止所有用户线程。
- 新生代:复制存活对象到 Survivor 区或晋升到老年代。
- 老年代:标记存活对象并整理内存。
- 恢复用户线程。
特点
- 实现简单,单线程无多线程切换开销。
- 在小堆内存环境下效率高。
- 停顿时间长,不适合多核 CPU。
适用场景
- 单核 CPU。
- 小堆内存(如桌面应用、嵌入式系统)。
- 对停顿时间不敏感的应用。
2. Parallel GC(Parallel Scavenge + Parallel Old)
原理
- 多线程 垃圾收集器,目标是高吞吐量(Throughput)。
- 新生代使用复制算法(Parallel Scavenge)。
- 老年代使用标记-整理算法(Parallel Old)。
- 可自动调节新生代大小和晋升阈值(自适应调节策略)。
执行流程
- 多线程并行标记存活对象。
- 新生代:并行复制到 Survivor 区或晋升到老年代。
- 老年代:并行标记并整理内存。
- 恢复用户线程。
特点
- 吞吐量高(运行用户代码时间 / 总时间)。
- 停顿时间可能较长。
- 适合后台计算任务。
适用场景
- 多核 CPU。
- 大堆内存。
- 吞吐量优先的场景(如批处理、大数据计算)。
3. CMS(Concurrent Mark Sweep)GC
原理
- 老年代收集器,目标是低停顿。
- 使用标记-清除算法,并发执行部分 GC 阶段。
- 新生代通常搭配 ParNew GC。
执行流程
- 初始标记(STW):标记 GC Roots 直接引用的对象。
- 并发标记:与用户线程并发执行,标记所有可达对象。
- 重新标记(STW):修正并发标记阶段的变动。
- 并发清除:与用户线程并发执行,清理未标记对象。
特点
- 停顿时间短,适合低延迟场景。
- 并发阶段会占用 CPU 资源。
- 会产生内存碎片(标记-清除不整理内存)。
适用场景
- Web 服务器。
- 对响应时间要求高的应用。
- 多核 CPU。
4. G1(Garbage First)GC
原理
- 面向服务端的低延迟收集器。
- 将堆划分为多个Region(新生代、老年代混合分布)。
- 优先回收垃圾最多的 Region(Garbage First 策略)。
- 使用复制算法在 Region 内移动对象,避免碎片化。
- 支持并发标记和清理。
执行流程
- 将堆划分为多个 Region。
- 新生代 Region:复制算法回收。
- 老年代 Region:标记-整理回收。
- 并发标记阶段:可达性分析。
- 根据停顿时间目标(
-XX:MaxGCPauseMillis)选择回收的 Region。
特点
- 可预测停顿时间。
- 无碎片化。
- 调优复杂。
适用场景
- 大堆内存(数 GB 以上)。
- 低延迟、高吞吐量并重的应用。
- 多核 CPU。
5. 对比总结表
| 收集器 | 线程模式 | 新生代算法 | 老年代算法 | 停顿时间 | 吞吐量 | 碎片化 | 适用场景 |
|---|---|---|---|---|---|---|---|
| Serial | 单线程 | 复制 | 标记-整理 | 长 | 中 | 无 | 单核、小堆 |
| Parallel | 多线程 | 复制 | 标记-整理 | 中长 | 高 | 无 | 多核、大堆、吞吐量优先 |
| CMS | 并发 | 复制(ParNew) | 标记-清除 | 短 | 中 | 有 | 多核、低延迟 |
| G1 | 并发 | 复制 | 标记-整理 | 可控 | 高 | 无 | 大堆、低延迟 |
✅ 总结:
- 停顿时间优先:CMS、G1。
- 吞吐量优先:Parallel GC。
- 小堆单核:Serial GC。
- 大堆低延迟:G1 GC。
ZGC 与 Shenandoah 的特点?
1. ZGC(Z Garbage Collector)
设计目标
- 超低停顿时间 :单次 GC 停顿时间通常 < 10ms。
- 支持超大堆内存(可达 TB 级)。
- 几乎所有 GC 阶段都与应用线程并发执行。
核心原理
- Region-based:将堆划分为多个大小可变的 Region。
- Colored Pointers(彩色指针):在对象引用中嵌入标记位,用于记录 GC 状态(如是否已移动)。
- Load Barriers(加载屏障):在对象访问时检查并修正引用,保证并发移动对象的安全性。
- 并发压缩:对象移动和压缩在并发阶段完成,避免长时间 STW。
执行流程
- 并发标记:与应用线程并发标记存活对象。
- 并发重定位:将存活对象移动到新的 Region。
- 并发重映射:修正引用指向新的位置。
- 短暂 STW:仅在少数阶段(如初始标记)暂停应用线程,时间极短。
优点
- 停顿时间极短,几乎与堆大小无关。
- 支持超大堆内存。
- 无碎片化(并发压缩)。
缺点
- 实现复杂,调试难度大。
- 对 CPU 和内存有一定额外开销。
- 需要较新版本的 JDK(JDK 11+)。
适用场景
- 大堆内存(数百 GB ~ TB)。
- 对延迟极度敏感的应用(金融交易、实时系统)。
- 多核高性能服务器。
2. Shenandoah GC
设计目标
- 低停顿时间 :单次 GC 停顿时间通常 < 10ms。
- 堆大小对停顿时间影响很小。
- 尽量将所有 GC 阶段并发化。
核心原理
- Region-based:堆划分为固定大小的 Region。
- Brooks Pointers(转发指针):对象头中存储一个转发地址,用于在对象移动时快速定位新位置。
- Concurrent Compaction(并发压缩):对象移动在并发阶段完成。
- Write Barriers(写屏障):在对象引用更新时记录变动,保证并发压缩的正确性。
执行流程
- 并发标记:与应用线程并发标记存活对象。
- 并发压缩:移动对象到新的 Region。
- 引用更新:通过转发指针修正引用。
- 短暂 STW:仅在初始标记和最终引用更新阶段暂停应用线程。
优点
- 停顿时间短,几乎与堆大小无关。
- 并发压缩避免碎片化。
- 适合低延迟场景。
缺点
- 实现复杂,调试难度大。
- 对 CPU 有额外开销。
- 需要 JDK 12+(或 OpenJDK 特定版本)。
适用场景
- 中到大堆内存(几十 GB ~ 数百 GB)。
- 对延迟敏感的应用(在线服务、游戏服务器)。
- 多核高性能服务器。
3. ZGC vs Shenandoah 对比表
| 特性 | ZGC | Shenandoah |
|---|---|---|
| 设计目标 | 超低停顿,支持 TB 级堆 | 低停顿,堆大小对停顿影响小 |
| 堆划分 | 可变大小 Region | 固定大小 Region |
| 引用修正方式 | 彩色指针 + 加载屏障 | 转发指针 + 写屏障 |
| 并发压缩 | 支持 | 支持 |
| 停顿时间 | < 10ms | < 10ms |
| 堆大小支持 | TB 级 | 数百 GB |
| JDK版本 | JDK 11+ | JDK 12+(或 OpenJDK) |
| 适用场景 | 超大堆、极低延迟 | 中大堆、低延迟 |
| 额外开销 | CPU + 内存 | CPU |
✅ 总结:
- ZGC 更适合 超大堆内存 和 极低延迟场景,技术更先进(彩色指针)。
- Shenandoah 更适合 中到大堆 的低延迟应用,设计更偏向通用性。
- 两者都通过并发标记 + 并发压缩实现低停顿,并且堆大小对停顿时间影响极小。
如何触发 GC?
1. 自动触发 GC
JVM 会根据内存使用情况自动触发 GC,不需要开发者干预。不同类型的 GC 触发条件如下:
1.1 Minor GC(新生代 GC)触发条件
- Eden 区满时触发。
- 新生代对象存活率低,GC 频率高但速度快。
- 过程:
- 从 GC Roots 出发标记存活对象。
- 将存活对象复制到 Survivor 区或晋升到老年代。
- 清空 Eden 区。
1.2 Major GC(老年代 GC)触发条件
- 老年代空间不足时触发。
- 存活率高,GC 停顿时间长。
- 通常伴随一次 Minor GC(但不一定是 Full GC)。
1.3 Full GC(整堆 GC)触发条件
- 老年代空间不足。
- 元空间(方法区)空间不足。
- 调用
System.gc()(默认会触发 Full GC)。 - 新生代对象晋升到老年代时空间不足(晋升失败)。
- CMS GC 的并发收集失败(Concurrent Mode Failure)。
- G1 GC 的混合回收阶段需要回收老年代 Region。
2. 手动触发 GC
虽然 JVM 会自动管理内存,但我们也可以通过代码或命令手动触发 GC(一般不推荐,除非调试或特殊场景)。
2.1 代码触发
System.gc()- 请求 JVM 执行 Full GC(具体是否执行取决于 JVM)。
- 可通过
-XX:+DisableExplicitGC禁用。
Runtime.getRuntime().gc()- 与
System.gc()等效。
- 与
2.2 命令触发
- JVM 工具命令 :
jcmd <pid> GC.run→ 触发 Full GC。jcmd <pid> GC.run_finalization→ 触发对象终结。jmap -histo <pid>→ 在生成对象直方图前会触发 GC。
- JConsole / VisualVM :
- 在监控界面点击"执行 GC"按钮。
3. 特殊触发场景
- 大对象直接进入老年代 (超过
-XX:PretenureSizeThreshold)。 - 对象晋升失败(Survivor 区不足,老年代不足)。
- 元空间溢出(类加载过多)。
- 软引用/弱引用/虚引用的清理 :
- 软引用在内存不足时触发 GC 清理。
- 弱引用在下一次 GC 时直接清理。
- 虚引用用于对象回收跟踪。
4. 总结表
| GC 类型 | 触发条件 | 常见场景 |
|---|---|---|
| Minor GC | Eden 区满 | 创建大量短命对象 |
| Major GC | 老年代满 | 大对象晋升、长期存活对象积累 |
| Full GC | 老年代满、元空间满、晋升失败、System.gc() |
内存压力大、类加载过多 |
| 手动 GC | System.gc()、Runtime.gc()、jcmd |
调试、性能测试 |
✅ 总结:
- 自动触发是 JVM 的正常行为,主要由内存使用情况决定。
- 手动触发一般用于调试或特殊需求,生产环境应谨慎使用。
- 调优目标是减少 Full GC 频率,让大部分垃圾在 Minor GC 阶段就被清理掉。
GC 日志如何分析?
1. 开启 GC 日志
在不同 JDK 版本中,开启 GC 日志的参数不同:
JDK 8 及之前
bash
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log
-XX:+PrintGCDetails→ 打印详细 GC 信息-XX:+PrintGCDateStamps→ 打印时间戳-Xloggc:/path/gc.log→ 输出到文件
JDK 9+(统一日志框架)
bash
-Xlog:gc*:file=/path/gc.log:time,uptime,level,tags
gc*→ 打印所有 GC 相关日志time→ 日历时间uptime→ JVM 启动到现在的时间level→ 日志级别tags→ 日志标签
2. 常见 GC 日志格式与字段解析
2.1 Minor GC(JDK 8 示例)
2025-11-06T18:49:12.123+0800: 5.678: [GC (Allocation Failure) [PSYoungGen: 10240K->512K(9216K)] 20480K->10752K(19456K), 0.0056789 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
字段解析:
2025-11-06T18:49:12.123+0800→ 日历时间5.678→ JVM 启动后 5.678 秒(Allocation Failure)→ 触发原因(Eden 区满)[PSYoungGen: 10240K->512K(9216K)]→ 新生代 GC 前占用 10240K,GC 后 512K,总容量 9216K20480K->10752K(19456K)→ 整个堆 GC 前占用 20480K,GC 后 10752K,总容量 19456K0.0056789 secs→ GC 停顿时间[Times: user=0.01 sys=0.00, real=0.01 secs]→ 用户态 CPU 时间、内核态 CPU 时间、实际耗时
2.2 Full GC(JDK 8 示例)
2025-11-06T18:50:00.456+0800: 53.789: [Full GC (System.gc()) [PSYoungGen: 512K->0K(9216K)] [ParOldGen: 10240K->512K(10240K)] 10752K->512K(19456K), [Metaspace: 3500K->3500K(1056768K)], 0.0456789 secs] [Times: user=0.05 sys=0.00, real=0.05 secs]
字段解析:
(System.gc())→ 触发原因是手动调用System.gc()[ParOldGen: 10240K->512K(10240K)]→ 老年代 GC 前占用 10240K,GC 后 512K,总容量 10240K[Metaspace: 3500K->3500K(1056768K)]→ 元空间使用情况0.0456789 secs→ 停顿时间(Full GC 一般较长)
3. GC 日志分析步骤
3.1 确认 GC 类型和频率
- Minor GC 频率高 → 新生代太小或对象存活率高
- Full GC 频率高 → 老年代空间不足、晋升失败、元空间不足
3.2 关注停顿时间
- Minor GC:一般 < 50ms
- Full GC:一般 < 1s(低延迟系统要求更低)
- 如果停顿时间过长 → 考虑更换 GC 收集器或调优堆参数
3.3 观察 GC 前后内存变化
- GC 后内存下降不明显 → 存活对象多,可能存在内存泄漏
- GC 后老年代占用持续上升 → 晋升过多或对象生命周期长
3.4 分析触发原因
(Allocation Failure)→ Eden 区满(Metadata GC Threshold)→ 元空间满(System.gc())→ 手动触发(CMS Initial Mark)→ CMS 初始标记阶段
4. 常见问题定位
| 现象 | 可能原因 | 调优方向 |
|---|---|---|
| Minor GC 频繁 | 新生代太小、对象存活率高 | 增大新生代、优化对象创建 |
| Full GC 频繁 | 老年代不足、晋升失败 | 增大老年代、减少晋升 |
| GC 停顿长 | 收集器不适合、堆太大 | 换低延迟收集器(G1/ZGC) |
| GC 后内存不降 | 内存泄漏 | 使用 MAT/VisualVM 分析引用链 |
5. 辅助工具
- GCEasy(在线 GC 日志分析)
- GCViewer(可视化 GC 日志)
- Eclipse MAT(内存分析)
- JClarity Censum(商业 GC 分析工具)
✅ 总结:
- GC 日志分析的核心是看频率、看停顿、看回收效果、看触发原因。
- 频繁 Full GC 或停顿时间长是性能瓶颈信号。
- 结合工具可以更高效地定位问题。
如何避免频繁 Full GC?
1. 为什么会频繁 Full GC?
Full GC(整堆回收)会回收新生代 + 老年代 + 元空间 ,停顿时间长,对性能影响大。
常见触发原因:
| 触发原因 | 说明 |
|---|---|
| 老年代空间不足 | 晋升对象过多或大对象直接进入老年代 |
| 晋升失败(Promotion Failure) | Survivor 区不足,导致对象直接晋升到老年代,老年代又不足 |
| 元空间(Metaspace)不足 | 类加载过多或类卸载不及时 |
显式调用 System.gc() |
代码或第三方库主动触发 |
| CMS 并发收集失败 | CMS 回收速度跟不上分配速度 |
| G1 Mixed GC 触发过多 | 老年代 Region 占比过高 |
2. 避免频繁 Full GC 的优化思路
2.1 内存分配与代际比例优化
- 增大堆内存 (
-Xmx) → 给老年代更多空间,减少触发 Full GC 的概率。 - 调整新生代大小 (
-Xmn或-XX:NewRatio) → 减少对象过早晋升到老年代。 - 调整 Survivor 区比例 (
-XX:SurvivorRatio) → 增大 Survivor 区,减少晋升失败。
2.2 减少大对象直接进入老年代
- 大对象(超过
-XX:PretenureSizeThreshold)会直接分配到老年代。 - 优化方式 :
- 避免一次性创建超大数组或集合。
- 分批构建大数据结构。
- 调整
-XX:PretenureSizeThreshold(仅对 Serial/ParNew 有效)。
2.3 控制对象晋升速度
- 对象在 Survivor 区存活次数超过
-XX:MaxTenuringThreshold会晋升到老年代。 - 优化方式 :
- 增大
MaxTenuringThreshold,让对象在新生代多存活几次。 - 减少长生命周期对象的创建频率。
- 增大
2.4 避免显式 Full GC
-
检查代码中是否有
System.gc()或Runtime.getRuntime().gc()。 -
禁用显式 GC:
bash-XX:+DisableExplicitGC
2.5 元空间优化
- 元空间不足会触发 Full GC。
- 优化方式 :
- 增大元空间:
-XX:MetaspaceSize和-XX:MaxMetaspaceSize。 - 避免频繁动态生成类(如反射、CGLIB、JSP 编译)。
- 卸载无用类(配合
-XX:+ClassUnloadingWithConcurrentMark)。
- 增大元空间:
2.6 收集器选择与调优
- CMS :避免并发收集失败(
-XX:CMSInitiatingOccupancyFraction提前触发)。 - G1 :合理设置
-XX:InitiatingHeapOccupancyPercent,避免老年代 Region 占比过高。 - ZGC / Shenandoah:低延迟场景可直接替换,减少 Full GC 停顿。
3. 参数调优示例(以 G1 为例)
bash
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45
-XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=10
-XX:+DisableExplicitGC
说明:
- 固定堆大小,避免频繁扩容/收缩。
- 提前触发老年代回收(45% 占用率)。
- 增大 Survivor 区,减少晋升。
- 禁用显式 GC。
4. 代码层面优化
- 减少短时间内创建大量对象(尤其是大对象)。
- 复用对象(对象池、缓冲区)。
- 及时释放无用引用 (将不再使用的对象引用置为
null)。 - 避免内存泄漏(静态集合、缓存未清理、监听器未移除)。
- 使用合适的数据结构(避免过度扩容的集合)。
5. 监控与验证
-
开启 GC 日志:
bash-Xlog:gc*:file=gc.log:time,uptime,level,tags -
使用工具分析:
- GCEasy(在线 GC 日志分析)
- GCViewer(可视化 GC 日志)
- VisualVM / JMC(实时监控)
6. 总结表
| 问题原因 | 优化手段 |
|---|---|
| 老年代不足 | 增大堆、调整代际比例、减少晋升 |
| 晋升失败 | 增大 Survivor 区、提高 MaxTenuringThreshold |
| 大对象直接进入老年代 | 优化数据结构、调整 PretenureSizeThreshold |
| 元空间不足 | 增大元空间、减少动态类加载 |
| 显式 GC | 禁用 System.gc() |
| 收集器不适配 | 更换低延迟收集器(G1/ZGC/Shenandoah) |
✅ 一句话总结:
避免频繁 Full GC 的核心是减少老年代压力 ,通过合理的内存分配策略 + 收集器调优 + 代码优化,让大部分垃圾在新生代就被清理掉。
五、JVM 调优与参数
JVM 调优的目标是什么?
1. 性能目标
- 降低 GC 停顿时间
- 尤其是对延迟敏感的系统(如金融交易、在线游戏、实时服务)。
- 让 GC 对业务线程的影响最小化。
- 提高吞吐量
- 吞吐量 = 用户代码运行时间 / 总运行时间。
- 适合批处理、大数据计算等场景。
- 减少 GC 频率
- 避免频繁 GC,尤其是 Full GC。
- 让大部分垃圾在新生代就被清理掉。
2. 稳定性目标
- 避免内存溢出(OOM)
- 包括堆内存、元空间、直接内存等。
- 避免长时间停顿
- 防止 GC 暂停导致服务不可用。
- 防止内存泄漏
- 保证对象在不再使用时能被及时回收。
- 可预测的性能表现
- 在不同负载下保持稳定的响应时间。
3. 资源利用率目标
- 合理使用内存
- 堆大小、代际比例、元空间大小合理分配。
- 合理使用 CPU
- GC 线程数与业务线程数平衡。
- 避免资源浪费
- 避免过度分配导致内存闲置,或分配不足导致频繁 GC。
4. 具体调优方向
- 选择合适的 GC 收集器
- 吞吐量优先 → Parallel GC
- 低延迟优先 → CMS / G1 / ZGC / Shenandoah
- 调整堆内存大小与代际比例
-Xms、-Xmx、-Xmn、-XX:NewRatio
- 优化 GC 参数
-XX:MaxGCPauseMillis、-XX:InitiatingHeapOccupancyPercent、-XX:SurvivorRatio
- 代码层面优化
- 减少对象创建、复用对象、及时释放引用。
- 监控与验证
- GC 日志分析、JVM 监控工具(JMC、VisualVM、Prometheus+Grafana)。
5. 总结表
| 维度 | 目标 | 示例指标 |
|---|---|---|
| 性能 | 降低停顿、提高吞吐量、减少 GC 频率 | 停顿时间 < 200ms,吞吐量 > 99% |
| 稳定性 | 避免 OOM、长停顿、内存泄漏 | 无 OOM,响应时间稳定 |
| 资源利用率 | 合理使用内存和 CPU | 堆利用率 60~80%,CPU 利用率合理 |
✅ 一句话总结:
JVM 调优的最终目标是在有限的硬件资源下,让应用既快又稳,并且可预测,避免因为 GC 或内存问题影响业务。
常用的 JVM 启动参数有哪些?
1. 内存管理相关参数
| 参数 | 作用 | 示例 |
|---|---|---|
-Xms |
初始堆大小 | -Xms512m |
-Xmx |
最大堆大小 | -Xmx4g |
-Xmn |
新生代大小 | -Xmn1g |
-XX:NewRatio |
老年代与新生代比例 | -XX:NewRatio=2(老年代:新生代=2:1) |
-XX:SurvivorRatio |
Eden 与 Survivor 区比例 | -XX:SurvivorRatio=8 |
-XX:MaxTenuringThreshold |
对象晋升到老年代的最大年龄 | -XX:MaxTenuringThreshold=15 |
-XX:MetaspaceSize |
元空间初始大小 | -XX:MetaspaceSize=128m |
-XX:MaxMetaspaceSize |
元空间最大大小 | -XX:MaxMetaspaceSize=512m |
-XX:CompressedClassSpaceSize |
压缩类空间大小 | -XX:CompressedClassSpaceSize=128m |
-XX:MaxDirectMemorySize |
最大直接内存大小 | -XX:MaxDirectMemorySize=256m |
2. GC 相关参数
| 参数 | 作用 | 示例 |
|---|---|---|
-XX:+UseSerialGC |
使用串行 GC | 适合单核、小堆 |
-XX:+UseParallelGC |
使用并行 GC(吞吐量优先) | 默认 JDK8 |
-XX:+UseConcMarkSweepGC |
使用 CMS GC(低延迟) | JDK8 可用 |
-XX:+UseG1GC |
使用 G1 GC(低延迟) | JDK9+ 推荐 |
-XX:+UseZGC |
使用 ZGC(超低延迟) | JDK11+ |
-XX:+UseShenandoahGC |
使用 Shenandoah GC | OpenJDK 特定版本 |
-XX:ParallelGCThreads |
GC 并行线程数 | -XX:ParallelGCThreads=8 |
-XX:ConcGCThreads |
并发 GC 线程数 | -XX:ConcGCThreads=4 |
-XX:MaxGCPauseMillis |
期望最大 GC 停顿时间 | -XX:MaxGCPauseMillis=200 |
-XX:InitiatingHeapOccupancyPercent |
老年代占用率触发并发 GC | -XX:InitiatingHeapOccupancyPercent=45 |
-XX:+DisableExplicitGC |
禁用显式 GC(System.gc()) |
防止手动触发 Full GC |
3. 性能监控与日志参数
| 参数 | 作用 | 示例 |
|---|---|---|
-XX:+PrintGC |
打印 GC 简要信息 | |
-XX:+PrintGCDetails |
打印 GC 详细信息 | |
-XX:+PrintGCDateStamps |
打印 GC 时间戳 | |
-Xloggc:<file> |
将 GC 日志输出到文件 | -Xloggc:/path/gc.log |
-Xlog:gc* |
JDK9+ 打印 GC 日志 | -Xlog:gc*:file=gc.log:time,uptime,level,tags |
-XX:+PrintFlagsFinal |
打印所有 JVM 参数最终值 | |
-XX:+PrintCommandLineFlags |
打印命令行参数 | |
-XX:+HeapDumpOnOutOfMemoryError |
OOM 时生成堆转储文件 | |
-XX:HeapDumpPath |
堆转储文件路径 | -XX:HeapDumpPath=/path/heap.hprof |
4. 诊断与调试参数
| 参数 | 作用 | 示例 |
|---|---|---|
-XX:+UnlockDiagnosticVMOptions |
解锁诊断参数 | |
-XX:+PrintSafepointStatistics |
打印安全点统计信息 | |
-XX:+PrintConcurrentLocks |
打印并发锁信息 | |
-XX:+TraceClassLoading |
跟踪类加载 | |
-XX:+TraceClassUnloading |
跟踪类卸载 | |
-XX:+FlightRecorder |
启用 JFR(Java Flight Recorder) | |
-XX:StartFlightRecording |
启动 JFR 录制 | -XX:StartFlightRecording=duration=60s,filename=record.jfr |
5. 常用组合示例(G1 GC 调优)
bash
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45
-XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=10
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/heap.hprof
✅ 总结:
常用 JVM 启动参数主要分为 内存管理、GC 调优、性能监控、诊断调试 四类,调优时应结合业务场景选择合适的组合,而不是盲目堆参数。
如何定位内存泄漏问题?
1. 判断是否存在内存泄漏
内存泄漏(Memory Leak)是指对象不再被使用,但仍然被引用,导致 GC 无法回收 。
常见判断依据:
- 堆内存占用持续上升,且 GC 后没有明显下降。
- Full GC 频率增加,但回收效果差。
- 最终触发 OOM (
java.lang.OutOfMemoryError: Java heap space)。 - 监控工具(JVisualVM、JMC、Prometheus+Grafana)显示老年代占用率持续增长。
2. 收集证据
当怀疑内存泄漏时,先收集现场数据:
-
GC 日志 (分析 GC 频率、回收效果)
bash-Xlog:gc*:file=gc.log:time,uptime,level,tags # JDK9+ -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log # JDK8 -
堆转储文件(Heap Dump)
-
OOM 自动生成:
bash-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/heap.hprof -
手动生成:
bashjmap -dump:live,format=b,file=heap.hprof <pid>
-
-
线程 Dump (排查是否有线程持有引用)
bashjstack <pid> > thread.log
3. 分析工具
| 工具 | 作用 | 特点 |
|---|---|---|
| Eclipse MAT | 分析 Heap Dump,找出占用内存最多的对象及引用链 | 免费、功能强大 |
| VisualVM | 实时监控内存、生成 Heap Dump | JDK 自带 |
| JProfiler | 商业工具,内存分析、对象分配跟踪 | 界面友好 |
| YourKit | 商业工具,支持内存泄漏检测 | 性能好 |
| Arthas | 在线诊断,查看对象引用 | 适合生产环境 |
4. 排查方法
4.1 使用 MAT 分析 Heap Dump
- 打开
.hprof文件。 - 查看 Histogram (对象直方图):
- 找出占用内存最多的类。
- 使用 Dominator Tree :
- 找出 GC Roots 到对象的引用链。
- 分析引用链:
- 判断是否存在不必要的强引用(如静态集合、缓存、监听器)。
4.2 常见内存泄漏场景
| 场景 | 原因 | 解决方案 |
|---|---|---|
| 静态集合持有对象 | 静态 Map/List 未清理 |
使用弱引用或定期清理 |
| 缓存未过期 | 缓存策略不合理 | 使用 WeakHashMap 或带过期策略的缓存 |
| 监听器/回调未移除 | 注册后未注销 | 在对象销毁时移除监听器 |
| ThreadLocal 泄漏 | 线程池复用线程,ThreadLocal 未清理 | 调用 remove() |
| 连接未关闭 | 数据库/网络连接未释放 | 使用 try-with-resources |
| 第三方库 Bug | 库内部持有引用 | 升级或替换库 |
4.3 在线排查(生产环境)
-
Arthas :
bash# 查看类加载信息 sc -d com.example.MyClass # 查看对象实例数量 ognl '@java.lang.management.ManagementFactory@getMemoryMXBean()' -
jmap + MAT :
- 在生产环境 dump 堆文件,下载到本地分析。
5. 预防建议
- 及时释放无用引用 (将不再使用的对象置为
null)。 - 使用弱引用/软引用管理缓存。
- 定期清理集合(尤其是静态集合)。
- 关闭资源(数据库连接、文件流、Socket)。
- ThreadLocal 用完后调用
remove()。 - 监控内存趋势,提前发现问题。
6. 内存泄漏排查流程图
| 步骤 | 工具 | 目标 |
|---|---|---|
| 1. 发现问题 | 监控、GC 日志 | 判断是否泄漏 |
| 2. 收集数据 | jmap、HeapDump | 获取现场 |
| 3. 分析数据 | MAT、VisualVM | 找出可疑对象 |
| 4. 定位代码 | 引用链分析 | 找到持有引用的地方 |
| 5. 修复验证 | 修改代码、调优 | 确认问题解决 |
✅ 一句话总结:
定位内存泄漏的核心是先确认是否泄漏 → 再用 Heap Dump 找出可疑对象 → 分析引用链 → 修改代码释放引用。
如何定位 CPU 占用过高问题?
1. 发现问题
CPU 占用过高通常有两种情况:
- 系统层面 :
top/htop/ 监控平台显示某个 Java 进程 CPU 占用率异常。 - 应用层面:响应变慢、吞吐下降、GC 频繁。
初步判断:
- 如果是短时间峰值 → 可能是 GC 或瞬时计算任务。
- 如果是持续高占用 → 可能是死循环、频繁锁竞争、IO阻塞导致的忙等。
2. 收集数据
2.1 找到高 CPU 的进程
bash
top -Hp <pid> # 查看进程内线程的 CPU 占用
pid→ Java 进程 ID(用jps或ps -ef | grep java获取)
2.2 找到高 CPU 的线程
- 在
top -Hp输出中,记录占用高的 线程 ID(TID)。 - 将 TID 转换为十六进制:
bash
printf "%x\n" <tid>
3. 分析线程堆栈
3.1 使用 jstack
bash
jstack <pid> > thread.log
- 在
thread.log中搜索刚才的十六进制 TID。 - 找到对应的线程堆栈,分析它在执行什么代码。
3.2 常见高 CPU 原因
| 原因 | 堆栈特征 | 说明 |
|---|---|---|
| 死循环 | 同一方法反复出现 | 逻辑错误或条件永远不满足 |
| 频繁 GC | GC 相关线程占用高 | 内存不足或对象创建过多 |
| 锁竞争 | java.util.concurrent / synchronized 堆栈 |
线程等待锁,导致忙等 |
| IO 忙等 | java.net / java.nio 堆栈 |
网络/磁盘阻塞但线程未休眠 |
| 计算密集 | 算法方法占用高 | 大量数据计算或加密解密 |
4. 找到代码热点
4.1 在线分析
- Arthas:
bash
# 查看方法调用耗时
trace com.example.MyService myMethod
# 查看线程状态
thread
4.2 离线分析
- 使用 JFR(Java Flight Recorder):
bash
-XX:+FlightRecorder -XX:StartFlightRecording=duration=60s,filename=record.jfr
- 用 JMC(Java Mission Control) 打开
.jfr文件,查看 CPU Hotspot。
5. 解决与预防
5.1 针对不同原因的解决方案
| 原因 | 解决方案 |
|---|---|
| 死循环 | 修复循环条件,增加退出机制 |
| 频繁 GC | 调整堆大小、优化对象创建、减少内存泄漏 |
| 锁竞争 | 优化锁粒度,使用无锁数据结构 |
| IO 忙等 | 增加超时、使用异步 IO |
| 计算密集 | 优化算法、使用并行计算 |
5.2 预防建议
- 监控 CPU 使用率(Prometheus + Grafana)。
- 定期分析线程 Dump,发现潜在热点。
- 代码审查,避免死循环和无休眠轮询。
- 合理使用线程池,避免线程过多导致上下文切换开销。
6. CPU 高占用排查流程表
| 步骤 | 工具 | 目标 |
|---|---|---|
| 1. 确认进程 | top / jps |
找到高 CPU 的 Java 进程 |
| 2. 确认线程 | top -Hp |
找到高 CPU 的线程 |
| 3. 转换 TID | printf "%x" |
转为十六进制 |
| 4. 分析堆栈 | jstack / Arthas |
找到代码热点 |
| 5. 修复问题 | 修改代码 / 调优参数 | 降低 CPU 占用 |
✅ 一句话总结:
定位 CPU 占用过高的核心是先锁定线程 → 再看堆栈 → 找到热点代码 → 对症下药,工具组合用得好,定位速度会非常快。
JVM 调优的常用步骤是什么?
1. 明确调优目标
在调优前必须先确定目标,否则容易陷入"盲调":
- 性能目标:降低 GC 停顿时间、提高吞吐量、减少 GC 频率。
- 稳定性目标:避免 OOM、减少长时间停顿、保证响应时间稳定。
- 资源利用率目标:合理使用内存和 CPU,避免浪费或不足。
2. 收集运行数据
调优必须基于数据,而不是凭经验猜测。
常用数据来源:
-
GC 日志
bash-Xlog:gc*:file=gc.log:time,uptime,level,tags # JDK9+ -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log # JDK8 -
堆内存使用情况(VisualVM、JMC、Arthas)
-
线程状态 (
jstack、Arthas) -
系统监控(CPU、内存、磁盘、网络)
-
业务指标(响应时间、吞吐量)
3. 分析问题
根据收集的数据定位瓶颈:
- GC 频繁 → 新生代/老年代空间不足、对象晋升过快。
- Full GC 停顿长 → 老年代压力大、元空间不足。
- OOM → 内存泄漏、堆/元空间/直接内存不足。
- CPU 高占用 → 死循环、频繁 GC、锁竞争。
- 响应时间波动大 → GC 停顿、线程阻塞。
4. 参数调优
根据问题选择合适的调优策略:
4.1 内存参数
| 参数 | 作用 |
|---|---|
-Xms / -Xmx |
固定堆大小,避免频繁扩容/收缩 |
-Xmn / -XX:NewRatio |
调整新生代与老年代比例 |
-XX:SurvivorRatio |
调整 Eden 与 Survivor 区比例 |
-XX:MaxTenuringThreshold |
控制对象晋升年龄 |
-XX:MetaspaceSize / -XX:MaxMetaspaceSize |
调整元空间大小 |
4.2 GC 收集器选择
- 吞吐量优先 → Parallel GC
- 低延迟优先 → G1 GC / CMS / ZGC / Shenandoah
4.3 GC 调优参数
| 参数 | 作用 |
|---|---|
-XX:MaxGCPauseMillis |
期望最大停顿时间 |
-XX:InitiatingHeapOccupancyPercent |
老年代占用率触发并发 GC |
-XX:+DisableExplicitGC |
禁用显式 GC |
5. 验证与持续优化
- 验证效果:调优后重新收集数据,确认指标是否达标。
- 回归测试:确保调优不会引入新问题。
- 持续监控:上线后保持监控,防止问题反复。
- 迭代优化:根据业务变化和负载调整参数。
6. JVM 调优流程表
| 阶段 | 关键动作 | 工具 |
|---|---|---|
| 明确目标 | 确定性能/稳定性/资源利用率目标 | 业务需求文档 |
| 收集数据 | 获取 GC 日志、堆内存、线程、系统指标 | GC 日志、VisualVM、JMC、Arthas |
| 分析问题 | 找出瓶颈原因 | GCViewer、GCEasy、MAT |
| 参数调优 | 调整内存、GC 收集器、GC 参数 | JVM 启动参数 |
| 验证优化 | 对比调优前后指标 | 压测工具、监控平台 |
| 持续优化 | 监控并迭代调整 | Prometheus+Grafana |
✅ 一句话总结:
JVM 调优的核心是先明确目标 → 再用数据定位问题 → 针对性调整参数 → 验证效果 → 持续监控优化,避免盲目改参数。
六、JVM 常用工具
jps 的作用是什么?
jstat 的作用是什么?
jmap 的作用是什么?
jhat 的作用是什么?
jstack 的作用是什么?
jconsole 的作用是什么?
VisualVM 的作用是什么?
MAT(Memory Analyzer Tool)的作用是什么?
Arthas 的作用是什么?
## **如何使用这些工具进行线上问题排查?**
七、JVM 高级与面试加分题
类加载器的隔离与热替换原理?
JVM 如何实现方法调用?
JVM 中的即时编译器(JIT)是什么?
解释执行与编译执行的区别?
逃逸分析的原理与作用?
栈上分配与标量替换是什么?
JVM 中的锁优化有哪些?
偏向锁、轻量级锁、重量级锁的区别?
JVM 中的内存屏障是什么?
## **JVM 中的 Safepoint 是什么?**