Java虚拟机之垃圾收集概述

1,371 阅读16分钟

文章出自:JVM垃圾收集器和内存分配策略

1. GC 概述

垃圾收集(Garbage Collection,简称GC),无论是Python还是Go或者是Java都与GC息息相关,那么大家是否有这样的疑问:

  1. 那些内存需要回收?
  2. 什么时候回收?
  3. 如何回收? 你可能会说:目前内存的动态分配与内存回收技术已经相当成熟,一切看起来都进入了"自动化"的时代,那为什么我们还要去了解GC和内存分配呢?

答案是 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化“的技术实施必要的监控和调节。 下面我们分别针对上面的疑问,结合下面的分布图,探索答案: image.png

1.1 哪些内存需要回收

  • Java内存运行时区域的各个部分之中,程序计数器、虚拟机栈、本地方法栈 3个区域随线程而生,随线程而亡;
  • 栈中的栈帧随着方法的进入和退出而有条不紊的执行着入栈与出栈的操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域在方法结束或者线程结束时,内存自然就跟着回收了。
  • Java堆和方法区不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在运行期间才知道会创建那些对象,所以内存回收与分配重点关注的是 堆内存方法区内存

1.2 什么时候回收

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

  • 对于方法区,永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。
  • 对于堆,其中存放的是对象实例,对于对象实例的回收,我们首先要判断哪些对象是“存活的” ,对于那部分“死亡的”对象,就是我们要回收的。

判断对象是否存活的方法是:

  1. 引用计数算法
  2. 可达性分析算法(GC Roots)

1.3 可达性分析算法

无论是Java、C#都是通过可达性分析算法来判定对象是否存活的。通过一系列的称为”GC Roots”的对象作为起始点, 从这些节点开始向下搜索, 搜索所走过的路径称为引用链(Reference Chain), 当一个对象到GC Roots没有任何引用链相连时(图中从GC Roots到这个对象不可达),则证明此对象是不可用的。

可达性分析算法,判断对象是否可回收,那么在Java语言中,可作为GC Roots的对象包括哪几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象.
  2. 方法区中类静态属性引用的对象
  3. 方法区: 常量引用的对象;
  4. 本地方法栈JNI(Native方法)中引用的对象

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判断对象是否存活都与引用有关。

2. Java中引用机制

对象在堆上创建之后所持有的引用其实是一种变量类型,引用之间通过赋值可以构成引用链。从GC Root开始遍历,判断引用是否可达,引用的可达性是判断能否被垃圾回收的基本条件,根据引用类型语义的强弱可以决定垃圾回收的阶段。

image.png

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。

在JDK 1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

2.1 强引用(Strong Reference)

Object object=new Object() 这样的变量声明和定义就会产生对该对象的强引用。只要对象有强引用指向,并且GC Root可达,那么Java内存回收时,即使内存濒临耗尽,也不会回收该对象。

2.2 软引用(Soft Reference)

引用力弱与强引用,用在非必须对象的场景。在即将OOM之前,垃圾回收器会把这些引用指向的对象加入回收范围,以获得更多的内存空间,让程序能够继续健康运行。主要用来缓存服务器中间计算结果及不需要实时保存的用户行为等。

2.3 弱引用(Weak Reference)

引用强度较前两者更弱,也是用来描述非必须对象的。如果弱引用指向的对象只存在弱引用这一条线路,则在下一次 Y GC (年轻代GC)时会被回收。由于YGC的时间不确定性,弱引用何时被回收也具有不确定性。弱引用主要用于指向某个易消失的对象,在强引用断开后,此引用不会劫持对象,调用weakReference.get() 会返回空。

2.4 虚引用(phantom Reference)

定义完成后,就无法通过该引用获取指向的对象,为对象设置虚引用的唯一目的,就是希望能在这个对象被回收时,收到一个系统通知。

注意 虚引用与软引用和弱引用的一个区别在于,虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中

