JVM学习三:垃圾收集算法

一、 对象已死?

JVM垃圾收集器在对堆中内存进行回收前,需要首先确认哪些对象已死?哪些对象还活着?

常用的判断对象是否已死的方法有两种:引用计数算法和可达性分析算法

1. 引用计数算法

在对象中添加一个引用计数器,每当一个地方引用该对象,引用计数器的值加一;当引用失效时,引用计数器的值减一。任何时候引用计数器为零的对象就是不可能再被使用的对象

引用计数器虽然占用了一些额外内存,但是其原理简单,判定效率也很高,大多数情况下都是一个不错的选择。在Python中就使用了引用计数法进行内存管理。但是,在Java中却并没有使用该算法。主要的原因在于,这个算法有很多例外的情况需要考虑,必须配合大量额外的处理才能保证正常工作。比如,单纯的引用计数很难解决对象之间互相引用的问题。

2. 可达性分析算法

通过一系列“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程中所走过的路径称为“引用链”。如果某个对象到GC Roots之间没有任何引用链相连,或者说从GC Roots到这个对象不可达时,则证明这个对象不可能再被使用。主流的商用程序语言(Java、C#等)的内存管理采用的都是可达性分析算法。

可达性分析

Java中固定可以作为GC Roots的对象包括:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,如:当前正在运行的方法所使用的参数、局部变量、临时变量等;
  • 在方法区中的类静态属性引用的对象,如:Java类的引用类型静态变量
  • 在方法区中常量引用的对象,如:字符串常量池中的引用
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
  • Java虚拟机内部的引用,如:基本数据类型对应的Class对象、一些常驻的异常对象(比如:NullPointerException、OutOfMemoryError)、系统类加载器
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

除了以上这些固定的GC Roots集合外,根据用户所选用的垃圾回收器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成GC Roots集合。例如后续提到的分代收集和局部回收,存在某个区域的对象完全有可能被位于堆中的其他区域的对象所引用,这是就需要将这些关联区域的对象一并加入GC Roots集合进行可达性分析。

3. 关于引用

无论是引用计数算法还是可达性分析算法,判断对象是否存活都和“引用”离不开关系。在JDK 1.2之前,Java 中对于引用的定义很传统:如果reference类型的数据中存储的数据代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。随着Java的发展,这种定义逐渐变得狭隘。

在JDK 1.2之后,Java将引用分为:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference),这四种引用按照强度依次减弱

  • 强引用:程序代码中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,被引用的对象永远不会被回收
  • 软引用:描述一些还有用还是非必须的对象。只被软引用关联的对象,在系统将要发生内存溢出异常前,会将这些对象列入回收范围内进行二次回收,如果这次回收还没有释放足够内存,才会抛出内存溢出异常。Java中由SoftReference类实现软引用。
  • 弱引用:描述非必须对象。只被弱引用关联的对象,只能存活到下一次垃圾回收发生为止,当垃圾回收器开始工作,无论当前内存是否不足,都会回收这些对象。Java中由WeakReference类实现弱引用。
  • 虚引用一个对象是否有虚引用,完全不会对对象的生存时间构成影响,同时也无法通过虚引用获取一个对象实例。对一个对象设置虚引用的目的仅仅是为了对象回收时收到一个系统通知。Java中由PhantomReference类实现虚引用。
4. 对象的死亡

在可达性分析算法中,即使对象已经被判定为不可达,该对象也并非“非死不可”。真正宣判一个对象死亡,至少需要经历两次标记过程

  • 如果对象在可达性分析发现没有与GC Roots相连的引用链,它会被第一次标记;
  • 随后将进行一次筛选,条件是该对象是否有必要执行finalize()方法。如果对象没有覆盖finalize()方法,或者finalize()方法已经被执行过,虚拟机都会判定为“没有必要执行”。此时,对象才被标记为死亡。

如果对象被判定为有必要执行finalize()方法,该对象会被放置在一个名为“F-Queue”的队列中,之后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行finalize()方法。finalize()方法是对象逃脱死亡的最后一次机会,如果对象在finalize()方法中重新与引用链上的任何一个变量建立关联,如:将自己(this关键字)赋值给某个类变量或者对象的成员变量,该对象会被移除“即将回收”的集合,否则该对象就真的要被回收了。

