《JVM G1源码分析和调优》 —3.2 快速分配

举报
华章计算机 发表于 2019/12/20 13:44:31 2019/12/20
【摘要】 本节书摘来自华章计算机《JVM G1源码分析和调优》 一书中第3章,第3.2节,作者是彭成寒。

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来并行分配。

 image.png

图3-2 TLAB在分区中的使用

在图3-2中,Tn表示第n个线程,深灰色表示该TLAB块已经分配完毕,浅灰色表示该TLAB块还可以分配更多的对象。

从图中我们可以看出,线程T1已经使用了两个TLAB块,T1、T2和T4的TLAB块都有待分配的空间。这里并没有提及Eden和多个分区的概念,实际上一个分区可能有多个TLAB块,但是一个TLAB是不可能跨分区的。从图中我们也可以看出,每个线程的TLAB块并不重叠,所以线程之间对象的分配是可以并行的,且无影响。另外图中还隐藏了一些细节:

T1已经使用完两个TLAB块,这两个块在回收的时候如何处理?

我们可以想象TLAB的大小是固定的,但是对象的大小并不固定,因此TLAB中可能存在内存碎片的问题,这个该如何解决?请继续往下阅读。

快速TLAB对象分配也有两步:

从线程的TLAB分配空间,如果成功则返回。

不能分配,先尝试分配一个新的TLAB,再分配对象。

代码如下所示:

hotspot/src/share/vm/gc_interface/collectedHeap.inline.hpp

 

HeapWord* CollectedHeap::allocate_from_tlab(KlassHandle klass, Thread*

  thread, size_t size) {

  HeapWord* obj = thread->tlab().allocate(size);

  if (obj != NULL)    return obj; 

  // 省略一些判断比如是否需要申请一个新的TLAB

  return allocate_from_tlab_slow(klass, thread, size);

}

从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中是通过下面的公式计算的:

hotspot/src/share/vm/memory/threadLocalAllocBuffer.cpp

 

init_sz  = (Universe::heap()->tlab_capacity(myThread()) / HeapWordSize) /  

  (nof_threads * target_refills());

其中,tlab_capacity在G1CollectedHeap中实现,代码如下所示:

hotspot/src/share/vm/gc_implementation/g1/g1CollectedHeap.cpp

 

size_t G1CollectedHeap::tlab_capacity(Thread* ignored) const {

  return (_g1_policy->young_list_target_length() - young_list()->survivor_

    length()) * HeapRegion::GrainBytes;

}

简单来说,tlab_capacity就是Eden所有可用的区域。另外要注意的是,这里采用的启发式推断也仅仅是一个近似值,实际上线程在使用内存分配对象时并不是无关的(不完全服从均匀分布),另外不同的线程类型对内存的使用也不同,比如一些调度线程、监控线程等几乎不会分配新的对象。

在Java对象分配时,我们总希望它位于TLAB中,如果TLAB满了之后,如何处理呢?前面提到TLAB其实就是Eden的一块区域,在G1中就是HeapRegion的一块空闲区域。所以TLAB满了之后无须做额外的处理,直接保留这一部分空间,重新在Eden/堆分区中分配一块空间给TLAB,然后再在TLAB分配具体的对象。但这里会有两个小问题。

1.如何判断TLAB满了?

按照前面的例子TLAB是1M,当我们使用800K,还是900K,还是950K时被认为满了?问题的答案是如何寻找最大的可能分配对象和减少内存碎片的平衡。实际上虚拟机内部会维护一个叫做refill_waste的值,当请求对象大于refill_waste时,会选择在堆中分配,若小于该值,则会废弃当前TLAB,新建TLAB来分配对象。这个阈值可以使用TLABRefillWasteFraction来调整,它表示TLAB中允许产生这种浪费的比例。默认值为64,即表示使用约为1/64的TLAB空间作为refill_waste,在我们的这个例子中,refill_waste的初始值为16K,即TLAB中还剩(1M - 16k = 1024 - 16 = 1008K)1008K内存时直接分配一个新的,否则尽量使用这个老的TLAB。

2.如何调整TLAB

如果要分配的内存大于TLAB剩余的空间则直接在Eden/HeapRegion中分配。那么这个1/64是否合适?会不会太小,比如通常分配的对象大多是20K,最后剩下16K,这样导致每次都进入Eden/堆分区慢速分配中。所以,JVM还提供了一个参数TLAB

