JVM(下)

三、垃圾收集【*】
3.1 讲讲 JVM 的垃圾回收机制【*】
垃圾回收就是对堆内存中已经死亡的或者长时间没有使用的对象进行清除或回收。
JVM 在做 GC 之前,会先搞清楚什么是垃圾,什么不是垃圾,通常会通过可达性分析算法来判断对象是否存活。

在确定了哪些垃圾可以被回收后,垃圾收集器(如 CMS、G1、ZGC)要做的事情就是进行垃圾回收,可以采用标记清除算法、复制算法、标记整理算法、分代收集算法等。
JVM(Java虚拟机)的垃圾回收机制(Garbage Collection, 简称 GC )是Java语言的核心特性之一。它自动管理内存,负责回收那些不再被程序使用的对象,从而避免了像C++那样需要手动释放内存(free/delete)的繁琐和风险(如内存泄漏)。
理解 GC 机制,主要需要掌握以下四个方面:
- 如何判断对象已死?(对象存活判定算法)
- 如何回收这些对象?(垃圾收集算法)
- 内存在哪回收?(堆内存的分代模型)
- 谁来回收?(垃圾收集器)
- 如何判断对象已死?
在回收之前,JVM 必须要知道哪些对象是"垃圾"。主要有两种方式:
A. 引用计数法 (Reference Counting)
- 原理:给对象添加一个引用计数器,每当有一个地方引用它,计数器+1;引用失效时,计数器-1。为0时即为垃圾。
- 缺点 :JVM 早期并未采用此方法 ,因为它很难解决循环引用的问题(即对象A引用B,B引用A,除此之外无其他引用,计数器都不为0,但实际上它们都无法被访问)。
B. 可达性分析算法 (Reachability Analysis) ------ JVM目前使用的方法
- 原理 :通过一系列称为 "GC Roots" 的对象作为起点,从这些节点开始向下搜索。如果一个对象到 GC Roots 没有任何引用链相连(即不可达),则证明该对象是不可用的。
- 哪些对象可以作为 GC Roots?
- 虚拟机栈(栈帧中的本地变量表)中引用的对象(如方法里的局部变量)。
- 方法区中类静态属性引用的对象(static 变量)。
- 方法区中常量引用的对象(final 常量)。
- 本地方法栈中 JNI(Native方法)引用的对象。
- 垃圾收集算法 (GC Algorithms)
确定了谁是垃圾后,就需要清理。常见的算法有三种:
A. 标记-清除算法 (Mark-Sweep)
- 过程:先标记出所有需要回收的对象,然后统一清除。
- 缺点:
- 效率低:标记和清除两个过程效率都不高。
- 内存碎片:清除后会产生大量不连续的内存碎片,导致以后需要分配大对象时无法找到连续内存而提前触发 GC。
B. 标记-复制算法 (Mark-Copying) ------ 新生代主要使用
- 过程:将内存分为两块,每次只用一块。当这一块满时,将存活的对象复制到另一块上去,然后把已使用的内存空间一次清理掉。
- 优点:没有内存碎片,实现简单,运行高效。
- 缺点:内存利用率低(因为要空出一半内存)。
- 优化 :JVM 在新生代中并没有按 1:1 划分,而是按 8:1:1 分为 Eden 区和两个 Survivor 区(S0, S1),每次只浪费 10% 的空间。
C. 标记-整理算法 (Mark-Compact) ------ 老年代主要使用
- 过程:标记过程同"标记-清除",但后续步骤不是直接清除,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
- 优点:解决了内存碎片问题。
- 内存的分代模型 (Generational Collection)
JVM 根据对象存活周期的不同,将堆内存划分为几块,采用不同的算法。这就是分代收集理论。
A. 新生代 (Young Generation)
- 特点:对象"朝生夕死",98%的对象很快就会变成垃圾。
- 组成:Eden 区 + Survivor 0 区 + Survivor 1 区。
- GC 类型 :Minor GC (Young GC)。
- 算法 :复制算法。
- 新对象在 Eden 分配。
- GC 时,Eden 和正在使用的 Survivor 区中存活的对象,复制到另一个空的 Survivor 区。
- 对象年龄+1。
B. 老年代 (Old Generation / Tenured)
- 特点:对象存活率高,没有额外的空间进行担保。
- 来源:
- 在新生代经历了多次(默认15次)GC 依然存活的对象。
- 大对象直接进入老年代。
- GC 类型 :Major GC (Old GC) 或 Full GC。
- 算法 :标记-清除 或 标记-整理。
C. 永久代 / 元空间 (PermGen / Metaspace)
- Java 8 之前:叫永久代 (PermGen),位于堆中。
- Java 8 及之后 :叫元空间 (Metaspace),位于本地内存 (Native Memory),不再占用堆内存。主要存储类的元数据信息。
- 垃圾收集器 (Garbage Collectors)
算法是方法论,收集器是具体的实现。
- Serial 收集器 :单线程,进行 GC 时必须暂停所有工作线程 (Stop The World, STW)。适用于客户端小程序。
- Parallel Scavenge / Parallel Old :多线程并行收集,关注吞吐量(CPU用于运行用户代码的时间占比)。是 JDK 8 默认的收集器。
- CMS (Concurrent Mark Sweep):
- 目标:获取最短回收停顿时间。
- 特点:并发收集、低停顿。使用"标记-清除"算法。
- 注:JDK 9 开始标记为废弃,JDK 14 被移除。
- G1 (Garbage-First):
- JDK 9 默认收集器。
- 打破了严格的物理分代,将堆划分为多个大小相等的 Region。
- 它可以预测停顿时间,优先回收垃圾最多的 Region(所以叫 Garbage-First)。
- ZGC / Shenandoah:
- 面向未来的收集器(JDK 11/15+)。
- 目标是停顿时间不超过 10ms(甚至 <1ms),且与堆大小无关(TB 级内存也能极快回收)。
总结:GC 的核心流程
- 对象在 Eden 区诞生。
- Eden 满了,触发 Minor GC。
- 存活对象被复制到 Survivor 区,年龄+1。
- 对象在 Survivor 区之间反复横跳,直到年龄达到阈值(如15),晋升到 Old (老年代)。
- Old 区满了,触发 Major GC / Full GC(通常伴随着 STW,整个程序卡顿)。
- 如果 Full GC 后内存仍然不足,抛出 OutOfMemoryError (OOM)。
(1)垃圾回收的过程
Java 的垃圾回收过程主要分为标记存活对象、清除无用对象、以及内存压缩/整理三个阶段。不同的垃圾回收器在执行这些步骤时会采用不同的策略和算法。
GC过程如下:
1.第一次标记:可达性判断
2.第二次标记:finalize()方法重写未执行判断
3.进行回收

