写点什么

JVM 分代回收机制和垃圾回收算法

作者:Ayue、
  • 2021 年 12 月 04 日
  • 本文字数:12361 字

    阅读完需:约 41 分钟

JVM分代回收机制和垃圾回收算法

GC 的基本概念

垃圾回收(Garbage Collector,GC),JVM 通过可达性分析判断那些对象可回收,而这些可回收的对象就是垃圾,为什么需要回收呢?

什么是 GC

在 C++中,对象所占的内存在程序结束运行之前一直被占用,在明确释放之前不能分配给其它对象;而在 Java 中,当没有对象引用指向原先分配给某个对象的内存时,该内存便成为垃圾。 垃圾回收能自动释放内存空间,减轻编程的负担,JVM 的一个系统级线程会自动释放该内存块。垃圾回收意味着程序不再需要的对象是"无用信息",这些信息将被丢弃。当一个对象不再被引用的时候,内存回收它占领的空间,以便空间被后来的新对象使用。事实上,除了释放没用的对象,垃圾回收也可以清除内存碎片。


由于创建对象和垃圾回收器释放丢弃对象所占的内存空间,内存会出现碎片。碎片是分配给对象的内存块之间的空闲内存洞。碎片整理将所占用的堆内存移到堆的一端,JVM 将整理出的内存分配给新的对象。


PS:什么是内存碎片


内存碎片一般是由于空闲的连续空间比要申请的空间小,导致这些小内存块不能被利用。产生内存碎片的方法很简单,举个栗子:


假设有一块一共有100个单位的连续空闲内存空间,范围是0~99


  • 如果你从中申请一块内存,如10个单位,那么申请出来的内存块就为0~9区间。

  • 这时候你继续申请一块内存,比如说5个单位大,第二块得到的内存块就应该为10~14区间。

  • 如果你把第一块内存块释放,然后再申请一块大于10个单位的内存块,比如说20个单位。因为刚被释放的内存块不能满足新的请求,所以只能从15开始分配出20个单位的内存块。

  • 现在整个内存空间的状态是0~9空闲,10~14被占用,15~24被占用,25~99空闲。其中0~9就是一个内存碎片了。

  • 如果10~14一直被占用,而以后申请的空间都大于10个单位,那么0~9就永远用不上了,造成内存浪费。

  • 如果你每次申请内存的大小,都比前一次释放的内村大小要小,那么就申请就总能成功。 通常来说,内存碎片是难以避免的,但却可以清除。

GC 的意义

Java 语言中一个显著的特点就是引入了垃圾回收机制,使 c++程序员最头疼的内存管理的问题迎刃而解,它使得 Java 程序员在编写程序的时候不再需要考虑内存管理。由于有个垃圾回收机制,Java 中的对象不再有作用域的概念,只有对象的引用才有作用域。垃圾回收可以有效的防止内存泄露,有效的使用空闲的内存。

分代回收机制

当前商业虚拟机的垃圾回收器,大多遵循分代收集的理论来进行设计,这个理论大体上是这么描述的:


  1. 绝大部分的对象都是朝生夕死

  2. 熬过多次垃圾回收的对象就越难回收。


根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代老年代


GC 分类

  • 新生代回收(Minor GC/Young GC):指只是进行新生代的回收。

  • 老年代回收(Major GC/Old GC):指只是进行老年代的回收。目前只有 CMS 垃圾回收器会有这个单独的回收老年代的行为。(Major GC 定义是比较混乱,有说指是老年代,有的说是做整个堆的收集,这个需要你根据别人的场景来定,没有固定的说法) 。

  • 整堆回收(Full GC):收集整个 Java 堆和方法区(注意包含方法区)。

垃圾回收算法

复制算法

复制算法(Copying),将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可。其大致步骤为:

  1. 假设将内存分为A、B两块,开始A中且内存没有使用完,新对象o1~o4被分配到A中,如下:


  1. 当再有一个对象o5想添加到A中,发现内存已满,就把A中还存活的对象赋值到B中,把A内存清理(格式化),并把o5分配到B中,如下:


  1. 当后续对像添加发现内存满了之后继续上面的操作。

