垃圾回收算法概述和HotSpot算法细节

概述

Java 堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

对象回收

在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。

引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。然而引用计数法,却有很多缺陷.
下面这种互相引用的情况,如果仅仅使用引用计数法,便不能回收,objA 和objB所占的内存空间。

public class ReferenceCountingGC {
 public Object instance = null;
 private static final int _1MB = 1024 * 1024;
 /**
 * 这个成员属性的唯一意义就是占点内存,以便能在 GC 日志中看清楚是否有回收过
 */
 private byte[] bigSize = new byte[2 * _1MB];
 public static void testGC() {
 ReferenceCountingGC objA = new ReferenceCountingGC();
 ReferenceCountingGC objB = new ReferenceCountingGC();
 objA.instance = objB;
 objB.instance = objA;
 objA = null;
 objB = null;
 // 假设在这行发生 GC,objA 和 objB 是否能被回收?
 System.gc();
 }
}

可达性分析算法

这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。

如图下图,对象 object 5、object 6、object 7 虽然互有关联,但是它们到 GC Roots 是不可达的,因此它们将会被判定为可回收的对象。

在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:
·在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

  • 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized 关键字)持有的对象。
  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

四种引用

Java 将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。

  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值
  • 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,
  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些
  • 虚引用 为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

生存还是死亡

要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。
假如对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。如果这个对象被判定为确有必要执行 finalize()方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer 线程去执行它们的 finalize() 方法。

回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收 Java 堆中的对象非常类似。

判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

用计数式垃圾收集(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。

分代收集理论

分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在三个分代假说之上:
弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

设计者一般至少会把 Java 堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。

假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局
的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GCRoots 进行扫描。

标记-清除算法

该算法算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
缺点

  1. 执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
  2. 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续存而不得不提前触发另一次垃圾收集动作请添加图片描述

标记-复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会
产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
请添加图片描述

新生代中的对象有98%熬不过第一轮收集。因此并不需要按照 1∶1 的比例来划分新生代的内存空间。

HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8∶1,也即每次新生代中可用内存空间为整个新生代容量的 90%(Eden 的 80%加上一个 Survivor 的 10%),只有一个 Survivor 空间,即 10%的新生代是会被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于 10%的对象存活,因此 Appel 式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。内存的分配担保好比我们去银行借款,如果我们信誉很好,在 98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也一样,如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。

针对老年代对象的存亡特征,“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
请添加图片描述

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:

  1. 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行
  2. 如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。
    HotSpot 虚拟机里面关注吞吐量的 Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的 CMS 收集器则是基于标记-清除算法的,

HotSpot 的算法细节实现

根节点枚举

所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的。

由于目前主流 Java 虚拟机使用的都是准确式垃圾收集,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在 HotSpot 的解决方案里,是使用一组称为 OopMa数据结构来达到这个目的。一旦类加载动作完成的时候, HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等 GC Roots 开始查找。

安全点

实际上 HotSpot 也的确没有为每条指令都生成 OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因
为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

**抢先式中断(Preemptive Suspension)主动式中断(Voluntary Suspension)**抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾集,避免没有足够内存分配新对象。

安全区域

程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于 Sleep 状态或者Blocked 状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

记忆集与卡表

为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围。所有涉及部分区域收集(Partial GC)行为的垃圾收集器,,都会面临相同的问题,
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

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

其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式.卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。
卡表最简单的形式可以只是一个字节数组,而 HotSpot 虚拟机确实也是这样做的。以下这行代码是 HotSpot 默认的卡表标记逻辑:

CARD_TABLE [this address >> 9] = 0;

字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以 2 的 N次幂的字节数,通过上面代码可以看出 HotSpot 中使用的卡页是 2 的 9 次幂,即 512 字节(地址右移 9 位,相当于用地址除以 512)。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。

写屏障

在 HotSpot 虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。写屏障以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写障覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot 虚拟机的许多收集器中都有使用到写屏障。

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用。除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
假设处理器的缓存行大小为 64 字节,由于一个卡表元素占 1 个字节,64 个卡表元素将共享同一个缓存行。这 64 个卡表元素对应的卡页总的内存为 32KB(64×512 节),也就是说如果不同线程更新的对象正好处于这 32KB 的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏.

并发的可达性分析

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
    请添加图片描述

当且仅当以下两个条件同时满足时,会产生“对象消失”,即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
    因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)。增量更新要破坏的是一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向
    白色对象的引用之后,它就变回灰色对象了。原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

过去日记

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值