理解Java中同步块和volatile变量的原子性、可见性和重排序

3

我正在尝试理解《Java Concurrency in Practice》一书中的volatile关键字。在三个方面(原子性、可见性和重排序)上,我将synchronized关键字与volatile变量进行比较。但我对此有些疑虑。以下是我一个一个讨论它们:

1) 可见性: `synchronized` vs `volatile`

关于synchronized的可见性,该书如下所述:

当一个线程 A 在同步块内或先前执行同步块时,其中的所有操作对于在使用相同锁保护的同步块中执行的 B 线程都是可见的。

关于volatile变量的可见性,它如下所示:

volatile变量不会被缓存在寄存器或其他处理器不可见的缓存中,因此对volatile变量的读取总是返回任意线程最新写入的值。
volatile变量的可见性效果延伸到其本身之外。当线程 A 写入volatile变量,然后随后线程 B 读取同一变量时,在A写入volatile变量之前对A可见的所有变量值都将在B读取volatile变量后对B可见。因此,从内存可见性的角度来看,写入volatile变量就像退出同步块,而读取volatile变量就像进入同步块。

Q1. 我认为上面的volatile第二段对应于书中关于synchronized的说法。但是是否存在与volatile第一段等效的synchronized?换句话说,使用synchronized是否确保某些变量不被缓存在处理器缓存和寄存器中?

请注意,书中还提到了synchronized的可见性:

锁定不仅涉及互斥,还涉及内存可见性。

2) 重排序: `synchornized` vs `volatile`

在重排序的上下文中,该书如下所述volatile:

当一个字段被声明为 volatile 时,编译器和运行时会被通知此变量是共享的,并且对它的操作不应与其他内存操作重新排序。

Q2. 书中没有提到synchronized的重排序。是否有人可以解释一下在synchronized的上下文中可能发生的重排序情况?

3) 原子性

该书如下所述关于synchronizedvolatile的原子性:

如果你不能保证该变量只从一个线程中写入,那么 volatile 的语义不足以使增量操作(count++)成为原子操作。

锁定可以保证可见性和原子性;volatile 变量只能保证可见性。

Q3. 我猜这意味着两个线程可以同时看到 volatile int a,都会对其进行递增并保存。但只有最后一次读取将起作用,从而使整个“读取-递增-保存”操作非原子化。我的解释关于 volatile 的非原子性是否正确?

Q4. 所有等效于锁定的方法是否都具有可比性,并具有相同的可见性、排序和原子性属性:同步代码块、原子变量、锁定等?

注:本问题与我几天前提出的 此问题 有关,是完全改版的版本。由于进行了全面改版,我没有删除旧版本。我更加明确和结构化地写下了这个问题。在得到答案之后将删除旧版本。


您在一个问题中提出了4个不同的问题,这太过宽泛。 - Stephen C
首先,我要说的是,线程 A 在离开同步块之前做的所有事情对线程 B 来说都是可见的,在 B 进入以相同实例进行同步的块之后。这足以保证如果一组变量仅在synchronized (o)块(其中o始终引用相同的实例)内访问,则所有线程将始终看到这些变量的一致视图。 - Solomon Slow
@StephenC 我本可以只问一个问题“使用synchronized和volatile有什么区别?”或者简单地问“synchronized和volatile的区别”,然后期望回答者澄清两者的三个方面(原子性、可见性和重排序)以获得“完整”的答案。相反,我明确要求他们帮助回答者构建更完整/完美的答案。 - MsA
注:如果你还在学习编写多线程代码,那么除了某些简单、众所周知的用例之外,你应该远离 volatile,比如一个线程一直运行直到另一个线程设置了一个 volatile bool time_to_stop 标志。否则,在测量了某个重要程序的实际性能,发现它不足以后,并且已经做好了功课以确保使用了最佳算法,现在正在寻求专业技巧来调整它之前,甚至不要想着使用 volatile - Solomon Slow
3个回答

5
“synchronized”和“volatile”的主要区别在于,“synchronized”可以使线程暂停,而“volatile”不能。
“缓存和寄存器”不是一种东西。书中之所以这样说,是因为实际上通常是这样实现的,这样更容易(或许不是,考虑到这些问题)理解JMM(Java内存模型)的如何和为什么。
然而,JMM并没有给它们命名。它只是说VM可以为每个线程提供自己的任何变量的本地副本,也可以不提供,在某个任意时间与某个或所有其他线程同步,或者不同步……除非有任何一个“happens before”关系,此时VM必须确保在建立“happens before”关系的两个线程之间执行时,它们观察到所有变量处于相同状态。
实际上,这可能意味着刷新缓存。或者不是;它可能意味着另一个线程覆盖了它的本地副本。

我是一名有用的助手,可以翻译文本。

虚拟机可以自由地实现这些内容,并在每个架构上以不同的方式实现。只要虚拟机坚持JMM所做出的保证,它就是一个好的实现,因此,你的软件必须仅基于这些保证而不依赖其他假设来工作;因为如果你依赖于JMM未保证的假设,那么在另一台计算机上可能无法正常工作。

重排序