需要注意的是,任何一个对象的finalize()方法都只会被系统自动调用一次

还有一点需要注意的是,虽然finalize()方法可以让对象摆脱被回收的命运,但是仍然不建议大家使用该方法拯救对象。更好的建议是,大家可以完全忘记这个方法的存在,而finalize()方法能做的所有工作,try-finally或者其他方法都可以做的更好。

5. 方法区的回收

《Java虚拟机规范》中提到可以不要求虚拟机在方法区实现垃圾回收,而方法区垃圾回收的性价比也非常低。不同于Java堆,尤其是新生代中的一次垃圾回收通常可以回收70%到99%的内存空间,方法区的垃圾回收需要苛刻的判定条件。

方法区中的回收内容主要包含两部分:废弃的常量和不再使用的类型

对于废弃的常量池回收与回收Java堆中的对象非常类似。以常量池中的字面量为例,假如一个字符串曾经进入常量池中,但是没有任何字符串对象引用常量池中的该字符串,且虚拟机中也没有其他地方引用该字符串。此时如果发生内存回收,而且垃圾收集器判断确有必要,就会将该字符串清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

相比于常量,判断一个类型是否属于“不再被使用的类”的条件就更苛刻了,需要同时满足三个条件

  • 该类所有的实例都已经被回收,即Java堆中不存在该类以及任何派生子类的实例
  • 加载该类的类加载器已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

满足以上三个条件的无用类允许被回收,但不同于对象一样,没有了引用就必然会回收。

在大量使用反射、动态代理、CGlib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不对方法区造成过大的内存压力。

二、垃圾收集算法

垃圾收集算法可以被划分为“引用计数式垃圾收集”和“追踪式垃圾收集”,这两类算法也被称为“直接垃圾收集”和“间接垃圾收集”。这里所介绍的算法都属于追踪式垃圾收集算法。

1. 分代收集理论

当前商用虚拟机中的垃圾收集器,大多遵循了“分代收集”理论,该理论建立在两个分代假设:

  • 弱分代假说:绝大多数对象都是朝生夕灭的
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡

这两个分代假说奠定了多款垃圾收集器的一致设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。将朝生夕灭的大多数对象集中放到一起,每次回收时只关注如何保留少量存活对象;将剩下的难以消亡的对象集中到一起,可以使用较低的频率回收该区域。

基于以上的设计原则,设计者将Java堆划分出了新生代、老年代两个区域,也出现了“Minor GC”、“Major GC”、“Fulll GC”等不同回收类型的划分。

  • 新生代收集(Minor GC / Young GC):目标只是新生代的垃圾收集
  • 老年代收集(Major GC / Old GC):目标只是老年代的垃圾收集,目前只有CMS收集器有这种行为
  • 混合收集(Mixed GC):收集整个新生代和部分老年代的垃圾收集,目前只有G1 收集器有这种行为
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

以上提到的新生代收集、老年代收集和混合收集,都统称为部分收集(Partial GC)

以上的设计原则看似合理,实际还存在一个明显的困难:对象并不是孤立的,对象之间会存在跨代引用。假如现在只对新生代进行回收(Minor GC),而新生代的对象又被老年代所引用,此时就需要在固定GC Roots之外,额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,这样的做法会为内存回收带来很大的负担。

为了解决上述问题,需要对分代理论添加第三条法则:

  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数

基于第三条法则,只需要在新生代上建立一个全局的数据结构(称为记忆集),该结构将老年代划分为若干小块,表示出老年代的哪一块内存会存在跨代引用。当发生Minor GC时,只有包含跨代引用关系的小块内存会被加入到“GC Roots”中。

2. 标记 - 清除算法

该算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成之后,统一回收所有被标记的对象;或者,标记所有存活的对象,统一回收所有未被标记的对象。这里的标记过程就是之前介绍的判断对象是否属于垃圾的判断过程。

