ThreadLocalAllocBuffer原理剖析
- 前言
- ThreadLocalAllocBuffer原理剖析
- TLAB (ThreadLocalAllocBuffer) 核心设计原理
- 1. 核心设计思想
- 2. TLAB 的内存结构指针
- OpenJDK 8源码深度剖析
- 1. TLAB 数据结构定义
- 2. 快速路径分配(Fast Path)
- 3. 慢速路径分配与 TLAB 刷新策略(Slow Path)
- 4. TLAB 的“退役”与堆可解析性
- TLAB 核心参数动态自适应调整机制
- 1. 期望大小 `_desired_size` 的计算
- 2. 浪费阈值 `_refill_waste_limit` 的动态递增
- 系统工程师视角下的 TLAB 调优总结
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正
ThreadLocalAllocBuffer原理剖析
TLAB (ThreadLocalAllocBuffer) 核心设计原理
在多线程并发的高并发应用场景下,虚拟机堆内存的年轻代(Eden 区)是所有线程共享的。如果多个线程同时申请分配内存,传统的分配方式必须通过加锁或者采用CAS (Compare And Swap)自旋操作来保证指针更新的原子性。在高并发下,这种对单一指针(Top 指针)的竞争会成为严重的性能瓶颈。
为了解决这一问题,OpenJDK 引入了TLAB (ThreadLocalAllocBuffer)技术。
1. 核心设计思想
- 内存独占化:从共享的 Eden 区域中,为每个线程预先分配一块专属的内存区域(即 TLAB)。
- 无锁化分配(Fast Path):当线程内部需要创建对象时,优先在自己的 TLAB 中进行分配。由于这块内存在同一时刻只属于该线程,分配逻辑只需要移动指针(Pointer Bumping)即可,没有任何并发锁竞争,性能极高。
- 全局同步退化(Slow Path):只有当线程的 TLAB 空间耗尽,需要向 Eden 申请一块新的 TLAB,或者对象体积过大无法在 TLAB 中容纳时,才会触发全局同步机制(使用 CAS 抢占 Eden 空间)。
2. TLAB 的内存结构指针
一个 TLAB 区域的核心由四个关键指针来界定:
start:指向当前 TLAB 内存块的起始首地址。top:指向当前 TLAB 内部已分配内存与未分配内存的分界点。每次成功分配对象,top指针向前推进对象大小。end:指向当前 TLAB 的逻辑终点,通常end = hard_end - alignment_reserve(保留对齐空间)。hard_end:指向当前 TLAB 的物理真实边界,等于申请到的内存块末尾。
OpenJDK 8源码深度剖析
在 OpenJDK 8源码中,TLAB 的数据结构定义在src/share/vm/gc_implementation/shared/threadLocalAllocBuffer.hpp,而具体的分配退化逻辑和补充策略则在对应的.cpp文件及CollectedHeap中。
1. TLAB 数据结构定义
以下是ThreadLocalAllocBuffer类的核心成员变量与解析:
// 源码路径:src/share/vm/gc_implementation/shared/threadLocalAllocBuffer.hppclassThreadLocalAllocBuffer:publicCHeapObj<mtThread>{friendclassVMStructs;private:HeapWord*_start;// TLAB 内存区域的起始地址HeapWord*_top;// 当前分配指针,指向下一个空闲位置HeapWord*_end;// 逻辑终点,预留了对象的对齐填充空间HeapWord*_hard_end;// 物理终点,从 Eden 申请到的真实末尾位置size_t _desired_size;// 期望的 TLAB 大小(单位为 HeapWords),根据运行期动态计算size_t _refill_waste_limit;// 拒绝分配并引发 refill 的最大浪费空间阈值staticsize_t _target_refill_waste_allocations;// 在一次 GC 周期内,期望每个线程 refill 的目标次数// 默认值由参数 -XX:TLABWasteTargetPercent 控制(默认 1%)// 统计指标,用于动态调整 TLAB 大小size_t _number_of_refills;// 当前线程 TLAB 重新填充的次数size_t _fast_alloc_attempts;// 快速分配(Fast Path)尝试次数size_t _slow_alloc_attempts;// 慢速分配(Slow Path)尝试次数size_t _gc_waste;// 发生 GC 时,由于未用完而被浪费的内存总量// ... 忽略部分辅助方法public:// 初始化及置空逻辑voidinitialize(HeapWord*start,HeapWord*top,HeapWord*end);voidclear();// 核心分配方法:Fast Path 指针碰撞inlineHeapWord*allocate(size_t size);};2. 快速路径分配(Fast Path)
当我们在 Java 层通过new关键字创建对象,或者是字节码解释器/JIT 编译器遇到分配指令时,会直接内联执行快速分配。其本质就是无锁的指针叠加。
// 源码路径:src/share/vm/gc_implementation/shared/threadLocalAllocBuffer.inline.hppinlineHeapWord*ThreadLocalAllocBuffer::allocate(size_t size){// in_use() 检查当前 TLAB 是否已经初始化并激活if(gclog_or_tty!=NULL&&GC_egress_bits_words>0){// 生产环境中主要直接走下面的无锁指针碰撞}HeapWord*obj=top();// 核心判断:如果当前 top 指针加上所需大小小于等于逻辑终点 endif(pointer_delta(end(),obj)>=size){// 成功:更新 top 指针,并直接返回原 top 地址(即对象首地址)set_top(obj+size);returnobj;}// 空间不足,返回 NULL,意味着需要进入慢速分配路径(Slow Path)returnNULL;}3. 慢速路径分配与 TLAB 刷新策略(Slow Path)
当 Fast Path 返回NULL时,JVM 运行时会调用CollectedHeap::allocate_from_tlab_slow方法。在这个阶段,JVM 需要做出一个关键决策:是放弃当前 TLAB 申请个新的,还是保留当前 TLAB,让大对象直接分配在堆(Eden)中?
// 源码路径:src/share/vm/gc_interface/collectedHeap.inline.hppHeapWord*CollectedHeap::allocate_from_tlab_slow(Thread*thread,size_t size){// 1. 尝试在当前线程现有的 TLAB 剩余空间里进行“慢速分配”// 这种情况通常是因为并发控制或某些特殊的对齐要求导致的HeapWord*obj=thread->tlab().allocate(size);if(obj!=NULL){returnobj;}// 2. 走到这里说明 TLAB 确实没有足够的空间容纳当前 size 的对象// 检查当前 TLAB 的剩余空间(浪费空间)是否超过了 _refill_waste_limit 阈值// 核心公式:剩余空间 = end - topsize_t free_words=pointer_delta(thread->tlab().end(),thread->tlab().top());if(free_words<thread->tlab().refill_waste_limit()){// 场景 A:剩余空间小于阈值(说明浪费得起)// 记入统计指标:这部分未分配空间将作为 GC 浪费处理thread->tlab().record_gc_waste(free_words);// 废弃旧的 TLAB:为了维持堆的连续性和可解析性(Parsability),// 必须把旧 TLAB 的剩余空间用一个“填充物对象”(通常是 int 数组)填满thread->tlab().retire();}else{// 场景 B:剩余空间大于等于阈值(说明里面还有很多空闲内存,丢弃它太可惜了)// 此时选择保留当前的 TLAB,不进行刷新。// 转而直接在共享的 Eden 区通过 CAS 竞争分配当前的大对象(Direct Allocation)returnallocate_outside_tlab(size,thread);}// 3. 申请分配并初始化一块全新的 TLAB// 首先计算新 TLAB 的期望大小size_t new_tlab_size=thread->tlab().compute_size(size);// 强制使旧 TLAB 失效,将其指针重置thread->tlab().clear();if(new_tlab_size==0){returnNULL;}// 4. 从 Eden 区中通过全局 CAS 锁申请一块新的大内存块// 此处调用具体垃圾回收器的内存分配(如 G1, ParallelGC 等)HeapWord*actual_tlab_start=allocate_new_tlab(new_tlab_size);if(actual_tlab_start==NULL){returnNULL;// 内存不足,触发 GC}// 计算真实的物理边界和逻辑边界HeapWord*actual_tlab_end=actual_tlab_start+new_tlab_size;// 5. 将这块全新的内存绑定给当前线程,重新初始化该线程的 TLAB 指针thread->tlab().initialize(actual_tlab_start,actual_tlab_start+size,actual_tlab_end);// 新 TLAB 已经扣除了当前对象所需的大小(通过上面初始化时将 top 设为 start + size)// 直接返回这块新内存的起始地址作为对象的首地址returnactual_tlab_start;}4. TLAB 的“退役”与堆可解析性
当旧的 TLAB 被放弃或者发生 GC 时,由于 TLAB 的_top指针可能没有走到_end,这部分空白区域在堆中必须能够被垃圾回收器正确识别。垃圾回收器通过顺序扫描堆来标记对象,如果遇到无规则的“乱码”内存会导致崩溃。
因此,JVM 引入了堆的可解析性(Parsability):在丢弃 TLAB 前,必须在[top, end)这段空白区域填充一个虚设的、合法的结构(通常是一个int[]类型的 Dummy 填充对象)。
// src/share/vm/memory/threadLocalAllocBuffer.cppvoidThreadLocalAllocBuffer::clear_before_allocation(){_slow_refill_waste+=free();// 统计被浪费的内存空间// 核心:将当前 TLAB 剩余的空白区包装为 Dummy 对象,保持堆全局可连续扫描make_parsable(true);// 重置当前线程的 TLAB 指针为 NULL_start=_top=_end=NULL;}voidThreadLocalAllocBuffer::make_parsable(boolretire){if(CMSIncrementalMode||!ParsableTLAB)return;// 如果包含了有效的内存区间if(start()!=NULL){assert(top()!=NULL&&end()!=NULL,"inconsistency");if(retire){// 如果确定要退休,将一些统计数据落地}// 在当前 top 到 end 之间注入一个 int 数组(Filler Object)// 这样 GC 扫描到这里时,会认为这是一个普通的 int 数组对象,从而可以直接跳过这段空白区CollectedHeap::fill_with_object(top(),end(),retire);// 写入 Dummy 对象后,逻辑上将 _top 推进到 _end,表示该 TLAB 已经完全填满set_top(end());}}TLAB 核心参数动态自适应调整机制
HotSpot 默认开启了-XX:+ResizeTLAB。这意味着 TLAB 的大小(_desired_size)以及浪费阈值(_refill_waste_limit)并不是固定不变的,而是随着应用的运行、线程的分配速率动态计算的。
1. 期望大小_desired_size的计算
在每个线程发生 Refill 或者 GC 触发 TLAB 重置时,JVM 会根据当前线程近期的内存分配行为,计算下一次所需的 TLAB 大小:
// src/share/vm/memory/threadLocalAllocBuffer.cppsize_tThreadLocalAllocBuffer::compute_size(size_t obj_size){// 根据历史分配行为、线程总数以及 Eden 区总大小,估算一个期望的字长(Words)size_t blk_size=alloc_fraction()*TargetPLABWastePct;// 确保新计算出来的 TLAB 大小能够容纳当前请求分配的对象 obj_sizesize_t min_size=align_object_size(obj_size+alignment_reserve());size_t size=MAX2(blk_size,min_size);// 限制不能超过 TLAB 的最大上限(通常是 Eden 的一个比例或固定的最大值)size=MIN2(size,max_size());returnsize;}在每一个 GC 周期结束时,JVM 会根据线程过去分配的内存速率,重新计算_desired_size:
new_desired_size = Thread Allocation Rate Target Refill Waste Allocations \text{new\_desired\_size} = \frac{\text{Thread Allocation Rate}}{\text{Target Refill Waste Allocations}}new_desired_size=Target Refill Waste AllocationsThread Allocation Rate
如果线程在两个 GC 周期内频繁申请内存,_desired_size会逐渐调大,从而减少由于 TLAB 耗尽而进入 Slow Path 全局加锁的次数。
2. 浪费阈值_refill_waste_limit的动态递增
为了防止线程频繁触发慢速分配(直接去公共 Eden 分配对象而不 refill),如果线程频繁在共享空间分配大对象,JVM 会自适应地拉高_refill_waste_limit。
为了防止空间浪费,_refill_waste_limit也是动态递增的。当线程不断触发 TLAB 刷新,且每次都留下大块空白时,JVM 会调高该线程的_refill_waste_limit。这意味着阈值变大后,更不容易满足free_words < refill_waste_limit的条件,从而迫使大对象直接去 Eden 区分配,保护了 TLAB 的空间不被频繁废弃。
voidThreadLocalAllocBuffer::record_slow_allocation(size_t word_size){// 每触发一次慢速分配,说明当前的配置可能导致了较多的共享空间竞争_slow_allocations++;// 动态调大浪费阈值(增加 TLABRefillWasteIncrement,默认值为 4)// 阈值变大意味着:后续即使 TLAB 剩余空间较多,也允许丢弃并 refill,从而减少进入共享空间竞争的次数_refill_waste_limit+=TLABRefillWasteIncrement;}系统工程师视角下的 TLAB 调优总结
理解了上述源码实现,在遇到高并发、高吞吐量的 Java 系统性能瓶颈时,可以采取以下生产级调优策略:
| 参数 | 默认值 | 系统工程师调优建议 |
|---|---|---|
-XX:+UseTLAB | true | 高并发系统严禁关闭此参数。 |
-XX:+ResizeTLAB | true | 默认开启。若系统运行中对象分配速率极度平稳,可考虑关闭以减少运行期计算开销;若波动大,保持开启。 |
-XX:TLABSize=设置值 | 0(动态) | 默认由 JVM 自动计算。如果在性能监控(如 JFR)中发现大量的object_allocation_outside_tlab事件,且对象并非超大对象,可显式调大此参数(如512k或1m)。 |
-XX:TLABWasteTargetPercent=N | 1(%) | 每一个 TLAB 占 Eden 区的百分比。高并发、多线程环境下,如果线程数极多,可适当调小该值(如改为1或更小),防止 TLAB 占用过多 Eden 空间导致频繁的 Young GC。 |
关键认知:TLAB 的本质是一种空间换时间及解耦并发竞争的思想。它通过赋予线程局部独占性,将对象的快速分配拉高到了极致。但代价是会产生一定的堆内存碎片(即被 Filler 对象填充的 Gap 空间)。作为系统工程师,调优的核心平衡点就在于“全局 CAS 竞争的锁开销”与“碎片化导致的 Young GC 频率变高”之间寻找最优解。