3. G1和CMS对比选型

3.1 首选

  • G1是包括年轻代和年老代的GC
  • CMS是年老代GC(年轻代只能和serial GC或者praNew 搭配使用)
  • 二者在某些时候都需要FullGC(serial old GC)的辅助

3.2 CMS 垃圾收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器,CMS收集器是基于“”标记--清除”(Mark-Sweep)算法实现的,整个过程分为四个步骤:

  1. 初始标记 (Stop the World事件 CPU停顿, 很短) 初始标记仅标记一下GC Roots能直接关联到的对象,速度很快;
  2. 并发标记 (收集垃圾跟用户线程一起执行) 初始标记和重新标记需要“stop the world”,并发标记过程就是进行GC Roots Tracing的过程;
  3. 重新标记 (Stop the World事件 CPU停顿,比初始标记稍微长,远比并发标记短)修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记时间短
  4. 并发清理 -清除算法

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

优缺点对比 优点:

  • 并发收集,低停顿,低延迟。由于在整个过程和中最耗时的并发标记和 并发清除过程收集器程序都可以和用户线程一起工作,所以总体来说,Cms收集器的内存回收过程是与用户线程一起并发执行的 缺点:

  • CMS收集器对CPU资源非常敏感 :在并发阶段,虽然不会导致用户线程停顿,但是会因为占用了一部分线程使应用程序变慢,总吞吐量会降低,为了解决这种情况,虚拟机提供了一种“增量式并发收集器” 的CMS收集器变种, 就是在并发标记和并发清除的时候让GC线程和用户线程交替运行,尽量减少GC 线程独占资源的时间,这样整个垃圾收集的过程会变长,但是对用户程序的影响会减少。 (效果不明显,不推荐)

  • CMS处理器无法处理浮动垃圾:CMS在并发清理阶段线程还在运行, 伴随着程序的运行自然也会产生新的垃圾,这一部分垃圾产生在标记过程之后,CMS无法在当次过程中处理,所以只有等到下次gc时候再清理掉,这一部分垃圾就称作“浮动垃圾”

  • 大量的空间碎片:CMS是基于“标记--清除”算法实现的,所以在收集结束的时候会有大量的空间碎片产生。空间碎片太多的时候,将会给大对象的分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象的,只能提前触发 full gc。

为了解决这个问题,CMS提供了一个开关参数,用于在CMS顶不住要进行full gc的时候开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片没有了,但是停顿的时间变长了

3.3 G1 垃圾收集器

G1垃圾回收器是一款主要面向服务端应用的垃圾收集器。作为垃圾回收器技术发展史上里程碑的成果,G1垃圾回收器不同于以往的垃圾回收器,首先是思想上的转变。

G1 对 Java 堆区域的划分

image.png

图中连续的Java堆空间划分为多个大小相等的独立区域(Region),每个Region都可以成为 Eden空间、Survivor空间、老年代空间。

这种思想上的转变和设计,使得G1可以面向堆内存任何部分来组成回收集来进行回收,衡量标准不再是它属于哪个分代,而是哪块内存存放的垃圾最多,回收收益最大,这就是G1收集器的 Mixed GC模式,即混合GC模式。

Region还有一类特殊的 Humongous 区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。如果是那些超过了整个Region容量的超大对象,将会放在连续 N 个 Humongous Region区域。

Region的取值范围为 1M ~ 32M
Region的默认个数为 2048个

## 设置堆区域 region的大小
-XX:G1HeapRegionSize = N

G1这么做看起来是由一种焕然一新的感觉,但细心的小伙伴可能已经发现:

如果 Region之间存在跨区引用对象,那这些对象如何解决?

  1. 不管是G1还是其他分代收集器,JVM都是使用 记忆集(Remembered Set) 来避免全局扫描。
  2. 每个Region都有一个对应的记忆集。
  3. 每次Reference类型数据写操作时,都会产生一个 写屏障(Write Barrier)暂时去终止操作
  4. 然后检查将要写入的引用 指向的对象是否和该Reference类型数据在不同的 Region(其他收集器:检查老年代对象是否引用了新生代对象)
  5. 如果不同,通过 卡表(Card Table)把相关引用信息记录到引用指向对象的所在Region对应的记忆集(Remembered Set) 中
  6. 当进行垃圾收集时,在GC Roots枚举范围加上记忆集;就可以保证不进行全局扫描了。

G1的记忆集可以理解为一个哈希表,Key就是别的Region的起始地址,Value就是卡表的索引号集合。

image.png

因为G1将Java堆划分为一个个Region的缘故,而Region数量相比于传统分代数量明显多得多,所以G1相比于传统的垃圾回收器来说,需要消耗相当于Java堆容量 10%~ 20%的额外空间来维持收集器的工作。

G1 垃圾回收工作流程

G1(Garbage First)是一款面向服务端应用的垃圾收集器,整个过程分为四个步骤:

  1. 初始标记(Initial Marking): 这阶段仅仅只是标记GC Roots能直接关联到的对象并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的可用的Region中创建新对象,这阶段需要停顿线程,但是耗时很短。而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  2. 并发标记(Concurrent Marking): 从GC Roots开始对堆的对象进行可达性分析,递归扫描整个堆里的对象图,找出存活的对象,这阶段耗时较长,但是可以与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  3. 最终标记(Final Marking): 对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
  4. 筛选回收(Live Data Counting and Evacuation): 负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。可以自由选择多个Region来构成会收集,然后把回收的那一部分Region中的存活对象==复制==到空的Region中,在对那些Region进行清空。

G1除了并发标记这一步,其他都要stop the world。

优缺点对比

优点

  • 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短stop-The-World停顿时间。部分其他收集器原本需要停顿线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行
  • 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。它能够采用不同的方式去处理新创建的对象和已经存活了一段时间,熬过多次GC的旧对象以获取更好的收集效果。
  • 空间整合:与CMS的“标记--清理”算法不同 ,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的
  • 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内。
  • 在G1中,还有一种特殊的区域Humongous。 如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。
  • G1回收是选择一些内存块,而不是整代内存来回收。其它GC每次回收都会回收整个Generation的内存(Eden, Old), 而回收内存所需的时间就取决于内存的大小,以及实际垃圾的多少,所以垃圾回收时间是不可控的;而G1每次并不会回收整代内存,到底回收多少内存就看用户配置的暂停时间,配置的时间短就少回收点,配置的时间长就多回收点,伸缩自如。

缺点

如果应用的内存非常吃紧,对内存进行部分回收根本不够,始终要进行整个Heap的回收,那么G1要做的工作量就一点也不会比其它垃圾回收器少,而且因为本身算法复杂了一点,可能比其它回收器还要差。

因此G1比较适合内存稍大一点的应用(一般来说至少4G以上),小内存的应用还是用传统的垃圾回收器比如CMS比较合适。

3.4 小总结

  1. G1从整体上来看是 标记-整理 算法,但从局部(两个Region之间)是复制算法。而CMS是 标记-清除算法 所以说,G1不会产生内存碎片,而CMS会产生内存碎片
  2. CMS使用了 写后屏障来维护卡表,而G1不仅使用了写后屏障来维护卡表,还是用了 写前屏障来跟踪并发时的指针变化情况(为了实现原始快照)
  3. CMS对Java堆内存使用的是传统的 新生代和老年代划分方法,而G1使用的全新的划分方法。
  4. CMS收集器只收集老年代,可以配合新生代的Serial和ParNew收集器一起使用。G1收集器收集范围是老年代和新生代。不需要结合其他收集器使用
  5. CMS使用 增量更新解决并发标记下出现的错误标记问题,而G1使用原始快照解决

相关资料

  1. 由浅入深G1垃圾收集器