优点:实现简单,运行高效,没有内存碎片。

缺点:这种算法的代价是将内存缩小为了原来的一半。

复制算法的优化

复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。所以,复制算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么复制算法的效率将会大大降低,因为能够使用的内存缩减到原来的一半。

那么有没有 什么方式去解决内存利用率的问题?当然有,

一种更加优化的复制回收分代策略:具体做法是分配一块较大的 Eden 区和两块较小的 Survivor 空间(你可以叫做 From 或者 To,也可以叫做 Survivor1 和 Survivor2)

专门研究表明,新生代中的对象 98%是朝生夕死的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor[1]。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被浪费。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。



对象可以无限制的在新生代复制吗,答案是否定的,新生代采用复制算法的原因是因为其中的对象基本都是朝生夕死的,那对于存活的对象每次发生Young GC时,对象头中的分代年龄便会增加 1 岁,当它的年龄增加到一定程度时(一般是 15 岁),就会被移动到老年代中。

看到一个有趣的解释,一个对象的这一辈子

我是一个普通的Java对象,我出生在 Eden 区,在 Eden 区我还看到和我长的很像的小兄弟,我们在 Eden 区中玩了挺长时间。有一天 Eden 区中的人实在是太多了,我就被迫去了 Survivor 区的 From 区,自从去了 Survivor 区,我就开始漂了,有时候在 Survivor 的 From 区,有时候在 Survivor 的 To 区,居无定所。直到我 15 岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了老年代那边,老年代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在老年代里,我也不知道能活多久,可能很久,也可能......

标记清除算法

标记清除算法(Mark-Sweep),分为标记和清除两个阶段:

  1. 首先扫描所有对象标记出需要回收的对象。

  2. 在标记完成后扫描回收所有被标记的对象。

优点:算法简单、容易实现,且不会移动对象

缺点:回收效率略低,因为需要扫描两遍。如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低。 最主要的问题是标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代(简单来说就是新生代的对象太多了,影响效率)。



标记整理算法

标记整理算法(Mark-Compact),算法分为标记、整理和清除三个阶段:

  1. 首先标记出所有需要回收的对象。

  2. 在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动。

  3. 然后直接清理掉端边界以外的内存。

优点:不会产生内存碎片。

缺点:两遍扫描、指针需要调整,因此效率偏低。

我们知到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新(直接指针需要调整)。



通常,老年代采用的标记整理算法与标记清除算法。

PS:为什么新生代不用标记清除算法或标记整理算法?


经典的垃圾回收器

在新生代中,每次垃圾回收时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成回收。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清理或者标记—整理算法来进行回收。


对于算法的实现主要是垃圾回收器在不同条件下有不同的分类,这些条件包括内存大小,是否多线程,STW(Stop The World)的时间等等。


PS:Stop The World(STW),单线程进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束。这个暂停称之为Stop The World,但是这种 STW 带来了恶劣的用户体验,例如:应用每运行一个小时就需要暂停响应 5 分。这个也是早期 JVM 和 java 被 C/C++语言诟病性能差的一个重要原因。所以 JVM 开发团队一直努力消除或降低 STW 的时间。


并行和并发


并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。

并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

Serial/Serial Old

Serial(中文翻译为串行,按顺序的),所以根据名字可以知道它是一个单线程工作的收集器,但 JVM 刚诞生就只有这种,最古老的,单线程,独占式,成熟,适合单 CPU,一般用在客户端模式下。


这种垃圾回收器只适合几十兆到一两百兆的堆空间进行垃圾回收(可以控制停顿时间再 100ms 左右),但是对于超过这个大小的内存回收速度很慢(STW 的时间变长),所以对于现在来说这个垃圾回收器已经是一个鸡肋。

运行过程


PS:Safepoint是什么

参数设置

在 JVM 中可以通过参数-XX:+UseSerialGC 去设置


