7000字还搞不懂JVM垃圾回收器详解:垃圾优先内存管理,就别学了

垃圾优先

垃圾优先(Garbage First,简称G1)也是一款增量垃圾回收器。它和CMS的定位非常类似,但是由于CMS设计中存在先天不足,G1的设计初衷是为了解决CMS不足的问题。CMS不足主要是因为垃圾回收时停顿时间不可控,主要表现如下:

1)回收新生代的停顿时间不可控。其主要原因是新生代采用复制算法进行回收,回收引起的停顿时间与新生代中存活对象的个数和大小成正比,新生代中存活对象的数量越多,回收所需的时间越长。CMS中新生代的大小是固定的,所以回收时间与新生代中对象的状态、应用运行的情况紧密相关。而应用在运行过程中的不同时刻,对象的使用情况并不相同,这就导致新生代回收的停顿时间不固定。

2)CMS的老生代回收也会引起停顿时间不可控。CMS的老生代回收分为初始标记(initial mark)、并发标记(concurrent mark)、再标记(remark)、清除(cleanup)几个阶段。初始标记产生的停顿时间和根集合中活跃对象的大小相关,再标记产生的停顿时间和并发标记中修改的对象个数相关。通常来说,初始标记和再标记引起的停顿时间都比较小,但是当处理的对象数目突然增加时,会导致停顿时间不可控。另外,通常CMS为了减少标记的耗时,会先触发一次新生代回收,这也将导致停顿时间不可控。

3)CMS中一定会触发Full GC,从而导致停顿时间不可控。由于CMS的老生代采用空闲列表的分配方法,当经过一段时间的使用之后,老生代不可避免地产生碎片化,当碎片化达到一定程度,无法满足内存分配的请求之后,会触发Full GC,而运行Full GC,将导致停顿时间完全不可控。

除了停顿时间不可控之外,CMS算法本身的复杂性也是其先天不足之一。

CMS是JVM中唯一一款抢占式垃圾回收器,在CMS设计中,老生代的回收既可以主动进行,又可以被动进行。主动进行回收指的是老生代按照固定的时间间隔尝试回收老生代;

被动回收是指当触发新生代回收后,新生代回收过程会向老生代空间晋升对象,当无法成功晋升时会触发老生代的被动回收。但如果老生代正在发生主动回收,此时主动回收会被抢占。抢占导致CMS的实现非常复杂,因此CMS也是缺陷(bug)最多的垃圾回收器之一。

可控的停顿时间是交互式应用的刚性需求,为什么CMS的停顿时间不可控呢?该如何设计来尽量避免停顿时间不可控?

1)新生代回收中停顿时间不可控的原因主要是新生代大小是固定的,在回收过程中无法根据应用使用内存的情况来调整新生代大小。如果新生代的大小能够根据应用使用内存的情况做出调整,那么从理论上讲就能解决停顿时间不可控的问题。

2)老生代回收中的初始标记和再标记的引起的停顿时间不可控,主要是对象个数不同导致的,可以通过并发处理来限制处理对象的个数。

3)解决碎片化唯一的方法是不再采用空闲列表的分配方法,从而避免因为碎片化而导致的垃圾回收。

G1作为CMS的替代产品,在设计中继承了CMS的优秀思想,同时又尽量避免了CMS中的不足之处。G1在设计上的变化主要有:

1)内存被划分成多个小块(G1中小块的内存称为分区,即Region),新生代、老生代不再是一大块、不可变的内存。

2)以停顿时间为出发点来计算垃圾回收器的内存工作集大小,从而确定应用程序可以使用的内存量。具体如下:

新生代的大小与应用使用内存的情况相关。应用内存使用的情况反映在回收的停顿时间上就是内存中存活对象的个数和大小。理论上新生代的大小应根据未来应用中内存的使用情况来调整,但是未来应用的使用情况无法得到,所以一个合理的方法是根据以往内存的使用情况来预测未来的使用情况。结合内存的使用情况和停顿时间的关系,新生代被设计成根据以往停顿时间的情况来调整其大小。

G1中抛弃了CMS中空闲列表的分配方法,对老生代的回收采用了复制算法。G1中对于老生代回收创新的地方在于并不启动单独的回收过程,而是在新生代回收的过程中增量回收一部分老生代的内存,在回收老生代内存的时候优先选择内存中垃圾比较多的内存块(也就是Region),这也是垃圾优先名称的来源。在实现中G1为了区别新生代回收和新生代回收中包含了部分老生代的内存的回收,把这样的回收称为混合回收(Mixed GC)。

在回收中G1对老生代做了不同的处理,但是在回收之前,老生代中对象的标记仍然采用并发标记算法,算法仍然包含初始标记、并发标记、再标记和清除,但是该算法在实现上和CMS的实现并不相同。

