你这段整体思路是对的,已经很像面试回答了。可以按 "什么是垃圾 → 怎么找垃圾 → 怎么回收垃圾 → JVM 为什么分代 → 常见垃圾回收器 → GC 调优" 这个顺序理解。
我帮你重新解释一遍,并顺便指出几个容易说错的点。
一、什么是垃圾?
在 Java 中,垃圾对象 指的是:
程序后续不可能再使用到的对象。
比如:
java
User user = new User();
user = null;
原来的 new User() 对象已经没有变量引用它了,后续无法再访问,它就可能成为垃圾。
二、怎么判断一个对象是不是垃圾?
1. 引用计数法
早期思想是:每个对象维护一个引用计数器。
java
User user = new User();
这个对象被 user 引用,引用计数 +1。
java
user = null;
引用断开,引用计数 -1。
如果引用计数变成 0,就认为对象是垃圾。
但是它有一个大问题:循环引用。
java
class A {
B b;
}
class B {
A a;
}
假设:
java
A a = new A();
B b = new B();
a.b = b;
b.a = a;
a = null;
b = null;
虽然外部已经无法访问 A 和 B 了,但 A 和 B 彼此引用,引用计数都不是 0。
所以引用计数法会误以为它们不是垃圾,从而无法回收,造成内存泄漏。
2. 根可达性分析算法
Java 主流 JVM 使用的是 GC Roots 可达性分析算法。
它的核心思想是:
从一组特殊对象 GC Roots 出发,沿着引用链往下找,能找到的对象就是存活对象,找不到的对象就是垃圾对象。
可以理解成这样:
text
GC Roots
|
v
对象 A
|
v
对象 B
A、B 能从 GC Roots 访问到,所以它们不是垃圾。
如果某个对象完全不在这条引用链上,就说明程序已经没办法访问它了,它就是垃圾。
常见的 GC Roots 有:
text
1. 虚拟机栈中引用的对象
2. 方法区中静态变量引用的对象
3. 方法区中常量引用的对象
4. 本地方法栈 JNI 引用的对象
5. 正在运行的线程对象
比如:
java
public static User staticUser = new User();
public void test() {
User localUser = new User();
}
staticUser 引用的对象可以作为静态变量关联对象存活。
localUser 引用的对象在方法执行期间,也可以通过虚拟机栈中的局部变量表找到,所以暂时不是垃圾。
三、垃圾回收算法
垃圾找出来之后,JVM 要考虑怎么回收。常见算法主要有三类。
1. 标记-清除算法
流程:
text
第一步:标记存活对象或垃圾对象
第二步:清除垃圾对象
示意:
text
回收前:
[存活][垃圾][存活][垃圾][存活]
回收后:
[存活][空闲][存活][空闲][存活]
优点:
text
实现相对简单
不用移动对象
回收速度相对较快
缺点:
text
会产生内存碎片
所谓内存碎片就是:虽然总的空闲空间够,但不连续。
比如现在有三个空闲块:
text
2MB + 3MB + 4MB = 9MB
但是如果你要分配一个 6MB 的大对象,可能找不到连续空间,还是会触发 GC,甚至 OOM。
所以你说的"会产生内存碎片,造成内存浪费和溢出"是对的。
2. 标记-整理算法
流程:
text
第一步:标记存活对象
第二步:把存活对象向一端移动
第三步:清理边界外的垃圾
示意:
text
回收前:
[存活][垃圾][存活][垃圾][存活]
整理后:
[存活][存活][存活][空闲][空闲]
优点:
text
不会产生内存碎片
适合老年代
缺点:
text
需要移动对象
效率比标记-清除低
因为对象移动之后,对象的引用地址也需要更新,成本比较高。
3. 复制算法
流程:
把内存分成两块,每次只使用其中一块。
text
From 区:正在使用
To 区:空闲
GC 时,把存活对象复制到 To 区,然后清空 From 区。
示意:
text
From 区:
[存活][垃圾][垃圾][存活]
复制到 To 区:
[存活][存活][空闲][空闲]
然后 From 区整体清空
优点:
text
不会产生内存碎片
回收效率高
缺点:
text
浪费一部分内存空间
如果存活对象很多,复制成本会很高
所以复制算法非常适合年轻代。
因为年轻代对象大多是"朝生夕死",垃圾多,存活对象少,需要复制的对象少,效率就很高。
四、为什么要分代垃圾回收?
Java 堆中的对象生命周期差异很大。
有些对象:
java
new User();
可能方法执行完就没用了。
有些对象,比如缓存、单例、Spring Bean,可能会存活很久。
所以 JVM 使用了 分代思想:
text
年轻代:存放生命周期短的对象
老年代:存放生命周期长的对象
这样可以做到:
text
年轻代频繁回收
老年代较少回收
不同区域使用不同回收算法
五、年轻代和老年代怎么回收?
年轻代
年轻代通常分为:
text
Eden 区
Survivor From 区
Survivor To 区
大多数新对象会先分配到 Eden 区。
当 Eden 区满了,会触发 Minor GC。
Minor GC 时:
text
Eden 和 From 中存活的对象复制到 To 区
然后清空 Eden 和 From
From 和 To 角色互换
对象每熬过一次 Minor GC,年龄通常会 +1。
当对象年龄达到一定阈值,比如默认最大年龄 15 左右,就可能晋升到老年代。
所以年轻代一般使用 复制算法。
老年代
老年代对象存活时间长,回收频率低。
如果老年代也用复制算法,就会很亏,因为老年代存活对象多,复制成本高。
所以老年代通常使用:
text
标记-清除
标记-整理
或者两者结合
六、CMS 垃圾回收器
CMS 全称是 Concurrent Mark Sweep。
它主要是针对 老年代 的垃圾回收器。
它的目标是:
尽量减少 STW 停顿时间,追求低延迟。
STW 是 Stop The World,意思是垃圾回收时暂停所有用户线程。
CMS 的大致过程:
text
1. 初始标记
2. 并发标记
3. 重新标记
4. 并发清除
其中:
text
初始标记、重新标记需要 STW
并发标记、并发清除可以和用户线程一起执行
所以 CMS 停顿时间比较短。
但是 CMS 有几个问题:
text
1. 使用标记-清除算法,会产生内存碎片
2. 并发执行时会占用 CPU 资源
3. 并发清理过程中用户线程还在运行,可能产生浮动垃圾
你原文中说:
CMS 采用标记清除算法,优点是速度快;并发标记和并发清理允许用户线程和 GC 线程同时工作。
这个说法是可以的。
不过要补充一句:
CMS 的缺点是容易产生内存碎片,所以后来逐渐被 G1 替代。
七、G1 垃圾回收器
G1 全称是 Garbage First。
它是一个面向服务端应用的垃圾回收器,特点是:
可以设置期望的最大停顿时间,并尽量在这个时间内完成 GC。
比如:
bash
-XX:MaxGCPauseMillis=200
意思是希望每次 GC 停顿尽量控制在 200ms 以内。
注意:这是 目标,不是绝对保证。
G1 的核心思想
传统分代是物理连续的:
text
年轻代一整块
老年代一整块
G1 把整个堆切成很多大小相等的 Region:
text
Region 1
Region 2
Region 3
Region 4
...
每个 Region 可以扮演不同角色:
text
Eden Region
Survivor Region
Old Region
Humongous Region
G1 会统计每个 Region 的垃圾比例和回收收益。
回收时优先回收垃圾最多、收益最高的 Region。
这就是 Garbage First 的含义:
垃圾最多的区域优先回收。
G1 为什么能控制停顿时间?
因为它不是每次都回收整个老年代,而是挑一部分 Region 回收。
比如它估算:
text
回收 20 个 Region 可能需要 180ms
回收 30 个 Region 可能需要 260ms
如果目标停顿时间是 200ms,它就可能只回收 20 个 Region。
所以 G1 可以做到相对可预测的停顿时间。
八、GC 调优怎么说?
GC 调优本质上不是盲目调参数,而是:
根据业务场景,在吞吐量、延迟、内存占用之间做权衡。
常见目标有两个:
1. 吞吐量优先
适合后台任务、批处理、计算任务。
比如:
text
任务可以接受短暂停顿
但希望整体处理速度快
这种场景可以考虑 Parallel GC。
JDK 8 默认常见就是 Parallel GC。
2. 低延迟优先
适合接口服务、交易系统、实时系统。
这种场景希望:
text
单次 GC 停顿尽量短
请求响应时间稳定
JDK 8 可以考虑 CMS 或 G1。
更现代的 JDK 里,可以考虑 G1、ZGC、Shenandoah。
但如果你面试说 JDK 8,重点说 CMS 和 G1 就够了。
九、你这段里有一个点需要修正
你原文说:
JDK7 到 JDK8 的变化是把方法区的实现从堆内存移到了本地内存。
这个大方向是对的,但表达可以更准确:
方法区是 JVM 规范中的概念。JDK 7 及以前,HotSpot 使用永久代 PermGen 来实现方法区;JDK 8 移除了永久代,改用元空间 Metaspace 实现方法区,而元空间使用的是本地内存。
也就是说:
text
方法区:规范概念
永久代:JDK 8 之前 HotSpot 对方法区的实现
元空间:JDK 8 之后 HotSpot 对方法区的实现
为什么要移除永久代?
因为永久代大小受 JVM 参数限制,容易出现:
text
java.lang.OutOfMemoryError: PermGen space
尤其是大量动态生成类、频繁部署应用、反射、代理、框架较多的场景。
JDK 8 之后,类元信息放到本地内存中的 Metaspace,默认情况下会根据系统内存动态扩展,所以更不容易因为类元信息太多导致永久代 OOM。
但注意:元空间也不是无限的,也可以通过参数限制:
bash
-XX:MaxMetaspaceSize
十、逃逸分析和栈上分配为什么能减轻 GC 压力?
正常情况下,对象大多分配在堆上。
堆上的对象需要 GC 管理。
但是 JVM 可以通过逃逸分析判断:
这个对象会不会逃出当前方法或当前线程?
如果对象没有逃逸,JIT 编译器可能会进行优化,比如:
text
栈上分配
标量替换
锁消除
比如:
java
public void test() {
User user = new User();
user.name = "Tom";
user.age = 18;
}
如果 user 没有返回,也没有赋值给外部变量,也没有被其他线程访问,那么 JVM 可能认为它没有逃逸。
这时 JVM 可能不真的在堆上创建完整对象,而是把它拆成几个局部变量:
text
name = "Tom"
age = 18
这样对象都没进堆,自然就减少了 GC 压力。
不过面试里说的时候最好加一句:
逃逸分析是一种 JVM 优化手段,不代表所有未逃逸对象一定会栈上分配,具体要看 JIT 优化结果。
面试版回答可以这样说
你这段可以整理成下面这版:
Java 的垃圾回收主要解决两个问题:第一,判断哪些对象是垃圾;第二,如何回收这些垃圾对象。
判断垃圾对象时,早期有引用计数法,但是引用计数法解决不了循环引用问题,所以 Java 主流 JVM 使用的是 GC Roots 可达性分析算法。从 GC Roots 出发,沿着引用链能够访问到的对象就是存活对象,访问不到的对象就是垃圾对象。常见的 GC Roots 包括虚拟机栈中的引用、静态变量引用、常量引用、本地方法栈 JNI 引用等。
找到垃圾之后,常见回收算法有三种:标记-清除、标记-整理和复制算法。标记-清除效率较高,但会产生内存碎片;标记-整理不会产生碎片,但需要移动对象,成本较高;复制算法效率高、没有碎片,但会浪费一部分空间,适合存活对象少的区域。
因为 Java 对象生命周期不同,所以 JVM 采用分代回收思想。年轻代对象大多朝生夕死,适合使用复制算法;老年代对象存活时间长,适合使用标记-清除或标记-整理算法。
常见垃圾回收器中,CMS 是针对老年代的低停顿垃圾回收器,采用标记-清除算法,并且在并发标记、并发清除阶段可以和用户线程一起执行,因此停顿时间较短,但缺点是会产生内存碎片。G1 是面向整个堆的垃圾回收器,它把堆划分成多个 Region,优先回收垃圾收益高的 Region,并且可以设置期望的最大停顿时间,适合大堆内存、低延迟场景。
GC 调优方面,通常会根据业务场景选择合适的垃圾回收器。如果追求吞吐量,可以考虑 Parallel GC;如果追求低延迟,可以考虑 CMS、G1,现代 JDK 还可以考虑 ZGC。除此之外,还可以通过合理设置堆大小、年轻代比例、排查频繁 Full GC、减少对象创建、利用逃逸分析等方式降低 GC 压力。
你原来的内容已经比较完整了。最需要注意的是这三点:
text
1. G1 的停顿时间是"尽量达到目标",不是绝对保证。
2. JDK8 不是"方法区没了",而是"方法区的实现从永久代变成元空间"。
3. 逃逸分析不等于一定栈上分配,更常见的优化还有标量替换。