Parallel Scavenge/Parallel Old

Parallel(中文翻译为并行),为了提高回收效率,从 JDK1.3 开始,JVM 使用了多线程的垃圾回收机制,关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。


所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即:


吞吐量 = 运行用户代码时间/(运行用户代码时间+垃圾收集时间)


虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。该垃圾回收器适合回收堆空间上百兆~几个 G。

运行过程

参数设置

在 JVM 中可以通过参数-XX:+UseParallelGC 去设置


JDK1.8 默认就是以下组合,-XX:+UseParallelGC 新生代使用 Parallel Scavenge,老年代使用 Parallel Old



同时,收集器提供了两个参数用于精确控制吞吐量,分别控制的停顿时间的-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX:GCTimeRatio 参数。


  • 对于-XX:MaxGCPauseMillis ,不要认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的。系统把新生代调得小一些,收集 300MB 新生代肯定比收集 500MB 快,但这也直接导致垃圾收集发生得更频繁,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、 每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。

  • 对于-XX:GCTimeRatio ,参数的值则应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。 例如:把此参数设置为 19,那允许的最大垃圾收集时占用总时间的 5% (即 1/(1+19)), 默认值为 99,即允许最大 1% (即 1/(1+99))的垃圾收集时间。


因此,如果手动去控制参数大小实际是不可控的,因为每个项目是不同的,所以 JDK 提供了默认了一个默认参数-XX:+UseAdaptiveSizePolicy,默认是开启的。


这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、 晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

ParNew

多线程垃圾回收器,与 CMS 进行配合,对于 CMS(CMS 只回收老年代),新生代垃圾回收器只有 Serial 与 ParNew 可以选。和 Serial 基本没区别,唯一的区别:多线程,多 CPU 的,停顿时间比 Serial 少。(在 JDK9 以后,把 ParNew 合并到了 CMS 了)

CMS

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。

运行过程

从名字(Mark Sweep)上就可以看出,CMS 收集器是基于标记—清除算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为 4 个步骤,如下:


  1. 初始标记,时间较短,仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。

  2. 并发标记,和用户的应用程序同时进行,进行 GC Roots 追踪的过程,标记从 GC Roots 开始关联的所有对象开始遍历整个可达分析路径的对象。这个时间比较长,所以采用并发处理(垃圾回收器线程和用户线程同时工作)。

  3. 重新标记,时间较短,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

  4. 并发清除,用户和应用程序同时进行,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。


由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。



概念性的东西往往难以记住,我们换个方式来理解,假如你去饭店吃饭:


  1. 当服务员刚上菜的时候,是两只大龙虾,服务员告诉你为了不影响其他顾客用餐不要把龙虾壳乱扔(这个大龙虾我们把他当做比较明显的 GC Roots 对象),这个时候你的状态是在听取意见(暂停)而并没有开始进餐,也就是对应的初始标记

  2. 服务员说完之后你开始进餐,你在吃龙虾的过程中,把龙虾壳放在了你正前面的盘子里面,这个时候服务员对你说如果盘子里的龙虾壳(垃圾)满了他就来清理,此时你吃你的,他说他的,互不干扰,是一个同时进行的过程,这个过程可以理解为并发标记

  3. 当你面前的盘子满了之后,你把盘子从你的正前面放到了另外的一个位置,并且你叫服务员来清理一下,然后准备等清理之后在继续吃第二只龙虾(也就是说你现在处于暂停的状态),但此时服务员手上都是垃圾,于是他又重新记住了盘子的位置说等下就来,这个过程可以理解为重新标记

  4. 此时,服务员重新给了你一个新的盘子,然后你就继续吃下一只龙虾,服务员就把装满龙虾壳的盘子给收走了,你吃你的,他收他的,这个过程可以理解为并发清除


也就是说,你吃东西的过程当做用户线程,而服务员收垃圾的过程当做 GC,感觉是挺好理解的,反正我是记住了,哈哈哈。

存在的问题

