JVM内存与GC(12)- 线程局部缓存TLAB

最后更新:2020-01-12

Java对象的内存分配过程是如何保证线程安全的?

对象的内存分配过程中,主要是对象的引用指向这个内存区域,然后进行初始化操作。但是,因为堆是全局共享的,因此在同一时间,可能有多个线程在堆上申请空间,那么,在并发场景中,如果两个线程先后把对象引用指向了同一个内存区域,怎么办。

为了解决这个并发问题,对象的内存分配过程就必须进行同步控制。但是我们都知道,无论是使用哪种同步方案(实际上虚拟机使用的可能是CAS),都会影响内存的分配效率。

而Java对象的分配是Java中的高频操作,所有,人们想到另外一个办法来提升效率。这里我们重点说一个HotSpot虚拟机的方案:

每个线程在Java堆中预先分配一小块内存,然后再给对象分配内存的时候,直接在自己这块”私有”内存中分配,当这部分区域用完之后,再分配新的”私有”内存。

这种方案被称之为TLAB分配,即Thread Local Allocation Buffer。这部分Buffer是从堆中划分出来的,但是是本地线程独享的。

什么是TLAB

TLAB全称ThreadLocalAllocBuffer,是线程的一块私有内存,如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用,这个申请动作还是需要原子操作的。

TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,均摊对GC堆(eden区)里共享的分配指针做更新而带来的同步开销。

TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。

所以说,因为有了TLAB技术,堆内存并不是完完全全的线程共享,其eden区域中还是有一部分空间是分配给线程独享的。

这里值得注意的是,我们说TLAB是线程独享的,但是只是在“分配”这个动作上是线程独享的,至于在读取、垃圾回收等动作上都是线程共享的。而且在使用上也没有什么区别。

也就是说,虽然每个线程在初始化时都会去堆内存中申请一块TLAB,并不是说这个TLAB区域的内存其他线程就完全无法访问了,其他线程的读取还是可以的,只不过无法在这个区域中分配内存而已。

并且,在TLAB分配之后,并不影响对象的移动和回收,也就是说,虽然对象刚开始可能通过TLAB分配内存,存放在Eden区,但是还是会被垃圾回收或者被移到Survivor Space、Old Gen等。

还有一点需要注意的是,我们说TLAB是在eden区分配的,因为eden区域本身就不太大,而且TLAB空间的内存也非常小,默认情况下仅占有整个Eden空间的1%。所以,必然存在一些大对象是无法在TLAB直接分配。

遇到TLAB中无法分配的大对象,对象还是可能在eden区或者老年代等进行分配的,但是这种分配就需要进行同步控制,这也是为什么我们经常说:小的对象比大的对象分配起来更加高效。

TLAB的大小可以通过:-XX:TLABSize来设置。如果没有设置TLAB,那么TLAB的大小就是分配线程的平均值。

TLAB的最小值可以通过:-XX:MinTLABSize来设置。

默认情况下:-XX:ResizeTLABresize开关是默认开启的,那么JVM可以对TLAB空间大小进行调整。

既然TLAB是有大小的,如果一个线程中定义了一个非常大的对象,TLAB放不下了,该怎么办呢?

我们假设现在的TLAB的大小是100K:

第一种可能性:

目前TLAB被使用了20K,还剩80K的大小,这时候我们创建了一个90K大小的对象,现在90K大小的对象放不进去TLAB,这时候需要直接在heap空间去分配这个对象,这种操作实际上是一种退化操作,官方叫做 slow allocation。

第二种个可能性:

目前TLAB被使用了90K,还剩10K大小,这时候我们创建了一个15K大小的对象。这个时候就要考虑一下是否仍然进行slow allocation操作。因为TLAB差不多已经用完了,为了保证后面new出来的对象仍然可以有一个TLAB可用,这时候JVM可以尝试将现在的TLAB Retire掉,然后分配一个新的TLAB空间,把15K的对象放进去。

如果采用方案1,那么就可能存在着一种极端情况,就是TLAB只剩下1KB,就会导致后续需要分配的大多数对象都需要在堆内存直接分配。

如果采用方案2,也有可能存在频繁废弃TLAB,频繁申请TLAB的情况,而我们知道,虽然在TLAB上分配内存是线程独享的,但是TLAB内存自己从堆中划分出来的过程确实可能存在冲突的,所以,TLAB的分配过程其实也是需要并发控制的。而频繁的TLAB分配就失去了使用TLAB的意义。

为了解决这两个方案存在的问题,虚拟机定义了一个refill_waste的值,这个值可以翻译为“最大浪费空间”。

当请求分配的内存大于refill_waste的时候,会选择在堆内存中分配。若小于refill_waste值,则会废弃当前TLAB,重新创建TLAB进行对象内存分配。

假设TLAB总空间100KB,使用了80KB,剩余20KB,如果设置的refill_waste的值为25KB,那么如果新对象的内存大于25KB,则直接堆内存分配,如果小于25KB,则会废弃掉之前的那个TLAB,重新分配一个TLAB空间,给新对象分配内存。

**-XX:TLABWasteTargetPercent=N**这个开关的默认值是1。表示如果新分配的对象大小如果超出了设置的这个百分百,那么就会执行slow allocation。否则就会分配一个新的TLAB空间。

同时JVM还定义了一个开关:-XX:TLABWasteIncrement=N 为了防止过多的slow allocation,JVM定义了这个开关(默认值是4),比如说第一次slow allocation的极限值是1%,那么下一次slow allocation的极限值就是%1+4%=5%。

TLAB空间中的浪费

如果新分配的TLAB空间,那么老的TLAB中没有使用的空间该怎么办呢?

这个叫做TLAB Waste。因为不会再在老的TLAB空间中分配对象了,所以剩余的空间就浪费了。

相关参数

TLAB功能是可以选择开启或者关闭的,可以通过设置-XX:+/-UseTLAB参数来指定是否开启TLAB分配。

TLAB默认是eden区的1%,可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

默认情况下,TLAB的空间会在运行时不断调整,使系统达到最佳的运行状态。如果需要禁用自动调整TLAB的大小,可以使用-XX:-ResizeTLAB来禁用,并且使用-XX:TLABSize来手工指定TLAB的大小。

TLAB的refill_waste也是可以调整的,默认值为64,即表示使用约为1/64空间大小作为refill_waste,使用参数:-XX:TLABRefillWasteFraction来调整。

如果想要观察TLAB的使用情况,可以使用参数-XX+PringTLAB 进行跟踪。

总结

为了保证对象的内存分配过程中的线程安全性,HotSpot虚拟机提供了一种叫做TLAB(Thread Local Allocation Buffer)的技术。

在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,当需要分配内存时,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

所以,“堆是线程共享的内存区域”这句话并不完全正确,因为TLAB是堆内存的一部分,他在读取上确实是线程共享的,但是在内存分配上,是线程独享的。

TLAB的空间其实并不大,所以大对象还是可能需要在堆内存中直接分配。那么,对象的内存分配步骤就是先尝试TLAB分配,空间不足之后,再判断是否应该直接进入老年代,然后再确定是再eden分配还是在老年代分配。

参考资料

https://www.jianshu.com/p/cd85098cca39

https://zhuanlan.zhihu.com/p/113551358

Edgar

Edgar
一个略懂Java的小菜比