Java中内存屏障的行为
Posted
技术标签:
【中文标题】Java中内存屏障的行为【英文标题】:Behavior of memory barrier in Java 【发布时间】:2014-08-19 13:57:09 【问题描述】:在阅读了更多博客/文章等之后,我现在对内存屏障之前/之后的加载/存储行为感到非常困惑。
以下是 Doug Lea 在他的一篇关于 JMM 的澄清文章中引用的两句话,这两句话都非常直截了当:
-
线程 A 在写入 volatile 字段 f 时可见的任何内容在线程 B 读取 f 时变为可见。
请注意,两个线程访问同一个 volatile 变量以正确设置发生前的关系非常重要。并不是线程 A 在写入 volatile 字段 f 时对它可见的所有内容在它读取 volatile 字段 g 后对线程 B 都是可见的。
但是当我查看另一个关于内存屏障的 blog 时,我得到了这些:
-
存储屏障,x86 上的“sfence”指令,强制屏障之前的所有存储指令发生在屏障之前,并将存储缓冲区刷新到发出它的 CPU 的缓存中。
加载屏障,x86 上的“lfence”指令,强制屏障之后的所有加载指令发生在屏障之后,然后等待加载缓冲区为该 CPU 耗尽。
对我来说,Doug Lea 的澄清比另一个更严格:基本上,这意味着如果负载屏障和存储屏障在不同的监视器上,将无法保证数据的一致性。但后者意味着即使障碍在不同的监视器上,也能保证数据的一致性。我不确定我是否正确理解了这两个,也不确定其中哪个是正确的。
考虑以下代码:
public class MemoryBarrier
volatile int i = 1, j = 2;
int x;
public void write()
x = 14; //W01
i = 3; //W02
public void read1()
if (i == 3) //R11
if (x == 14) //R12
System.out.println("Foo");
else
System.out.println("Bar");
public void read2()
if (j == 2) //R21
if (x == 14) //R22
System.out.println("Foo");
else
System.out.println("Bar");
假设我们有 1 个写线程 TW1 首先调用 MemoryBarrier 的 write() 方法,然后我们有 2 个读线程 TR1 和 TR2 调用 MemoryBarrier 的 read1() 和 read2() 方法。考虑这个程序运行在不保存的 CPU 上排序(x86 DO 保留这种情况的排序,但情况并非如此),根据内存模型,W01/W02 之间会有一个 StoreStore 屏障(比如说 SB1),以及 R11/R12 和 R21/ 之间的 2 个 LoadLoad 屏障R22(比方说 RB1 和 RB2)。
-
由于 SB1 和 RB1 在同一个监视器 i 上,所以调用 read1 的线程 TR1 应该总是在 x 上看到 14,而且总是打印“Foo”。李>
SB1 和 RB2 在不同的显示器上,如果 Doug Lea 正确,线程 TR2 将不能保证在 x 上看到 14,这意味着可能偶尔会打印“Bar”。但是如果内存屏障像blog 中描述的 Martin Thompson 那样运行,Store 屏障会将所有数据推送到主内存,而负载屏障会将所有数据从主内存拉到缓存/缓冲区,那么 TR2 也将保证看到 14 on x.
我不确定哪一个是正确的,或者两者都是正确的,但 Martin Thompson 所描述的只是针对 x86 架构。 JMM 不保证对 x 的更改对 TR2 可见,但 x86 实现可以。
谢谢~
【问题讨论】:
您不应该关心 x86 上的内存屏障。 Java 的语义和Java 内存模型是在抽象机器 上定义的。这是唯一重要的事情。 Java 运行时负责确保 抽象机器 做出的保证在运行时得到满足。 事实上,x86 语义(包括缓存一致性)比 jmm 要求的要强,但是如果您不从事正如 nosid 正确指出的那样,用于 x86 的 java 运行时。 您的担忧是有效的。 /可能/读者 2 会打印 Bar。但是,除非读取器线程之前已与内存屏障类交互并缓存了 x 的值,否则读取器 2 将打印 foo,因为它将第一次访问 x。与 write 的交互意味着对 x 的更改将是可见的。也许更有趣的测试是让 read1 和 read2 在 W1 之前和之后执行。CountDownLatch
引入了额外的同步。因此,如果您使用CountDownLatch
确保read2
在write
之后执行,那么read2
将始终打印"Foo"
。
@asticx:答案是:"Bar"
显然是可能的,如果write
和read2
之间没有同步,则不可能,如果有同步。我想这不是你感兴趣的。所以,请提供更多你真正想知道的信息。
【参考方案1】:
道格·李是对的。您可以在Java 语言规范的§17.4.4 部分找到相关部分:
§17.4.4 Synchronization Order
[..] 对 volatile 变量 v 的写入(第 8.3.1.4 节)同步所有后续对 v 的读取任何线程(其中“后续”是根据同步顺序定义的)。 [..]
具体机器的内存模型无关紧要,因为Java编程语言的语义是根据抽象机器定义的——独立于混凝土机械。 Java 运行时环境有责任以这样的方式执行代码,使其符合Java 语言规范给出的保证。
关于实际问题:
如果没有进一步同步,方法read2
可以打印"Bar"
,因为read2
可以在write
之前执行。
如果有一个与CountDownLatch
的额外同步以确保read2
在write
之后执行,那么方法read2
将永远不会打印"Bar"
,因为同步与CountDownLatch
一起删除x
上的数据竞争。
独立易失变量:
对 volatile 变量的写入不会与对任何其他 volatile 变量的读取同步,这有意义吗?
是的,这是有道理的。如果两个线程需要相互交互,它们通常必须使用相同的volatile
变量来交换信息。另一方面,如果一个线程使用 volatile 变量而不需要与所有其他线程交互,我们不想为内存屏障付出代价。
这实际上在实践中很重要。让我们举个例子。下面的类使用了一个 volatile 成员变量:
class Int
public volatile int value;
public Int(int value) this.value = value;
想象一下这个类只在方法中本地使用。 JIT 编译器可以轻松检测到该对象仅在此方法中使用 (Escape analysis)。
public int deepThought()
return new Int(42).value;
通过上述规则,JIT 编译器可以移除所有volatile
读写的影响,因为volatile
变量不能被任何其他线程访问。
这种优化实际上存在于 Java JIT 编译器中:
src/share/vm/opto/memnode.cpp【讨论】:
澄清一下,如果write
发生read2
,按时间顺序没有额外同步,"Bar"
还能打印吗?
@SotiriosDelimanolis 我认为不,14
只能从x
读取之后(我的意思是,发生在之后在write
中,所以只会打印Foo
。
@AlexeyMalev 是的,但是在此之前,它读作j
,而不是i
,所以IMO 引用1.
不适用。
@SotiriosDelimanolis:如果read2
发生在write
之后,你怎么知道(无需额外同步)?
@Voo 我猜 x86 提供了比 JMM 更强的保证,以使 read2 线程可以看到对 x 的更改。 JMM 本身并不保证会发生这种情况。【参考方案2】:
据我了解,问题实际上是关于易失性读/写及其发生前的保证。说到这部分,我对 nosid 的回答只有一件事要补充:
在普通写入之前不能移动易失性写入,在普通读取之后不能移动易失性读取。这就是为什么 read1()
和 read2()
的结果会像 nosid 写的那样。
谈到障碍 - 定义对我来说听起来不错,但可能让您感到困惑的一件事是,这些是在热点中实现 JMM 中描述的行为的事物/工具/方式/机制(随便称呼它)。使用 Java 时,您应该依赖 JMM 保证,而不是实现细节。
【讨论】:
以上是关于Java中内存屏障的行为的主要内容,如果未能解决你的问题,请参考以下文章
Linux 内核 内存管理优化内存屏障 ④ ( 处理器内存屏障 | 八种处理器内存屏障 | 通用内存屏障 | 写内存屏障 | 读内存屏障 | 数据依赖屏障 | 强制性内存屏障 |SMP内存屏障 )
Linux 内核 内存管理优化内存屏障 ④ ( 处理器内存屏障 | 八种处理器内存屏障 | 通用内存屏障 | 写内存屏障 | 读内存屏障 | 数据依赖屏障 | 强制性内存屏障 |SMP内存屏障 )