CMS 相比前面讲到的回收器是比较优秀的,主要就是体现在它的并发和低停顿,但同时它也存在一些缺点,主要表现在这 3 个方面:


  1. CPU 敏感:CMS 对处理器资源敏感,因为采用了并发的收集、当处理核心数不足 4 个时,CMS 对用户的影响较大。

  2. 浮动垃圾:由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为浮动垃圾。

  3. 并发模式失败:由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。在 1.6 的版本中老年代空间使用率阈值(92%) 如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。

  4. 简单来说就是在老年代内存要满的时候会进行Full GC,但在Full GC的过程中可能会有新的对象进入老年代,那此时必定会进入 STW 的状态,并且 CMS 会自动切换到用Serial old垃圾收集器来回收。Serial是一个单线程的垃圾回收器。那这种情况出现是不是会严重降低我们的执行效率?

  5. 内存碎片:CMS 采用的是标记 - 清除算法,因此会导致产生不连续的内存碎片。


总体来说,CMS 是 JVM 推出了第一款并发垃圾收集器,所以还是非常有代表性。但是最大的问题是 CMS 采用了标记清除算法,所以会有内存碎片,当碎片较多时,给大对象的分配带来很大的麻烦,为了解决这个问题,CMS 提供一个参数:-XX:+UseCMSCompactAtFullCollection,一般是开启的,如果分配不了大对象,就进行内存碎片的整理过程。


那为什么 CMS 采用标记-清除?

在实现并发的垃圾回收时,如果采用标记整理算法,那么还涉及到对象的移动(对象的移动必定涉及到引用的变化,这个需要暂停业务线程来处理栈信息,这样使得并发收集的暂停时间更长,而 CMS 的主要目的就是为了降低 STW 的时间),所以使用简单的标记-清除算法才可以降低 CMS 的 STW 的时间。该垃圾回收器适合回收堆空间几个 G~ 20G 左右。

G1

G1(Garbage First),被 Oracle 官方称为全功能的垃圾收集器

设计思想

随着 JVM 中内存的增大,STW 的时间成为 JVM 急迫解决的问题,但是如果按照传统的分代模型,总跳不出 STW 时间不可预测这点。为了实现 STW 的时间可预测,首先要有一个思想上的改变。G1 将堆内存化整为零,将堆内存划分成多个大小相等独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。回收器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。


Region 到底是什么?


Region 可能是 Eden,也有可能是 Survivor,也有可能是 Old,另外 Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。 G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数-XX:G1HeapRegionSize 设定,取值范围为 1MB~32MB,且应为 2 的 N 次幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中,G1 的进行回收大多数情况下都把 Humongous Region 作为老年代的一部分来进行看待。



因此,对于 G1 最主要的特点是 G1 的内存区域是不固定的。如下 E 变为 S,O 变为了 E:


运行过程

G1 的运作过程大致可划分为以下四个步骤:


  1. 初始标记,仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。 这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。

  2. TAMS 是什么?

  3. 要达到 GC 与用户线程并发运行,必须要解决回收过程中新对象的分配(虽然我在进行垃圾回收,但是还是会有新对象产生),所以 G1 为每一个 Region 区域设计了两个名为 TAMS(Top at Mark Start)的指针,从 Region 区域划出一部分空间用于记录并发回收过程中的新对象。这样的对象认为它们是存活的,不纳入垃圾回收范围。

  4. 并发标记,从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后 ,并发时有引用 变动的对象,这些对象会漏标 , 漏标的对象会被一 个叫做 SATB(snapshot-at-the-beginning)算法来解决(不理解先不急,往下看)。

  5. 最终标记,对用户线程做另一个短暂的暂停,用于处理并发阶段结后仍遗留下来的最后那少量的 SATB 记录(漏标对象)。

  6. 筛选回收,负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划(也就是说用户可以指定停顿时间),可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。


存在的问题

