TLAB整理

TLAB整理

  • HotSpot VM在JAVA堆中对象创建,布局,访问全过程(仅限于普通java对象,不包括数组和Class对象等)

    • 对象创建

      • vm遇到new指令时 检查指令的参数是否能在常量池中定位到一个类的符号引用 并检查这个符号引用代表的类是否已经加载,解析,初始化过
      • 如果没有则先执行类加载;在类加载检查通过后,为新对象分配内存,对象所需内存大小在类加载完后完全确定;
      • 堆是规整的 (用过的内存在一遍,没有用过的在另一边,中间一个指针)
        • 分配内存直接挪动指针忘空闲的那边 - 指针碰撞 bump the pointer
      • 堆不规整
        • 已使用内存和空闲内存相互交错,维护一个列表记录哪些内存是可用的,空闲列表 free-list
      • 垃圾回收器是否带有压缩整理功能
        • Serial, ParNew等带有Compact过程的收集器时 采用分配算法是 指针碰撞
        • CMS这种基于标记-清除的通常采用 空闲列表 算法
      • 并发问题
        • 即便上面指针碰撞也会出现并发问题 因为创建对象分配空间太频繁了
        • solution
          • 1.分配空间的动作进行同步处理(实际上VM采用cas+失败重试的方式保证原子性)
          • 2,预先给线程分配一块空间TLAB,后面分配空间先在TLAB上分配,TLAB不够了再从堆上分配
            • -XX:+UseTLAB
      • 内存分配完后,vm将分配到的内存空间都初始化为0(不包括对象头) TLAB的情况下清0过程可以提前至TLAB分配时
      • vm对对象进行必要的设置:设置对象头信息等
      • 执行<init>方法按程序员的意愿初始化对象
      • bytecodeinterpreter.cpp源码
    • 对象在内存中的布局

      • 对象头

        • Mark Word(对象自身运行时数据: hashcode, gc年龄, 锁状态标识, 线程持有的锁, 偏向线程ID, 偏向时间戳等)
          • mark word在32,64bits JVM中分别是32bit, 64bit(为开启指针压缩)
          • 对象自身运行时需要存储的信息很多,所以mark word被设计成一个非固定的数据结构,根据对象状态复用存储空间
        • 类型指针(哪个类的实例) 但不是所有的查找对象的元数据信息都有经过对象本身
        • 记录数组长度
          • 如果对象是一个JAVA数组
        • 通过普通java对象的元数据信息可以确定java对象的大小,但是从数组的元数据无法确定数组的大小(数组长度存储在对象头中)
        • markOop.cpp
          • mark word
      • 实例数据

        • 存储顺序
          • 分配策略参数
            • longs/doubles, ints, shorts/chars, bytes/booleans, oops(ordinary object pointer) (默认分配策略)
            • 相同字宽的字段总是分配到一起, 父类定义的变量在子类之前
          • 字段在java源代码中定义顺序
      • 对齐填充

        • 对象起始地址必须是8字节的整数倍 也就是对象的大小必须是8字节的整数倍 而对象头部分正好是8字节的倍数
    • 对象访问

      • Java程序通过栈上的reference数据来操作堆上的具体对象(只规定了reference一个指向对象的引用)
        • 句柄
          • reference(java栈本地变量表)->句柄池(java heap)->两个指针分别指向:实例池,方法区
          • 对象被移动,reference本身不需要修改;reference存储的是稳定的句柄地址
        • 直接指针
          • reference->堆中对象->方法区中的对象类型
          • 速度更快 节省一次指针定位时间 对象的访问太频繁
      • HotSopt VM 使用第二种,直接指针定位方式
  • TLAB细节

    • 什么是TLAB
      • TLAB全称ThreadLocalAllocBuffer,是线程的一块私有内存,如果设置了虚拟机参数 -XX:+UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用,这个申请动作还是需要原子操作的(CAS+重试)
      • TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,均摊对GC堆(eden区)里共享的分配指针做更新而带来的同步开销
      • TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。
    • TLAB实现
      • 源码 openjdk/hotspot/src/share/vm/memory/threadLocalAllocBuffer.hpp
      • TLAB简单来说本质上就是三个指针:start,top 和 end,每个线程都会从Eden分配一大块空间,例如说100KB,作为自己的TLAB,其中 start 和 end 是占位用的,标识出 eden 里被这个 TLAB 所管理的区域,卡住eden里的一块空间不让其它线程来这里分配。而 top 就是里面的分配指针,一开始指向跟 start 同样的位置,然后逐渐分配,直到再要分配下一个对象就会撞上 end 的时候就会触发一次 TLAB refill,refill过程后续会解释。
      • _desired_size 是指TLAB的内存大小。
      • _refill_waste_limit 是指最大的浪费空间,假设为5KB,通俗一点讲就是:
        • 1、假如当前TLAB已经分配96KB,还剩下4KB,但是现在new了一个对象需要6KB的空间,显然TLAB的内存不够了,这时可以简单的重新申请一个TLAB,原先的TLAB交给Eden管理,这时只浪费4KB的空间,在_refill_waste_limit 之内。
        • 2、假如当前TLAB已经分配90KB,还剩下10KB,现在new了一个对象需要11KB,显然TLAB的内存不够了,这时就不能简单的抛弃当前TLAB,这11KB会被安排到Eden区进行申请。
      • 在Java代码中执行new Thread()的时候,会触发以下代码
        // The first routine called by a new Java thread void JavaThread::run() { // initialize thread-local alloc buffer related fields this->initialize_tlab(); // used to test validitity of stack trace backs this->record_base_of_stack_pointer(); // Record real stack base and size. this->record_stack_base_and_size(); // Initialize thread local storage; set before calling MutexLocker this->initialize_thread_local_storage(); this->create_stack_guard_pages(); this->cache_global_variables(); }
        void initialize_tlab() { if (UseTLAB) { tlab().initialize(); } }
      • 其中tlab()返回的就是一个ThreadLocalAllocBuffer对象,调用initialize()初始化TLAB,实现如下
        • 1、设置当前TLAB的_desired_size,该值通过initial_desired_size()方法计算;
          • 字段_desired_size的计算过程分析
            • TLABSize在argument模块中默认会设置大小为 256 * K,也可以通过JVM参数选择进行设置,不过即使设置了也会和一个最大值max_size进行比较,然后取一个较小值,其中max_size计算如下:
              • 这里明确说明了TLAB的大小不能超过可以容纳 int[Integer.MAX_VALUE]
        • 2、设置当前TLAB的_refill_waste_limit,该值通过initial_refill_waste_limit()方法计算;
          • 字段_refill_waste_limit计算分析
            size_t initial_refill_waste_limit() { return desired_size() / TLABRefillWasteFraction(默认64); }
        • 3、初始化一些统计字段,如_number_of_refills、_fast_refill_waste、_slow_refill_waste、_gc_waste和_slow_allocations;
    • 内存分配
      • 对象的内存分配入口为instanceKlass::allocate_instance(),通过CollectedHeap::obj_allocate()方法在堆内存上进行分配
      • 其中common_mem_allocate_init()方法最终会调用CollectedHeap::common_mem_allocate_noinit()方法,实现如下
        • 根据UseTLAB的值,决定是否在TLAB上进行内存分配,如果JVM参数中没有手动取消UseTLAB,会调用allocate_from_tlab()在TLAB上尝试分配,因为可能存在分配失败的情况,比如TLAB容量不足,看下allocate_from_tlab()的实现:
          • 从上述实现可以看出,先会尝试调用ThreadLocalAllocBuffer 的 allocate 方法,如果返回为空,再执行allocate_from_tlab_slow()进行分配,从这个方法命名可以看出这是比较慢的分配路径。
            • ThreadLocalAllocBuffer 的 allocate 方法实现如下:
              • 通过判断当前TLAB的剩余容量是否大于需要分配的大小,来决定分配结果,如果当前剩余容量不够,就返回NULL,表示分配失败。
          • 慢分配allocate_from_tlab_slow()实现如下
            • 1、如果当前TLAB的剩余容量大于浪费阈值,就不在当前TLAB分配,直接在共享的Eden区进行分配,并且记录慢分配的内存大小;
            • 2、如果剩余容量小于浪费阈值,说明可以丢弃当前TLAB了;
            • 3、通过allocate_new_tlab()方法,从eden新分配一块裸的空间出来(这一步可能会失败),如果失败说明eden没有足够空间来分配这个新TLAB,就会触发YGC。
            • 申请好新的TLAB内存之后,执行TLAB的fill()方法,实现如下:
              • 1、统计refill的次数
              • 2、初始化重新申请到的内存块
              • 3、将当前TLAB抛弃(retire)掉,这个过程中最重要的动作是将TLAB末尾尚未分配给Java对象的空间(浪费掉的空间)分配成一个假的“filler object”(目前是用int[]作为filler object)。这是为了保持GC堆可以线性parse(heap parseability)用的。
  • RednaxelaFX、你假笨关于TLAB的一些分析总结

    • TLAB refill包括下述几个动作:
      • 将当前TLAB抛弃(retire)掉。这个过程中最重要的动作是将TLAB末尾尚未分配给Java对象的空间(浪费掉的空间)分配成一个假的“filler object”(目前是int[]作为filler object)。这是为了保持GC堆可以线性parse(heap parseability)用的。
      • 从eden新分配一块裸的空间出来(这一步可能会失败)
      • 将新分配的空间范围记录到ThreadLocalAllocBuffer里TLAB refill不成功(eden没有足够空间来分配这个新TLAB)就会触发YGC。
    • 注意“撞上”指的是在某次分配请求中,top + new_obj_size >= end 的情况,也就是说在被判定“撞上”的时候,top 常常离 end 还有一段距离,只是这之间的空间不足以满足新对象的分配请求 new_obj_size 的大小。这意味着在触发TLAB refill的时候,有可能会浪费掉位于该TLAB末尾的一部分空间:该TLAB已经占用了这块空间所以其它线程无法在这里分配Java对象,但该TLAB要refill的话它自己也不会在这块空间继续分配Java对象,从应用层面看这块空间就浪费了。
    • 每次分配TLAB的大小不是固定的,而是每个线程根据该线程启动开始到现在的历史统计信息来自己单独调整的。如果一个线程上跑的代码的内存分配速率非常高,则该线程会选择使用更大的TLAB以达到均摊同步开销的效果,反之亦然;同时它还会统计浪费比例,并且将其放入计算新TLAB大小的考虑因素当中,把浪费比例控制在一定范围内。
    • GC很重要的一点是对heap parseability的依赖。GC做某些需要线性扫描堆里的对象的操作时,需要知道堆里哪些地方有对象而哪些地方是空洞。一种办法是使用外部数据结构,例如freelist或者allocation BitMap之类来记录哪里有空洞;另一种办法是把空洞部分也假装成有对象,这样GC在线性遍历时会看到一个“对象总是连续分配的”的假象,就可以以统一的方式来遍历:遍历到一个对象时,通过其对象头记录的信息找出该对象的大小,然后跳到该大小之后就可以找到下一个对象的对象头,依此类推。HotSpot选择的是后者的做法,假装成有对象的这种东西就叫做filler object(填充对象)。
    • PLAB也是个非常有趣的东西,提到TLAB的话也可以顺带说下PLAB。HotSpot里的TLAB是只在eden里分配的,用于给新建的小对象用。(本来其实也有考虑让TLAB在任意位置分配,但后来没实现)。PLAB则是在old gen里分配的一种临时的结构。就是笨神说的promotion LAB。
    • 在多GC线程并行做YGC的时候,大家都要为了晋升对象而在old gen里分配空间,于是old gen的分配指针就热起来了。大量的竞争会使得并行度降低,所以跟TLAB用同样的思路,old gen在处理YGC的晋升对象的分配也一样可以用(GC)线程私有的分配区。这就是PLAB。另外在CMS里old gen的剩余空间不是连续的,而是有很多空洞。这些剩余空间是通过freelist来管理的。
    • 如果ParNew要把对象晋升到CMS管理的old gen,不优化的话就得在freelist上做分配。于是就可以通过类似PLAB的方式,每个GC线程先从freelist申请一块大空间,然后在这块大空间里线性分配(bump pointer)。这样就既降低了对分配指针/freelist的竞争,又可以降低freelist分配的频率而转为用线性分配。
  • References

推荐阅读更多精彩内容