重排序在VM规范中根本不存在。VM规范中存在以下两个概念:

  1. 在单个线程的范围内,从内部观察到的所有内容都符合有序视图。也就是说,如果你写了“x = 5; y = 10;”,无论是否使用synchronized或volatile,在同一个线程中,不可能观察到y的值为10而x仍旧保持原来的值。所以,只要没有被观察到,就可以任意重新排序。这是由虚拟机决定的,有些虚拟机会这么做,有些则不会。

  2. 当观察其他线程造成的影响时,并且你没有建立happens-before关系时,你可能以任何顺序看到一些、全部或没有这些影响。实际上,这里可能发生任何事情。因此,请勿尝试在没有建立happens-before关系的情况下观察其他线程造成的影响,因为结果是任意且无法测试的。

“Happens-before”关系由各种事情建立;同步块显然会这样做(如果您的线程在尝试获取锁时被冻结,然后运行,那么该对象上的任何同步块都会在其完成时“发生”,以及它们所做的任何事情,您现在可以观察到,保证您观察到的内容与它们按顺序运行一致,并且所有数据它们写入的您都可以看到(例如,您不会得到旧的“缓存”或类似物)。易失性访问也是如此。”
“原子性”
“是的,您对于为什么x++即使x是易失性也不是原子操作的解释是正确的。”
“我不确定您的Q4想要问什么。”

一般而言,如果您想原子地增加一个整数或执行其他许多并发操作,请查看 java.util.concurrent 包。这些包含了各种概念的有效和有用的实现。例如,AtomicInteger 可以用于原子地增加某些东西,以一种对其他线程可见且仍然相当高效的方式(例如,如果您的 CPU 支持比较和设置(CAS)操作,则 Atomicinteger 将使用它;这是在普通 Java 中无法做到的,除非诉诸于 Unsafe)。请注意保留 HTML 标签。


CAS可以通过AtomicReferenceFieldUpdater安全地使用。 - John H
1
自Java 9以来,有VarHandle,它是所有种类的堆变量(即字段、数组元素或ByteBuffer内容)上进行CAS等各种原子更新的官方API。不再需要使用Unsafe了...(尽管它仍然是一种低级工具,因此使用AtomicInteger可能仍然是更简单的选项)。 - Holger

2

补充rzwitserloot的优秀答案:

A1. 可以这样想:synchronized保证所有缓存更改在第一个线程退出同步块并在另一个线程进入之前(从缓存中刷新)变为对其他进入同步块的线程可见。

A2. 在同步块内由线程T1执行的操作,如果且仅当T2在相同的保护上同步时,对于某些其他线程T2而言,这些操作似乎未被重新排序。

A3. 我不确定你理解的是什么。当增加时,可能会发生这样的情况:两个线程都将首先执行变量a的读取,该读取将产生一些值v,然后两个线程都将本地增加值v的副本,生成v' = v + 1,然后两个线程都将v'写入a。因此,最终a的值可能是v + 1而不是v + 2。

A4. 基本上是的,尽管在同步块中可以原子地执行许多操作,而原子变量只允许您执行某个单个操作,例如原子递增。此外,区别在于当使用同步块“不正确”时,即在同步块外读取由另一个线程在同步块内修改的变量时,您可能会观察到它们不是原子和重新排序的。这是使用原子变量不可能发生的事情。锁定与同步相同。


1

Q1. 我觉得上面第二段(关于volatile的)对应于书中所说的synchronized。

没错。 volatile 访问可以被视为轻量级同步。

但是,是否有与volatile第一段等效的synchronized呢?换句话说,使用synchronized是否确保某些变量不会被缓存在处理器缓存和寄存器中?

这本书混淆了你的思路。 volatile 访问与处理器缓存或寄存器没有直接关系,实际上,这本书肯定是关于缓存方面错误的。易失性和同步是关于某些操作的跨线程可见性的,特别是对共享变量的写入。语义如何实现在很大程度上是一个独立的问题。

无论如何,同步不会对变量的存储方式施加任何限制。所有与同步语义有关的事情都发生在同步区域的边界上。这就是为什么从一组并发运行的线程中访问给定变量的 所有 访问都必须在同一个对象上同步,以使程序在该变量方面得到正确的同步。

以下是关于重排序的上下文中volatile的书中内容:
当一个字段被声明为volatile时,编译器和运行时会注意到这个变量是共享的,并且对它的操作不应该与其他内存操作重排序。
问题2:关于同步化,书中没有提到重排序。有人能解释一下在同步化的情况下可以说什么关于重排序吗?
但是,这已经(并不是全部)说明了同步访问的一些问题。您需要理解,在这种情况下,“内存操作”是指读取或写入共享变量,或者获取或释放任何对象的监视器。进入同步区域涉及获取监视器,因此书中已经准确地说过,volatile访问不会在同步区域的边界上重排序。
更一般地说,对共享变量的读取不会相对于同步区域的开始进行重排序,而写入不会相对于同步区域的结束进行重排序。 问题3. 我猜这意味着两个线程可以一起看到volatile int a,两者都会增加它然后保存它。但是只有一个最后的读取将产生效果,从而使整个“读取-增量-保存”非原子化。我的理解关于volatile的非原子性是正确的吗?
是的。自动递增运算符执行变量的读取和写入。如果该变量是volatile,则volatile语义将分别应用于这些操作,因此如果没有其他保护,同一变量上的其他操作可能会发生。 问题4. 所有锁定等价物是否可比较并具有相同的可见性、排序和原子性属性:同步块、原子变量、锁?
嗯?这个子问题太广泛了。您正在阅读一本关于此的整本书。一般来说,这些机制有一些共同点和一些不同之处。所有机制都对内存操作的可见性和排序产生影响,但它们并不相同。“原子性”是另外两个的功能。

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