没有一款垃圾收集器是完美无缺的,只能分场景选择最适合的垃圾收集器,对于于 G1 来说,主要存在以下问题:


  1. 跨代引用,堆空间通常被划分为新生代和老年代。由于新生代的垃圾收集通常很频繁,如果老年代对象引用了新生代的对象,那么回收新生代的话,需要跟踪从老年代到新生代的所有引用,所以要避免每次 Young GC 时扫描整个老年代,减少开销。

  2. 并发情况下的漏标问题,通过三色标记来分析。


三色标记(重点)

针对 CMS 和 G1 存在的漏标问题,JVM 通过三色标记算法来解决。


在三色标记法之前的算法叫 Mark-And-Sweep(标记清除)。这个算法会设置一个标志位来记录对象是否被使用。最开始所有的标记位都是 0,如果发现对象是可达的就会置为 1,一步步下去就会呈现一个类似树状的结果。等标记的步骤完成后,会将未被标记的对象统一清理,再次把所有的标记位设置成 0 方便下次清理。


这个算法最大的问题是 GC 执行期间需要把整个程序完全暂停,不能异步进行 GC 操作。因为在不同阶段标记清扫法的标志位 0 和 1 有不同的含义,那么新增的对象无论标记为什么都有可能意外删除这个对象。对实时性要求高的系统来说,这种需要长时间挂起的标记清扫法是不可接受的。所以就需要一个算法来解决 GC 运行时程序长时间挂起的问题,那就三色标记法。


三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC。


根据 GC Roots 可达性分析算法遍历对象的过程中,按照对象是否访问过这个条件标记成以下三种颜色:


  • 黑色:根对象,或者该对象与它的子对象都被扫描过。

  • 灰色:对本身被扫描,但是还没扫描完该对象的子对象。

  • 白色:未被扫描对象,如果扫描完所有对象之后,最终为白色的为不可达对象,既垃圾对象。

存在的问题

需要注意的是,对象是否需要被回收主要是通过可达性分析来判断的,但是在 GC 的并发标记过程中,程序还是在跑的状态,因此对象之间的引用可能会发生改变,这样可能出现两种后果:

  • 多标,把原本死亡的对象错误标记为存活,导致的后果是在本次垃圾回收不会收集。

  • 漏标,把原本存活的对象错误标记为已死亡,这就是非常致命的后果了,程序肯定会因此发生错误。如下:

针对 CMS 的解决方案-增量更新

对于存在的漏标问题,CMS 主要是采用增量更新算法(Incremental Update)来解决。


什么是增量更新?


当一个白色对象被一个黑色对象引用,将黑色对象重新标记为灰色,让垃圾回收器重新扫描。


有何缺点?

上面重新扫描的情况是比较简单的,一旦 A 对象的引用很多,那必定会扫描这个黑色对象的所有引用,耗时增加。

针对 G1 的解决方案-SATB

对于 G1 来讲,主要是通过 SATB(snapshot-at-the-beginning)的方式来处理,简单来说就是在标记之前记录下一个快照(可理解为照片),然后再回收之前和前面的快照进行对比即可,其步骤可以简单的描述为:

  1. 如下图,在灰色对象 B 取消对白色对象的引用之前(即B.c=null),先把它记录下来。


  1. 在线程 1 和线程 2 完成标记之前在对比,发现原来 B 和 C 的连线断了,而 A 和 C 之间却相连,也就是引用。



  1. 再以这个引用指向的白色对象为根,直接对它的引用进行扫描。



所以,SATB 可以简单理解为,当一个灰色对象取消了对白色对象的引用,那么这个白色对象变为灰色下次继续被扫描。这也就回到了上面讲的 G1 运行过程中的第 3 步,最终标记


有何缺点?


如果说在快照对比的时候发现这个白色对象并没有黑色对象去引用它,但是对比之后仍然把它置为灰色,此时本应该是要被回收的,但实际还是没有被回收,在这次的 GC 存活下来,这就是所谓的浮动垃圾,但相比增量更新来说,只是浪费了一点空间,但是却节约了时间。


为什么会存在漏标对象?


