引言
程序的运行必然需要申请内存资源,使用结束后的内存资源如果不及时释放就会造成内存中的垃圾越来越多,最终造成内存溢出,而垃圾回收就是把无用的内存垃圾清理掉,这样内存就可以被程序反复使用。
垃圾回收(Garbage Collection 简称GC)是Java体系最重要的组成部分之一,和C/C++的手工内存管理方式不同,JVM虚拟机提供了一套全自动的内存管理方案,以减少开发人员在内存管理方面的相关工作。
(一) 常见的垃圾回收算法和垃圾回收器
1. 常见的垃圾回收算法
a) 标记清除算法(Mark-Sweep)
最早出现也是最基础的垃圾回收算法,算法整体分为两个阶段“标记”和“清除”,首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,该算法简单快速,但是缺点明显:一是标记和清除两个过程的效率都不高。二是清除之后会产生大量不连续的内存碎片。内存碎片过多可能导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而提前触发另一次垃圾回收动作。图1中展示了标记-清楚算法的过程。
图 1
b) 标记复制算法(Copying)
为了解决标记清除算法的大对象回收效率和内存碎片化问题。提出了另一种“半区复制”的算法,核心思想就是将原有的内存空间分为两块,每次只使用一半区域。垃圾回收时将使用的对象复制到未使用的半区中,之后清除当前使用半区的所有对象,最后交换两个内存角色,完成回收工作。虽然解决了内存碎片化的问题,但是如果活动对象较多,就会导致复制的对象过多,复制的成本很高且仅能使用一半的内存,因此单纯的复制算法也有很多问题。图2展示了复制算法的过程。
图 2
c) 标记压缩算法(Mark-Compact)
标记压缩算法的标记过程与标记清除算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向空闲内存的一端移动,然后直接清理掉边界以外的所有内存,这种方法避免了内存碎片的产生,又不需要两块相同的内存空间,标记压缩算法的最终效果等于标记清除执行后再进行一次内存碎片整理。图3展示了标记压缩算法的过程。
图3
2.分代回收理论(GenerationalCollecting)
分代回收是一种回收思想,目前被虚拟机广泛使用,在前面介绍的算法中,没有一种算法可以完全替代其他算法,分代收集就是基于这种思想,将内存根据对象的特点分成几块,根据每块内存的特点使用不同的回收算法,提高内存的回收效率。主流的JVM虚拟机里面一般会把JAVA堆内存划分为年轻代(Young Generation)和老年代(Old Generation)两个区域,每次垃圾收集时会有大批的对象被回收,少量存活的对象将逐步转移到老年代存放。图4展示了主流JVM虚拟机的内存的分代情况。
图 4
3. 常见的垃圾回收器
再说垃圾回收器之前,需要再说一下为什么需要不断优化垃圾回收器,一切都源于一个词语“Stop The World”简称STW,JVM虚拟机会自动发起和自动完成回收垃圾的工作,用户在不可干预的情况下,需要暂停所有正常工作线程来等待垃圾回收的完成。试想下每工作几小时就需要暂停几分钟,这样的程序是无法让人接受的。
垃圾回收的算法为垃圾回收器提供了理论基础,垃圾回收器就是这些理论算法的具体实现。图5展示了七种不同的垃圾回收器,如果两回收器之前存在连线,就说明可以搭配使用。
图5
a) 串行回收器(Serial + Serial Old)
最古老的垃圾回收器,也是最基本的垃圾回收器之一,是一个单线程的垃圾回收器,在年轻代工作时使用的是标记复制算法,在老年代工作时使用的是标记压缩算法。在CPU性能受限的情况下,它的性能表现依然很优秀。图6展示了串行垃圾回收器的回收过程。
图6
b) 并发回收器(ParNew和CMS)
ParNew回收器是一款只能工作在年轻代的并行收集器 ,它是Serial收集器的多线程版本,由于使用多线程进行垃圾回收,在计算能力较强的CPU上,产生的停顿时间要小于串行回收器。图7展示了ParNew并行的回收的过程。
图7
CMS(Concurrent Mark Sweep)是一款只能工作在老年代的收集器,第一款设计较为的复杂的收集器,也是JVM虚拟机追求低停顿的第一次尝试,但是也有明显的缺点,图8展示了CMS收集器的回收过程。总的来说有三点:首先CMS收集器对CPU性能比较敏感,如果CPU性能不足或者本身的负载就很高,那这会让整个垃圾回收的过程变长。其次,在并发标记和并发清除的阶段,用户线程会有新的垃圾产生,就会产生“浮动垃圾(Floating Garbage)”,所以就不能像其他回收器那样等到老年代100%再进行回收,需要预留一部分内存提供给用户线程使用。最后,CMS是一个基于标记清除算法实现的回收器,这就会产生大量的内存碎片,如果有大对象需要处理,碎片过多时就需要对Old区再进行一次垃圾回收进行内存整理。ParNew和CMS垃圾回收器一般搭配来进行使用,不过这两个收集器已经在JDK9中被标记为废弃,JDK14该回收器将被正式删除。
图8
c) 并行回收(ParallelGC+ParallelOldGC)
ParallelGC和ParallelOldGC是JDK8中默认使用的两个回收器分别用在年轻代和老年代, 并且他们都是多线程回收器。ParallelGC采用的是复制算法进行垃圾回收,它和ParNew不同的是可以控制系统的吞吐量和最大停顿时间,并且增加了自调优的功能,相当于ParNew的升级版本。ParallelOldGC使用的是标记压缩算法,这个回收器在JDK6时开始提供使用。图9展示了ParallelGC和ParallelOldGC的回收过程。
图9
d)分区回收器(Garbage First)
随着大数据时代的来临,JVM虚拟机的内存也越来越大,在相同条件下,内存空间越大,一次GC所需的时间就越长,产生的停顿就越长。为了更好的控制GC产生的STW时间。Garbage First回收器(简称G1)出现了,JDK6时开始推出试验版本,JDK7 Update4中逐渐的成熟起来,终于在JDK8 Update40以后G1提供并发的类卸载功能成为了可以替代CMS的回收器,JDK9版本中G1被设置成默认的垃圾回收器。G1回收器引入了分区(Region)的概念,将整个内存空间分为不同大小的小分区,每个小分区单独使用,独立回收。不过G1也还是遵循了分代回收的理论,还是会区分年轻代和老年代的概念,从整体看G1是基于标记压缩算法实现的,但是从局部看每个分区之间又是基于标记复制算法实现的。
图10
(二) 垃圾回收器内存分配详解
1. 分代垃圾回收器
分代的垃圾回收器是如何进行内存分配和管理的呢?我们再来回顾下分代思想。如图11所示,整个的JVM空间被分成2个区域年轻代(Young Generation)和老年代(Old Generation),而Young区又被分成了伊甸园区(Eden,统简称Eden)和生存区(Survivor),而Survivor又被分为From(Survivor0,统简称“S0”)和To(Survivor1,统简称“S1”)两个区域。年轻代和老年代比例为1:2(默认参数),在年轻代中内存中又被分成了三份(默认为8:1:1)。
G行已经开始逐步开始从JDK6向JDK8进行替换,关于这部分内容主要针对JDK8版本进行说明。
图 11
几乎所有新生成的对象首先都是放在年轻代,大部分对象在 Eden 区中生成,当Eden区内存空间不足时,则会发起一次GC,回收器会将Eden区存活对象复制到S0,然后清空Eden区。如图12展示的过程。
图 12
下一次Eden区空间不足时,会将Eden区和S0区的存活对象复制到S1区,然后清空Eden区和S0区。如图13展示的过程。
图 13
这时候会又出一个问题,对象什么时候去老年代呢?对象每次在S0和S1之间复制一次,这个对象的年龄就长一岁,当15岁(默认为15岁,可通过参数调整)之后这个对象就会被复制到老年代去。如图14展示的过程。
图 14
如此这样循环往复,当老年代也空间不足时,回收器就会用对老年代进行回收来释放内存空间,也就是通常说的Full GC。
2. 分区垃圾回收器
传统的GC收集器将连续的内存空间划分为新生代、老年代和永久代(JDK 8去除了永久代,引入了元空间Metaspace)。如下图15所示,不过现在请大家忘记它吧。
图 15
G1的内存存储地址是不连续的,G1 将连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代Old空间,每个Region的大小可以取值范围为1MB~32MB,且应为2的N次幂,并且新增一个区域叫巨大对象(humongous object,H-obj),只要大小超过了一个Region容量一半即可判定为大对象,直接放入大对象区。对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中。如下图16所示G1内存的分配情况。
图 16
在分配一般对象时,当所有Eden Region使用达到最大阈值并且无法申请足够内存时,会触发一次年轻代Region的GC。每次GC会回收所有Eden以及Survivor,并且将存活对象复制到空白的Survivor区。如下图17所示。
图 17
那内存什么时候进入老年代的Region呢?在G1回收器中有两种情况会进入到老年代Region:
同分代回收的规则,内存每在年轻代的Region被复制一次,年龄就长一岁,当15岁(默认为15岁,可通过参数调整)之后这个对象就会被复制到老年代的Region。
动态年龄判断规则,某次年轻代GC 过后,发现 Survivor 区中相同年龄的对象达到了 Survivor 的 50%,那么该年龄及以上的对象,会被直接移动到老年代中。例如Survivor 区中存在年龄分别为 1、2、3、4 的对象,而年龄为 3 的对象超过了 Survivor 区的 50%,那么年龄大于等于 3 的对象,就会被全部移动到老年代的Region。
最后再谈下分区回收独有的混合回收(Mixed GC),在G1中不存在单独回收老年代Region的行为,而是当要发生老年代的回收时,同时也会对新生代以及大对象进行回收,因此这个阶段称之为混合回收。当老年代Region的使用率占比达到 45%时,就会触发混合回收。
不过在G1中Full GC还是存在的,如果空闲的 Region 大小无法放得下存活对象的内存大小时系统就不得不暂停应用程序,进行一次 Full GC。进行 Full GC 时采用的是单线程进行标记、清理和整理内存,这个过程是非常漫长的,因此应该尽量避免 Full GC 的触发。
(三) 垃圾回收器的优化思路
垃圾回收器的优化思路
垃圾回收器的选择是JVM优化的一个重要配置,选择合适的垃圾回收器可以让JVM性能有一个很大的提升。其实JVM调优主要是调整两个指标:
JVM虚拟机停顿时间(Stop The World)
吞吐量是指CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /( 运行用户代码时间 + 垃圾收集时间 )
下面分享下关于回收器选择上的一些经验。
1. 小内存,默认优先:
大部分应用JVM堆内存都在4G以内,优先使用JDK8默认的垃圾回收器。如今大部分系统都运行在虚拟机上,G1固然是更先进的垃圾回收器,但是G1在垃圾回收时产生的内存占用也更高,所以小内容使用G1作为回收器会增加GC的次数,吞吐量会下降。
2. 大内存,G1优先:
当内存大于8G后,应该优先考虑G1垃圾回收器,因为当内存增大后,在进行垃圾回收时会将对象从s0复制到s1内存越大,复制的时间越长,会增加系统STW的时间,导致系统的停顿时间过长。
总结
随着Java的不断发展,有很多新的回收器出现,如:shenandoahGC和ZGC,同为新一代的低延迟收集器, 分别由RedHat和Oracle开发, 不过还在实验阶段, 尚未使用于生产环境,针对不同类型的应用Java提供了多种垃圾回收策略。
本文对Java虚拟机垃圾回收器及其回收策略进行逐一介绍,同时对垃圾回收的优化思路做一些简单讨论,以期读者能对Java虚拟机的垃圾回收增加理解,同时对垃圾回收的优化有一些初步认识,为后续工作中的Java应用调优打下基础。