内存一致性错误 vs 线程干扰

17

内存一致性错误和线程干扰有什么区别?使用同步避免它们的方式有何不同或相同?请举例说明。我在Sun Java教程中没有理解清楚。有没有阅读材料可以帮助我纯粹地从Java的角度理解这个问题。

6个回答

16

内存一致性错误不仅限于Java程序——多CPU系统上的共享内存行为细节高度依赖于体系结构,更糟糕的是,在最初针对多处理器设计的体系结构(例如POWER和SPARC)中,x86(大多数人今天学习编码的地方)与程序员友好的语义相比要简单得多,因此大多数人实际上不习惯考虑内存访问语义。

我举个常见例子说明内存一致性错误可能会给你带来麻烦。假设在这个例子中,x 的初始值为 3。几乎所有体系结构都保证,如果一个 CPU 执行以下代码:

STORE 4 -> x     // x is a memory address
STORE 5 -> x 

另一个CPU执行

LOAD x
LOAD x

从其两个LOAD指令的视角来看,将会看到3,33,44,44,55,5。基本上,CPU保证单个内存位置写入的顺序在所有CPU的视角下都得到维护,即使每个写入的确切时间允许变化。

CPU之间的区别通常存在于涉及不同内存地址的LOADSTORE操作所做出的保证。对于此示例,假设xy的初始值均为4。

STORE 5 -> x   // x is a memory address
STORE 5 -> y // y is a different memory address

然后另一个CPU执行

LOAD x
LOAD y
在这个例子中,在某些体系结构中,第二个线程可以看到4,45,54,5或者5,4。糟糕了!大多数架构以32位或64位字为粒度处理内存——这意味着在32位POWER/SPARC机器上,您不能更新64位整数内存位置并从另一个线程中安全地读取它,除非显式同步。很荒谬,对吗?线程干扰要简单得多。基本思想是Java不保证Java代码的单个语句原子执行。例如,增加一个值需要读取该值,增加它,然后再次存储它。所以你可以有int x = 1在两个线程执行x++之后,x可以变成23,这取决于低层代码交错的方式(在这里工作的低层抽象代码可能看起来像LOAD x, INCREMENT, STORE x)。这里的基本思想是Java代码被分解成更小的原子片段,除非使用同步原语,否则您无法假设它们如何交错。有关更多信息,请查看这篇文章。它很冗长、单调,而且是由一个臭名昭著的混蛋撰写的,但嘿,它也相当不错。另请参阅这个(或者只需搜索“双重检查锁定已损坏”)。这些内存重排序问题在几年前许多C++/java程序员尝试以他们的单例初始化变得过于聪明时露出了丑陋的头。

5
线程干扰是指线程互相覆盖彼此的语句(比如,线程 A 对计数器进行增量操作,线程 B 在“同一时间”内对其进行减量),导致计数器的实际值不可预测的情况。通过强制进行独占访问,每次只能一个线程访问,可以避免这种情况。
另一方面,内存不一致性是指线程间的可见性。例如,线程 A 可能会对计数器进行增量操作,但线程 B 可能尚未意识到这个更改,因此可能读取之前的某个值。通过建立 happens-before 关系,即“一个特定语句的内存写入对另一个特定语句可见的保证(来自Oracle)”,可以避免这种情况。请注意:保留 HTML 标签。

2
参考阅读文章是2010年8月ACM通信杂志第53卷第8期中Adve和Boehm的"Memory Models: A Case for Rethinking Parallel Languages and Hardware"。该文章可供计算机设备协会会员在线获取(http://www.acm.org),它详细讨论了一般问题,并讨论了Java内存模型。
欲了解更多有关Java内存模型的信息,请参阅http://www.cs.umd.edu/~pugh/java/memoryModel/

0

1. 线程干扰

class Counter {
    private int c = 0;

    public void increment() {
        c++;
    }

    public void decrement() {
        c--;
    }

    public int value() {
        return c;
    }

}

假设有两个线程 Thread-A 和 Thread-B 在同一个计数器实例上工作。假设 Thread-A 调用 increment(),在同一时间 Thread-B 调用 decrement()。此时 Thread-A 读取值 c 并将其增加 1。同时 Thread-B 读取该值(因为 Thread-A 尚未设置增加的值),将其减少并将其设置为-1。现在 Thread-A 设置了该值为 1。
2. 内存一致性错误
内存一致性错误发生在不同的线程对共享数据具有不一致的视图时。在以上计数器类中,假设有两个线程正在使用同一计数器实例,并调用 increment 方法将计数器的值增加 1。但这并不能保证一个线程所做的更改对另一个线程可见。
欲知详情,请访问 this

0

内存一致性问题通常表现为破碎的发生前关系。

Time A: Thread 1 sets int i = 1
Time B: Thread 2 sets i = 2
Time C: Thread 1 reads i, but still sees a value of 1, because of any number of reasons that it did not get the most recent stored value in memory.

您可以通过在变量上使用volatile关键字或使用java.util.concurrent.atomic包中的AtomicX类来防止这种情况发生。这两种方法都可以确保没有第二个线程会看到部分修改的值,并且没有人会看到不是内存中最新实际值的值。

(同步getter和setter也可以解决问题,但可能会让其他程序员感到奇怪,因为他们不知道为什么要这样做,并且在面对使用反射的绑定框架和持久性框架等问题时也可能会出现问题。)

--

线程交错是指两个线程同时操作一个对象,导致看到不一致的状态。

我们有一个PurchaseOrder对象,其中包含itemQuantity和itemPrice属性,自动逻辑生成发票总额。

Time 0: Thread 1 sets itemQuantity 50
Time 1: Thread 2 sets itemQuantity 100
Time 2: Thread 1 sets itemPrice 2.50, invoice total is calculated $250
Time 3: Thread 2 sets itemPrice 3, invoice total is calculated at $300

线程1执行了错误的计算,因为其他一些线程在他的操作之间干扰了该对象。

您可以通过使用synchronized关键字来解决此问题,以确保每次只有一个人可以执行整个过程,或者使用java.util.concurrent.locks包中的锁。对于新程序而言,通常使用java.util.concurrent是首选方法。


1
你的第一个例子没有意义,因为不能保证在多个CPU上进行操作时有确定性的单个时钟。此外,几乎所有常用的CPU都保证针对单个内存地址不会违反happens-before关系——您需要多个内存地址才能有效地说明这个概念。一旦摆脱了损坏的单时钟想法,您只需要从一个线程的角度来观察操作,这时您的示例就变成了简单的线程干扰。 - blucz
虽然你所写的一切都是正确的,但我认为它并没有帮助 OP 达到学习编写安全 Java 程序的目标。Java 虚拟机具有自己的内部内存模型,可以将程序与底层平台的细微差别隔离开来。我给出的例子是他在编写不当的 Java 程序中实际会遇到的问题,尽管如你所说,它并不严格符合计算机工程定义的内存一致性问题。 - Affe
1
我建议您检查一下JVM规范的理解。JVM不会隐藏我所使用的“细微差别”作为例子。我的观点是,您提供的两个例子都涉及线程干扰。根据定义,MCE必须涉及底层平台。 - blucz
在互联网上为一个一周前的帖子争论是否值得,但那个例子是Sun/Oracle教程中关于内存一致性问题的例子,正如楼主在他的问题中所提到的。因此,我尝试补充一些更多的信息,以使两个例子之间的区别更加清晰。如果原始文章有误,我想我们应该让Oracle知道! :) - Affe

