第1章 GC基础概念:垃圾回收原理、分代与常见算法
各位好,我是你们的老朋友。做后台开发这么多年,我遇到过最头疼的问题之一就是GC抖动和内存泄漏。记得有一次线上服务每隔几分钟就卡一下,查了半天才发现是GC参数没调好。从这一章开始,我们先把地基打牢——聊聊垃圾回收的基本原理、分代收集以及三种经典算法。
1.1 垃圾回收到底在回收什么?
先说个最基础的问题:哪些对象算是“垃圾”?很简单,程序运行过程中,那些再也无法被引用的对象就是垃圾。比如你在方法里 new 了一个临时对象,方法执行完就没人用它了,那它就该被回收。
JVM 怎么判断对象是不是“活着”?主要靠可达性分析。从一组称为“GC Roots”的根对象出发(比如栈帧中的局部变量、静态变量、JNI引用等),顺着引用链往下走。能到达的对象就是活的,到不了的就等着被清理。
1.2 分代收集:为什么要把堆分成两代?
其实是个很朴素的观察——大部分对象“朝生夕死”。你在for循环里new的临时对象,往往很快就不可达了。而少部分对象会长期存活,比如缓存对象、全局配置。所以JVM把堆分成新生代(Young)和老年代(Old),分别用不同的策略处理。
- 新生代:对象刚出生的地方。GC 频率高,但每次收集快。占堆的大约1/3。
- 老年代:经过多次GC还活着的对象会晋升到这里。GC 频率低,但单次开销大。
分代的核心思想:对不同特性的对象用不同的算法,达到吞吐量和延迟的平衡。你看,设计者还是挺聪明的。
| 分代 | 特点 | GC频率 | 常见算法 |
|---|---|---|---|
| 新生代 | 对象多,死得快 | 高(Minor GC) | 复制算法 |
| 老年代 | 对象少,常存活 | 低(Major GC / Full GC) | 标记-清除 / 标记-整理 |
1.3 三种经典GC算法,各自什么脾气?
1.3.1 标记-清除(Mark-Sweep)
算法分两步:先标记所有可达对象,然后清理掉没被标记的。听起来简单,但有两个大问题:
- 产生内存碎片 —— 清理后的空间不连续,下次分配大对象时可能找不到足够大的连续块,导致提前触发GC。
- 效率不稳定 —— 随着对象越来越多,标记和清理的时间都会增长。
我在一个日志系统里见过被碎片坑惨的案例:老年代空闲空间还有40%,但因为碎片严重,一个大对象分配失败,直接触发了Full GC。嗯,标记-清除虽然实现简单,但生产环境很少单独用。
1.3.2 复制算法(Copying)
把可用内存分成两块,每次只使用其中一块。GC时,把存活的对象复制到另一块,然后清空当前块。完美解决碎片问题,而且效率高(因为大多数对象都是死的,复制工作少)。
缺点也很明显:浪费了一半内存。不过,新生代的对象死亡率高,浪费一点可以接受。所以HotSpot的新生代默认采用这种算法,并且把空间划分为一个较大的Eden区和两个较小的Survivor区,比例通常是8:1:1。这样实际浪费只有10%。
1.3.3 标记-整理(Mark-Compact)
结合了标记-清除和复制算法的优点。先标记,然后把存活对象向一端移动,整理成连续空间,再清理掉边界之外的内存。既有复制算法消除碎片的优点,又不需要额外内存。
但整理阶段需要移动大量对象,还要更新所有引用,停顿时间会比较长。所以,标记-整理主要用在老年代,而且通常配合低延迟的垃圾回收器(比如CMS的并发标记阶段之后做一次整理)。我参与过一个金融交易系统,要求GC停顿不能超过50ms,当时我们就是用的G1收集器(底层就是标记-整理思想),配合调优后效果不错。
| 算法 | 优点 | 缺点 | 适用代 |
|---|---|---|---|
| 标记-清除 | 实现简单,不移动对象 | 内存碎片,效率不稳定 | 老年代(很少单独用) |
| 复制 | 无碎片,效率高 | 浪费空间(新生代可接受) | 新生代 |
| 标记-整理 | 无碎片,内存利用率高 | 移动对象慢,停顿大 | 老年代 |
1.4 本知识体系一览
下面是本章核心概念的框架图,把GC原理、分代和算法串在一起。你可以把它当作一张“地图”,后面讲具体收集器时,会反复引用这些基础。
1.5 本章小结
GC的基础就三个事:怎么判断垃圾、怎么分代、怎么回收。不用急着背算法细节,先理解为什么需要分代、三种算法各自对应的场景。下一段路会更精彩——我们会把这些概念用到线上故障分析中。嗯,先消化今天的内容,我们后面聊。