(2)哪些阶段会触发 STW?
SWT是在垃圾回收过程中,为了保证内存数据的一致性,JVM 必须要暂停所有的用户线程。GC线程运行的时候其他应用线程停止

一定要SWT的情况:
- 标记阶段:**在标记阶段,STW 是必需的。**无论是哪种垃圾回收算法,标记阶段都需要准确找出所有存活对象。如果标记的时候业务线程还在运行,就会标记不准确。因为业务线程可能会在 GC 标记某个对象为垃圾的同时,访问这个对象。为了避免这种冲突,标记阶段必须暂停所有业务线程。
- 清楚阶段使用复制算法或标记-整理算法:如果使用的是复制算法或标记-整理算法(需要移动对象),那就必须 STW。因为对象被移动后,所有指向这个对象的引用都要更新,这个过程中不能让业务线程访问这些对象。
果使用的是标记-清除算法(不移动对象),理论上可以不 STW,业务线程和 GC 线程可以并发执行。
不同的垃圾收集器(GC算法),触发 STW 的时机和时长不同。我们可以从通用场景 和特定收集器两个维度来回答这个问题。
通用场景:根节点的枚举
不同收集器的SWT阶段:
1.Serial和Parallel收集器
对于新生代的垃圾收集器 Serial 和 ParNew,整个垃圾回收过程都会触发 STW,但因为年轻代小,通常很快(几毫秒到几十毫秒)。
2.CMS
CMS 在此基础上进行了改进,通过并发执行来减少 STW 时间,会在初始标记和重新标记阶段触发 STW。
3.G1
G1 采用的是增量回收的思想 ,不是一次性把整个堆都清理,而是把堆分成很多小的区域,每次只清理一部分区域。这样就可以分散 STW 的时间,单次 STW 的时间就短了。
4.ZGC
ZGC 采用了并发整理的技术 ,可以在应用线程运行的同时进行对象的整理和移动,几乎消除了 STW 时间。ZGC 的 STW 时间通常在 10 毫秒以内。
(3)垃圾回收机制的时机?能手动触发垃圾回收吗?垃圾回收会抢占业务代码的CPU吗?
1.JVM自动进行垃圾回收
2.JVM 提供了 System.gc() 这个方法,可以手动调用它来触发垃圾回收,但一般不建议这么做。
3.会,因为SWT,垃圾回收会抢占 CPU,垃圾回收时会触发 STW,暂停所有业务线程。Minor GC 的 STW 时间较短,Major GC 的 STW 时间较长。
3.2 如何判断对象仍然存活?【*】
主要有两种:引用计数和可达性分析。引用计数一般不应
(1)可达性算法
一般使用可达性算法,通过一组"GC Roots"的根对象,开始递归扫描,无法从跟对象到达的对象就是"垃圾",可以被回收。
什么对象可以做GC Roots?
- 虚拟机栈(栈帧中的本地变量表)中引用的对象(如方法里的局部变量)。
- 方法区中类静态属性引用的对象(static 变量)。
- 方法区中常量引用的对象(final 常量)。
- 本地方法栈中 JNI(Native方法)引用的对象。
(2)引用计数
每个对象有一个引用计数器,记录引用它的次数。当计数器为零时,对象可以被回收。

引用计数器的问题
引用计数法无法解决循环引用的问题。例如,两个对象互相引用,但不再被其他对象引用,它们的引用计数都不为零,因此不会被回收。
(3)做可达性分析有那些前置操作?
做可达性分析之前应该STW,暂停所有应用线程。因为要保证内存中对象之间的引用关系不被修改。
这是因为可达性分析过程必须确保在执行分析时,内存中的对象关系不会被应用线程修改。如果不暂停应用线程,可能会出现对象引用的改变,导致垃圾回收过程中判断对象是否可达的结果不一致,从而引发严重的内存错误或数据丢失。
3.3 Java中可作为GC Roots的引用有哪几种?
有4种对象可以作为GC Roots
所谓的 GC Roots,就是一组必须活跃的引用,它们是程序运行时的起点,是一切引用链的源头。在 Java 中,GC Roots 包括以下几种
- 虚拟机栈中的引用(局部变量和方法参数)
- 本地方法栈中的引用,也就上JNI方法中的引用
- 静态变量static
- 元空间运行时常量池中的常量(String/Class)

(1)说说虚拟机栈中的引用?
示例:
java
public class StackReference {
public void greet() {
Object localVar = new Object(); // 这里的 localVar 是一个局部变量,存在于虚拟机栈中
System.out.println(localVar.toString());
}
public static void main(String[] args) {
new StackReference().greet();
}
}
localVar是局部变量,可以认为是GC Roots,在在 greet 方法执行期间,localVar 引用的对象是活跃的,因为它是从 GC Roots 可达的。
当 greet 方法执行完毕后,localVar 的作用域结束,localVar 引用的 Object 对象不再由任何 GC Roots 引用(假设没有其他引用指向这个对象),因此它将有资格作为垃圾被回收掉。
(2)说说本地方法栈中 JNI 的引用?
JNI:Java 通过 JNI 提供了一种机制,允许 Java 代码调用本地代码(通常是 C 或 C++ 编写的代码)。
当调用 Java 方法时,虚拟机会创建一个栈帧并压入虚拟机栈,而当它调用本地方法时,虚拟机会通过动态链接直接调用指定的本地方法。