0
首先,请注意,您的源代码并不是学习您想要学习的最佳地方。您可以阅读@blucz的答案(以及他的一般回答),即使它超出了Java的范围,也会有所收获。Oracle Trails本身并不差,但它们简化了问题并掩盖了它们,因此您可能会发现自己不理解刚学到的内容是否有用以及有多少用处。
现在,试图在Java上下文中回答问题。
线程干扰发生在线程操作交错时,也就是混合时。我们需要两个执行器(线程)和共享数据(干扰的位置)。来自turnoff.us网站的Daniel Stori的图片:

Daniel Stori, turnoff.us

在图像中,您可以看到GNU/Linux进程中的两个线程可能会相互干扰。Java线程本质上是指向本地线程的Java对象,如果它们在同一数据上操作(就像这里的“Rick”搞乱了他弟弟的数据-绘图),它们也可能会相互干扰。

内存一致性错误-MCE

关键点在于内存可见性、发生顺序和硬件-由@blucz提出。
MCE显然是内存变得不一致的情况。实际上,对于计算机来说,内存始终保持一致(除非被损坏)。这些“不一致”是人类“看到”的东西,因为他们不理解发生了什么,期望发生其他事情。“为什么是1?应该是2?”
这种“感知上的不一致性”,这种“差距”,涉及到内存可见性,也就是不同线程在查看内存时看到的内容。因此,它们所操作的内容也会有所不同。
你看,当我们考虑代码时(特别是在逐行思考代码执行方式时),对内存的读写是线性的……但实际上并非如此,尤其是涉及到线程时。因此,你所阅读的教程给出了一个例子,即两个线程增加计数器的值,以及第二个线程如何读取与第一个线程相同的值。内存不一致的实际原因可能是由于 javac、JIT 或硬件内存一致性模型对代码进行的优化(也就是 CPU 人员为加快 CPU 运行速度和提高效率所做的事情)。这些优化包括预测性存储、分支预测等,现在你可以将它们视为重新排列代码,以便最终运行更快且使用/浪费更少的 CPU 周期。然而,为了确保优化不失控(或过度),需要做出一些保证。这些保证形成了“先于发生”的关系,我们可以确定在这一点之前和之后发生的事情。想象一下你主持一个聚会并记住 Tom 比 Suzie 先到,因为你知道 Rob 在 Tom/Suzie 到来之前到达。Rob 是你用来形成 Tom/Suzie 到来之前发生关系的事件。

https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/package-summary.html#MemoryVisibility

上面的链接告诉您更多关于内存可见性以及在Java中建立happens-before关系的内容。这并不会让人感到惊讶,但是:

  1. 同步化可以
  2. 启动线程
  3. 加入线程
  4. volatile关键字告诉您写操作发生在后续读操作之前,也就是说,在写操作之后的读操作不会被重新排序为“在”写操作之前,因为那样会破坏“happens-before”关系。

由于所有这些都涉及内存,因此硬件至关重要。您的平台有自己的规则,虚拟机试图通过使所有平台行为类似来使它们通用,但仅仅这一点就意味着在平台A上将有比平台B上更多的内存屏障。

您的问题

内存一致性错误和线程干扰之间有什么区别? MCE是关于程序线程对内存的可见性,而没有在读写之间建立happens-before关系,因此在人们认为“应该是”的内容与“实际上是”的内容之间存在差距。

线程干扰是指线程操作相互重叠、混在一起、交错和触及共享数据,从而在过程中损坏它,这可能导致线程A的良好绘图被线程B破坏。由于干扰具有危害性,通常标记临界区域,因此同步工作。
引用:
如何使用同步避免它们有何不同或相同?
请还要阅读关于轻量级锁、重量级锁和线程竞争的内容。为了避免线程干扰,同步只允许一个线程访问关键部分,其他线程被阻止(成本高,线程竞争)。当涉及到MCE同步时,在锁定和解锁互斥锁时建立happens-before,详见早期链接至java.util.concurrent包说明。
例如:请参见前面的两个部分。

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