1. 引言
本教程将深入探讨线程本地分配缓冲区(Thread-Local Allocation Buffers, TLAB)。我们将了解它是什么、JVM如何使用它,以及如何对其进行管理。
2. Java中的内存分配
Java中的某些操作会触发内存分配,最明显的是new
关键字,但还有其他方式——比如使用反射。每当执行这些操作时,JVM必须在堆上为新对象预留内存。具体来说,JVM的所有内存分配都发生在Eden空间(即年轻代空间)中。
在单线程应用中,这很简单。因为同一时间只会有一个内存分配请求,线程只需获取下一个合适大小的内存块即可:
然而,在多线程应用中,事情就没这么简单了。如果还按单线程的方式处理,就有可能出现两个线程同时请求内存,并且被分配到同一块内存的情况:
为了避免这种情况,我们需要对内存分配进行同步,确保两个线程不会同时请求同一块内存。但是,如果对所有内存分配都进行同步,实际上就变成了单线程分配,这可能会成为应用的巨大瓶颈。
3. 线程本地分配缓冲区(TLAB)
JVM通过线程本地分配缓冲区(TLAB)来解决这个问题。TLAB是堆内存中为特定线程预留的区域,仅供该线程用于内存分配:
通过这种方式,由于只有一个线程能从该缓冲区分配内存,因此无需同步。缓冲区本身的分配是同步的,但这个操作频率要低得多。
由于对象内存分配是相对频繁的操作,TLAB能带来巨大的性能提升。具体提升多少呢?我们可以通过一个简单的测试来验证:
@Test
public void testAllocations() {
long start = System.currentTimeMillis();
List<Object> objects = new ArrayList<>();
for (int i = 0; i < 1_000_000; ++i) {
objects.add(new Object());
}
Assertions.assertEquals(1_000_000, objects.size());
long end = System.currentTimeMillis();
System.out.println((end - start) + "ms");
}
这个测试相对简单,但足以说明问题。我们将分配100万个新的Object实例,并记录耗时。然后,我们分别在开启和关闭TLAB的情况下多次运行测试,观察平均耗时(我们将在第5节介绍如何关闭TLAB):
差异显而易见。开启TLAB的平均耗时为33毫秒,关闭后则升至110毫秒。仅通过调整这一个设置,性能就下降了230%。
3.1. TLAB空间耗尽时会发生什么
显然,TLAB空间是有限的。那么,当空间用完时会发生什么呢?
当应用尝试为新对象分配空间而TLAB剩余空间不足时,JVM有四种可能的处理方式:
- ✅ 为该线程分配新的TLAB空间,从而增加可用空间
- ✅ 在TLAB空间之外为该对象分配内存
- ✅ 尝试通过垃圾回收器释放一些内存
- ❌ 分配失败并抛出错误
第4种情况是灾难性的,因此我们要尽可能避免,但在其他方式都不可行时,它也是一种选择。
JVM使用一系列复杂的启发式算法来决定采用哪种方式,这些算法在不同JVM和版本中可能有所不同。不过,影响决策的关键因素包括:
- 一段时间内可能发生的分配次数。如果预计会分配大量对象,那么增加TLAB空间会更高效;如果预计分配次数很少,增加TLAB空间反而可能降低效率
- 请求的内存大小。请求的内存越大,在TLAB外分配的成本就越高
- 可用内存量。如果JVM有大量可用内存,增加TLAB空间就比内存使用率很高时容易得多
- 内存争用程度。如果JVM中有大量线程都需要内存,增加TLAB空间的成本可能比线程很少时高得多
3.2. TLAB容量权衡
使用TLAB似乎是提升性能的绝佳方式,但总有代价。 为防止多线程分配同一内存区域所需的同步操作,使得TLAB本身的分配成本相对较高。如果JVM内存使用率特别高,我们可能还需要等待足够的内存可用。因此,我们希望尽可能少地执行TLAB分配。
然而,如果为线程分配的TLAB空间超过了其实际需求,多余的空间将闲置浪费。更糟的是,这种浪费会使其他线程更难获取TLAB空间,进而拖慢整个应用。
因此,关于分配多少空间存在权衡:
- ⚠️ 分配太多会浪费空间
- ⚠️ 分配太少则会花费过多时间在TLAB分配上
幸运的是,JVM会自动处理这些权衡。不过,我们很快会看到如何在必要时根据需求进行调整。
4. 观察TLAB使用情况
既然我们了解了TLAB及其对应用的影响,那么如何观察它的实际运行情况呢?
遗憾的是,jconsole工具无法像监控标准内存池那样提供TLAB的可见性。
不过,JVM本身可以输出一些诊断信息。这使用了新的统一GC日志机制,因此我们必须使用-Xlog:gc+tlab=trace
标志启动JVM才能查看这些信息。JVM会定期打印当前的TLAB使用情况。例如,在GC运行期间,我们可能会看到类似这样的输出:
[0.343s][trace][gc,tlab] GC(0) TLAB: gc thread: 0x000000014000a600 [id: 10499] desired_size: 450KB slow allocs: 4 refill waste: 7208B alloc: 0.99999 22528KB refills: 42 waste 1.4% gc: 161384B slow: 59152B
从这条日志中,我们可以了解到该特定线程的以下信息:
- 当前TLAB大小为450 KB(
desired_size
) - 自上次GC以来,有4次在TLAB外进行的分配(
slow allocs
)
注意,不同JVM和版本的日志格式可能有所不同。
5. 调整TLAB设置
我们已经了解了开启和关闭TLAB的影响,但还能做些什么呢?我们可以在启动应用时通过提供JVM参数来调整多项设置。
首先,我们来看如何关闭TLAB。通过传递JVM参数-XX:-UseTLAB
即可。设置该参数后,JVM将停止使用TLAB,并在每次内存分配时强制使用同步。
我们也可以保持TLAB开启,但通过设置-XX:-ResizeTLAB
参数禁止其调整大小。这意味着,当某个线程的TLAB用尽后,后续所有分配都将在TLAB外进行,并需要同步。
我们还可以配置TLAB的大小。通过提供-XX:TLABSize
参数并指定值,可以设置JVM为每个TLAB建议的初始大小(即每个线程应分配的大小)。如果设置为0(默认值),JVM会根据当前状态动态决定每个线程的分配量。
在允许JVM动态决定大小时,我们可以通过-XX:MinTLABSize
指定每个线程TLAB大小的下限,通过-XX:MaxTLABSize
指定上限。
所有这些参数都有合理的默认值,通常直接使用即可。但如果遇到问题,我们确实可以进行一定程度的控制。
6. 总结
本文介绍了线程本地分配缓冲区(TLAB)的概念、使用方式以及管理方法。 当下次应用出现性能问题时,不妨考虑是否需要调查TLAB相关的问题。简单粗暴地调整TLAB设置有时能带来意想不到的性能提升,但务必在充分理解的基础上进行操作,避免踩坑。