另外,上面提到CMS在初始标记、再标记过程中可能存在停顿时间不可控的情况,G1与CMS关于老生代的标记算法思想基本一致,所以G1中仍然可能存在初始标记、再标记过程中停顿时间不可控的情况。对于这一问题后续介绍的其他垃圾回收器会有一些解决的办法。

内存管理概述

G1把内存划分成分区进行管理。需要解决的问题是:分区该如何划分(即分区的大小为多大)?划分的依据是什么?例如,堆空间为1GB,每个分区是1MB,则整个堆空间可以划分成1024个分区;如果分区设计为2MB,则整个堆空间可以划分成512个分区。

分区主要用于分配对象,如果分区过小,内存管理器可能不断地向OS请求新的分区,从而导致分配效率下降,也可能因为分配导致分区碎片化严重;而如果分区过大,分配效率可能比较高,但可能因为分区过大,存活对象过多而导致回收时效率低下。所以需要设计一个合理的分配机制,在分配速度和回收效率之间取得平衡。

但要回答这个问题并不容易,首先我们并不知道堆空间有多大,其次也不知道应用运行时分配速率如何。一个解决方法是:预先设计不同的分区大小和分区的个数,然后根据堆空间的大小来计算一个相对合适的值。在JVM中分区大小可以是1MB、2MB、4MB、8MB、16MB和32MB,分区大小可以通过参数指定;默认情况下,整个堆空间分为2048个分区。当没有设置相关参数时,可以通过下列方法简单计算得到分区大小和分区个数。

1)根据堆空间大小和默认的分区数计算得到一个分区大小,记为RegionSize,其计算公式为

,其中MaxHeapSize是设置的堆空间值,InitialHeapSize是设置的初始堆空间值(通常是最小堆空间值),2048是默认分区个数。

2)当RegionSize位于[1MB,32MB]之间时,则对RegionSize向上取整,确保其落在集合{1MB, 2MB, 4MB, 8MB, 16MB, 32MB}中;当RegionSize不属于区间[1MB, 32MB]且RegionSize大于32MB时,则RegionSize设置为32MB,重新计算分区个数;当RegionSize不属于区间[1MB,32MB]且RegionSize小于1MB时,则RegionSize设置为1MB,重新计算分区个数。对于需要重新计算分区个数的两种情况,分区个数(记为RegionNum)的计算公式为

通过这样简单的方式计算分区的大小,可以平衡分配和回收效率。当然用户可以根据应用的特性自行设置分区大小,但设置的分区大小也需要落在集合{1MB, 2MB, 4MB, 8MB, 16MB, 32MB}中,否则也会被丢弃。

除此之外,分区管理也会对内存的使用效率产生影响。分区大小固定,对象分配使用的空间都从这个分区中分配。但当一个分区中剩余的空间不足以满足下一个对象所需的空间时,该如何处理呢?通常有两种方法:

1)利用剩下的空间,让对象跨越两个分区。即一个对象的前半部分在第一个分区中,后半部分在第二个分区中。这样的设计提高了内存使用效率,减少了内存的碎片,但是带来了管理上的复杂度,最根本的原因是多个分区之间并不连续,在一些场景中会根据对象的起始地址访问整个对象。按照这种设计,就需要在对象的访问中不断地检查是否跨越了分区。

2)直接放弃分区中剩余的空间,新分配一个分区供对象使用,保证分区的起始地址总是指向一个有效的对象,不用处理对象跨越分区的情况。这样设计的好处是处理对象分配简单,对象访问也非常简单。但问题是可能会有一定的空间浪费。另外还有一种特殊的情况,就是对象非常大(简称为大对象),超过一个分区的大小时,按照这种设计就无法分配。对于大对象,则需要特殊的处理。一种解决方法是:当大对象占多个分区时,要求占用的多个分区的内存必须连续,但当连续空间比较大时,会对内存使用造成压力(可能因为内存碎片化没有连续的大空间供大对象分配使用)。

不同的JVM的产品会选择不同的实现。在JVM中选择的是第二种方法,OpenJ9中采用的则是第一种方法,它对大对象使用多个不连续分区的方法来优化存储。在OpenJ9的Balanced GC中对大对象数组采用了不同的设计方式,即数组对象可能使用不连续的地址来保存对象。

使用不连续的分配方式可能对Java应用的运行造成一定的问题,主要原因是程序在使用时,可能通过大对象头获得对象的地址,并根据这个地址访问对象的成员变量。如果地址不连续,那么通过地址访问连续空间就会发生内存访问异常问题。例如,JNI中有一些API(GetStringCritical、GetPrimitiveArrayCritical等)可以返回对象的地址,本地函数中可以直接操作内存。而JVM中对应大对象使用的连续空间则不会出现这样的问题,所以OpenJ9的Balanced GC在使用这些JNI的API时和JVM并不相同。如果读者需要使用不同的虚拟机,在JNI编程中需要注意这些细节。

前面提到,由于应用运行的特性,分代设计会提高垃圾回收的效率。在G1中也支持分代,下面我们来讨论一下分代和分区是如何结合的。

分代下的分区管理

从应用运行的视角来说,只看到了两个代:新生代和老生代。新生代主要用于响应应用的内存分配请求,老生代主要用于新生代回收后长期存活对象的晋升。所以从应用的角度,根本不知道内存是如何组织的,而且应用也不用考虑内存的组织方式,只需要按需使用内存,只要内存空间没有耗尽,都应该能正常地分配内存。另外,从应用的使用角度来说,应用可以通过对象地址访问一块连续的内存空间,所以可以认为应用看到的内存是连续的。

从JVM内部来看,内存被划分成分区,并且分区被映射到新生代或者老生代中,供应用使用。

两种视角关注点有所不同,分代下分区的内存管理模型如图6-1所示。

图6-1 应用和JVM看到不同的内存管理模型

在JVM内部关注的是分区的大小、分区的个数、内存碎片化等问题,而分代关注的是如何保障应用高效运行。例如,整空间中划分多大给应用?为什么这样划分?如何进行垃圾回收的触发、运行?下面两个小节来回答这些问题。

新生代大小设计

G1的设计目标是以响应时间优先,尽量保证应用在期望运行时间完成运行和垃圾回收。那么G1怎么满足用户的期望呢?这就需要一个所谓的停顿预测模型。G1根据这个模型统计历史数据来预测本次收集需要选择的分区数量(即选择收集哪些内存空间),从而尽量满足用户设定的目标停顿时间。

举一个简单的例子来解释一下这个思想。到下一次垃圾回收时,应用运行的时间(记为Time)和应用使用的内存空间(记为Space)都可以在垃圾回收结束时得到,这是一个二元组,记为<Time, Space>。通过收集一段时间垃圾回收运行的情况,得到一个二元组的序列,假设记为<Time1, Space1>,<Time2, Space2>,…,<Timex, Spacex>。通过这个序列,可以建立回收时间和回收空间的统计模型,例如使用简单的算术平均值建立一个时间和空间的线性函数:

,其中时间值(Time)为自变量,空间值(Space)为因变量。通过函数就可以根据期望的时间值来预测空间值。

例如,发生了10次垃圾回收,一共收集了10个二元组,假设10个结果得到的平均分配速率为10GB/s(即

),当用户期望到下一次垃圾回收运行的时间为200ms时,则可以得到空间Space=10GB/s×200ms=2GB,即根据以往的历史推测,最好使用2GB的内存空间运行应用。在这个例子里有两个值需要确定,第一就是平均分配速率,第二个就是到下一次垃圾回收时的最大运行时间。

G1的预测逻辑基于衰减平均值。衰减平均(Decaying Average)是一种简单的数学方法,用来计算一个数列的平均值,核心是给近期的数据更高的权重,即强调近期数据对结果的影响。衰减平均值的计算公式如下:

式中,α为历史数据权值,1-α为最近一次的数据权值。即α越小,最新的数据对结果的影响越大,而最近一次的数据对结果影响最大。不难看出,其实传统的算术平均就是α取值为的情况。

使用均值是一个非常常见的预测方法,但是预测的结果会受到样本波动的影响。在统计学中,为了减少这种影响,通常会使用标准差或者方差进行一定的中和。衰减标准差的定义如下:

该如何使用衰减平均和标准差对二元组<Time, Space>进行建模呢?G1的做法如下:

1)根据二元组计算分配速率,记为AllocateRate,其计算方法为

,这个AllocateRate就是公式(6-1)中的V。二元组序列<Time1,Space1>,<Time2,Space2>,…,<Timex, Spacex>可以转变为AllocateRate序列:AllocateRate1, AllocateRate2, …, AllocateRatex。

现在的目标是根据AllocateRate序列预测要赋给AllocateRatex+1的值,使用的就是公式(6-1),假设计算得到预测值为AllocateRate'。

2)对AllocateRate'进行修正,修正的方式是对预测的结果加上标准差,修正的结果记为AllocateRatex+1,AllocateRatex+1=AllocateRate'+σ×dvarn;在G1中σ要求大于0,可以通过参数G1ConfidencePercent来设置其值,该参数的默认值为50,等价于设置σ为0.5。

3)根据用户设置的应用运行的期望时间Timex+1 和预测得到的分配速率AllocateRatex+1,可以得到应用运行时需要的内存空间Space=Timex+1×AllocateRatex+1。

4)得到的Space值就是新生代的大小,通过该值可以得到新生代由多少个分区组成,分区个数记为YoungRegionNum,

。得到新生代分区的个数后,就可以从自由空间中按需得到内存分区。

另外,这里还有一个小小的问题:根据历史数据进行预测需要收集一定数量的数据,那需要收集多少历史数据才能保证预测得更准确?在G1的实现中选择使用10条历史记录,选择10条是否足够保证运行的效果?是否有数学理论支持?在第8章的扩展阅读中会介绍这方面的知识。

回收机制的设计

由于内存还是按照分代来管理的,因此自然的做法就是对新生代和老生代采用不同的回收算法进行回收。根据新生代的特性,采用复制算法进行回收。新生代回收的作用范围就是新生代空间。问题的关键在于该如何对老生代进行回收?

按照G1的设计理念,期望应用运行时以固定时长执行垃圾回收,这就意味着垃圾回收产生的停顿时间比较稳定。而老生代分区中的活跃对象通常比较多。如果要高效地回收老生代,一个可能的方法就是老生代进行增量回收,而且每次回收都回收垃圾比较多的分区。

举一个例子来演示老生代增量回收的概念。假设程序运行一段时间,整个堆空间的内存情况如图6-2所示,其中黑色的块表示活跃对象。新生代中存在3个分区,按活跃对象从多到少排序为2>1>3。老生代存在6个分区,按活跃对象从多到少排序为6>4>5>8>9>7;其中分区3和7中的活跃对象数为0。假设堆空间初始状态如图6-2所示。

图6-2 垃圾优先回收初始状态

通常的新生代回收示意图如图6-3所示。

图6-3 新生代回收示意图

新生代回收针对的是新生代的分区,如图6-3所示的分区1、2、3。关键问题就是如何识别分区的对象,才能产生图中的结果。G1中新生代回收采用并行复制算法,其基本思想与ParNew、Parallel Scavenger基本相同。但由于G1的分区设计不同,这里稍微有一点不同。主要有:

1)G1的新生代分区仅仅包含了Eden空间和Survivor的From空间。G1的新生代不包含To空间,在复制算法执行的时候直接从Free空间中获取分区,这样的设计可以极大地提高内存的利用率。但是可能存在一种情况——在新生代回收时,无法从Free空间中获得分区,这将导致新生代回收失败。因为G1新生代完全不保留To空间,所以发生新生代回收失败的概率更大一些。如果新生代在此时失败,将会把新生代回收升级为Full GC,而Full GC的时间开销非常大。所以G1在这个地方提供了一个参数G1ReservePercent,用于在Free空间中保留一部分分区,在新生代回收时使用。G1ReservePercent的范围是[0, 50],通过该参数将在Free空间中保留空间的大小为新生代大小乘以该参数的值。

2)引用集的处理。在ParNew中需要将老生代空间划分成多个块,然后多个线程并行地对每个块进行引用处理,确保老生代引用到新生代的对象都是活跃对象。对于G1来说,老生代天然划分成分区。但是因为G1的引用集的设计与CMS、PS的不一样,所以对于引用集的处理也有所不同。在G1中引用关系保存在被引用者所在的分区,所以只需要处理被引用者分区,多个线程即可并行地处理新生代中的每一个分区对应的引用集。

对于增量垃圾回收来说,优先选择垃圾比较多的分区(即活跃对象比较少的分区)。在回收过程中活跃对象越少,复制对象的成本(包含复制对象所需要的时间和空间成本)就越低。老生代的增量回收应该按照分区7、9、8、5、4、6这样的顺序依次选择并进行回收;假设回收分区7,不需要额外的空间存储活跃对象,成本最低,所以应该优先回收;而回收分区6时,需要复制大量的对象,空间和时间都消耗很大,这个分区最后回收。当然,为了确保回收的效率,还应该设置当活跃对象超过一定数量的分区时不参与回收(此时回收分区获得空间收益非常小,而时间成本很高)。假设分区9、8、5满足回收条件,此时增量回收的示意图如图6-4所示。

图6-4 老生代增量回收示意图

在这里有两个问题需要解决:首先,如何确定分区的活跃对象及对象的数量;其次,虽然我们期望按照垃圾从多到少的顺序回收分区,但是要回答需要几次回收完成整个老生代分区、每一次选择哪些分区、什么时候因为回收的成本过高放弃剩余分区的回收这些问题。关于这两个问题,留在并发标记中详细介绍。

新生代回收需要引用集进行标记扫描的加速,针对老生代的增量回收同样需要引用集进行标记扫描的加速。那么该如何设计引用集才能确保增量回收的效率比较高呢?

  • 7
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值