简书 占小狼
转载请注明原创出处,谢谢!
本文由臧秀涛撰稿,经过R大润色,由占小狼倾情分享,这些分析总结道出了TLAB的来龙去脉,不得不说R大语言基本功真是大写的服字。
在JVM研究群里,占小狼同学发来一篇文章:JVM源码分析之线程局部缓存TLAB
大家对相关概念还有有些疑问,RednaxelaFX、你假笨等朋友就此做了很多分享。
以下内容主要根据大家的问题和RednaxelaFX、你假笨的分享整理。
TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,均摊对GC堆(eden区)里共享的分配指针做更新而带来的同步开销。
TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。
TLAB简单来说本质上就是三个指针:start,top 和 end (实际实现中还有一些额外信息但这里暂不讨论)。
其中 start 和 end 是占位用的,标识出 eden 里被这个 TLAB 所管理的区域,卡住eden里的一块空间说其它线程别来这里分配了哈。而 top 就是里面的分配指针,一开始指向跟 start 同样的位置,然后逐渐分配,直到再要分配下一个对象就会撞上 end 的时候就会触发一次 TLAB refill。
要注意TLAB这个词其实有两层意思:一个是指存在管理Java线程的元数据对象 JavaThread 里的 ThreadLocalAllocBuffer对象,它持有上述三个指针,仅用于管理用而不存储对象自身;另一个是指在eden中分配出来的、被一个线程的ThreadLocalAllocBuffer所管理的一块空间,这才是实际存放对象的地方。本讨论不特地指出的时候会自由混用这两层意思,把它们当作一个整体来看待。
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(填充对象)。
实现代码上,
TLAB的慢速分配和重新申请空间的逻辑在这里:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/cf85f331361b/src/share/vm/gc_interface/collectedHeap.cpp#l264
申请好了空间并且zero完之后就会设置进TLAB里:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/cf85f331361b/src/share/vm/gc_interface/collectedHeap.cpp#l304
TLAB里的filler object是这样用的:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/cf85f331361b/src/share/vm/memory/threadLocalAllocBuffer.cpp#l110
CollectedHeap里的裸allocate动作是不关心分配的东西是什么类型的,只管在GC堆里看有没有地方可以分配,有的话bump分配指针并返回bump前的指针。TLAB从eden重新分配空间就是问CollectedHeap再allocate一块这样的裸的空间,然后把这块空间的首尾记录到自己的start和end里去。
另外建议把TLAB翻译为线程私有分配区,而不是线程局部分配缓存这样词对词的直译。毕竟TLAB并不是一个缓存,而且它的重点也不是局部,而是让那个分配指针成为线程私有的东西。
无论是加锁还是CAS,HotSpot的共享堆分配都是用碰撞指针(pointer bumping / bump-the-pointer)来做的。加锁跟bump不在一个层面上,不应该并列。锁或者CAS只是同步的机制,实际想要做的事情都一样是bump pointer。如果在不需要与其它线程竞争的条件下,bump pointer就不用同步保护。
例如在TLAB里,又例如在PLAB里,又例如在共享部分但在safepoint中没有竞争的情况下。
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分配的频率而转为用线性分配。