易失性与原子性的区别。

173

我在某个地方读到了以下内容。

Java中的volatile关键字并不意味着具有原子性,这是一个常见的误解, 在声明为volatile后,使用 ++ 操作仍然不是原子操作,在Java中需要使用synchronized方法或块确保独占访问以使其成为原子操作。

那么如果两个线程同时请求一个volatile 原始类型变量会发生什么?

这是否意味着谁获得锁定,谁将首先设置它的值。如果在此期间,其他一些线程正在更改其值时,某个线程读取旧值,那么新线程不会读取其旧值吗?

原子和volatile关键字之间有什么区别?


3
AtomicInteger 和 volatile int 的区别是什么? - Vaibhav
原子操作像++。那里有大约10个原子操作。 - Pacerier
14
volatile关键字仅能进行单个原子读或写操作,而原子类型可以执行多种原子操作(例如getAndAdd) - Dmytro Melnychuk
6个回答

217
volatile关键字的作用大致是,对该变量进行的每个读取或写入操作都是原子可见的,即对所有线程都可见。 值得注意的是,需要进行多次读/写操作的操作(例如i++,它等同于i = i + 1,执行一次读取和一次写入)不是原子性的,因为在读取和写入之间可能会有其他线程写入i。 像AtomicIntegerAtomicReference这样的Atomic类提供了更广泛的原子操作,特别包括AtomicInteger的递增操作。

22
还有一点需要补充,AtomicInteger 中存储的 intprivate volatile int value; - vallentin
13
@Vallentin: 是的,但是 AtomicInteger 使用一些特殊的工具 -- 具体包括 Unsafe.compareAndSwapInt -- 来安全地执行更强大的并发操作。 - Louis Wasserman
2
这只是真相的一半,你忽略了非常重要的执行顺序问题。请看下面我的解释。 - TwoThe
31
@LouisWasserman:“volatile”关键字的作用是……对该变量的操作是原子操作。” 我不同意这个说法的真实性。在Java中,没有8字节的个别变量的读写操作始终是原子操作的。 “volatile”关键字并不会使得可能不是原子的变量访问变成原子访问。它只是强制处理环境立即将对变量所作的更改刷新到主内存中,以便这些更改对所有线程可见。 - scottb
9
@ortang: 只是确认一下。我查了一下JLS (s17.7)。你说得对,一个volatile声明会使长整型和双精度浮点型的读写(否则将不是原子性的)变成原子访问,并提供线程间可见性。 - scottb
显示剩余7条评论

119

易失性和原子性是两个不同的概念。易失性确保在不同线程中,某个预期的(内存)状态是真实的,而原子操作则确保对变量的操作是原子执行的。

以Java中的两个线程为例:

线程A:

value = 1;
done = true;

线程 B:

if (done)
  System.out.println(value);

value = 0done = false 开始,线程规则告诉我们,不确定 Thread B 是否会打印 value。此外,在这一点上,value 也是未定义的! 要解释这个问题,您需要了解一些关于Java内存管理的知识(可能很复杂),简而言之:线程可能创建变量的局部副本,并且JVM可以重新排列代码以优化它,因此不能保证以上代码按照确切的顺序运行。将 done 设置为 true,然后设置 value 为 1,可能是JIT优化的一种可能结果。

volatile 只能确保在访问这样一个变量的时候,新的值将立即对所有其他线程可见,并且执行顺序确保代码处于您期望它的状态。因此,在以上代码的情况下,将 done 定义为 volatile 将确保无论何时 Thread B 检查该变量,它要么为 false,要么为 true,如果为 true,则 value 也已被设置为 1。

作为 volatile 的副作用,这种变量的值被原子地设置为线程范围(以极小的执行速度代价)。但是,这仅在使用长(64位)变量(或类似变量)等 i.E. 32位系统上重要,在大多数其他情况下,设置/读取变量是原子的。但是,原子访问和原子操作之间有一个重要区别。volatile 只确保访问是原子的,而 Atomics 则确保 操作 是原子的。

接下来是一个例子:

i = i + 1;

无论你如何定义i,在执行上述代码行时,另一个线程读取值时可能会获取i或i + 1,因为该操作不是原子的。如果另一个线程将i设置为不同的值,则在最坏的情况下,线程A可能会将i重新设置为之前的值,因为它正处于基于旧值计算i + 1的中间阶段,然后再将i设置回那个旧值+1。说明:

Assume i = 0
Thread A reads i, calculates i+1, which is 1
Thread B sets i to 1000 and returns
Thread A now sets i to the result of the operation, which is i = 1

原子操作,如AtomicInteger,确保这些操作是原子性的。因此,上述问题不可能发生,一旦两个线程都完成,i 将成为 1000 或 1001。


1
在这种特定情况下,从线程的本地视角来看,这是可能的,因此如果对于CPU来说很方便(例如内存批量写入),以那种方式重新排序是完全可能的。 - TwoThe
1
@momomo 这正是 Java 内存模型规范 警告的内容,可以在那里看到第一个例子。 - Alexander Malakhov
1
如果我有一个没有使用volatile修饰的AtomicBoolean,那么在同一时刻另外两个线程可能会得到不同的值吗? - Anderson
没关系,我在这里找到了答案,AtomicBoolean隐式地被标记为volatile。https://dev59.com/O1gQ5IYBdhLWcg3wXy1v#42868221 - Anderson
不是JVM无序执行代码,而是CPU。查看维基百科:https://en.wikipedia.org/wiki/Out-of-order_execution - Basil Bourque

104
在多线程环境中有两个重要的概念:
  1. 原子性
  2. 可见性
volatile 关键字可以消除可见性问题,但不能解决原子性问题。 volatile 可以防止编译器重排序涉及写入和读取 volatile 变量的指令;例如:k++
这里,k++ 不是单个机器指令,而是三个步骤:
  1. 将值复制到寄存器;
  2. 增加值;
  3. 将其放回。
因此,即使你将一个变量声明为 volatile,这也不会使此操作具有原子性;这意味着另一个线程可能会看到一个中间结果,这是其他线程的旧值或不需要的值。
另一方面,AtomicIntegerAtomicReference基于Compare and swap instruction。CAS有三个操作数:要操作的内存位置V、期望的旧值A和新值BCAS原子地将V更新为新值B,但仅当V中的值匹配期望的旧值A时才这样做;否则,它不执行任何操作。在任一情况下,它都返回当前在V中的值。如果底层处理器支持该功能,则AtomicIntegerAtomicReferencecompareAndSet()方法利用此功能;如果不支持,则JVM通过spin lock实现它。

61

正如所指出的那样,volatile只处理可见性。

在并发环境中考虑以下代码片段:

boolean isStopped = false;
    :
    :

    while (!isStopped) {
        // do some kind of work
    }

这里的想法是某个线程可以将isStopped的值从false改为true,以便向随后的循环表明停止循环的时间。

直观上看似乎没有问题。逻辑上,如果另一个线程将isStopped设置为true,则循环必须终止。事实上,即使另一个线程将isStopped设置为true,该循环很可能永远不会终止。

原因并不直观,但请考虑现代处理器具有多个内核,每个内核具有多个寄存器和多个级别的高速缓存 不能被其他处理器访问。换句话说,在一个处理器的本地内存中缓存的值对于在不同处理器上执行的线程来说是不可见的。这就是并发性的一个重要问题之一:可见性。

Java内存模型完全不保证在哪个时刻一个线程所做的变量更改将对其他线程可见。为了确保更新尽快可见,必须进行同步。

volatile关键字是同步的一种弱形式。尽管它对互斥或原子性没有作用,但它确实保证了在一个线程中对变量所做的更改将会尽快地对其他线程可见。由于Java中非8字节的单个读写变量是原子的,因此在不存在其他原子性或互斥要求的情况下,声明变量为volatile提供了一种简便的机制来提供可见性。


5
谢谢,我认为这个回答对我来说更好。非常清晰。我使用一个基本的布尔变量停止了一个线程,但我不知道为什么它在运行的某些时候没有停止(有时会停止)... - Plain_Dude_Sleeping_Alone
很好的答案。当我在网上阅读一些文章时,他们谈到Java可能从“缓存”返回值,这可能不反映新分配的值,让我感到困惑。我不知道在那种情况下缓存是什么意思。现在清楚了。 - KMC
我经常听到需要“刷新CPU缓存”的说法。但是,难道缓存一致性协议不会确保更新的缓存行被原子地广播到所有核心吗?在这种情况下,核心可能持有陈旧值的唯一位置是寄存器。 - raiks
我经常听说需要“清除CPU缓存”。难道缓存一致性协议不是确保更新的缓存行原子地广播到所有核心吗?在这种情况下,唯一可能存在过时值的地方就是寄存器。 - undefined