分析了漏标问题的解决方案,我们可以得出如果产生漏标对象,必然:


  1. 至少有一个黑色对象(也就是被标记的对象)指向了一个白色对象。

  2. 删除了灰色对象到白色对象的直接或间接引用。


为什么 G1 用 SATB?CMS 用增量更新?

跨代引用

堆空间通常被划分为新生代和老年代,所谓跨代引用,一般是指老年代对象引用了新生代的对象。如下图的 X 和 Y 引用:



我们知道新生代的垃圾收集通常很频繁(朝生夕死),如果老年代对象引用了新生代的对象,那么在回收新生代(Young GC)的时候,需要跟踪从老年代到新生代的所有引用。

记忆集

跨代引用主要存在于Young GC的过程中,除了常见的GC Roots之外,如果老年代有对象引用了的新生代对象,那么老年代的对象也属于GC Roots(如上图中的老年代对象B和C)对象,但是如果每次进行Young GC我们都需要扫描一次老年代的话,那我们进行垃圾回收的代价实在是太大了,因此收集器在新生代上建立一个全局的称为记忆集的数据结构来记录这种引用关系。

Rset(Remember Set),简单来说就是一种抽象数据结构用来存老年代对象新生代的引用(即引用 X 和 Y)。



卡表

卡表(CardTable)在很多资料上被认为是对记忆集的实现(我其实不大能理解,但先这样吧😂,它定义了记忆集的记录精度、与堆内存的映射关系等),由于在Young GC的过程中,需要扫描整个老年代,效率非常低,所以 JVM 设计了卡表,如果一个老年代的卡表中有对象指向新生代, 就将它设为 Dirty(标志位 1,反之设为 0),下次扫描时,只需要扫描卡表上是 Dirty 的内存区域即可。 而卡表和记忆集的关系可以理解为一个 HashMap,类似于下图的样子。



这个时候根据记忆集合卡表的记录,我可以直接确定扫描记忆集确定Card[1]的位置,而不需要扫描整个老年代。

在 Hotspot 虚拟机中,卡表是一个字节数组,数组的每一项对应着内存中的某一块连续地址的区域,即数组的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作卡页(Card Page)。 一般来说,卡页大小都是以 2 的 N 次幂的字节数,假设使用的卡页是 2 的 10 次幂,即 1M,内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0、1、2 号元素,分别对应了地址范围为 0x0000~0x03FF、0x0400 ~ 0x07FF、0x0800~0x011FF 的卡页内存(0x03FF,十六进制转为十进制也就是 1024k=1M)。


G1 中的记忆集

再次说明,G1 的内存区域是不固定的,是一块一块的区域(Region),所以每一个 Region 需要知道有哪些 Region 的引用指向了它,也就是说在 G1 中是每一个 Region 都需要一个 RSet 的内存区域,导致有 G1 的 RSet 可能会占据整个堆容量的 20%乃至更多。


对于 CMS 来说是分代收集(老年代一个内存空间)只需要一份,所以就内存占用来说,G1 占用的内存需求更大,虽然 G1 的优点很多,但是我们不推荐在堆空间比较小的情况下使用 G1,尤其小于 6 个 G。

安全点与安全区域

安全点

安全点(Safepoint),用户线程暂停,GC 线程要开始工作,但是要确保用户线程暂停的这行字节码指令是不会导致引用关系的变化。所以 JVM 会在字节码指令中,选一些指令,作为安全点,比如方法调用、循环跳转、异常跳转等,一般是这些指令才会产生安全点。


为什么它叫安全点,因为在 GC 时要暂停业务线程(STW),并不是抢占式中断(立马把业务线程中断)而是主动式中断。 就好比你咬一口甘蔗,并不是立马就吐出来,你得先把汁嚼出来之后才会吐出来。


主动式中断是设置一个标志,这个标志是中断标志,各业务线程在运行过程中会不停的主动去轮询这个标志,一旦发现中断标志为 True,就会在自己最近的安全点上主动中断挂起。

安全区域

为什么需要安全区域?