该算法的主要缺点有两个:

  • 第一是执行效率不稳定,如果Java堆中存在大量对象,且大部分都需要被回收,此时需要大量的标记和清除动作,导致执行效率随着对象数量的增长而降低
  • 第二是内存空间的碎片化。该算法会产生大量不连续的内存碎片,当空间碎片太多,程序运行过程中又需要分配大对象时,如果无法找到足够的连续内存,将会提前触发另一次垃圾收集动作

标记 - 清除算法

3. 标记 - 复制算法

1969年,Fenichel提出了“半区复制”垃圾收集算法:将可用内存按容量分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上,然后将已经使用的一块内存一次性清理掉。这种算法的运行效率高,实现简单,但缺陷也很明显,需要将可用内存缩小为原来的一半,空间浪费太多。

半区复制算法

现在的Java虚拟机大多都采用了标记 - 复制算法回收新生代,但是用的是改进后的版本。由于新生代中的对象98%都熬不过第一轮收集,因此不需要1 : 1划分新生代内存。

1989年,Andrew Appel提出了一种更优化的半区复制分代策略,称为“Appel 式回收”。具体做法是:将新生代分为一块较大的Eden区和两块较小的Survivor区,每次分配内存时只使用Eden区和其中一块Survivor区。当发生垃圾收集时,将Eden区和Survivor区中仍然存活的对象一次性复制到另外一块Survivor区,然后直接清理掉Eden区和使用过的那块Survivor区。Hot Spot虚拟机默认的Eden区和Survivor区大小比例时8 : 1。

“Appel 式回收”无法百分百保证每次回收都只有不多于10%的对象存活,此时就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保:当另一块Survivor空间没有足够空间存放上一代新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代

4. 标记 - 整理算法

针对老年代对象的存活特征,1974年,Edward Lueders提出了“标记 - 整理”算法。该算法的标记过程和“标记 - 清除算法”一样,但后续步骤是让所有存活对象都向内存空间的一端移动

标记 - 整理算法

这种算法需要移动对象,而移动存活对象并更新所有引用这些对象的地方将会是一种负重的操作,而且这种移动对象的操作必须全程暂停用户应用程序才能进行,这样的停顿被形象地称为“Stop The World”。

由于移动对象的操作过于负重,可以让虚拟机在平时多数时间都采用标记 - 清除算法,直到内存空间的碎片化程度大到影响对象分配时,再采用标记 - 整理算法收集一次。基于标记 - 清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理方法。

三、HotSpot的算法实现细节

上述部分提到了常用的垃圾回收算法,这部分介绍Java虚拟机内部是如何保证这些算法的高效执行。

1. 根结点枚举

迄今为止,所有收集器在根结点枚举这一个步骤都是必须暂停用户线程的,因此这一步也会面临“Stop The World”的困扰。即使现在已经可以保证查找引用链的过程可以和用户线程一起并发,但根结点枚举还是必须在一个能够保障一致性的快照中才能得以进行。这里所说的一致性,是指整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现根结点集合的对象引用关系还在不断变化的情况。这也是必须停顿所有用户线程的一个重要原因。

固定可作为GC Roots的节点主要在全局性的引用与执行上下文中,尽管目标明确,但是查找过程要做到高效却并不容易。随着Java应用的逐渐庞大,如果要逐个检查GC Roots需要耗费不少时间。

当前主流的Java虚拟机使用的都是准确式垃圾回收,当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应该是有办法直接得到哪些地方存放着对象引用的。HotSpot中,使用了一组称为OopMaps的数据结构,一旦类加载动作完成,就会将对象内什么偏移量上是什么类型的数据计算出来,在即时编译的过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。收集器在扫描时就可以直接得知这些信息,不需要真正一个不漏地从方法区等GC Roots开始查找

2. 安全点

由于OopMaps的存在,HotSpot虚拟机可以快速完成GC Roots的枚举。但是可能导致引用关系发生变化,或者说导致OopMaps内容变化的指令非常多。如果为每一条指令都生产对应的OopMaps,将会需要大量的内存空间。

HotSpot采用了在特定位置记录OopMaps信息,这些位置被称为安全点。由于安全点的存在,用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够停顿

