深入理解Java虚拟机(2.2)- 垃圾收集算法

2020年8月17日17:49:55 评论 179

垃圾收集算法

一共有 4 种:

  1. 标记-清除算法
  2. 复制算法
  3. 标记整理算法
  4. 分代收集算法

标记-清除算法

最基础的收集算法是“标记-清除”(Mark-Sweep)算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

它的主要不足有两个:

  1. 效率问题,标记和清除两个过程的效率都不高;
  2. 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记—清除算法的执行过程如下图。

深入理解Java虚拟机(2.2)- 垃圾收集算法

复制算法

为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。复制算法的执行过程如下图:

深入理解Java虚拟机(2.2)- 垃圾收集算法

现在的商业虚拟机都采用这种算法来回收新生代,IBM 研究指出新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。

当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden:Survivor = 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(其中一块Survivor不可用),只有 10% 的内存会被“浪费”。

当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

内存的分配担保就好比我们去银行借款,如果我们信誉很好,在 98% 的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。

内存的分配担保也一样,如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。关于对新生代进行分配担保的内容,在本章稍后在讲解垃圾收集器执行规则时还会再详细讲解。

标记-整理算法

复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的示意图如下:

深入理解Java虚拟机(2.2)- 垃圾收集算法

分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,根据对象存活周期的不同将内存划分为几块并采用不用的垃圾收集算法。

一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

HotSpot的算法实现

枚举根节点

以可达性分析中从 GC Roots 节点找引用链这个操作为例,可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。

另外,可达性分析对执行时间的敏感还体现在 GC 停顿上,因为这项分析工作必须不可以出现分析过程中对象引用关系还在不断变化的情况,否则分析结果准确性就无法得到保证。这点是导致 GC 进行时必须停顿所有 Java 执行线程(Sun将这件事情称为"Stop The World")的其中一个重要原因,即使是在号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。

因此,目前的主流 Java 虚拟机使用的都是准确式 GC(即虚拟机可以知道内存中某个位置的数据具体是什么类型。),所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。

在 HotSpot 的实现中,是使用一组称为 OopMap 的数据结构来达到这个目的的,在类加载完成的时候,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定的位置记录栈和寄存器中哪些位置是引用。这样, GC 在扫描时就可以直接得知这些信息了。

安全点(Safepoint)

在 OopMap 的协助下,HotSpot 可以快速且准确地完成 GC Roots 枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外空间,这样 GC 的空间成本将会变得很高。

实际上,HotSpot 也的确没有为每条指令都生成 OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置称为安全点,即程序执行时并非在所有地方都能停顿下来开始 GC ,只有在到达安全点时才能暂停。

Safepoint 的选定既不能太少以致于 GC 过少,也不能过于频繁以致于过分增大运行时的负荷。

对于 Safepoint,另一个需要考虑的问题是如何在 GC 发生时让所有线程都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

其中抢先式中断不需要线程的执行代码主动去配合,在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应 GC 事件。

而主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

安全区域(Safe Region)

使用 Safepoint 似乎已经完美地解决了如何进入 GC 的问题,但实际情况却并不一定。Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的Safepoint。但是,程序“不执行”的时候呢?

所谓的程序不执行就是没有分配 CPU 时间,典型的例子就是线程处于 Sleep 状态或者 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全的地方去中断挂起,JVM 也显然不太可能等待线程重新被分配 CPU 时间。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。

在这个区域中的任意地方开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safepoint。在线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region,那样,当在这段时间里 JVM 要发起 GC 时,就不用管标识自己为 Safe Region 状态的线程了。在线程要离开 Safe Region 时,它要检查系统是否已经完成了根节点枚举(或者是整个 GC 过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开 Safe Region 的信号为止。


垃圾收集相关链接:

深入理解Java虚拟机(2.1)- 垃圾收集机制

深入理解Java虚拟机(2.2)- 垃圾收集算法

深入理解Java虚拟机(2.3)- 垃圾收集器

深入理解Java虚拟机(2.4)- 内存分配与回收策略

回到目录深入理解Java虚拟机(全解析)

(转载自:https://www.cnblogs.com/czwbig/p/11127159.html)

素课网
  • 本文由 发表于 2020年8月17日17:49:55
  • 转载请注明:https://www.suketech.com/9313.html
深入理解Java虚拟机(3.2)- 类加载器 Java虚拟机

深入理解Java虚拟机(3.2)- 类加载器

类加载器 把实现类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块称为“类加载器”。 将 class 文件二进制数据放入方法区内,然后在堆内(heap)创建一个 ja...
匿名

发表评论

匿名网友 填写信息

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: