带你读《JVM G1源码分析和调优》之三:G1的对象分配

点击查看第一章
点击查看第二章

第3章

G1的对象分配
对象分配直接关系到内存的使用效率、垃圾回收的效率,不同的分配策略也会影响对象的分配速度,从而影响Mutator的运行。
本章主要介绍G1的对象分配是怎样的。大体来说G1提供了两种对象分配策略:基于线程本地分配缓冲区(Thread Local Allocation Buffer,TLAB)的快速分配和慢速分配;当不能成功分配对象时就会触发垃圾回收,所以本章还总结了垃圾回收触发的时机;最后介绍了对象分配过程中涉及的参数调优。值得注意的是本章介绍的内容不仅适用于G1的对象分配,大多数调优参数也适用于其他的垃圾回收器。

3.1 对象分配概述

为了提高效率,无论快速分配还是慢速分配,都应该在STW之外调用,即都应该尽量避免使用全局锁,最好满足不同Mutator之间能并行分配且无干扰。但实际上堆空间只有一个,所以JVM的设计者致力于优秀的内存分配算法,把内存分配算法设计成几个层次,首先进行无锁分配,再进行加锁,从而尽可能地满足并行化分配。
我们以一个普通的Java对象分配为例,来梳理一下对象分配的过程。根据Java对象在JVM中的实现,JVM会先创建instanceklass,然后通过allocate_instance分配一个instanceOop。入口在InstanceKlass::allocate_instance,代码如下:

带你读《JVM G1源码分析和调优》之三:G1的对象分配

在CollectedHeap::obj_allocate中完成内存分配,如果成功则初始化对象;如果不成功则抛出异常。主要工作在CollectedHeap::common_mem_allocate_noinit()中,我们直接来看这个函数。该函数包含了我们上面提到的两种分配方法:TLAB快速分配allocate_from_tlab和慢速分配Universe::heap()->mem_allocate。代码如下:

带你读《JVM G1源码分析和调优》之三:G1的对象分配
带你读《JVM G1源码分析和调优》之三:G1的对象分配

对象分配相对来说逻辑清晰,图3-1为对象分配的全景流程图。

带你读《JVM G1源码分析和调优》之三:G1的对象分配

3.2 快速分配

TLAB产生的目的就是为了进行内存快速分配。通常来说,JVM堆是所有线程的共享区域。因此,从JVM堆空间分配对象时,必须锁定整个堆,以便不会被其他线程中断和影响。为了解决这个问题,TLAB试图通过为每个线程分配一个缓冲区来避免和减少使用锁。
在分配线程对象时,从JVM堆中分配一个固定大小的内存区域并将其作为线程的私有缓冲区,这个缓冲区称为TLAB。只有在为每个线程分配TLAB缓冲区时才需要锁定整个JVM堆。由于TLAB是属于线程的,不同的线程不共享TLAB,当我们尝试分配一个对象时,优先从当前线程的TLAB中分配对象,不需要锁,因此达到了快速分配的目的。
更进一步地讲,实际上TLAB是Eden区域中的一块内存,不同线程的TLAB都位于Eden区,所有的TLAB内存对所有的线程都是可见的,只不过每个线程有一个TLAB的数据结构,用于保存待分配内存区间的起始地址(start)和结束地址(end),在分配的时候只在这个区间做分配,从而达到无锁分配,快速分配。
另外值得说明的是,虽然TLAB在分配对象空间的时候是无锁分配,但是TLAB空间本身在分配的时候还是需要锁的,G1中使用了CAS来并行分配。

带你读《JVM G1源码分析和调优》之三:G1的对象分配

在图3-2中,Tn表示第n个线程,深灰色表示该TLAB块已经分配完毕,浅灰色表示该TLAB块还可以分配更多的对象。
从图中我们可以看出,线程T1已经使用了两个TLAB块,T1、T2和T4的TLAB块都有待分配的空间。这里并没有提及Eden和多个分区的概念,实际上一个分区可能有多个TLAB块,但是一个TLAB是不可能跨分区的。从图中我们也可以看出,每个线程的TLAB块并不重叠,所以线程之间对象的分配是可以并行的,且无影响。另外图中还隐藏了一些细节:
□T1已经使用完两个TLAB块,这两个块在回收的时候如何处理?
□我们可以想象TLAB的大小是固定的,但是对象的大小并不固定,因此TLAB中可能存在内存碎片的问题,这个该如何解决?请继续往下阅读。
快速TLAB对象分配也有两步:
□从线程的TLAB分配空间,如果成功则返回。
□不能分配,先尝试分配一个新的TLAB,再分配对象。
代码如下所示:

带你读《JVM G1源码分析和调优》之三:G1的对象分配

从TLAB已分配的缓冲区空间直接分配对象,也称为指针碰撞法分配,其方法非常简单,在TLAB中保存一个top指针用于标记当前对象分配的位置,如果剩余空间(end-top)大于待分配对象的空间(objSize),则直接修改top = top + ObjSize,相关代码位于thread->tlab().allocate(size)中。对于分配失败,处理稍微麻烦一些,相关代码位于allocate_from_tlab_slow()中,在学习这部分代码之前,先思考一下这样的内存分配管理该如何设计。
如果TLAB过小,那么TLAB则不能存储更多的对象,所以可能需要不断地重新分配新的TLAB。但是如果TLAB过大,则可能导致内存碎片问题。假设TLAB大小为1M,Eden为200M。如果有40个线程,每个线程分配1个TLAB,TLAB被填满之后,发生GC。假设TLAB中对象分配符合均匀分布,那么发生GC时,TLAB总的大小为:40×1×0.5 = 20M(Eden的10%左右),这意味着Eden还有很多空间时就发生了GC,这并不是我们想要的。最直观的想法是增加TLAB的大小或者增加线程的个数,这样TLAB在分配的时候效率会更高,但是在GC回收的时候则可能花费更长的时间。因此JVM提供了参数TLABSize用于控制TLAB的大小,如果我们设置了这个值,那么JVM就会使用这个值来初始化TLAB的大小。但是这样设置不够优雅,其实TLABSize默认值是0,也就是说JVM会推断这个值多大更合适。采用的参数为TLABWasteTargetPercent,用于设置TLAB可占用的Eden空间的百分比,默认值1%,推断方式为TLABSize = Eden×2×1%/线程个数(乘以2是因为假设其内存使用服从均匀分布),G1中是通过下面的公式计算的:

带你读《JVM G1源码分析和调优》之三:G1的对象分配

简单来说,tlab_capacity就是Eden所有可用的区域。另外要注意的是,这里采用的启发式推断也仅仅是一个近似值,实际上线程在使用内存分配对象时并不是无关的(不完全服从均匀分布),另外不同的线程类型对内存的使用也不同,比如一些调度线程、监控线程等几乎不会分配新的对象。
在Java对象分配时,我们总希望它位于TLAB中,如果TLAB满了之后,如何处理呢?前面提到TLAB其实就是Eden的一块区域,在G1中就是HeapRegion的一块空闲区域。所以TLAB满了之后无须做额外的处理,直接保留这一部分空间,重新在Eden/堆分区中分配一块空间给TLAB,然后再在TLAB分配具体的对象。但这里会有两个小问题。
1.如何判断TLAB满了?
按照前面的例子TLAB是1M,当我们使用800K,还是900K,还是950K时被认为满了?问题的答案是如何寻找最大的可能分配对象和减少内存碎片的平衡。实际上虚拟机内部会维护一个叫做ref?ill_waste的值,当请求对象大于ref?ill_waste时,会选择在堆中分配,若小于该值,则会废弃当前TLAB,新建TLAB来分配对象。这个阈值可以使用TLABRef?illWasteFraction来调整,它表示TLAB中允许产生这种浪费的比例。默认值为64,即表示使用约为1/64的TLAB空间作为ref?ill_waste,在我们的这个例子中,ref?ill_waste的初始值为16K,即TLAB中还剩(1M - 16k = 1024 - 16 = 1008K)1008K内存时直接分配一个新的,否则尽量使用这个老的TLAB。
2.如何调整TLAB
如果要分配的内存大于TLAB剩余的空间则直接在Eden/HeapRegion中分配。那么这个1/64是否合适?会不会太小,比如通常分配的对象大多是20K,最后剩下16K,这样导致每次都进入Eden/堆分区慢速分配中。所以,JVM还提供了一个参数TLAB
WasteIncrement(默认值为4个字)用于动态增加这个ref?ill_waste的值。默认情况下,TLAB大小和ref?ill_waste都会在运行时不断调整,使系统的运行状态达到最优。在动态调整的过程中,也不能无限制变更,所以JVM提供MinTLABSize(默认值2K)用于控制最小值,对于G1来说,由于大对象都不在新生代分区,所以TLAB也不能分配大对象,HeapRegion/2就会被认定为大对象,所以TLAB肯定不会超过HeapRegionSize的一半。
如果想要禁用自动调整TLAB的大小,可以使用-XX:-ResizeTLAB禁用ResizeTLAB,
并使用-XX:TLABSize手工指定一个TLAB的大小。-XX:+PrintTLAB可以跟踪TLAB的使用情况。一般不建议手工修改TLAB相关参数,推荐使用虚拟机默认行为。
继续来看TLAB中的慢速分配,主要的步骤有:
□TLAB的剩余空间是否太小,如果很小,即说明这个空间通常不满足对象的分配,所以最好丢弃,丢弃的方法就是填充一个dummy对象,然后申请新的TLAB来分配对象。
□如果不能丢弃,说明TLAB剩余空间并不小,能满足很多对象的分配,所以不能丢弃这个TLAB,否则内存浪费很多,此时可以把对象分配到堆中,不使用TLAB分配,所以可以直接返回。
TLAB慢速分配代码如下所示:

带你读《JVM G1源码分析和调优》之三:G1的对象分配

为什么要对老的TLAB做清理动作?
TLAB存储的都是已经分配的对象,为什么要清理以及清理什么?其实这里的清理就是把尚未分配的空间分配一个对象(通常是一个int[]),那么为什么要分配一个垃圾对象?代码说明是为了栈解析(Heap Parsable),Heap Parsable是什么?为什么需要设置?下面继续分析。
内存管理器(GC)在进行某些需要线性扫描堆里对象的操作时,比如,查看Heap Region对象、并行标记等,需要知道堆里哪些地方有对象,而哪些地方是空白。对于对象,扫描之后可以直接跳过对象的长度,对于空白的地方只能一个字一个字地扫描,这会非常慢。所以可以把这块空白的地方也分配一个dummy对象(哑元对象),这样GC在线性遍历时就能做到快速遍历了。这样的话就能统一处理,示例代码如下:

带你读《JVM G1源码分析和调优》之三:G1的对象分配

具体我们可以在新生代垃圾回收的时候再来验证这一点。我们再看一下如何申请一个新的TLAB缓冲区,代码如下所示:

带你读《JVM G1源码分析和调优》之三:G1的对象分配

它最终会调用到G1CollectedHeap中分配,其分配主要是在attempt_allocation完成的,步骤也分为两步:快速无锁分配和慢速分配。图3-3为慢速分配流程图。
TLAB缓冲区分配代码如下所示:

带你读《JVM G1源码分析和调优》之三:G1的对象分配
带你读《JVM G1源码分析和调优》之三:G1的对象分配
带你读《JVM G1源码分析和调优》之三:G1的对象分配

快速无锁分配:指的是在当前可以分配的堆分区中使用CAS来获取一块内存,如果成功则可以作为TLAB的空间。因为使用CAS可以并行分配,当然也有可能不成功。对于不成功则进行慢速分配,代码如下所示:

带你读《JVM G1源码分析和调优》之三:G1的对象分配

对于不成功则进行慢速分配,慢速分配需要尝试对Heap加锁,扩展新生代区域或垃圾回收等处理后再分配。
□首先尝试对堆分区进行加锁分配,成功则返回,在attempt_allocation_locked完成。
□不成功,则判定是否可以对新生代分区进行扩展,如果可以扩展则扩展后再分配TLAB,成功则返回,在attempt_allocation_force完成。
□不成功,判定是否可以进行垃圾回收,如果可以进行垃圾回收后再分配,成功则返回,在do_collection_pause完成。
□不成功,如果尝试分配次数达到阈值(默认值是2次)则返回失败。
□如果还可以继续尝试,再次判定是否进行快速分配,如果成功则返回。
□不成功重新再尝试一次,直到成功或者达到阈值失败。
所以慢速分配要么成功分配,要么尝试次数达到阈值后结束并返回NULL。代码如下:

带你读《JVM G1源码分析和调优》之三:G1的对象分配
带你读《JVM G1源码分析和调优》之三:G1的对象分配
带你读《JVM G1源码分析和调优》之三:G1的对象分配

这里GCLocker是与JNI相关的。简单来说Java代码可以和本地代码交互,在访问JNI代码时,因为JNI代码可能会进入临界区,所以此时会阻止GC垃圾回收。这部分知识相对独立,有关GCLocker的知识可以参看其他文章。

日志及解读

从一个Java的例子出发,代码如下:

带你读《JVM G1源码分析和调优》之三:G1的对象分配

通过命令设置参数,如下所示:
-Xmx128M -XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
-XX:+PrintTLAB -XX:+UnlockExperimentalVMOptions -XX:G1LogLevel=finest
可以得到:
garbage-first heap total 131072K, used 37569K [0x00000000f8000000,
0x00000000f8100400, 0x0000000100000000)
region size 1024K, 24 young (24576K), 0 survivors (0K)
TLAB: gc thread: 0x0000000059ade800 [id: 16540] desired_size: 491KB slow
allocs: 8 ref?ill waste: 7864B alloc: 0.99999 24576KB ref?ills: 50
waste 0.0% gc: 0B slow: 816B fast: 0Bd
对于多线程的情况,这里还会有每个线程的输出结果以及一个总结信息。由于篇幅的关系此处都已经省略。下面我们分析日志中TLAB这个信息的每一个字段含义:
□desired_size为期望分配的TLAB的大小,这个值就是我们前面提到如何计算TLABSize的方式。在这个例子中,第一次的时候,不知道会有多少线程,所以初始化为1,desired_size=24576/50 = 491.5KB这个值是经过取整的。
□slow allocs为发生慢速分配的次数,日志中显示有8次分配到heap而没有使用TLAB。
□refill waste为retire一个TLAB的阈值。
□alloc为该线程在堆分区分配的比例。
□refills发生的次数,这里是50,表示从上一次GC到这次GC期间,一共retire过50个TLAB块,在每一个TLAB块retire的时候都会做一次ref?ill把尚未使用的内存填充为dummy对象。
□waste由3个部分组成:

  • gc:发生GC时还没有使用的TLAB的空间。
  • slow:产生新的TLAB时,旧的TLAB浪费的空间,这里就是新生成50个TLAB,浪费了816个字节。
  • fast:指的是在C1中,发生TLAB retire(产生新的TLAB)时,旧的TLAB浪费的空间。

3.3 慢速分配

当不能进行快速分配,就进入到慢速分配。实际上在TLAB中也有可能进入到慢速分配,就是我们前面提到的attempt_allocation,前面已经解释过。
这里的慢速分配是指在TLAB中经过努力分配还不能成功,再次进入慢速分配,我们来看一下这个更慢的慢速分配:
□attempt_allocation尝试进行对象分配,如果成功则返回。值得注意的是在attempt_
allocation里面可能会进行垃圾回收,这里的垃圾回收是指增量的垃圾回收,主要是新生代或者混合收集,关于收集的内容将在下面的章节介绍,分配相关的代码在3.2节已经介绍过了,不再赘述。
□如果大对象在attempt_allocation_humongous,直接分配的老生代。
□如果分配不成功,则进行GC垃圾回收,注意这里的回收主要是Full GC,然后再分配。因为这里是分配的最后一步,所以进行几次不同的垃圾回收和尝试。主要代码在satisfy_failed_allocation中。
□最终成功分配或者失败达到一定次数,则分配失败。
慢速分配代码如下所示:

带你读《JVM G1源码分析和调优》之三:G1的对象分配
带你读《JVM G1源码分析和调优》之三:G1的对象分配

3.3.1 大对象分配

大对象分配和TLAB中的慢速分配基本类似。唯一的区别就是对象大小不同。步骤主要:
□尝试垃圾回收,这里主要是增量回收,同时启动并发标记。
□尝试开始分配对象,对于大对象分为两类,一类是大于HeapRegionSize的一半,但是小于HeapRegionSize,即一个完整的堆分区可以保存,则直接从空闲列表直接拿一个堆分区,或者分配一个新的堆分区。如果是连续对象,则需要多个堆分区,思路同上,但是处理的时候需要加锁。
□如果失败再次尝试垃圾回收,之后再分配。
□最终成功分配或者失败达到一定次数,则分配失败。
大对象分配代码如下所示:

带你读《JVM G1源码分析和调优》之三:G1的对象分配
带你读《JVM G1源码分析和调优》之三:G1的对象分配
带你读《JVM G1源码分析和调优》之三:G1的对象分配

3.3.2 最后的分配尝试

先尝试分配一下,因为并发之后可能可以分配:
□尝试扩展新的分区,成功则返回。
□不成功进行Full GC,但是不回收软引用,再次分配成功则返回。
□不成功进行Full GC,回收软引用,最后一次分配成功则返回;不成功返回NULL,即分配失败。
最后尝试分配代码如下所示:

带你读《JVM G1源码分析和调优》之三:G1的对象分配
带你读《JVM G1源码分析和调优》之三:G1的对象分配

3.4 G1垃圾回收的时机

通常来说,在分配对象时如果内存不足,就会触发垃圾回收,G1提供了3种垃圾回收的算法,分别是新生代回收、混合回收和Full GC,所以在内存分配的地方可以看到这3种收集算法。
总结来看,回收发生在两个时机:第一,在分配内存时发现内存不足,进入垃圾回收;第二,外部显式地调用回收的方法,如在Java代码中调用system.gc()进入回收。不同的回收时机选择的回收方式也不同。

3.4.1 分配时发生回收

前面提到快速分配和慢速分配在内存不足时,都有可能发生垃圾回收,回收之后再继续分配。在分配时将涉及3种回收算法,前面已经介绍此处不再赘述。

3.4.2 外部调用的回收

常见有两种外部调用情况可以激活垃圾回收:
□外部显式调用system.gc触发。一般来说,如果我们没有设置DisableExplicitGC(默认为false),表示可以接受这个函数显式地触发GC。这个时候触发的GC都是Full GC,但是如果设置了ExplicitGCInvokesConcurrent,则表示可以进行并发的混合回收。
□如果和JNI交互,JNI代码进入了临界区(比如JNI代码为了优化性能,提供了一个函数jni_GetPrimitiveArrayCritical/jni_GetStringCritical用于直接访问原始内存数据,但是为了保证安全必须使用GCLocker进行加锁。当加锁后发生了GC请求,此时GC会被延迟,直到GCLocker执行了unlock会重新补一个GC),而且设置了GCLockerInvokesConcurrent,则可以进行并发混合回收,如果没有设置则可能启动新生代回收。
实际上JVM还提供了WhiteBox API用于JVM内部测试,也可以执行GC,因此也会触发新生代回收、FGC等。

3.5 参数介绍和调优

本章详细介绍了G1中对象的快速分配和慢速分配,其中快速分配和TLAB相关。本节给出实际应用中对象分配用到的相关参数和一些个人经验,如下所示:
□在优化调试TLAB的时候,在调试环境中可以通过打开PrintTLAB来观察TLAB分配和使用的情况。
□参数UseTLAB,指是否使用TLAB。大量的实验可以证明使用TLAB能够加速对象分配;该参数默认是打开的,不要关闭它。
□参数ResizeTLAB,指是否允许TLAB大小动态调整。前面提到TLAB会进行动态化调整,主要是基于历史信息(分配大小、线程数等),有基准测试表明使用动态调整TLAB大小效率更高。
□参数MinTLABSize,指设置TLAB的最小值。实际应用需要设置该值,比如64K,一般可以根据情况设置和调整该值。
□参数TLABSize,指设置TLAB的大小。实际中不要设置TLABSize,设置之后TLAB就不能动态调整了,即会使用一个固定大小的TLAB,前面我们提到GC可以根据情况动态调整TLAB,在分配效率和内存碎片之间找到一个平衡点,如果设置该值则这种平衡就失效了。
□参数TLABWasteTargetPercent,指的是TLAB可占用的Eden空间的百分比,默认值是1。可以根据情况调整TLABWasteTargetPercent,增大则可以分配更多的TLAB,3.1节中给出了具体的计算方式;另外如果实际中线程数目很多,建议增大该值,这样每个线程的TLAB不至于太小。
□参数TLABRef?illWasteFraction,指的是TLAB中浪费空间和TLAB块的比例,默认值是64。可以根据情况调整TLABRef?illWasteFraction,主要考量点是内存碎片和分配效率的平衡,如果发现日志waste中的slow和fast很大,说明浪费严重,可以适当减少该参数值。
□参数TLABWasteIncrement,指的是动态的增加浪费空间的字节数,默认值是4。增加该值会增加TLAB浪费的空间;一般不用设置。
□参数GCLockerRetryAllocationCount默认值为2,表示当分配中的垃圾回收次数超过这个阈值之后则直接失败。
最后再强调一点,TLAB不是G1才引入的,对象分配是JVM提供的基础分配功能,只不过G1结合自己内存分区的特征,以及垃圾回收的具体实现,重新实现了分配的策略,重用了这些参数的功能和使用方法,且没有引入额外的参数,所以这一部分内容不仅适用于G1的调优,其他的垃圾回收器同样适用。

上一篇:来,加入前端自动化单元测试


下一篇:带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之一:简介