写这篇文章是为了用简明易懂的写法,尽可能的在较短的篇幅内写出对Java内存垃圾回收策略的理解。解析Java内存垃圾回收策略,算法的文章很多,有些讲的还很深入。但是对平时不常接触Java虚拟机和垃圾回收(Garbage Collection 简称GC)策略的人来说,有些过于晦涩了,概念很多,层次复杂,既不方便理解,也难以记忆。作者也有这方面难题,对Java内存垃圾回收策略有一定了解,但在面试等场合很难有条理的讲解清楚,因此用此文以简洁的写法予以呈现。目标就是读完此文后,对垃圾回收策略(GC策略)有个简明的,全局的,有序的理解,对面试时一些八股文的问题可以有条理的解答。
Java ,C# ,Go这类带有内存垃圾回收能力的语言,不需要程序员再手动管理对象生命周期。要有的时候直接新建对象即可,对象的销毁,内存的释放都无需关注,垃圾回收器自然会帮助识别垃圾对象和管理内存。这大大方便了程序开发,减少了开发者的心智负担。不必如C++一般把太多的精力放在程序细节,而是可以更加专注于要实现的目标。
但是Java虚拟机的垃圾回收器并不是万能的,使用不当仍然会造成内存溢出,内存泄漏等问题。因此,还是有必要了解垃圾回收机制。
Java 的垃圾回收器并不是特指一种,Java官方本身就提供了很多个GC回收器供用户选择,随着现代Java的发展,越来越多回收算法也被加入Java主线。还有各个Java虚拟机厂商(例如 Azul 的PCG、C4,RedHat的Shenandoah GC)也自己设计开发了很多优秀的垃圾回收器。
Stop The World是指在垃圾回收过程中,程序停止响应的状态。当 Stop The World 发生时,除垃圾回收所需的线程外,所有的线程都进入等待状态,所有Java代码停止,native代码可以执行,但不能与JVM交互,一切停止直到内存垃圾回收任务完成。显然,Stop The World时程序停止响应对使用方来说是很大的困扰,尤其是时间敏感性应用。由于垃圾回收原理的限制,就算是不同的垃圾回收算法,仍然会导致程序停止响应。所以,每一代的Java垃圾回收器,都把缩减 Stop The World 停顿时间作为很重要的目标。
由于不同虚拟机的实现细节不一样,这里主要讨论的还是Oracle HotSpot虚拟机
了解Java内存结构的人都知道,Java虚拟机分为三大部分,类加载系统,运行时数据区,执行引擎。垃圾回收并不会在每个部分上都发生作用。
Java虚拟机的垃圾回收 主要发生在运行时数据区的Java对象堆区和方法区。
有一些文章说垃圾回收只会发生在以上区域,其实并不是的。
在直接内存区域(Direct Memory) 的内存,也是可以被垃圾回收器回收的,是需要注意的是直接内存仅能在Full GC时被回收。
栈区的数据,基本类型数据,或者被内联展开的局部变量,在超出作用域后会自动出栈释放掉,所以其不在JVM GC的管理范围内。
在GC执行垃圾回收之前,首先需要区分出内存中那些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,GC才会在垃圾回收过程时,释放掉其所占用的内存空间。
那么如何判断对象是否可以被回收,或者说判断对象的生命周期是否结束?
当一个对象已经不再被任何存活的对象引用时,就可以宣判为死亡。
判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
引用计数法
引用计数法(Reference Counting)比较简单,对每一个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象的引用计数器的值为0,即表示对象A不能在被使用,可进行回收。
可达性分析法(根搜索算法)
Java,C# ,Go都是使用可达性分析算法来判断对象是否存活的,这个算法也可以称之为根搜索算法。
这个算法的基本原理是通过一系列可被作为 GC Roots 的根对象来作为起始节点,从这些节点开始,根据引用关系向下搜索,搜索过程的就是一条引用链(Reference Chain),没有在这个链条上面的对象,也就是根节点通过引用链不可达到这个对象时,就认为这个对象是可以被回收的。
上面说了,判断对象是否存活,需要从垃圾回收搜索根 GC Roots开始搜索,遍历全部可达对象。
那么哪些对象可以作为GC根节点呢?这里很多文章列举许多可以作为搜索根节点的对象,但是很散乱,云里雾里的,看不出什么规律。
其实,这块知道了原理,是比较好理解的。判断对象是否存活,还要从根开始搜索。那么自然是程序运行时不会被销毁的对象,和当前程序运行时刻明确已知存活的对象,作为搜索根最合适啊。
JVM并不能在程序运行到任意位置都可以开启垃圾回收,只有在那些被标记为安全点的位置,JVM才能开始内存垃圾回收。
安全点的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。
安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。只要不离开这个安全点,Java虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。
程序运行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点。
对于安全点,另一个需要考虑的问题就是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。
这个对这个问题,有两种解决方案:
抢先式中断(Preemptive Suspension)
抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式来暂停线程从而响应GC事件。
主动式中断(Voluntary Suspension)
主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
安全区域 Safe Region
指在一段代码片段中,引用关系不会发生变化。在这个区域中任意地方开始GC都是安全的。也可以把Safe Region看作是被扩展了的Safepoint。
当程序创建一个新的对象或者基本类型的数据,内存空间不足时,会触发GC的执行。
不同的垃圾回收器,会有不同的回收策略,但大致可以分为两类:分代回收和局部回收两种策略。
垃圾回收策略不等于垃圾回收算法,正因为不同的回收算法有各自的缺陷,所以才会使用不同的垃圾回收策略。
垃圾回收算法,主流的都是基于先标记垃圾对象,然后进行处理的方式。
这个算法和它的名字一样,分两个步骤:标记 和 清除。首先标记出所有存活的对象,再扫描整个空间中未被标记的对象直接回收。
内存中的对象构成一棵树。开始垃圾回收时,第一:标记,标记从树根可达的对象(图中水红色),第二:清除(清除不可达的对象)。标记清除的时候程序会停止运行,如果不停止,此时如果存在新产生的对象,这个对象是树根可达的,但是没有被标记(标记已经完成了),会产生漏标记。
标记 - 清除算法一次回收过后,可以看到完整的内存区域产生了大量空洞。这时由于回收后没有进行整理的操作,所以会存在内存空间碎片化的问题,这个确实是缺点,但也是这个算法的特点,正因为它不进行整理,所以效率才高。
另一个问题就是如果要创建一个较大的对象,长度超过每个空洞的,则无法创建。此时虽然总的剩余内存空间大小足够,但由于不能连续分配,导致大对象无法创建。
把内存分成两块相等区域:空闲区域和活动区域,每次只使用其中的一块,称为活动区域。进行垃圾回收时,第一还是标记可达对象,标记之后把可达的对象复制到空闲区,然后将空闲区变成活动区。同时把以前活动区对象不可达的内存垃圾对象清除掉,变成空闲区,以备下次交换。
这种算法的优点是效率高,因为是整块内存进行清除的,同时复制到空闲区域的对象是在内存中连续分布的,不会有空穴,所有内存不会有碎片化的问题。缺点就是耗费空间,毕竟有一块相等大小的空间不能使用了。
还有个问题就是可能存在劣化,假定活动区域全部是活动对象,这个时候进行交换的时候就相当于多占用了一倍空间,但是没有回收到内存空间,耗费了一次清理时间效果却非常差。
标记-清除算法会产生内存碎片,可能导致在内存足够的情况下不能分配大对象。而标记-整理算法,就是在其基础之上,增加了整理这个操作,去解决这些内存空间碎片化的问题。
标记—整理算法就是优化了的标记—清除算法。
清理过程和标记-清除算法一样,先标记,但清除之前,会先进行内存碎片整理。把所有存活的对象往内存空间的头部移动,然后清理掉存活对象边界以外的内存,即完成了清除的操作。标记-整理算法是在 标记-清除 算法之上,又进行了对象的移动排序整理,因此成本更高,但却解决了内存碎片的问题。
上面介绍的三种垃圾回收算法,都有各自的缺陷。那么有没有办法扬长避短呢?有的,现代Java的垃圾回收器都是采用分代回收策略,组合使用不同的算法,以期实现最优的垃圾回收机制。
大多数的商业虚拟机,都采用分代回收的理论来设计垃圾收集器,这个理论建立在两个分代假说上:
弱分代假说:绝大多数对象都是朝生夕死的。
强分代假说:熬过越多次的垃圾回收的对象,就越难消亡
既然绝大多数对象都熬不过几次垃圾回收,而熬过多次回收的对象又很难消亡,那么可以根据对象的年龄把它们划分到不同的区域,例如新生代区域和老年代区域,然后分而治之。
例如新生代,绝大多数对象都是朝生夕死的,每次触发GC,这个区域里大部分对象都会被回收,使用可达性分析法,从根节点顺着引用链遍历下去,只有在这个引用链上的才是存活的,假设本次触发GC,这个区域里90%的对象都要被回收,但实际上只需要操作引用链上10%的对象就可以了。
对于熬过很多次依然存活的对象,这种对象一般很难被回收了,这样的情况下,每次GC都对他们进行搜索标记,太浪费资源。把它们放到老年代区,这样JVM就能以较少的频率来回收这个区域,假如老年代的空间占比是60%,在不触发老年代回收的情况下,只需要对占比40%内存空间的新生代进行搜索和释放,效率提升还是很明显的!
各区域触发垃圾回收的类型:
针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:
有说法,将Minor GC等同于只回收新生代区域youngGC,Major GC等同于只回收老年代区域的oldGC。
并不是这样的,Minor GC和Major GC是指在Hotspot JVM实现的Serial GC, Parallel GC, CMS, G1 GC收集器收集过程中的,大致可以对应到某个Young GC和Old GC算法组合的阶段。所以Minor GC和Major GC并不是Young GC,Old GC对应的。MajorGC通常和FullGC等价,并不一定等于Old GC。
绝大多数新创建的对象都会被分配到这里,这个区域触发的垃圾回收称之为Minor/Young GC。
对象在新生代周期中存活了下来的,会被晋升到老年代。一些较大的对象,也会被直接分配到老年代。默认情况下这个区域分配的空间要比新生代多,当然这是可以调整的。正是由于对象经历的GC次数越多越难回收,加上相对大的空间,发生在老年代的GC次数要比新生代少得多。这个区域触发的垃圾回收称之为Old GC。
方法区主要回收废弃的常量和类型,例如常量池里不会再被使用的各种符号引用等等。类型信息的回收相对来说就比较严苛了,必须符合以下3个条件才会被回收:
直接内存并不直接控制于JVM,这些内存只有在DirectByteBuffer
回收掉之后才有机会被回收,而 Young GC 的时候只会将年轻代里不可达的DirectByteBuffer
对象及其直接内存回收,如果这些对象大部分都晋升到了年老代,那么只能等到Full GC的时候才能彻底地回收DirectByteBuffer
对象及其关联的直接内存。因此,直接内存的回收依赖于 Full GC。
新生代中的对象很有可能会被老年代里的对象所引用,当新生代触发GC的时候,只搜索新生代的区域明显是不够的,还得搜索老年代的对象是否引用了新生代中非 GC Roots 引用链上的对象,来确保正确性。但这样做会带来很大的性能开销。为了解决这个问题,Java定义了一种名为记忆集(Remembere Set
)的抽象的数据结构,用于记录存在跨区域引用的对象指针集合。
大多数的虚拟机,都采用一种名为卡表(Card Table
)的方式去实现记忆集。卡表由一个数组构成,每个卡表数组元素对应一片老年代的内存区域,这块内存区域被称之为卡页(Card Page
),每一个卡页,可能会包含N个存在跨区域引用的对象,只要存在跨代引用的对象,这个卡页就会被标识为脏卡Dirty Card
。当GC发生的时候,就不需要扫描整个老年代了,只需要把这些被标识为Dirty Card
的卡页加入 GC Roots 里一起扫描即可。
收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。垃圾收集器按不同的分类可以分为多种,按线程数划分(指的是垃圾收集线程),可以分为串行垃圾收集器和并行垃圾收集器。按照工作模式,可以分为并发式垃圾回收器和独占式垃圾回收器。等等。
但是目前常用的就那么几种,一般都是按照工作的内存区间分,可以分为年轻代垃圾收集器和老年代垃圾收集器。下面直接列出来常用的收集器,其中C4GC为非HotSpot JVM中的实现,仅供参考。
新生代收集器 | 线程 | 算法 | 优点 | 缺点 |
---|---|---|---|---|
Serial | 单线程(串行) | 标记-复制 | 默认的新生代收集器。无线程交互的开销,简单而高效 | GC时程序暂停响应 |
Parallel Scavenge | 多线程(并行) | 标记-复制 | 吞吐量优先,适用在后台运行不需要太多交互的任务,有GC自适应的调节策略开关 | 不适合响应优先的程序,无法与CMS收集器配合使用 |
ParNew | 多线程(并行) | 标记-复制 | Serial收集器的多线程版本。程序响应优先,一般采用ParNew和CMS组合。多CPU和多核心的环境中更高效 | - |
老年代收集器 | 线程 | 算法 | 优点 | 缺点 |
---|---|---|---|---|
Serial Old | 单线程(串行) | 标记-整理 | 无线程交互的开销,简单而高效 | GC时程序暂停响应 |
Parallel Old | 多线程(并行) | 标记-整理 | 吞吐量优先,适用在后台运行不需要太多交互的任务,有GC自适应的调节策略开关 | 不适合响应优先的程序,可能会导致长时间StopTheWorld |
CMS收集器 | 多线程(并发) | 标记-清除 | 响应优先,集中在互联网站或B/S系统服务、端上的应用。,并发收集、低停顿 | 1、对CPU资源非常敏感。收集会占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低 2、无法处理浮动垃圾 3、清理阶段新垃圾只能下次回收 4、标记-清除算法导致的空间碎片 |
统一收集器 | 线程 | 算法 | 优点 | 缺点 |
---|---|---|---|---|
G1 | 多线程(并发) | 标记-整理+复制 | 1、面向服务端应用的垃圾收集器 2、分代回收 3、可预测的停顿 这是G1相对CMS的一大优势 | 占用CPU资源,降低吞吐量 |
ZGC | 多线程(并发) | - | 1、低延迟,亚秒级暂停 2、 良好的可伸缩性,对堆大小不敏感,即使是TB级大小的堆性能无劣化 3、易用性高,无需配置复杂的GC参数 | 仅Linux实现 |
Shenandoah GC | 多线程(并发) | - | 1、响应度优先,程序高可用友好,目标是亚毫秒级暂停 2、类似G1的可预测短暂停顿 3、 类似ZGC的良好的可伸缩性,对堆大小不敏感,即使是TB级大小的堆性能无劣化 | RedHat公司主导,尚在实验中 |
C4 GC | 多线程(并发) | - | 几乎无停顿,程序暂停时间接近系统波动误差 | 仅Azul公司的ZingJVM支持,需要高付费购买 |
分代垃圾回收策略,就是组合使用不同的垃圾回收器以组合不同的算法。显然单线程的Serial收集器是最低效的选择,一般不去考虑。
其他收集器则区分使用目的,以吞吐量为优先的程序,如计算任务,数据仓库等程序可以使用吞吐量优先的组合:Parallel Scavenge+Parallel Old
.
响应优先的程序,在Java8及之前,可以使用ParNew+CMS
的组合,Java 8版本之后,优先使用统一收集器G1
。而使用Java 17之后版本的程序,则可以考虑使用ZGC
这一优秀成果。
而不考虑成本的场合,Azul Zing JVM是最好的选择,无暂停的C4 GC
可以提供如C++等无GC语言程序一般的性能。
串行垃圾收集器是指在同一时间段内只允许执行垃圾回收操作,此时用户线程被暂停,直到垃圾收集完成。
Serail 收集器是最基本、历史最悠久的垃圾收集器了。在 JDK 1.3 之前,是回收新生代的唯一选择。
Serail 收集器采用标记-复制算法、串行回收方式执行内存回收。
Serail 垃圾收集器还提供了用于执行老年代垃圾收集的 Serail Old 收集器。Serail Old 同样采用了 串行回收机制,只不过内存回收算法使用的是“标记-压缩算法”。
Serail Old 作为老年代 CMS 收集器的 后备垃圾收集方案,在CMS并发收集失败时,退化成Serail Old收集器。
Serail 收集器是一个单线程的串行收集器,但是它的“单线程串行”的意义并不仅仅说明它只会使用一个CPU 或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其它所有的工作线程,直到它收集结束(Stop The World)。
Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。在单 CPU 处理器或者较小的应用内存等硬件平台不是特别优秀的场合,串行垃圾收集器的性能表现可以超过并行垃圾收集器。
但时至今日,Java已退出低端设备的应用(Java ME早已废弃)。以及IoT设备都已多核化的当下,Serial GC并不是一个合适的选择。所以,除了测试场景,不推荐任何场合使用。
并发垃圾收集器不能称之为并行垃圾回收器,因为并发不仅仅是指可以运用多个 CPU 同时执行垃圾回收,而是可以在运行用户程序的同时,进行垃圾回收。目标是将程序暂停响应的时间,缩短到最小。
前面讲了,主流的垃圾回收算法都是先标记然后处理。串行标记的性能较低,标记时程序暂停响应,用户体验会很差。因此一般使用并发标记来提升性能。
但是,并发标记一共会有两个问题:一个是错标,标记过不是垃圾的,变成了垃圾(也叫浮动垃圾);第二个是本来已经当做垃圾了,但是又有新的引用指向它。
为了解决这些问题,提出了三色标记法。
三色标记法是一种垃圾回收法,它可以让JVM不发生或仅短时间发生程序暂停响应(Stop The World),从而达到清除JVM内存垃圾的目的。JVM中的「CMS、G1垃圾回收器」所使用垃圾回收算法即为三色标记法。
三色标记法将要进行扫描的对象的颜色分为了黑、灰、白,三种颜色。
从GC Root开始向下查找,用黑灰白的规则,标记出所有跟GC Root相连接的对象。扫描一遍结束后,一般需要进行一次短暂的STW(Stop The World),再次进行扫描,此时因为黑色对象的属性都也已经被标记过了,所以只需找出灰色对象并顺着继续往下标记(且因为大部分的标记工作已经在第一次并发的时候发生了,所以灰色对象数量会很少,标记时间也会短很多), 此时程序继续执行,GC线程扫描所有的内存,找出扫描之后依旧被标记为白色的对象(垃圾),进行清除。
具体流程:
创建白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:
在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃圾对象),由于不会再对黑色标记过的对象重新扫描,所以不会被发现,这个对象不是白色的不会被清除,重新标记也不能从GC Root中去找到,所以成为了浮动垃圾,本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。
这个问题是比较致命的,如果错杀了,就会出现运行结果不符合预期的情况。并发标记的过程中,一个业务线程将一个未被扫描过的白色对象断开引用成为垃圾(删除引用),同时黑色对象引用了该对象(增加引用)(这两部可以不分先后顺序);因为黑色对象的含义为其属性都已经被标记过了,重新标记也不会从黑色对象中去找,导致该对象被程序所需要,却又要被GC回收,此问题会导致系统出现问题。
如下面的例子,D是黑色的,E是灰色的,但是D又指向了G,和E断开了指向G。 因为D已经标记了是黑色,但是E断开了引用,所以G就当做了是白色的。这个时候如果不操作的话,就会把G错杀掉。这种问题是必须解决掉的。
底层使用CPU的读写屏障来解决漏标问题,有两种解决方案: 增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning,SATB)。
CMS与G1,两种垃圾收集器在使用三色标记法时,都采取了一些措施来应对这些问题,「CMS对增加引用环节进行处理,也就是写屏障+增量更新(Incremental Update) ,G1则对删除引用环节进行处理,也就是写屏障+原始快照(SATB)。」
而Shenandoah GC 类似CMS,使用写屏障 + 原始快照,ZGC则最保守的使用读屏障来应对这些问题。
增量更新就是当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来(记录到一个集合里), 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根(A对象), 重新扫描一次。 这可以简化理解为:黑色对象(A对象)一旦新插入了指向白色对象的引用之后, 它(A对象)就变回灰色对象了,灰色对象还会继续被扫描的。
原始快照就是当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)。
ParNew 收集器 则是 Serail 收集器的多线程版本。
ParNew 中的 Par 是 Parallel 的缩写,New 代表是新生代,就是ParNew 新生代并行收集器。
ParNew 垃圾收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew 在新生代采用复制算法机制。
HotSpot 的年轻代中除了 ParNew 收集器是基于并行的回收以外,Parallel Scavenge 收集器同样也采用了复制算法、并行回收和“Stop The World”机制。
和 ParNew 不同,Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量,也被称为吞吐量优先的垃圾收集器。
自适应调节策略也是 Parallel Scavenge 与 ParNew 的一个重要区别。
高吞吐量则可以高效率的利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Scavenge 收集器同样提供了用于执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serail Old 收集器。Parallel Old 收集器采用标记-整理算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器。
Java 8默认的新生代和老年代收集器就是Parallel收集器。
Concurrent Mark Sweep 并发标记清除收集器,是一种以获取最短回收停顿时间为目标的收集器。
从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GCRoots能直接关联到的对象,速度很快;
并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;
最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS以流水线方式拆分了收集周期,将耗时长的操作单元保持与应用线程并发执行。只将那些必需STW才能执行的操作单元单独拎出来,控制这些单元在恰当的时机运行,并能保证仅需短暂的时间就可以完成。这样,在整个收集周期内,只有两次短暂的暂停(初始标记和重新标记),达到了近似并发的目的。
CMS收集器对CPU资源敏感
在并发阶段虽然不会导致用户线程停顿,但是会因为占用了一部分CPU资源,如果在CPU资源不足的情况下应用会有明显的卡顿。
当然在现代服务器场景下,这已经不是很严重的问题了。
浮动垃圾过多时可能会性能劣化
在执行‘并发清理’步骤时,用户线程也会同时产生一部分可回收对象,但是这部分可回收对象只能在下次执行清理是才会被回收,这些不能在本次垃圾回收过程中被处理的垃圾对象就是浮动垃圾。
浮动垃圾如果过多,那么可能在下次清理过程中预留给用户线程的内存不足,此时就会出现并发收集模式失败Concurrent Mode Failure
,一旦出现此错误时便会切换到SerialOld收集方式。SerialOld单线程收集器,收集时stop-the-world,程序完全停止响应,对时间敏感型应用很不友好。
产生大量的内存碎片
CMS清理后会产生大量的内存碎片,当有不足以提供整块连续的空间给新对象/晋升为老年代对象时又会触发Full GC。
Azul Zing JVM的独牌秘籍,Continuously Concurrent Compacting Collector,连续并发压缩回收器,简称C4 GC。
Java性能优化之JVM GC
JVM架构和GC垃圾回收机制
详解三色标记法