安全点位置的选择基本上是以“是否具有让程序长时间执行的流程”为标准进行选定的。“长时间执行”的最明显特征就是指令顺序的复用,如:方法调用、循环跳转、异常跳转等都属于指令序列复用,只有具有这些功能的指令才会产生安全点。

那么,如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点呢?常见的方案有两种:抢先式中断和主动式中断。

抢先式中断:不需要线程的执行代码主动去配合,当垃圾收集发生时,系统先将所有用户线程全部中断,如果发现用户线程中断的地方不在安全点上,就恢复这条线程执行,让它跑到安全点上再中断。现在几乎没有虚拟机实现采用这种方法暂停线程响应GC事件。

主动式中断:当垃圾收集需要中断线程时,不直接对线程操作,仅仅简单设置一个标志位,各个线程执行过程中不断主动轮询这个标志,一旦发现中断标志为真时,自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

由于主动式中断中,轮询操作会非常频繁,Hotspot使用内存保护陷阱的方式,将轮询操作精简至只有一条汇编指令。

3. 安全区域

安全点机制保证了程序执行时,在不太长的时间内就可以遇到可进入垃圾收集过程的安全点。但是,当程序“不执行”时呢?所谓程序“不执行”就是没有分配处理器时间,典型的场景就是用户线程处于Sleep状态或者Blocked状态,此时线程无法响应虚拟机的中断请求,无法走到安全点。此时就需要引入“安全区域”。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,在这个区域中任意位置开始垃圾回收都是安全的

当用户线程执行到安全区域里面的代码时,首先会标识自己进入了安全区域,当发生垃圾回收时就不需要去管这些处于安全区域内的线程。当线程要离开安全区时,先要检查虚拟机是否已经完成根结点枚举(或者垃圾回收过程中其他需要暂停用户线程的阶段),如果已经完成,线程继续往下执行;否则线程需要一直等待,直到收到可以离开安全区域的信号位置。

4. 记忆集与卡表

前述部分提到,新生代中建立了名为记忆集的数据结构,用于记录老年代对新生代的跨代引用关系。涉及到部分收集的垃圾回收器都会使用到记忆集。

记忆集是一种记录从非回收区域指向回收区域的指针集合的抽象数据结构,最简单的实现可以用非收集区域中所有包含跨代引用的对象数组来表示,但是这种实现无论在空间占用还是维护成本上都相当高昂。实际实现时可以选择更为粗旷的记录粒度来节省记忆集的存储和维护成本,以下为可供选择的记录精度:

  • 字节长度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位),该字节包含跨代指针
  • 对象精度:每个记录精确到一个对象,该对象中有字段含有跨代指针
  • 卡精度:每个记录精确到一块内存区域,该内存区域内有对象含有跨代指针

对应于“卡精度”的记录精度,指的就是“卡表”,这也是目前最常用的一种记忆集实现方式。记忆集是一种抽象的数据结构,而卡表是记忆集的一种具象实现,它定义了记忆集的记录精度、与对内存的映射关系等。

卡表最简单的形式是一个字节数组,如下所示。

CARD_TABLE [this address >> 9] = 1;

字节数组CARD_TABLE的每一个元素对应着其标识的内存区域中一块特定大小的内存快,这个内存快被称为“卡页”。卡页大小一般都是以2的N次幂的字节数,HotSpot中的卡页是2的9次幂,即512字节。如果卡表标识内存区域的起始位置是0x0000,CARD_TABLE的元素对应的地址范围如图所示:

卡表和卡页的对应示意图

一个卡页中包含的对象通常不止一个,只要卡页中有至少一个对象的字段存在跨代指针,对应卡表的数组元素的标识为1,称这个元素变脏,否则标识为0。

5. 写屏障

前述部分已经解决了如何使用记忆集缩短GC Roots扫描范围的问题,但是卡表元素如何维护?比如:它们何时变脏?谁来把它们变脏?

正常来说,有其他分区的对象引用了本分区的对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。那么如何变脏呢?假如是解释执行的字节码,虚拟机负责每条字节码指令的执行,有充分的介入空间。但是在编译执行的场景呢?即时编译后的代码已经是纯粹的机器指令流,这就需要一个在机器码层面的手段,将维护卡表的动作放到每一个赋值操作之中

