很多写了几年业务代码的兄弟,对 JVM 的垃圾回收(GC)都有一个巨大的误解:“既然 Java 帮我自动回收内存了,那我管它底层怎么扫地干嘛?我只要负责new对象不就行了?”
如果你抱着这个想法,那线上一旦出现接口偶尔超时、CPU 突然飙升 100%、或者半夜突然收到 OOM(内存溢出)报警,你绝对会像没头苍蝇一样抓狂。
今天,咱们不背那些干巴巴的八股文。我们就从**“怎么找垃圾”、“怎么扫垃圾”,以及“懂了这些到底对咱们天天写代码有什么用”**这三个直击灵魂的角度,把 JVM 垃圾回收的底裤彻底扒掉!
一、 是什么:JVM 到底是怎么揪出“内存垃圾”的?
在 JVM 这个大工厂里,内存是极其宝贵的资源。如果你申请了内存但不用了,这就叫垃圾。那么保洁阿姨(GC 线程)怎么判断你是不是垃圾呢?
致命误区:引用计数法(Reference Counting)
很多人的第一直觉是:给每个对象贴个数字,有一个人引用它就 +1,没人引用了变成 0,就当垃圾收走。
被打脸的现实:这种方法有个致命 Bug 叫**“循环引用”**。假设 A 引用了 B,B 又引用了 A,但外界根本没人用它俩。这俩货的计数器永远是 1,保洁阿姨看着它俩干瞪眼,这块内存就永远泄露了。JVM 早就抛弃了这种弱智玩法!
现代基石:可达性分析算法(Reachability Analysis)
现代 JVM 使用的是极其霸道的“顺藤摸瓜”策略。
JVM 在内部选定了一小撮绝对不能被回收的“超级大靠山”,统称为GC Roots(比如:正在执行的方法里的局部变量、类的静态变量等)。
大白话直觉:保洁阿姨只认大靠山。她从 GC Roots 开始拉一根绳子往下捋。只要你能顺着这根绳子和 GC Roots 攀上关系(引用链可达),你就是良民,绝对安全。如果你和所有的大靠山都断了联系,哪怕你和另外几个废柴紧紧抱团(循环引用),在阿姨眼里也是一堆死掉的“孤岛”,统统拉去火化!
二、 核心机制:保洁阿姨的三大“扫地绝学”
找出了垃圾,接下来怎么清理?JVM 根据不同区域的特性,配备了三套不同的打扫方案。
1. 标记-清除(Mark-Sweep):最懒的做法
怎么扫:第一遍走访,给所有垃圾贴上罚单(标记);第二遍走访,直接把贴罚单的垃圾原地炸毁(清除)。
致命缺陷:内存碎片!原地炸毁后,可用内存变得坑坑洼洼。下次你要
new一个大数组,发现虽然总空间够,但连不起来,只能被迫再次触发 GC。
2. 复制算法(Copying):空间换时间的土豪玩法
怎么扫:把房间一劈两半,平时只用左边。大扫除时,把左边活着的好东西,一股脑全搬到右边,并且紧紧挨着码放整齐。然后把左边直接引爆全清空。
实战场景:专用于新生代!因为新生代 98% 的对象都是“朝生夕死”的,活下来的极少,搬运成本极低。最大优势是绝对没有内存碎片。
3. 标记-整理(Mark-Compact):强迫症的福音
怎么扫:发现垃圾后不急着炸,而是像玩“俄罗斯方块”一样,把所有活着的对象全部往内存的一侧推,紧紧贴在一起。最后把边界线以外的垃圾一刀切掉。
实战场景:专用于老年代!老年代里全是活了很久的老油条,如果用复制算法太浪费空间。推一次虽然慢(会引发较长的系统停顿 STW),但一劳永逸。
三、 极简实战:几行代码,亲手制造一次“内存暗杀”
光说不练假把式。我们来看看如果不懂 GC Roots,是如何在不经意间把系统干崩溃的。
Java
import java.util.ArrayList; import java.util.List; public class GCRootLeakDemo { // 致命毒药:这是一个 static 变量! // 在 JVM 规范里,类的静态属性是铁打的 GC Root(大靠山)! private static final List<byte[]> MEMORY_LEAK_LIST = new ArrayList<>(); public static void main(String[] args) throws InterruptedException { System.out.println("====== 危险业务上线 ======"); while (true) { // 模拟用户请求:每次请求产生 1MB 的临时数据 byte[] tempData = new byte[1024 * 1024]; // 实习生为了所谓“统计”或“缓存”,把临时数据塞进了静态集合里 MEMORY_LEAK_LIST.add(tempData); System.out.println("处理了一次请求,向静态集合塞入 1MB 数据..."); Thread.sleep(100); // 注意:因为 tempData 被 static 的 MEMORY_LEAK_LIST 死死抓住, // 哪怕这次请求结束了,这 1MB 数据依然与 GC Root 可达! // 保洁阿姨根本不敢动它!最终必将 OOM! } } } /** * 运行输出(搭配 -Xmx50m 运行): * ====== 危险业务上线 ====== * 处理了一次请求,向静态集合塞入 1MB 数据... * ... (几十次之后) * Exception in thread "main" java.lang.OutOfMemoryError: Java heap space */看懂了吗?这就是线上最经典的内存泄漏场景!因为你不懂静态变量是 GC Root,无限制地往里面塞东西,导致垃圾永远无法被回收,硬生生把内存撑爆了。
四、 灵魂拷问:懂了 GC,对天天写 CRUD 的你有什么意义?
如果你能把上面的逻辑盘明白,你的代码段位就已经超越了 80% 的同行。了解 GC 对开发者的真实意义,体现在以下三大“避坑”直觉中:
| 业务开发常见操作 | 不懂 GC 的灾难后果 | 懂 GC 的神仙操作(降维打击) |
| 滥用全局缓存 | 直接弄个static Map存用户 Token,越存越多,由于是 GC Root,老年代塞满,系统一天崩一次。 | 明白生命周期,改用 Redis,或者使用具备淘汰机制的本地缓存(如 Guava Cache / Caffeine)。 |
| 超大对象的创建 | 查数据库时,动不动就select *捞几十万条数据塞进 List。 | 知道超大对象会直接绕过新生代,砸进老年代引发极耗时的 Full GC 导致接口卡死。果断改用分页查询! |
| ThreadLocal 的使用 | 线程池里用完ThreadLocal不调用remove()。 | 知道 ThreadLocalMap 的 Key 是弱引用,但 Value 是强引用。线程不销毁,Value 就一直与线程(GC Root)可达,导致隐蔽的内存泄漏!用完必在finally里remove! |
| 接口偶发性超时 | 接口平时 20ms,偶尔几秒钟不响应,到处抓包查网络。 | 意识到可能是触发了老年代的 GC 导致了STW (Stop-The-World 全局停顿)。直接去查 GC 日志,调整年轻代比例或更换 G1 回收器来平滑停顿时间。 |
总结一句话:
学习垃圾回收,绝不是为了去造一个 JVM。而是让你在写每一行new Object()、定义每一个static变量时,脑子里都能清晰地看到这块内存未来的生老病死。这种掌控感,才是高级工程师和代码搬运工的核心壁垒。