JVM如何确保为新对象分配内存的线程安全性

19

假设这将在真正的并行环境中发生,一个虚拟机,在同一时间内:

// Thread 1:
  new Cat()

// Thread 2:
  new Dog()

// Thread 3:
  new Mouse()

JVM如何确保堆上内存分配的线程安全性?

堆是所有线程共享的,它有自己的内部数据。

为了简单起见,假设使用一个简单的压缩垃圾收集器实现-XX:+UseSerialGC,-XX:+UseParallelGC,并具有简单的增量指针来标记空闲空间的开始和在Eden(堆)中的一个连续空闲空间。

当为Cat、Dog和Mouse实例分配堆空间时,线程之间必须进行某种同步,否则它们很容易相互覆盖。这是否意味着每个new操作符都隐藏在一些同步块内?这样,许多“无锁”算法实际上并不完全无锁 ;)

我假设内存分配是由应用程序线程自己同步进行的,而不是由另一个专用线程进行的。

我知道TLAB,或者叫线程局部分配缓冲区。它允许线程在Eden中拥有一个单独的内存区域来进行分配,因此不需要同步。但是我不确定TLAB是否默认设置,这是一个非常难以理解的HotSpot功能。注意:不要将TLAB和ThreadLocal变量混淆!
我还假设,对于更复杂的垃圾收集器(如G1或非压缩垃圾收集器),需要维护更复杂的堆结构数据,例如CMS的空闲块列表,因此需要更多的同步。
更新:请让我澄清一下。我接受HotSpot JVM实现及具有和没有激活TLAB的变体的答案。

更新: 根据我的快速测试,在我的64位JDK 7上,串行、并行和CMS垃圾收集器默认启用TLAB,但对于G1 GC则未启用。


它就是这样。为每个线程分配自己的“区域”或“池”是一种方法。但通常会使用某种锁定协议来处理从单个池中分配的大型对象。 - Hot Licks
2个回答

13

这个答案中,我简要描述了HotSpot JVM中的分配过程。
对象的分配方式取决于它所分配的堆区域。

1. TLAB。最快且最频繁的方式。

TLAB是Eden保留用于线程本地分配的区域。每个线程可以创建多个TLAB:一旦一个填满了,使用 #2 中描述的技术创建一个新的TLAB。也就是说,创建新的TLAB类似于直接在Eden中分配一个大的元对象。

每个Java线程有两个指针:tlab_toptlab_limit。在TLAB中进行分配只是一个指针增量操作。由于指针是线程本地的,因此不需要同步。

if (tlab_top + object_size <= tlab_limit) {
    new_object_address = tlab_top;
    tlab_top += object_size;
}

-XX:+UseTLAB 默认启用。如果关闭它,对象将根据下面所述在 Eden 中分配。

2.在 Eden(年轻代)中分配。

如果 TLAB 中没有足够的空间来存储新的对象,则会创建一个新的 TLAB 或直接在 Eden 中分配对象(取决于 TLAB 浪费限制和其他人体工程学参数)。

在 Eden 中分配类似于在 TLAB 中分配。也有两个指针:eden_topeden_end,它们是整个 JVM 的全局指针。分配也是一个指针递增,但由于 Eden 空间在所有线程之间共享,因此使用原子操作。通过使用特定于架构的原子指令实现线程安全:CAS(例如,在 x86 上使用 LOCK CMPXCHG)或LL/SC(在 ARM 上)。

3.在老年代中分配。

这取决于 GC 算法,例如 CMS 使用空闲列表。通常只有垃圾收集器本身执行老年代中的分配,因此它知道如何同步自己的线程(通常使用分配缓冲区的混合、无锁原子操作和互斥锁)。


好的,看起来很不错。只有一件事,如果TLAB没有激活,对象会直接在伊甸园上分配,就像第2节中描述的那样。你能更新一下你的答案吗?谢谢。 - Espinosa
2
还有逃逸分析,它会导致在堆栈上进行分配。 - David Ehrmann
@DavidEhrmann在HotSpot中不存在“堆栈分配”的概念。我在[其他答案](https://dev59.com/xYLba4cB1Zd3GeqPlOsh#25562143)中已经描述了这一点。 - apangin
@apangin,你在纠结一个未来可能会改变的实现细节。 - David Ehrmann
@apangin,如果两个不同的线程恰好在堆中分配相同大小的内存,为什么CAS可以确保线程安全? - choxsword
@scottxiao 因为CAS是原子操作,所以一次只能有一个线程递增当前堆指针。分配大小无关紧要。 - apangin

5
这在Java规范中没有具体说明。这意味着每个JVM都可以按照自己的方式完成,只要它能够工作并遵循Java的内存保证即可。
对于具有移动GC的情况,一个好的猜测是每个线程都有自己的分配区域。在分配对象时,它只需进行简单的指针增加。这样就可以实现非常简单且非常快速的分配,而不需要锁定。当该区域已满时,可能会为其分配一个新的分配区域,或者GC将所有活动对象移动到堆的连续部分,并将现在的空闲区域返回给每个线程。我不确定这是否是任何JVM中实际实现的方式,也可能在GC同步方面比较复杂。

非常不可能。几乎肯定有一个同步步骤。 - user207421
@EJP,Smith_61提出的方案中隐含了同步步骤。每次线程使用完当前分配区域时,都必须锁定互斥量。(注意:我不断言知道Oracle的JVM或任何其他JVM实际上如何进行分配。) - Solomon Slow
通过使用区域/池,系统只需要在需要刷新区域/池时进行同步。我可以向您保证,这是某些JVM中的实现方式。 - Hot Licks
每个分配区/池都是特定线程本地的,这意味着所有空闲内存都归属于该线程。从该区域分配的每个对象都是全局的。 - Smith_61
1
@MartinJames - 没有必要复制一个对象来“信号”另一个线程。无论对象在哪里/如何分配。 - Hot Licks
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接