谁是垃圾
当一个对象不再被外界引用时,就成为了垃圾;GC 主要回收的区域是堆和方法区,因为程序计数器和栈的生命周期和线程相同。
堆的回收
怎样确定哪些是垃圾,主要有下面两种方法:
-
引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
引用计数法难以解决间接引用或循环引用问题:
class A{ Object b = null; } public static void main(String s[]){ A a = new A(); B b = new B(); a.b = b; a = null; b = null; } // 不能在一轮中回收a和b,因为a对象中有一个属性引用到b对象,只有a回收了,b才能回收; // 还有可能循环引用,就是上述 a和b中的属性互相引用b和a对象,这时候引用计数法永远无法回收a和b; -
可达性分析法
这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。

-
哪些可以当做 GC Roots 对象:
- 栈中的局部变量,因为方法执行过程中使用到的对象资源当然不能被回收;
- 类的静态引用型变量,只要类不卸载,这些引用就一直存在;
- JVM 内常驻内存的对象,如基本型的 Class 对象、异常对象、类加载器等;
- 本地方法栈中 JNI (Native 方法)引用的对象;
- ……
-
过程分析:
假如有一个静态引用型变量 obj,它就可以作为 GC Roots 中的一员,从它向下搜索,对引用链上的对象进行标记,如 obj 内有一个 A 成员对象,A 又有 B、C 两个成员对象,那 A、B、C 就不会被回收;把 GC Roots 中的根节点全部按此方法操作一番,剩下没标记的就是垃圾,等待被回收。
方法区的回收
主要回收常量池和卸载类信息。
- 判定废弃的常量比较容易,像回收堆中对象一样,若没有指向该常量的引用,就可以回收了;
- 判定不再使用的类非常困难,需要满足下面的条件:
- 所有该类及其子类的实例都被回收了;
- 该类的 Class 对象没有被引用;
- 加载该类的类加载器已经被回收;
- 这一点很难满足,因为大多都是采用默认加载器加载,我们不可能让默认加载器被回收;但其实这些类不卸载也没关系,它们一般不会占太大空间。
垃圾收集思想
分代回收理论
收集器将堆划分成具有不同年龄的区域,主要目的是对其进行不同频率的回收。至少分为新生代与老年代,随着收集次数的增加,那些存活下来的“年长的对象“被转移到老年代,老年代的对象往往有生命周期往更长发展的趋向性,我们就可以减少老年代回收垃圾的频率,以提升整个 GC 的效率。
MinorGC、MajorGC、FullGC 分别对应着新生代回收、老年代回收、整堆回收;GC 是很耗费资源的,而 FullGC 的代价更不用说(一般会进行碎片整理),针对不同的区域采用合适的垃圾回收算法,可以减少 FullGC 的次数;后面会提到 G1 收集器,它属于 MixedGC 混合回收。
分代回收有一个非常大的问题就是,两代对象之间可能存在跨代引用,这样在 MinorGC 过程中就不得不再遍历老年代对象。而经过实践证明或者由我们猜想可以得知:存在互相引用关系的两个对象有共存亡的趋向性。再结合上面说到的老年代有更年长的趋向性可以知道,跨代引用中的新生代对象有着很大的概率也会迈向老年代。因此我们可以不为跨代引用去遍历老年代。
年龄在对象头 MarkWord 中用 4 bit 表示,最大 15。
垃圾收集算法
标记 - 清除(Mark-Sweep)
根据前面说的判断垃圾的方法,对需要回收的对象进行标记,标记完之后进行统一回收;
缺点:产生大量零头,后面如果碰到大对象而没有合适大小内存分配,有可能会引发 FullGC;还有就是效率不稳定,随着对象数量的增加,不论是标记还是清除耗费的时间都是不可控的。后面会讲到 GC 追求低延迟,这就要求在时间上要有一定程度的可控性。
标记 - 复制(Mark-Copying)
复制算法解决了清除算法产生大量零头的问题;
该算法将内存分成 1:1 的 A、B 两块,每次只用其中一块,当 A 满了之后进行标记,将要存活下来的对象复制到另一块 B 上,然后清除 A 块,不断往复。主要运用于新生代;
缺点:显而易见,浪费了很大的空间,同时在存活对象较多时复制操作效率也比较低。
经验发现,新生代对象有将近 98% 都逃不过第一次回收,即不需要对内存进行 1:1 的划分,因此又有了下面的变体形式:
将新生代内存分为两块较小的 Survivor 空间和一块较大的 Eden 空间,HotSpot 默认比例为 1:1:8,每次只用 Eden 和 Survivor 中的一块,方法和标记 - 复制一样,每次将存活下来的对象复制到另一块 Survivor 上;
注意:
- 当存活的对象超过一块 Survivor 大小时,通常需要由老年代内存进行分配担保,即有可能将“年龄不够”的对象提前转入老年代(tenure)。
- 有一些大对象,如大数组、字符串,会直接进入老年代,避免在新生代的 Eden 和 Survivor 之间来回复制。
标记 - 整理(Mark-Compact)
整理算法和复制算法有异曲同工之妙,它通过移动对象来消除了复制算法中浪费的内存;
标记完之后将存活的对象都向内存的一端移动,然后清除边界以后的对象。主要用于老年代
缺点:整理非常耗费时间,不但如此,移动对象并更新引用还会使用户进程被暂停,被形象成为“Stop The World”;
STW 主要是由于枚举根节点造成的,因为用户线程运行中的引用关系可能会发生改变,影响根节点枚举结果的准确性,所以必须暂停用户线程;查找引用链工作可以与用户线程并发执行。
垃圾收集器
JDK1.8 默认使用 ParallelScavenge + ParallelOld 收集器;
JDK1.9 默认使用 G1 收集器。


