整体思路
先考虑3个问题
-
哪些内存需要收集
- 堆和方法区需要收集;程序计数器、虚拟机栈、本地方法栈都不需要做垃圾回收(按照其功能很容易理解)
-
什么时候收集
- 对象已死。引申出另一个问题,怎么判断对象已死呢?
- 当程序内存不足时
-
怎么收集
-
分代收集理论
-
垃圾回收算法
-
一、对象已死
1.1 引用计数法
定义:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
**优点:**原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。
**缺点:**循环引用问题。两个对象再无任何引用,实际上这两个对象已 经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也 就无法回收它们。
Java虚拟机用的不是引用计数法
1.2 可达性分析
**定义:**通过一系列成为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过所走的路径成为引用链,如果某个对象到 GC Roots 间没有任何引用链相连,则证明此对象是不可达的。
可作为 GC Roots 的对象:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,如方法参数、局部变量、临时变量等;
- 在方法区中类静态属性引用的对象,如 Java 类的应用类型的静态变量;
- 在方法区中常量引用的对象,如字符串常量池里的引用;
- 在本地方法栈中 JNI (Native 方法)引用的对象;
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器;
- 所有被同步锁(synchronized关键字)持有的对象;
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
1.3 引用详细分类
**背景:**在 JDK1.2 版以前,Java 里的引用是很传统的定义,即 reference 类型的数据中存储的数值代表另一块内存的起始地址,就称该 reference 数据代表某块内存、某个内存的引用。在这种定义下,一个对象只有 “被引用” 或者 “未被引用” 两种状态,对于描述一些“ 食之无味,弃之可惜”的对象就显 得无能为力。譬如我们希望能庙述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应 用场景。
所以在 JDK 1.2 版之后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用四种。
**强引用:**指最传统的引用定义,即 Object obj = new Object() 这种引用关系。只要强引用关系还在,对象永远不会被回收。
**软引用:**描述一些还有用,但非必须的对象。只被软引用关联着的对象,
**弱引用:**来描述那些非必须对象,但是它的强度比软引用更弱一些。
**虚引用:**是最弱的一种引用关系。一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚 引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
二、垃圾回收算法
2.1 分代收集理论
2.1.1 背景
当前商业虚拟机的垃圾收集器,大多遵循了 “分代收集” 的理论进行设计,它建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分 出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
好处:将大多数朝生夕灭的对象集中放在一起,每次回收时只需要关注如何保留少量存活而不是去标注那些大量要被回收的对象,就能以较低代价回收大量空间;将剩下的难以消亡的对象集中放在一块,虚拟机便可以较低频率回收这个区域,这就同时兼顾了垃圾回收时间和内存空间的有效利用。
2.1.2 应用
回收类型的划分:根据回收区域的位置不同,划分出 Minor GC、Major GC、Full GC
回收算法:根据不同区域中对象存活特征的不同,发展出标记-复制算法、标记-清除算法、标记-整理算法
2.1.3 存在的难点——跨代引用
**问题:**分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不 是孤立的,对象之间会存在跨代引用,假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可 能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性。
为了解决这个问题,添加第三条经验法则:
- 跨代引用假说: 跨代引用相对于同代引用来说仅占极少数。
解决方式:依据这条假说,我们不必扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在以及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称 为**“记忆集”**,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会 存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。
2.2 标记-清除法
**算法思路:**算法分为 “标记” 和 “清除” 两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。也可以反过来。
**优点:**简单
缺点:
-
执行效率不稳定。果Java堆中包含大量对 象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过 程的执行效率都随对象数量增长而降低
-
内存空间碎片化的问题,标记、清除之后会产生大量不连续的内存碎片,导致大对象无法找到足够的内存分配,从而会触发另一次垃圾回收
2.3 标记-复制法
**算法思路:**为了解决标记-清除算法面对大量可回收对象时执行效率低 的问题,1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用 内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
**优点:**实现简单,运行高效。每次都针对整个半区进行清理,不用考虑内存碎片的问题;而且内存中对象大多数都是可回收的,所以需要复制的对象并不多。
**缺点:**这种复制回收法将可用内存缩小为原来的一半,空间浪费太多。
**改进:**IBM对新生代 “朝生夕灭” 的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。Andrew Appel 提出了一种更优化的半区复制分代策略,将新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空。间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新 生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会 被“浪费”的。
**内存分配担保:**上述改进是基于98%的对象可被回收的理论,任何人都没有办法百分百 保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。
**缺点:**老年代没有可用的内存为其作担保
2.4 标记-整理法
**定义:**老年代没有可用的内存为其作担保,所以不可用标记-复制算法。标记-整理法与标记-清除算法一样,但后续步骤不是直接对可 回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
**优点:**可以避免空间碎片化问题
**缺点:**移动存活对象并更新所有引用这些对象的地方是一种极为负重的操作,这种对象移动操作必须全程暂停用户应用程序才能进行。(ZGC和Shenandoah收集器使用读屏障(Read Barrier)技术实现了整理过程与用户线程的并发 执行)
三、Hotspot虚拟机的实现
第一章和第二章从原理上讲了常见的对象存活判定算法和垃圾收集算法,Java虚拟机实现这些算法时,必须对算法的执行效率有严格的考量才能保证虚拟机的高效运行。本章介绍 Hotspot 虚拟机是如何高效实现上述算法的。
下面这些技术都是针对可达性分析遇到的问题的解决方案
3.1 根节点枚举
**问题:**在做可达性分析时,首先需要查找 GC Roots,查找过程实现高效不是一件容易的事情,光是光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,若要逐个检 查以这里为起源的引用肯定得消耗不少时间。
解决方式: 虚拟机记录哪些地方时存放着对象引用的。在HotSpot虚拟机里,使用一组称为 OopMap 的数据结构来达到这个目的。一旦类加载完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也 会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信 息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。
**注意事项:**迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的。现在可达性分析算法耗时 最长的查找引用链的过程已经可以做到与用户线程一起并发(具体见3.6),但根节点枚举始终还 是必须在一个能保障一致性的快照中才得以进行。
3.2 安全点
**问题:**在OopMap的协作下,HotSpot可以快速准确地完成 GC Roots 枚举,但是导致 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将需要大量的额外存储空间。
解决方式:在 “特定的位置” 生成 OopMap,这些位置成为 安全点。有了安全点的设定,也就决定了用户程序执行时并非在指令流的任意位置都能停顿下来开始垃圾收集,而是必须达到安全点后才能暂停。
**安全点的选取:**以 “是否具有让程序长时间执行的特征” 为标准。“长时间执行” 的明显特征是指令序列的复用,如方法调用、循环跳转、异常跳转等。
**注意:**当用户的所有线程到达安全点后才可以进行垃圾收集。
3.3 安全区域
**问题:**如果存在用户线程处于不执行(如线程处于 Sleep 或 Blocked)状态,那就到达不了安全点,就没办法进入垃圾回收了。对于这种情况,,必须引入安全区域来解决。
解决方式:安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。当用户线程进入到安全区域里面的代码时,首先会标识自己已经进入了安全区域。当用户离开安全区域时,只有在完成根节点枚举的情况下才能离开。
3.4 记忆集和卡表
问题: 在 GC Roots 扫描时,有些对象存在跨代引用的情况,为了避免将整个老年代加入扫描范围,垃圾收集器在新生代中建立了名为记忆集的数据结构。
**记忆集:**记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。实现记忆集可以有不同的精度:
- **字长精度:**每个记录精确到一个机器字长,该字包含跨代指针。
- **对象精度:**每个记录精确到一个对象,该对象里有字段含有跨代指针。
- **卡精度:**每个记录精确到一块内存区域,该区域内有对象含有跨代指针。可以用 “卡表” 的方式实现记忆集,这也是目前最常用的一种记忆集实现形式。
**卡表:**卡表最简单的形式可以只是一个字节数组[2],而HotSpot虚拟机确实也是这样做的。以下这行代 码是HotSpot默认的卡表标记逻辑[3]:
CARD_TABLE [this address >> 9] = 0;
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个 内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可 以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代 指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。
3.5 写屏障
**问题:**有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢? 在编译执行的场景中,经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。
**写屏障:**HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。
应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外 的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。
3.6 并发的可达性分析
看书3.4.6