如果业务线程都不执行(业务线程处于 Sleep 或者是 Blocked 状态),那么程序就没办法进入安全点,对于这种情况,就必须引入安全区域。


安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区城看作被扩展拉伸了的安全点。



当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,这段时间里 JVM 要发起 GC 就不必去管这个线程了。


当线程要离开安全区域时,它要 JVM 是否已经完成了(根节点枚举,或者其他 GC 中需要暂停用户线程的阶段)


  1. 如果完成了,那线程就当作没事发生过,继续执行。

  2. 否则它就必须一直等待, 直到收到可以离开安全区域的信号为止。

其他的垃圾回收器

经典的垃圾回收器一般情况下内存占用、吞吐量、延时只能同时满足两个。但是现在的发展,延迟这项的目标越来越重要。所以就有低延迟的垃圾回收器

Eplison

这个垃圾回收器不能进行垃圾回收,是一个不干活的垃圾回收器,由 RedHat 退出,它还要负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责,主要用于需要剥离垃圾收集器影响的性能测试和压力测试。

ZGC

类似于 G1 的 Region,没有分代。


标志性的设计是染色指针 ColoredPointers,染色指针有 4TB 的内存限制,但是效率极高,它是一种将少量额外的信息存储在指针上的技术。


G1 的目标是在可控的停顿时间内完成垃圾回收,所以进行了分区设计,在回收时采用部分内存回收(在 YGC 时会回收所有新生代分区,在混合回收时会回收所有的新生代分区和部分老生代分区),支持的内存也可以达到几十个 GB 或者上百个 GB。为了进行部分回收,G1 实现了 RSet 管理对象的引用关系。基于 G1 设计上的特点,导致存在以下问题:


  1. 停顿时间过长,通常 G1 的停顿时间要达到几十到几百毫秒;这个数字其实已经非常小了,但是我们知道垃圾回收发生导致应用程序在这几十或者几百毫秒中不能提供服务,在某些场景中,特别是对用户体验有较高要求的情况下不能满足实际需求。

  2. 内存利用率不高,通常引用关系的处理需要额外消耗内存,一般占整个内存的 1%~20%左右。

  3. 支持的内存空间有限,不适用于超大内存的系统,特别是在内存容量高于 100GB 的系统中,会因内存过大而导致停顿时间增长。


ZGC 作为新一代的垃圾回收器,在设计之初就定义了三大目标:


  1. 支持 TB 级内存。

  2. 停顿时间控制在 10ms 之内。

  3. 对程序吞吐量影响小于 15%。

Shenandoah

第一款非 Oracle 公司开发的垃圾回收器,有类似于 G1 的 Region,没有分代。也用到了染色指针 ColoredPointers。效率没有 ZGC 高,大概几十毫秒的目标。

GC 日志详解

Young GC


  • GC:一般来说没有标明是Full GC,那就是Young GC

  • System.gc():产生 GC 的原因,一般还有Allocation FailureErgonomics

  • PSYoungGen: Young 区,即Parallel Scavenge

  • 5249KYoungGC前,新生代占用的内存大小

  • 808KYoungGC后,新生代占用的内存大小

  • 76288K: 新生代总大小

  • 5249KYoungGC前,JVM 堆内存占用的内存大小

  • 816KYoungGC后, JVM 堆内存占用的内存大小

  • 251392KJVM堆内存的总的大小

  • 0.0006186 secsYoungGC 的耗时时间,单位为秒(如果没有特殊标记就是 ms)

  • Times:user=0.00 sys=0.00, real=0.00 secs 分别为三个时间

  • user:用户耗时时间

  • sys:系统耗时时间

  • real:实际耗时时间

Full GC

GC 参数

GC 的一些参数配置,可在官方文档查看,这里总结一部分。

常用参数


Parallel 常用参数


CMS 常用参数


G1 常用参数


发布于: 5 小时前阅读数: 9
用户头像

Ayue、

关注

还未添加个人签名 2019.10.16 加入

学习知识,目光坚毅

评论

发布
暂无评论
JVM分代回收机制和垃圾回收算法