可以看到上面显示 HotSpot 工作在 Server 模式,相对的还有 Client 模式,二者的主要区别就在于 JVM 默认的一些参数和垃圾收集器的选用上有所不同,因为一般桌面端与服务器端的硬件差别是非常大的,在不同的应用场景下选择不同的垃圾收集器可以让用户获取更好的体验。
新生代收集器
- Serial 收集器
单线程收集器,进行回收时必须暂停其他线程(STW),但是它简单高效,只专心于做垃圾回收没有额外的线程交互开销,因此是 HotSpot 虚拟机 Client 模式下默认的新生代收集器。
- ParNew 收集器
是 Serial 收集器的多线程版,主要用于 Server 模式,在进行回收时仍会 STW。
- ParallelScavenge 收集器
在 ParNew 的基础上追求更高的吞吐量,这就要使垃圾回收时 STW 的最大时间尽可能的可控。
-XX:MaxGCPauseMillis 设置最大垃圾收集停顿时间,设置太小反而会频繁 GC;
-XX:GCTimeRatio 设置 GC 时间比率,取 0-99,即 GC 占总运行时间的倒数 ;
-XX:+UseAdaptiveSizePolicy 自适应调节,运行期间收集数据动态去调整各个参数;
老年代收集器
- SerialOld 收集器
Serial 收集器的老年代版,单线程,主要用于 Client 端,可以配合 Serial、ParNew 工作。
- ParallelOld 收集器
ParallelScavenge 的老年代版,多线程,在其出现之前,ParallelScavenge 只能和 SerialOld 配合工作,拖慢了整个服务器端 GC 的速度。
- CMS 收集器
上面的收集器新生代均基于复制算法,老年代基于整理算法,因此回收时都会 STW(枚举根节点、复制、整理不可避免 STW)。CMS(Concurrent Mark Sweep)收集器追求最短的 STW 时间,所以采用的是标记 - 清除算法。
- 运作机制分为下面四个步骤:
- 初始标记:速度很快,只标记根节点能直接关联到的对象,枚举根节点会 STW;
- 并发标记:遍历整个引用链进行标记,速度较慢;
- 重新标记:重新标记第 2 步执行过程中用户线程运作导致的变化,会 STW;
- 并发清除:清除;
- 缺点:
- 不必说的就是产生大量零头,可能会提前引发 FullGC;
- +UseCMSCompactAtFullCollection:默认开启,在每次 FullGC 后进行碎片整理;
- -XX:CMSFullGCsBeforeCompaction:设置 N 次 FullGC 后进行碎片整理;
- 无法处理“浮动垃圾”(即并发清除期间产生的新垃圾),只能下一次处理;
- 并发操作虽然不会 STW,但是占用了一部分线程,拖慢了用户线程,降低了吞吐量。
- 不必说的就是产生大量零头,可能会提前引发 FullGC;
整堆收集器
1.11 开始有了 ZGC、Shenandoah
前面的收集器在内存上进行了物理分代,而 G1 的分代只体现在逻辑上。
GarbageFirst(G1 收集器)主要使用在 Server 端,它基于将内存划分为很多区域(Region)的思想,实现了局部回收的功能。可以通过 -XX:MaxGCPauseMillis 参数设置一个 GC 停顿时间的期望值,一定程度上达到停顿时间可控。
G1 收集器打破了传统的分代回收思想,它将堆划分为多个大小相等的 Region,在进行回收时不再判断对象属于哪一代,而是判断哪块 Region 垃圾最多,回收价值最高,优先去回收这些区域,这也是为什么叫 G1 收集器。当然 G1 也没有抛弃分代思想,因为分代很大程度上提升了 GC 的效率。每一块 Region 都有其“年龄“,这样 GC 就可以对这块 Region 该不该回收根据其年龄进行权衡。称为混合回收(MixedGC )。
- 步骤,前三步与 CMS 类似:
-
初始标记:枚举根节点,会 STW;
-
并发标记:可以与用户线程并发;
-
最终标记:会 STW;
-
筛选回收:根据回收效益对每个 Region 的进行优先级排序,再根据用户设置的停顿期望值指定回收计划,将要回收的 Region 中的存活对象复制到另一块空 Region 上,再清除掉旧 Region。因为复制移动了对象,引用关系发生了改变,所以会 STW;
- 存在的问题:首先可以看到的是清理不干净,但是这换来了其他地方的提升,只要回收的速度快于分配的速度,那就不会出问题;跨代跨区引用使得 G1 得在内存中维护大量的表(标记算法相关)。
- **注意:**G1 也会有 FullGC,当分配速度大于回收速度,放不下的时候就会发生 FullGC,除了增强硬件(CPU、内存),可以降低 MixedGC 的阈值(默认 45%),使每次 MixedGC 提前执行。G1 的 Full GC 使用的是 serial old GC,效率非常低。(JDK 10 对 G1 的 FullGC 进行了并行化)
小结
- 枚举根节点、标记 - 复制、标记 - 整理时会发生 STW。因为枚举根节点时需要保证准确性,而复制和整理时引用关系发生了变化。
- GC 的发展和硬件的发展有一定相关性,内存大小是一个很重要的影响因素,如今内存越来越大,单线程的 GC 已不再适用。
- GC 的选择很大程度上取决于期望的吞吐量、响应时间。如 PS + PO 和 G1 进行比较,前者虽然整个 gc 过程都要 STW,但是吞吐量高,后者在 gc 过程中有并发标记的阶段,减少了 STW 时间,响应时间短,但 gc 线程和用户线程一起运行,一定程度上降低了吞吐量。
- CMS 和 G1(G1 在 JDK 10 以前)的 FullGC 都需要 Serial Old GC 配合。其中 CMS 必须搭配 Serial Old,因为 CMS GC 采用的是标记清除算法,FGC 时需要搭配 Serial Old 整理内存碎片;而 G1 采用标记整理算法,应当尽力避免 FGC。