23
volatile关键字有以下用途:
  • 使非原子64位操作变为原子操作:longdouble。(所有其他的基本访问都已经保证是原子的!)
  • 使变量更新保证被其他线程看到以及可见性效应:在写入volatile变量后,所有在写入该变量之前可见的变量,在读取相同的volatile变量后将对另一个线程可见(先行发生顺序)。
根据java文档java.util.concurrent.atomic.*类是:

一组支持单变量无锁线程安全编程的小型工具类。实际上,此包中的类将volatile值、字段和数组元素的概念扩展到那些还提供形式为:

boolean compareAndSet(expectedValue, updateValue);

的原子条件更新操作的值。

原子类是围绕映射到原子CPU指令的原子compareAndSet(...)函数构建的。与volatile变量一样,原子类引入了发生在之前的顺序(除了weakCompareAndSet(...))。
从java文档中可以看到:

当线程看到由weakCompareAndSet引起的原子变量更新时,它不一定会看到在weakCompareAndSet之前发生的任何其他变量的更新。

对于你的问题:

这是否意味着无论谁锁定它,都将首先设置其值。如果同时,有另一个线程出现并在第一个线程更改其值时读取旧值,那么新线程不会读取其旧值吗?

答案是:当使用volatile关键字时,并不需要锁来保证同步。当一个线程写入volatile变量时,所有其他线程都会在读取该变量时看到最新的值,因此不会读取旧值。但是,如果多个线程尝试同时写入同一个volatile变量,则可能存在竞争条件,因此需要使用原子操作来实现线程安全。

您并没有锁定任何内容,您所描述的是一个典型的竞态条件,如果线程在没有适当同步的情况下访问共享数据,这种情况最终会发生。正如已经提到的,在这种情况下声明变量volatile只能确保其他线程看到变量的更改(值不会被缓存在某个仅由一个线程看到的寄存器或某个缓存中)。

AtomicIntegervolatile int之间有什么区别?

AtomicIntegerint上提供原子操作,并进行适当的同步(例如incrementAndGet()getAndAdd(...)等),而volatile int仅确保将int对其他线程可见。


原子性和易失性是两个互斥的概念。请参阅 https://dev59.com/JmIj5IYBdhLWcg3w8JRZ#19744659 - Pacerier
5
Java规范17.7节:对于非易失性(long或double)变量的单次写入被视为两个独立的32位写入:一个写入前32位,另一个写入后32位。这可能会导致情况出现:一个线程从一个写入中看到64位值的前32位,而从另一个写入中看到其后32位。对易失性(long或double)变量的写入和读取始终是原子性的。无论引用变量实现为32位或64位值,对它们的写入和读取始终是原子性的。 - Philip Guin
1
@Pacerier 我总体上同意你的观点,但在Java中有一件事情,你需要使用volatile关键字来使64位值具有原子性。因此它们不再是互斥的了。 - Ortwin Angermeier

16
如果两个线程同时攻击一个易失性基本变量,会发生什么?
通常,每个线程都可以增加该值。但是有时,两个线程将同时更新该值,而不是总共增加2,两个线程只会增加1并添加1。
这是否意味着谁锁定它,谁将首先设置其值?
没有锁。这就是`synchronized`的作用。
如果在此期间,其他某个线程出现并读取旧值,而第一个线程正在更改其值,那么新线程不会读取旧值吗?
是,
原子和易失关键字之间有什么区别?
AtomicXxxx包装易失性,因此它们基本上相同,区别在于它提供了更高级别的操作,例如CompareAndSwap,用于实现递增。
AtomicXxxx还支持lazySet。这类似于易失性集,但不会使管道等待写入完成。这意味着如果您读取刚刚写入的值,可能会看到旧值,但是您不应该这样做。区别在于设置易失性需要大约5 ns,但lazySet大约需要0.5 ns。

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