JNI 引用是在 Java 本地接口代码中创建的引用,这些引用可以指向 Java 堆中的对象。
java
// 假设的JNI方法
public native void nativeMethod();
// 假设在C/C++中实现的本地方法
/*
* Class: NativeExample
* Method: nativeMethod
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_NativeExample_nativeMethod(JNIEnv *env, jobject thisObj) {
jobject localRef = (*env)->NewObject(env, ...); // 在本地方法栈中创建JNI引用
// localRef 引用的Java对象在本地方法执行期间是活跃的
}
在本地代码中,localRef 是对 Java 对象的一个 JNI 引用,它在本地方法执行期间保持 Java 对象活跃,可以被认为是 GC Roots。
一旦 JNI 方法执行完毕,除非这个引用是全局的,否则它指向的对象将会被作为垃圾回收掉(假设没有其他地方再引用这个对象)。
(3)说说类静态变量?
java
public class StaticFieldReference { // 类静态变量要在类被卸载才会被回收
private static Object staticVar = new Object(); // 类静态变量
public static void main(String[] args) {
System.out.println(staticVar.toString());
}
}
StaticFieldReference类中的staticVar引用一个Object对象,这个引用存储在元空间,可以被认为是 GC Roots。
只要 StaticFieldReference 类未被卸载,staticVar 引用的对象都不会被垃圾回收。如果 StaticFieldReference 类被卸载(这通常发生在其类加载器被垃圾回收时),那么 staticVar 引用的对象也将有资格被垃圾回收(如果没有其他引用指向这个对象)。
(4)说说运行时常量池中的常量?
final,在运行时常量池
java
class ConstantPoolReference {
public static final String CONSTANT_STRING = "Hello, World"; // 常量,存在于运行时常量池中
public static final Class<?> CONSTANT_CLASS = Object.class; // 类类型常量
public static void main(String[] args) {
System.out.println(CONSTANT_STRING);
System.out.println(CONSTANT_CLASS.getName());
}
}
在 ConstantPoolReference 中,CONSTANT_STRING 和 CONSTANT_CLASS 作为常量存储在运行时常量池。它们可以用来作为 GC Roots。
这些常量引用的对象(字符串"Hello, World"和 Object.class 类对象)在常量池中,只要包含这些常量的 ConstantPoolReference 类未被卸载,这些对象就不会被垃圾回收。
3.4 finalize()方法了解吗?
主要作用在这一部分,标记垃圾,就是在第一次被标记后的再一次救场
如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选。
筛选的条件是对象是否有必要执行 finalize()方法。
如果对象在 finalize() 中成功拯救自己------只要重新与引用链上的任何一个对象建立关联即可。
譬如把自己 (this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它就"逃过一劫";但是如果没有抓住这个机会,那么对象就真的要被回收了。
3.5 垃圾收集算法了解吗?【*】
三种垃圾收集算法:标记-整理、标记-复制、标记-清除,分代收集(主流,但是其中的代里又有运用前几种收集的),要知道那里在用,怎么用的?
(1)标记-清除?
先标记后清楚,很简单
标记-清除算法分为两个阶段:
- 标记:标记所有需要回收的对象
- 清除:回收所有被标记的对象
优点是实现简单,缺点是回收过程中会产生内存碎片。

(2)标记-复制?
2分内存,一块标记-》存活移动到另一块-》原来一块全部清理
标记-复制算法可以解决标记-清除算法的内存碎片问题,因为它将内存空间划分为两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后清理掉这一块。
缺点是浪费了一半的内存空间。

(3)标记-整理?
标记-复制升级版本,不在划分,就直接把存活的整理在一端,其余的全部清除
标记-整理算法是标记-清除复制算法的升级版,它不再划分内存空间,而是将存活的对象向内存的一端移动,然后清理边界以外的内存。
缺点是移动对象的成本比较高。

(4)分代收集
目前最主流的垃圾收集算法,将堆区分为新生代和老年代,然后新生代分eden、s0、s1,这里使用标记-复制,然后老年代就是标记-整理。

新生代用复制算法,因为大部分对象生命周期短。老年代用标记-整理算法,因为对象存活率较高。
(5)为什么要用分代收集?
分代收集算法的核心思想是根据对象的生命周期优化垃圾回收。
- 新生代的对象生命周期短 ,使用复制算法可以快速回收。
- 老年代的对象生命周期长 ,使用标记-整理算法可以减少移动对象的成本。
(6)标记复制的标记过程和复制过程会不会停顿?
标记开始之前一定会STW,复制也会
- 标记阶段停顿是为了保证对象的引用关系不被修改。
- 复制阶段停顿是防止对象在复制过程中被修改。
3.6 Minor GC、Major GC、Mixed GC、Full GC 都是什么意思?
Minor GC、Major GC、Mixed GC、Full GC不同时机、不同区域,不同垃圾收集器(CMS、G1、ZGC等)
(1)Minor GC
Minor GC 也称为 Young GC,是指发生在年轻代的垃圾收集。年轻代包含 Eden 区以及两个 Survivor 区。
(2)Major GC
Major GC 也称为 Old GC ,主要指的是发生在老年代的垃圾收集 。是 CMS 的特有行为。
(3)Mixed GC
Mixed GC 是 G1 垃圾收集器特有的一种 GC 类型,它在一次 GC 中同时清理年轻代和部分老年代。
(4)Full GC
Full GC 是最彻底的垃圾收集,涉及整个 Java 堆和方法区。它是最耗时的 GC,通常在 JVM 压力很大时发生。
Full GC怎么做的?
Full GC 会从 GC Root 出发,标记所有可达对象。新生代使用复制算法,清空 Eden 区。老年代使用标记-整理算法,回收对象并消除碎片。
停顿时间较长,会影响系统响应性能。
3.7 Young GC 什么时候触发?
Eden满的时候,如果 Eden 区没有足够的空间时,就会触发 Young GC 来清理新生代。
3.8 什么时候会触发 Full GC?
它是最耗时的 GC,通常在 JVM 压力很大时发生
在进行 Young GC 的时候,如果发现老年代可用的连续内存空间 < 新生代历次 Young GC 后升入老年代的对象总和的平均大小,说明本次 Young GC 后升入老年代的对象大小,可能超过了老年代当前可用的内存空间,就会触发 Full GC。(也就上空间分配没有担保,老年代的位置不够新生代升上来的对象了)
执行 Young GC 后老年代没有足够的内存空间存放转入的对象,会立即触发一次 Full GC。
(1)空间分配担保是什么?
空间分配担保是指在进行 Minor GC 前,JVM 会确保老年代有足够的空间存放从新生代晋升的对象。如果老年代空间不足,可能会触发 Full GC。
3.9 知道哪些垃圾收集器?【*】
Serial、Paralla、CMS、G1、ZGC,不同代(新生/老年)不同,不同JDK版本用收集器不同、收集器不同收集算法更不相同。
JVM 的垃圾收集器主要分为两大类:分代收集器和分区收集器,分代收集器的代表是 CMS,分区收集器的代表是 G1 和 ZGC。

CMS:CMS 是第一个关注 GC 停顿时间的垃圾收集器,JDK 1.5 时引入,JDK9 被标记弃用,JDK14 被移除。
G1:G1 在 JDK 1.7 时引入,在 JDK 9 时取代 CMS 成为了默认的垃圾收集器。
ZGC:JDK11 推出的一款低延迟垃圾收集器,适用于大内存低延迟服务的内存管理和回收,在 128G 的大堆下,最大停顿时间才 1.68 ms,性能远胜于 G1 和 CMS。(性能最好)
(1)说说 Serial 收集器?(串行收集器)对应Serial Old
Serial 收集器是最基础、历史最悠久的收集器。垃圾收集线程就1条。
如同它的名字(串行),它是一个单线程工作的收集器,使用一个处理器或一条收集线程去完成垃圾收集工作。并且进行垃圾收集时,必须暂停其他所有工作线程,直到垃圾收集结束------这就是所谓的"Stop The World"。STW
Serial/Serial Old 收集器的运行过程如图:

(2)说说 ParNew 收集器?
ParNew 收集器实质上是 Serial 收集器的多线程并行版本,使用多条线程进行垃圾收集。
就是比Serial多了可以多线程并行GC

(3)说说 Parallel Scavenge 收集器?对应
Parallel Scavenge 收集器是一款新生代收集器,基于标记-复制算法实现,也能够并行收集。
与ParNew的不同之处:Parallel Scavenge 主要关注的是垃圾收集的吞吐量------所谓吞吐量,就是 CPU 用于运行用户代码的时间和总消耗时间的比值,比值越大,说明垃圾收集的占比越小。

根据对象存活周期的不同会将内存划分为几块,一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。(Serial-Serial Old/ Parallel Scavenge-Parallel Old)
就算法变量,收集器的核心理念没变
(4)说说 Serial Old 收集器?
Serial Old 是 Serial 收集器的老年代版本 ,它同样是一个单线程收集器,使用标记-整理算法。
(5)说说 Parallel Old 收集器?
Parallel Old 是 Parallel Scavenge 收集器的老年代版本**,基于标记-整理算法实现,**使用多条 GC 线程在 STW 期间同时进行垃圾回收。

(6)说说 CMS 收集器?
CMS历程:JDK1.5(引入)-->JDK9(移除)-->JDK14(启用)
CMS 是一种低延迟的垃圾收集器 ,采用标记-清除算法,分为初始标记、并发标记、重新标记和并发清除四个阶段,优点是垃圾回收线程和应用线程同时运行,停顿时间短,适合延迟敏感的应用,但容易产生内存碎片,可能触发 Full GC。

这样看初始标记(标记与GC Roots直接相连)和重新标记会STW,并且只有初始标记是单线程。
(7)说说 G1 收集器?
分区收集器,不同于分代的方法,JDK1.7引入,JDK9默认
G1 是一种面向大内存、高吞吐场景的垃圾收集器,它将堆划分为多个小的 Region,通过标记-整理算法,避免了内存碎片问题。优点是停顿时间可控,适合大堆场景,但调优较复杂。

(8)说说 ZGC 收集器?
分区收集器,JDK11引入
ZGC 是 JDK 11 时引入的一款低延迟的垃圾收集器,最大特点是将垃圾收集的停顿时间控制在 10ms 以内,即使在 TB 级别的堆内存下也能保持较低的停顿时间。(延迟很低)
它通过并发标记和重定位来避免大部分 Stop-The-World 停顿,主要依赖指针染色来管理对象状态。

- 标记对象的可达性 :通过在指针上增加标记位,不需要额外的标记位即可判断对象的存活状态。
- 重定位状态:在对象被移动时,可以通过指针染色来更新对象的引用,而不需要等待全局同步。
适用于需要超低延迟的场景,比如金融交易系统、电商平台。
(9)垃圾回收器的作用是什么?
垃圾回收器是用来自动管理Java程序的运行时内存,不用像C/c++那样手动进行内存的管理。
这一过程减少了程序员手动管理内存的负担,降低了内存泄漏和溢出错误的风险。
3.10 能详细说一下 CMS 的垃圾收集过程吗?【*】
4个阶段:初始标记、并发标记、重新标记、并发清理。

CMS 使用标记-清除算法进行垃圾收集,分 4 大步:
1.初始标记:标记所有从 GC Roots 直接可达的对象,这个阶段需要 STW,但速度很快。
2.并发标记:从初始标记的对象出发,遍历所有对象,标记所有可达的对象。这个阶段是并发进行的(不用STW,和用户线程并行)
3.重新标记:完成剩余的标记工作,包括处理并发阶段遗留下来的少量变动,这个阶段通常需要短暂的 STW 停顿。
4.并发清除:清除未被标记的对象,回收它们占用的内存空间。
(1)你提到了remark,那它remark具体是怎么执行的?三色标记法?
remark就是重新标记阶段,为什么?目的是修正并发标记阶段中可能遗漏的对象引用变化。remark 阶段通常会结合三色标记法来执行,确保在并发标记期间所有存活对象都被正确标记
在 remark 阶段,垃圾收集器会停止应用线程(STW),以确保在这个阶段不会有引用关系的进一步变化。这种暂停通常很短暂。remark 阶段主要包括以下操作:
- 处理写屏障记录的引用变化 :在并发标记阶段,应用程序可能会更新对象的引用(比如一个黑色对象新增了对一个白色对象的引用),这些变化通过写屏障记录下来。在 remark 阶段,GC 会处理这些记录,确保所有可达对象都正确地标记为灰色或黑色。
- 扫描灰色对象:再次遍历灰色对象,处理它们的所有引用,确保引用的对象正确标记为灰色或黑色。
- 清理:确保所有引用关系正确处理后,灰色对象标记为黑色,白色对象保持不变。这一步完成后,所有存活对象都应当是黑色的。
(2)什么是三色标记法?

使用黑、白、灰三种颜色:
- 白:就是尚未访问的对象。标记结束还是白色就是不可达对象;尚未访问的对象。垃圾回收结束后,仍然为白色的对象会被认为是不可达的对象,可以回收。
- 灰:已经访问到但是没有标记。灰色对象是需要进一步处理的。
- 黑:已经访问到并且其所有引用对象都已经标记过。黑色对象是完全处理过的,不需要再处理。
结合三色标记的CMS阶段:
1.初始标记(Initial Marking):从 GC Roots 开始,标记所有直接可达的对象为灰色。
2.并发标记(Concurrent Marking):在此阶段,标记所有灰色对象引用的对象为灰色,然后将灰色对象自身标记为黑色。这个过程是并发的,和应用线程同时进行。
遗留问题:此阶段的一个问题是,应用线程可能在并发标记期间修改对象的引用关系,导致一些对象的标记状态不准确。
也是remark的原因。
3.重新标记(Remarking):重新标记阶段的目标是处理并发标记阶段遗漏的引用变化。为了确保所有存活对象都被正确标记,remark 需要在 STW 暂停期间执行。
4.使用写屏障(Write Barrier)来捕捉并发标记阶段应用线程对对象引用的更新。通过遍历这些更新的引用来修正标记状态,确保遗漏的对象不会被错误地回收。(根据写屏障更新)
3.11 G1 垃圾收集器了解吗?【*】
分区收集器,JDK7引入,JDK9成为默认,

将堆划分为多个大小相等的独立Region,每个区域都可以扮演新生代或老年代的角色。
同时,G1 还有一个专门为大对象设计的 Region,叫 Humongous 区。而且G1使用mixed GC
大对象判断?
大对象的判定规则是,如果一个大对象超过了一个 Region 大小的 50%,比如每个 Region 是 2M,只要一个对象超过了 1M,就会被放入 Humongous 中。
这种区域化管理使得 G1 可以更灵活地进行垃圾收集,只回收部分区域而不是整个新生代或老年代。
(1)G1的运行过程?
三个大步骤:
1.**并发标记:**G1 通过并发标记的方式找出堆中的垃圾对象。并发标记阶段与应用线程同时执行,不会导致应用线程暂停。
2.**混合收集:**在并发标记完成后,G1 会计算出哪些区域的回收价值最高(也就是包含最多垃圾的区域),然后优先回收这些区域。这种回收方式包括了部分新生代区域和老年代区域。选择回收成本低而收益高的区域进行回收,可以提高回收效率和减少停顿时间。
3.可预测的停顿:G1 在垃圾回收期间仍然需要「Stop the World」 。不过,G1在停顿时间上添加了预测机制,用户可以 JVM 启动时指定期望停顿时间,G1会尽可能地在这个时间内完成垃圾回收。

(2)计算Region的时候怎么和业务代码并行执行
G1怎么一遍执行预测,一遍并行,关键在于写屏障(Write)
在 G1 中,堆被划分成很多个大小相等的区域,每个区域叫做 Region。G1 的工作方式是,每次不对整个堆进行垃圾回收,而是计算哪些 Region 中的垃圾最多、回收效率最高,然后只回收这些 Region。
类似以下
┌─────────────────────────────────────────────────┐
│ Region1 │ Region2 │ Region3 │ Region4 │ Region5 │
│ 年轻代 │ 年轻代 │ 老年代 │ 老年代 │ 空闲 │
│ 垃圾: 10%│ 垃圾: 5% │ 垃圾: 70%│ 垃圾: 15%│ │
└─────────────────────────────────────────────────┘
第一步:并行标记
G1 会启动一些 GC 标记线程,这些线程和业务线程并发运行。业务线程执行代码,标记线程同时遍历堆中的对象,找出哪些对象是活的,哪些是垃圾。
这里需要用到写屏障(Write Barrier)技术**,每当业务线程改变对象的引用时,写屏障就会记录下来:"有个引用被改变了"。GC 线程会根据这些记录来调整标记结果。**
java
// 简化的写屏障概念示意
class Object {
Object ref;
// 业务线程执行:obj.ref = newRef;
// 实际执行过程:
public void setRef(Object newRef) {
// 原始操作
this.ref = newRef;
// 写屏障操作(由 JVM 自动插入)
writeBarrier(newRef); // 通知 GC:"这个对象的引用被改变了"
}
}
第二步,并发计算占比,并发计算每个 Region 的垃圾占比。
第三步,G1 根据计算结果选择垃圾最多的几个 Region 进行回收。这时候才会触发 STW,暂停业务线程,对这些 Region 进行垃圾清理。
因为只清理少数几个 Region,而不是整个堆。所以 STW 的时间很短。
3.12 有了 CMS,为什么还要引入 G1?
CMS 适用于对延迟敏感的应用场景,主要目标是减少停顿时间,但容易产生内存碎片。
G1 则提供了更好的停顿时间预测和内存压缩能力,适用于大内存和多核处理器环境。
二者的区别
| 特性 | CMS | G1 |
|---|---|---|
| 设计目标 | 低停顿时间 | 可预测的停顿时间 |
| 并发性 | 是 | 是 |
| 内存碎片 | 是,容易产生碎片 | 否,通过区域划分和压缩减少碎片 |
| 收集代数 | 年轻代和老年代 | 整个堆,但区分年轻代和老年代 |
| 并发阶段 | 并发标记、并发清理 | 并发标记、并发清理、并发回收 |
| 停顿时间预测 | 较难预测 | 可配置停顿时间目标 |
| 容易出现的问题 | 内存碎片、Concurrent Mode Failure | 较少出现长时间停顿 |
1.分区和分代
2.关注点
3,使用场景
3.13 你们线上用的什么垃圾收集器?
我们生产环境中采用了设计比较优秀的 G1 垃圾收集器,因为它不仅能满足低停顿的要求,而且解决了 CMS 的浮动垃圾问题、内存碎片问题。
G1 非常适合大内存、多核处理器的环境。
以上是比较符合面试官预期的回答,但实际上,大多数情况下我们可能还是使用的 JDK 8 默认垃圾收集器。
可以通过以下命令查看当前 JVM 的垃圾收集器:
bash
java -XX:+PrintCommandLineFlags -version
UseParallelGC = Parallel Scavenge + Parallel Old,表示新生代用Parallel Scavenge收集器,老年代使用Parallel Old 收集器。
(1)工作中项目使用的什么垃圾回收算法?
我们生产环境中采用了设计比较优秀的 G1 垃圾收集器,G1 采用的是分区式标记-整理算法,将堆划分为多个区域,按需回收,适用于大内存和多核环境,能够同时考虑吞吐量和暂停时间。
或者:
我们系统采用的是 CMS 收集器,CMS 采用的是标记-清除算法,能够并发标记和清除垃圾,减少暂停时间,适用于对延迟敏感的应用。
再或者:
我们系统采用的是 Parallel 收集器,Parallel 采用的是年轻代使用复制算法,老年代使用标记-整理算法,适用于高吞吐量要求的应用。
3.14 垃圾收集器应该如何选择?
如果应用程序只需要一个很小的内存空间(大约 100 MB),或者对停顿时间没有特殊的要求,可以选择 Serial 收集器。
如果优先考虑应用程序的峰值性能,并且没有时间要求,或者可以接受 1 秒或更长的停顿时间,可以选择 Parallel 收集器。
如果响应时间比吞吐量优先级高,或者垃圾收集暂停必须保持在大约 1 秒以内,可以选择 CMS/ G1 收集器。
如果响应时间是高优先级的,或者堆空间比较大,可以选择 ZGC 收集器。
四、JVM调优
4.1 用过哪些性能监控的命令行工具?
操作系统层面的有:
用过 top、vmstat、iostat、netstat 等命令,可以监控系统整体的资源使用情况,比如说内存、CPU、IO 使用情况、网络使用情况。
jDK自带的命令行工具层面:
jps、jstat、jinfo、jmap、jhat、jstack、jcmd 等,可以查看 JVM 运行时信息、内存使用情况、堆栈信息等。
(1)一般怎么使用jmap
1.一般会使用 jmap -heap <pid> 查看堆内存摘要,包括新生代、老年代、元空间等。
2.使用 jmap -histo <pid> 查看对象分布。
3.还有生成堆转储文件:jmap -dump:format=b,file=<path> <pid>。
4.2 了解哪些可视化的性能监控工具?
我自己用过的可视化工具主要有:
①、JConsole:JDK 自带的监控工具,可以用来监视 Java 应用程序的运行状态,包括内存使用、线程状态、类加载、GC 等。
②、VisualVM:一个基于 NetBeans 的可视化工具,在很长一段时间内,VisualVM 都是 Oracle 官方主推的故障处理工具。集成了多个 JDK 命令行工具的功能,非常友好。
③、Java Mission Control:JMC 最初是 JRockit VM 中的诊断工具,但在 Oracle JDK7 Update 40 以后,就绑定到了 HotSpot VM 中。不过后来又被 Oracle 开源出来作为了一个单独的产品。
用过哪些第三方的工具?
- MAT:一个 Java 堆内存分析工具,主要用于分析和查找 Java 堆中的内存泄漏和内存消耗问题;可以从 Java 堆转储文件中分析内存使用情况,并提供丰富的报告,如内存泄漏疑点、最大对象和 GC 根信息;支持通过图形界面查询对象,以及检查对象间的引用关系。
- GChisto:GC 日志分析工具,可以帮助我们优化垃圾收集行为和调整 GC 性能。
- JProfiler:一个全功能的商业化 Java 性能分析工具,提供 CPU、 内存和线程的实时分析。
- arthas:阿里巴巴开源的 Java 诊断工具,主要用于线上的应用诊断;支持在不停机的情况下进行诊断;可以提供包括 JVM 信息查看、监控、Trace 命令、反编译等功能。
- async-profiler:一个低开销的性能分析工具,支持生成火焰图,适用于复杂性能问题的分析。
4.3 JVM 的常见参数配置知道哪些?
JVM 调优参数主要有 -Xms 设置初始堆大小,-Xmx 设置最大堆大小,-XX:+UseG1GC 使用 G1 垃圾收集器,-XX:MaxGCPauseMillis=n 设置最大垃圾回收停顿时间,-XX:+PrintGCDetails 输出 GC 详细日志等。
配置堆内存大小的参数有哪些?
-Xms:初始堆大小-Xmx:最大堆大小-XX:NewSize=n:设置年轻代大小-XX:NewRatio=n:设置年轻代和年老代的比值。如:n 为 3 表示年轻代和年老代比值为 1:3,年轻代占总和的 1/4-XX:SurvivorRatio=n:年轻代中 Eden 区与两个 Survivor 区的比值。如 n=3 表示 Eden 占 3 Survivor 占 2,一个 Survivor 区占整个年轻代的 1/5配置 GC 收集器的参数有哪些?
-XX:+UseSerialGC:设置串行收集器-XX:+UseParallelGC:设置并行收集器-XX:+UseParalledlOldGC:设置并行老年代收集器-XX:+UseConcMarkSweepGC:设置并发收集器配置并行收集的参数有哪些?
-XX:MaxGCPauseMillis=n:设置最大垃圾回收停顿时间-XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的比例-XX:+CMSIncrementalMode:设置增量模式,适合单 CPU 环境-XX:ParallelGCThreads=n:设置并行收集器的线程数打印 GC 回收的过程日志信息的参数有哪些?
-XX:+PrintGC:输出 GC 日志-XX:+PrintGCDetails:输出 GC 详细日志-XX:+PrintGCTimeStamps:输出 GC 的时间戳(以基准时间的形式)-Xloggc:filename:日志文件的输出路径
4.4 做过 JVM 调优吗?
JVM 调优是一个复杂的过程,调优的对象包括堆内存、垃圾收集器和 JVM 运行时参数等。

如果堆内存设置过小,可能会导致频繁的垃圾回收。
启动 JVM 的时候配置了 -Xms 和 -Xmx 参数,让堆内存最大可用内存为 2G(我用的丐版服务器)。
在项目运行期间,我会使用 JVisualVM 定期观察和分析 GC 日志,如果发现频繁的 Full GC,我会特意关注一下老年代的使用情况。
接着,通过分析 Heap dump 寻找内存泄漏的源头,看看是否有未关闭的资源,长生命周期的大对象等。
之后进行代码优化,比如说减少大对象的创建、优化数据结构的使用方式、减少不必要的对象持有等。
4.5 CPU 占用过高怎么排查?
首先,使用 top 命令查看 CPU 占用情况,找到占用 CPU 较高的进程 ID。接着,使用 jstack 命令查看对应进程的线程堆栈信息。然后再使用 top 命令查看进程中线程的占用情况,找到占用 CPU 较高的线程 ID。
接着在 jstack 的输出中搜索这个十六进制的线程 ID,找到对应的堆栈信息。最后,根据堆栈信息定位到具体的业务方法,查看是否有死循环、频繁的垃圾回收、资源竞争导致的上下文频繁切换等问题。

首先,使用 top 命令查看 CPU 占用情况,找到占用 CPU 较高的进程 ID。
top
接着,使用 jstack 命令查看对应进程的线程堆栈信息。
jstack -l <pid> > thread-dump.txt
上面这个命令会将所有线程的堆栈信息输出到 thread-dump.txt 文件中。
然后再使用 top 命令查看进程中线程的占用情况,找到占用 CPU 较高的线程 ID。
top -H -p <pid>
注意,top 命令显示的线程 ID 是十进制的,而 jstack 输出的是十六进制的,所以需要将线程 ID 转换为十六进制。
printf "%x\n" PID
接着在 jstack 的输出中搜索这个十六进制的线程 ID,找到对应的堆栈信息。
"Thread-5" #21 prio=5 os_prio=0 tid=0x00007f812c018800 nid=0x1a85 runnable [0x00007f811c000000]
java.lang.Thread.State: RUNNABLE
at com.example.MyClass.myMethod(MyClass.java:123)
at ...
最后,根据堆栈信息定位到具体的业务方法,查看是否有死循环、频繁的垃圾回收、资源竞争导致的上下文频繁切换等问题。
(1)接口超时的问题排查过吗?
接口超时的排查要从多个层面分析。首先看应用层的监控,比如接口的响应时间分布、错误率、QPS 等指标。
如果有 Skywalking 这种工具,可以看调用链路的详细耗时分布,定位是哪个环节慢了
数据库层面要检查慢查询日志,看是不是某些 SQL 执行时间过长。可能是缺少索引、查询条件不合适、或者锁等待。我会用 EXPLAIN 分析 SQL 的执行计划,看是否需要优化。
网络层面也要考虑,比如下游服务响应慢、网络抖动等。可以通过 ping、telnet 等工具测试网络连通性和响应时间。
4.6 内存飙高问题怎么排查?
内存飚高一般是因为创建了大量的 Java 对象导致的,如果持续飙高则说明垃圾回收跟不上对象创建的速度,或者内存泄漏导致对象无法回收。
排查的方法主要分为以下几步:
第一,先观察垃圾回收的情况,可以通过 jstat -gc PID 1000 查看 GC 次数和时间。或者使用 jmap -histo PID | head -20 查看堆内存占用空间最大的前 20 个对象类型。
第二步,通过 jmap 命令 dump 出堆内存信息。
第三步,使用可视化工具分析 dump 文件,比如说 VisualVM,找到占用内存高的对象,再找到创建该对象的业务代码位置,从代码和业务场景中定位具体问题。
\[
### 4.7 频繁 minor gc 怎么办?
频繁的 Minor GC 通常意味着新生代中的对象频繁地被垃圾回收,可能是因为新生代空间设置的过小,或者是因为程序中存在大量的短生命周期对象(如临时变量)。
可以使用 GC 日志进行分析,查看 GC 的频率和耗时,找到频繁 GC 的原因。
```bash
-XX:+PrintGCDetails -Xloggc:gc.log
```
或者使用监控工具查看堆内存的使用情况,特别是新生代(Eden 和 Survivor 区)的使用情况。
如果是因为新生代空间不足,可以通过 `-Xmn` 增加新生代的大小,减缓新生代的填满速度。
```bash
java -Xmn256m your-app.jar
```
如果对象需要长期存活,但频繁从 Survivor 区晋升到老年代,可以通过 `-XX:SurvivorRatio` 参数调整 Eden 和 Survivor 的比例。默认比例是 8:1,表示 8 个空间用于 Eden,1 个空间用于 Survivor 区。
```bash
-XX:SurvivorRatio=6
```
调整为 6 的话,会减少 Eden 区的大小,增加 Survivor 区的大小,以确保对象在 Survivor 区中存活的时间足够长,避免过早晋升到老年代。
### 4.8 频繁 Full GC 怎么办?
频繁的 Full GC 通常意味着老年代中的对象频繁地被垃圾回收,可能是因为老年代空间设置的过小,或者是因为程序中存在大量的长生命周期对象。
##### 该怎么排查 Full GC 频繁问题?
我厂会通过专门的性能监控系统,查看 GC 的频率和堆内存的使用情况,然后根据监控数据分析 GC 的原因。
如果是小厂,可以这么回复。
我一般会使用 JDK 的自带工具,包括 jmap、jstat 等。
```java
# 查看堆内存各区域的使用率以及GC情况
jstat -gcutil -h20 pid 1000
# 查看堆内存中的存活对象,并按空间排序
jmap -histo pid | head -n20
# dump堆内存文件
jmap -dump:format=b,file=heap pid
```
或者使用一些可视化的工具,比如 VisualVM、JConsole 等,查看堆内存的使用情况。
假如是因为大对象直接分配到老年代导致的 Full GC 频繁,可以通过 `-XX:PretenureSizeThreshold` 参数设置大对象直接进入老年代的阈值。
或者将大对象拆分成小对象,减少大对象的创建。比如说分页。
假如是因为内存泄漏导致的频繁 Full GC,可以通过分析堆内存 dump 文件找到内存泄漏的对象,再找到内存泄漏的代码位置。
假如是因为长生命周期的对象进入到了老年代,要及时释放资源,比如说 ThreadLocal、数据库连接、IO 资源等。
假如是因为 GC 参数配置不合理导致的频繁 Full GC,可以通过调整 GC 参数来优化 GC 行为。或者直接更换更适合的 GC 收集器,如 G1、ZGC 等。
## 五、类加载机制
### 5.1 了解类的加载机制吗?【\*】
JVM的操作对象是Class文件。JVM把Class文件中描述的数据结构加载到内存中,对数据进行校验、解析、初始化。**最终转化成可以被 JVM 直接使用的类型** ,这个过程被称为**类加载机制。**

比较重要的概念就是类加载器、类加载过程、双亲委派模型。
* 类加载器:用于加载类文件.class,将类文件加载到内存中,生成Class对象。
* 类加载过程:包括加载、验证、准备、解析、初始化,验证、准备和解析被称为链接
* 双亲委派模型:将类加载请求往父类加载器传递,父完不成再交给子。当一个类加载器接收到类加载请求时,它会把请求委派给父------类加载器去完成,依次递归,直到最顶层的类加载器,如果父------类加载器无法完成加载请求,子类加载器才会尝试自己去加载。
(1)说说类的加载过程?
类从被加载到 JVM 开始,到卸载出内存,整个生命周期分为七个阶段,分别是载入、验证、准备、解析、初始化、使用和卸载。其中验证、准备和解析这三个阶段统称为连接。
类加载过程除去使用和卸载。

除去使用和卸载,就是 Java 的类加载过程。这 5 个阶段一般是顺序发生的,但在动态绑定的情况下,解析阶段发生在初始化阶段之后。
### 5.2 加载器有哪些?
有4中:启动类加载器(BootstrapClassloader)、扩展类加载器(ExtensionClassloader)、 应用程序类加载器(Apllication Classloader),自定义类加载器
* **启动类加载器** ,负责加载 JVM 的核心类库,如 rt.jar 和其他核心库位于`JAVA_HOME/jre/lib`目录下的类。
* **扩展类加载器** ,负责加载`JAVA_HOME/jre/lib/ext`目录下,或者由系统属性`java.ext.dirs`指定位置的类库,由`sun.misc.Launcher$ExtClassLoader` 实现。
* **应用程序类加载器** ,负责加载 classpath 的类库,由`sun.misc.Launcher$AppClassLoader`实现。我们编写的任何类都是由应用程序类加载器加载的,除非显式使用自定义类加载器。
* **用户自定义类加载器**,通常用于加载网络上的类、执行热部署(动态加载和替换应用程序的组件),或者为了安全考虑,从不同的源加载类。
通过继承`java.lang.ClassLoader`类来实现。
(1)类加载由谁来执行?
由类加载器来执行,**JVM 有三个重要的类加载器:启动类加载器、扩展类加载器、应用类加载器。它们遵循双亲委派机制。**
(2)类加载器是个什么东西?
类加载器是JVM的一个组件。当JVM启动的时候,会在内存中创建几个类加载实例,**这些类加载器的作用就是根据类名去找到对应的.class文件,读取字节码内容,交给JCM进行解析和初始化。**

### 5.3 能说一下类的生命周期吗?
一个类从被加载到虚拟机内存中开始,到从内存中卸载,整个生命周期需要经过七个阶段:加载 、验证、准备、解析、初始化、使用和卸载。

### 5.4 类装载的过程知道吗?【\*】
类装载过程包括三个阶段:载入、链接和初始化。
1.载入:将**类的二进制字节码加载到内存中**。
2.**链接**可以细分为三个小的阶段:
* 验证:检查类文件格式是否符合 JVM 规范
* 准备:为类的静态变量分配内存并设置默认值。
* 解析:将符号引用替换为直接引用。
3.初始化:执行静态代码块和静态变量初始化。
\*\*在准备阶段,静态变量已经被赋过默认初始值了,在初始化阶段,静态变量将被赋值为代码期望赋的值。\*\*比如说 `static int a = 1;`,在准备阶段,`a` 的值为 0,在初始化阶段,`a` 的值为 1。(准备赋默认值,初始化赋设定初始值)
(1)载入过程 JVM 会做什么?
* 1)通过一个类的全限定名来获取定义此类的二进制字节流。
* 2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
* 3)在内存中生成一个代表这个类的 `java.lang.Class` 对象,作为这个类的访问入口。
### 5.5 什么是双亲委派模型?【\*】
双亲委派模型要求类加载器在加载类时,先委托父加载器尝试加载,**只有父加载器无法加载时,子加载器才会加载**。

这个过程会**一直向上递归**,也就是说,从子加载器到父加载器,再到更上层的加载器,一直到最顶层的启动类加载器。
启动类加载器会尝试加载这个类。如果它能够加载这个类,就直接返回;如果它不能加载这个类,就会将加载任务返回给委托它的子加载器。
子加载器尝试加载这个类。如果子加载器也无法加载这个类,它就会继续向下传递这个加载任务,依此类推。
**直到某个加载器能够加载这个类,或者所有加载器都无法加载这个类,最终抛出 ClassNotFoundException。**
### 5.6 为什么要用双亲委派模型?
有两个好处:
1.避免类的重复加载:父加载器加载的类,子加载器无需重复加载。
2.保证核心库的安全:如 `java.lang.*` 只能由 Bootstrap ClassLoader 加载,防止被篡改。
### 5.7 如何破坏双亲委派机制?
**重写ClassLoader的loadClass()方法**。
如果不想打破双亲委派模型,就重写 ClassLoader 类中的 `findClass()` 方法,那些无法被父类加载器加载的类最终会通过这个方法被加载。
### 5.8 有哪些破坏双亲委派模型的典型例子?
有两种:SPI机制和热部署

(1)说说SPI 机制?
**SPI 是 Java 的一种扩展机制,用于加载和注册第三方类库**,常见于 JDBC、JNDI 等框架。
**双亲委派模型会优先让父类加载器加载类,而 SPI 需要动态加载子类加载器中的实现。**
根据双亲委派模型,`java.sql.Driver` 类应该由父加载器加载,但父类加载器无法加载由子类加载器定义的驱动类,如 MySQL 的 `com.mysql.cj.jdbc.Driver`。
那么只能使用 SPI 机制通过 `META-INF/services` 文件指定服务提供者的实现类。
```java
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Enumeration