WasteIncrement(默认值为4个字)用于动态增加这个refill_waste的值。默认情况下,TLAB大小和refill_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慢速分配代码如下所示:

hotspot/src/share/vm/gc_interface/collectedHeap.cpp

 

HeapWord* CollectedHeap::allocate_from_tlab_slow(KlassHandle klass, Thread*

  thread, size_t size) {

 

  // 判断TLAB尚未分配的剩余空间是否可以丢掉。如果剩余空间大于阈值则保留,其中阈值为

  // refill waste limit,它由desired size和参数TLABRefillWasteFraction

  // 计算得到

  if (thread->tlab().free() > thread->tlab().refill_waste_limit()) {

    // 不能丢掉,根据TLABWasteIncrement更新refill_waste的阈值

    thread->tlab().record_slow_allocation(size);

    // 返回NULL,说明在Eden/HeapRegion中分配

    return NULL;

  }

 

  // 说明TLAB剩余空间很小了,所以要重新分配一个TLAB。老的TLAB不用处理,因为它属于Eden,

  // GC可以正确回收空间

  size_t new_tlab_size = thread->tlab().compute_size(size);

 

  // 分配之前先清理老的TLAB,其目的就是为了让堆保持parsable可解析

  thread->tlab().clear_before_allocation();

  if (new_tlab_size == 0)     return NULL;

 

  // 分配一个新的TLAB...

  HeapWord* obj = Universe::heap()->allocate_new_tlab(new_tlab_size);

  if (obj == NULL)     return NULL;

  // 发生一个事件,用于统计分配信息

  AllocTracer::send_allocation_in_new_tlab_event(klass, new_tlab_size *

    HeapWordSize, size * HeapWordSize);

  // 是否把内存空间清零

  if (ZeroTLAB)  Copy::zero_to_words(obj, new_tlab_size);

  // 分配对象,并设置TLAB的start、top、end等信息

  thread->tlab().fill(obj, obj + size, new_tlab_size);

  return obj;

}

为什么要对老的TLAB做清理动作?

TLAB存储的都是已经分配的对象,为什么要清理以及清理什么?其实这里的清理就是把尚未分配的空间分配一个对象(通常是一个int[]),那么为什么要分配一个垃圾对象?代码说明是为了栈解析(Heap Parsable),Heap Parsable是什么?为什么需要设置?下面继续分析。

内存管理器(GC)在进行某些需要线性扫描堆里对象的操作时,比如,查看Heap

Region对象、并行标记等,需要知道堆里哪些地方有对象,而哪些地方是空白。对于对象,扫描之后可以直接跳过对象的长度,对于空白的地方只能一个字一个字地扫描,这会非常慢。所以可以把这块空白的地方也分配一个dummy对象(哑元对象),这样GC在线性遍历时就能做到快速遍历了。这样的话就能统一处理,示例代码如下:

HeapWord* cur = heap_start;

while (cur < heap_used) {

  object o = (object)cur;

  do_object(o);

  cur = cur + o->size();

}

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

hotspot/src/share/vm/gc_implementation/g1/g1CollectedHeap.cpp

 

HeapWord* G1CollectedHeap::allocate_new_tlab(size_t word_size) {

  return attempt_allocation(word_size, &dummy_gc_count_before, &dummy_

    gclocker_retry_count);

}

它最终会调用到G1CollectedHeap中分配,其分配主要是在attempt_allocation完成的,步骤也分为两步:快速无锁分配和慢速分配。图3-3为慢速分配流程图。

TLAB缓冲区分配代码如下所示:

hotspot/src/share/vm/gc_implementation/g1/g1CollectedHeap.inline.cpp

 

inline HeapWord* G1CollectedHeap::attempt_allocation(…) {

  AllocationContext_t context = AllocationContext::current();

  HeapWord* result = _allocator->mutator_alloc_region(context)->attempt_

    allocation(word_size, false /* bot_updates */);

  if (result == NULL) {

    result = attempt_allocation_slow(…);

  }

 

  if (result != NULL)  dirty_young_block(result, word_size);

  return result;

}

 image.png

图3-3 申请TLAB分区和对象慢速分配流程图

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

hotspot/src/share/vm/gc_implementation/g1/heapRegion.inline.hpp

 

inline HeapWord* G1OffsetTableContigSpace::par_allocate_impl(size_t size,

                                            HeapWord* const end_value) {

  do {

    HeapWord* obj = top();

    if (pointer_delta(end_value, obj) >= size) {

      HeapWord* new_top = obj + size;

      HeapWord* result = (HeapWord*)Atomic::cmpxchg_ptr(new_top, top_addr(), obj);

     

      if (result == obj)    return obj;

    } else {

      return NULL;

    }

  } while (true);

}

对于不成功则进行慢速分配,慢速分配需要尝试对Heap加锁,扩展新生代区域或垃圾回收等处理后再分配。

首先尝试对堆分区进行加锁分配,成功则返回,在attempt_allocation_locked完成。

不成功,则判定是否可以对新生代分区进行扩展,如果可以扩展则扩展后再分配TLAB,成功则返回,在attempt_allocation_force完成。

不成功,判定是否可以进行垃圾回收,如果可以进行垃圾回收后再分配,成功则返回,在do_collection_pause完成。

不成功,如果尝试分配次数达到阈值(默认值是2次)则返回失败。

如果还可以继续尝试,再次判定是否进行快速分配,如果成功则返回。

不成功重新再尝试一次,直到成功或者达到阈值失败。

所以慢速分配要么成功分配,要么尝试次数达到阈值后结束并返回NULL。代码如下:

hotspot/src/share/vm/gc_implementation/g1/g1CollectedHeap.cpp

 

HeapWord* G1CollectedHeap::attempt_allocation_slow(…) {

 

  HeapWord* result = NULL;

  for (int try_count = 1; /* we'll return */; try_count += 1) {

    {

      // 加锁分配

      result = _allocator->mutator_alloc_region(context)->attempt_

        allocation_locked(word_size,    false /* bot_updates */);

      if (result != NULL)         return result;

 

      if (GC_locker::is_active_and_needs_gc()) {

        if (g1_policy()->can_expand_young_list()) {

          result = _allocator->mutator_alloc_region(context)->attempt_

            allocation_force(word_size, false /* bot_updates */);

          if (result != NULL)     return result;

        }

        should_try_gc = false;

      } else {       

        if (GC_locker::needs_gc()) {

          should_try_gc = false;

        } else {

 

          gc_count_before = total_collections();

          should_try_gc = true;

        }

      }

    }

 

    if (should_try_gc) {

      // GCLocker没有进入临界区,可以进行垃圾回收

      result = do_collection_pause(word_size, gc_count_before, &succeeded,

                                   GCCause::_g1_inc_collection_pause);

      if (result != NULL)       return result;

 

      if (succeeded) {

        // 稍后可以进行回收,可以先返回

        MutexLockerEx x(Heap_lock);

        *gc_count_before_ret = total_collections();

        return NULL;

      }

    } else {

      // JNI进入临界区中,判断是否达到分配次数阈值

      if (*gclocker_retry_count_ret > GCLockerRetryAllocationCount) {

        MutexLockerEx x(Heap_lock);

        *gc_count_before_ret = total_collections();

        return NULL;

      }

      GC_locker::stall_until_clear();

      (*gclocker_retry_count_ret) += 1;

    }

 

    // 可能因为其他线程正在分配或者GCLocker正在被竞争使用等,

    // 在进行加锁分配前再尝试进行无锁分配

    result = _allocator->mutator_alloc_region(context)->attempt_

      allocation(word_size, false /* bot_updates */);

    if (result != NULL)      return result;

  }

 

  ShouldNotReachHere();

  return NULL;

}

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

日志及解读

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

public class Test {

  private static final LinkedList<String> strings = new LinkedList<>();

  public static void main(String[] args) throws Exception {

    int iteration = 0;

    while (true) {

      for (int i = 0; i < 100; i++) {

        for (int j = 0; j < 10; j++) {

          strings.add(new String("String " + j));

        }

      }

      Thread.sleep(100);

    }

  }

}

通过命令设置参数,如下所示:

-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  refill waste: 7864B alloc: 0.99999    24576KB refills: 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的时候都会做一次refill把尚未使用的内存填充为dummy对象。

waste由3个部分组成:

gc:发生GC时还没有使用的TLAB的空间。

slow:产生新的TLAB时,旧的TLAB浪费的空间,这里就是新生成50个TLAB,浪费了816个字节。

fast:指的是在C1中,发生TLAB retire(产生新的TLAB)时,旧的TLAB浪费的空间。


【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。