HotSpot虚拟机采用的是写屏障的技术维护卡表状态的,写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面。在引用对象赋值时会产生一个环形通知,以供程序执行额外的动作,赋值的前后都在写屏障覆盖范围之内。赋值之前的写屏障称为“写前屏障”,赋值之后的称为“写后屏障”。

应用写屏障后。虚拟机会为所有赋值操作生成相应的指令,无论更新的是不是老年代对新生代的引用,每次对引用进行更新,都会产生额外的开销,但这个开销比Minor GC时扫描整个老年代的代价要低得多。

除开写屏障的开销,卡表在高并发场景下还面临着“伪共享”问题。

现代中央处理器的缓存系统中是以缓存行为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能下降,称之为“伪共享”问题

假如缓存行的大小为64字节,由于一个卡表元素占1个字节,那么64个卡表元素将共享一个缓存行,这64个卡表元素对应的卡页总的内存为32KB(64 * 512字节)。当不同的线程更新的对象刚好处于这32KB内存区域内,就会产生“伪共享”。

为了避免伪共享问题,不能采用无条件的写屏障,而是需要先检查卡表标记,只有当卡表元素未被标记过时才将其标记为变脏

JDK 7之后,引用一个新的参数:-XX:+UseCondcardMark,用于决定是否开启卡表更新的条件判断。开启之后会增加一次额外的判断开销,但是会避免伪共享问题。

6. 并发的可达性分析

可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能进行分析,这往往意味着必须全程冻结用户线程的运行。前述部分提到,迄今为止,根结点枚举部分都是必须暂停用户线程的,这部分的暂停随着各种优化技术(OopMaps)的改进,已经非常短暂且固定。但是从GC Roots往下遍历对象这一步呢?这一步的停顿时间与Java堆容量直接成正比。那么,是否有可能做到不需要暂停用户,并发地执行这一步呢?即用户线程和收集器遍历对象是并发进行的。

要想实现并发,就先得理解,为什么从GC Roots往下遍历对象这一步需要在一个能保障一致性的快照中才能进行。为了解释这个问题,引用三色标记作为工具来辅助推导,将遍历过程中遇到的对象按照“是否访问过”这个条件标记为三种颜色

  • 白色:表示对象尚未被垃圾收集器访问过。开始阶段所有对象都是白色的,遍历结束仍然是白色的对象即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色对象是安全存活的,且黑色对象不可能直接不经过灰色对象执行某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

可达性分析的扫描过程,实际上可以看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程。

那么,如果不在一个能保障一致性的快照中进行对象遍历,即用户线程和收集器并发工作,会遇到什么情况呢?收集器在对象图上标记颜色,同时用户线程再修改引用关系,将会遇到两种情况:

  • 将原本消亡的对象标记为存活。这种情况可以接受,大不了这些对象在下一次垃圾收集中再收集就好了
  • 将原本存活的对象标记为已消亡。这种情况就无法接受了,程序肯定会发生报错。

并发出现“对象消失”问题的示意图

1994年,Wilson证明了,当且仅当以下两个条件同时满足时,就会产生“对象消失”的问题:

  • 赋值器插入了一条或者多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或者间接引用。

那么,如果我们破坏这两个条件中的任意一个,就不会产生“对象消失”问题,不就可以实现用户线程和垃圾收集器并发执行而不出现问题了嘛!根据这个思路,分别有两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)

增量更新:破坏的是第一个条件。当黑色对象插入新的指向白色对象的引用关系时,将这个新插入的引用记录记录下来。当并发扫描结束之后,以这记录过的引用关系中的黑色对象为根,重新扫描一次。可以理解为,黑色对象一旦新插入指向白色对象的引用,它就变回了灰色对象。

原始快照:破坏的是第二个条件。当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录记录下来,在并发扫描结束之后,以这个记录过的引用关系中的灰色对象为根,重新扫描一次。可以理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是堆引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的

垃圾回收器中,CMS是基于增量更新来做并发标记的,而G1、Shenandosh则是基于原始快照实现并发标记

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值