Java虚拟机中的GC机制(以HotSpot为例)
说到GC机制,HotSpot虚拟机是绕不开的标杆。虽然咱们Android用的是ART,但很多核心思路都是从HotSpot借鉴过来的。我个人习惯把HotSpot的GC比作一个「分代清扫大队」——把内存分成不同年龄的房间,给年轻人和老年人安排不同打扫频率。
1. 对象存活判定:谁该被回收?
大家可能听过引用计数法,每个对象都记着自己被引用了多少次。但Android开发者都知道,循环引用就会出问题。我早期做Java服务端时,就踩过这个坑——两个Bean互相持有,引用计数永远不为0,最后内存泄漏到OOM。
HotSpot用的是可达性分析。从GC Roots(比如栈帧里的局部变量、静态变量)出发,像蜘蛛网一样往下搜。没被连上的对象,就是「不可达」——直接标记为垃圾。
记住这个要点:哪些能当GC Roots?线程栈里的引用、JNI全局引用、方法区里的静态字段。Android中同理,像Activity的Context引用要格外小心。
2. 三大基础算法:标记-清除、标记-复制、标记-整理
三种算法各有各的脾气,我整理了一张对比表,方便你快速回忆。
| 算法 | 核心方式 | 痛点 | 典型的应用场景 |
|---|---|---|---|
| 标记-清除 | 先标记存活对象,再清除不可达的 | 内存碎片多,大对象分配失败 | 老年代CMS收集器(早期版本) |
| 标记-复制 | 把存活对象复制到另一块空间,原空间整块清空 | 需要一倍空闲空间,内存利用率低 | 新生代(Eden→Survivor) |
| 标记-整理 | 存活对象往一头移动,消除碎片 | 移动对象开销大,STW时间长 | 老年代的Parallel/Old GC |
注意,这三种算法不是互斥的,HotSpot往往组合使用。比如新生代用复制,老年代用整理。
3. 分代收集:把内存分成「青年公寓」和「老年社区」
HotSpot把堆分成新生代和老年代。新生代又细分为一个Eden区和两个Survivor区(From、To)。
我记得调整过一个线上服务:默认的晋升阈值是15,但有些对象在Minor GC后总逃不掉,一直留在Survivor里来回复制。后来我通过 -XX:MaxTenuringThreshold=4 强制提前晋升,老年代GC频率反而降了。说白了,阈值不是越大越好,要看你具体业务对象的存活模式。
这里有个常用参数表:
| 参数 | 作用 |
|---|---|
-Xmn |
新生代大小 |
-XX:SurvivorRatio |
Eden与单个Survivor的比例 |
-XX:MaxTenuringThreshold |
对象晋升老年代的年龄阈值 |
-XX:PretenureSizeThreshold |
大对象直接进入老年代的阈值 |
4. 主流收集器:从CMS到G1
我个人比较喜欢G1,因为它把堆分成很多个Region(区域),可以做到「预测停顿」。但早期CMS在Android低版本ART中也有类似影子。说白了,收集器的选择就三个因子:吞吐量、停顿时间、内存占用。
简单罗列一下常见的:
- Serial / ParNew:单线程/多线程的新生代收集器,客户端用得多。
- Parallel Scavenge:注重吞吐量,后台任务场景合适。
- CMS:并发标记-清除,追求低停顿,但会有浮动垃圾和碎片。
- G1:Region化,可设定最大停顿时间,JDK 9后默认。
- ZGC:超低停顿(<10ms),但内存开销大。
我曾经在项目里遇到CMS的「Concurrent Mode Failure」——老年代回收速度赶不上分配速度,结果退化到Serial Old单线程GC,导致严重卡顿。避坑指南:一定要预留足够的空闲内存空间,比如 -XX:CMSInitiatingOccupancyFraction=70 提前触发。
5. GC触发条件与Stop-The-World
Minor GC(新生代GC)是在Eden区满时触发。Full GC老年代满、方法区满、或者显式调用 System.gc() 都会触发。注意,显式调用不一定立即发生,但会让JVM「尽力而为」——其实建议你永远别调,尤其在Android上,系统级GC比代码里瞎调靠谱。
所有收集器在做「标记」或「整理」时,都会暂停应用线程,这就是STW。我见过有人把安全点设得太密集,导致线程频繁进入安全点检查,反而增加停顿开销。
为什么会STW?因为不暂停线程,引用关系一直在变,标记结果就是「薛定谔的垃圾」。HotSpot会等所有线程到达安全点后,才执行GC操作。
安全点的选择一般放在循环末尾或方法调用处。如果你写了一个超长无中断的循环,GC线程会一直等你,这就是「无限期STW」的元凶之一。
核心流程图:HotSpot GC 完整演进
下面这张图能帮你快速温习整个分代收集过程。
嗯,看到这个流程你可能会说:不就是新生代复制、老年代整理吗?但实际优化时,每一个环节都可能成为卡顿的坑。比如Eden区太小会导致Minor GC频繁;老年代提前GC又可能引发Full GC。我建议你在调优时,先从GC日志入手——用 -XX:+PrintGCDetails 观察各代使用率变化,再调整分代比例。
一句话总结:HotSpot的GC本质是用「空间换时间」——复制算法牺牲一半空闲空间,换来无碎片;整理算法移动对象牺牲停顿时间,换来连续内存。你的选择决定了帧率是30fps还是60fps。