Java 中 volatile 和 synchronized 的区别
Posted
技术标签:
【中文标题】Java 中 volatile 和 synchronized 的区别【英文标题】:Difference between volatile and synchronized in Java 【发布时间】:2011-03-31 23:38:14 【问题描述】:我想知道在 Java 中将变量声明为 volatile
和始终在 synchronized(this)
块中访问变量之间的区别?
根据这篇文章http://www.javamex.com/tutorials/synchronization_volatile.shtml有很多话要说,有很多不同,也有一些相似之处。
我对这条信息特别感兴趣:
...
对 volatile 变量的访问永远不会被阻塞:我们只进行简单的读取或写入,因此与同步块不同,我们永远不会持有任何锁; 因为访问 volatile 变量永远不会持有锁,所以它不适合我们希望将 read-update-write 作为原子操作的情况(除非我们准备“错过更新");
read-update-write 是什么意思?写入不也是更新,还是仅仅意味着 更新 是依赖于读取的写入?
最重要的是,什么时候更适合声明变量volatile
而不是通过synchronized
块访问它们?将volatile
用于依赖于输入的变量是个好主意吗?例如,有一个名为render
的变量通过渲染循环读取并由按键事件设置?
【问题讨论】:
【参考方案1】:了解线程安全有两个方面很重要。
-
执行控制,以及
内存可见性
第一个与控制代码何时执行(包括执行指令的顺序)以及它是否可以并发执行有关,第二个与何时其他人可以看到已完成的内存中的效果有关线程。因为每个 CPU 在它和主内存之间都有多个级别的缓存,所以在不同 CPU 或内核上运行的线程在任何给定时间都可以看到不同的“内存”,因为线程被允许获取和处理主内存的私有副本。
使用synchronized
可以防止任何其他线程获得监视器(或锁)同一个对象,从而防止所有受同步保护的代码块在同一个对象上同时执行。同步也创建了一个“happens-before”内存屏障,导致内存可见性约束,使得在某个线程释放锁之前所做的任何事情出现给随后获取的另一个线程相同的锁在获得锁之前已经发生。实际上,在当前的硬件上,这通常会导致在获取监视器时刷新 CPU 缓存并在释放监视器时写入主内存,这两者都是(相对)昂贵的。
另一方面,使用volatile
会强制对 volatile 变量的所有访问(读取或写入)都发生在主内存中,从而有效地将 volatile 变量排除在 CPU 缓存之外。这对于一些只要求变量的可见性正确且访问顺序不重要的操作很有用。使用 volatile
还会更改对 long
和 double
的处理,以要求对它们的访问是原子的;在某些(较旧的)硬件上,这可能需要锁定,但在现代 64 位硬件上则不需要。在 Java 5+ 的新 (JSR-133) 内存模型下,volatile 的语义已得到加强,在内存可见性和指令顺序方面几乎与同步一样强大(参见 http://www.cs.umd.edu/users/pugh/java/memoryModel/jsr-133-faq.html#volatile)。出于可见性的目的,对 volatile 字段的每次访问都相当于半个同步。
在新的内存模型下,volatile 变量之间不能相互重新排序仍然是事实。不同之处在于,现在重新排序围绕它们的正常字段访问不再那么容易了。写入易失性字段与释放监视器具有相同的记忆效应,从易失性字段读取具有与监视器获取相同的记忆效应。实际上,由于新的内存模型对 volatile 字段访问与其他字段访问(无论是否为 volatile)的重新排序设置了更严格的限制,因此线程
A
在写入 volatile 字段f
时可见的任何内容都对线程 @987654330 可见@当它读取f
时。-- JSR 133 (Java Memory Model) FAQ
因此,现在两种形式的内存屏障(在当前 JMM 下)都会导致指令重新排序屏障,从而阻止编译器或运行时跨屏障重新排序指令。在旧的 JMM 中,volatile 并没有阻止重新排序。这可能很重要,因为除了内存屏障之外,唯一的限制是,对于任何特定线程,代码的净效果与指令在精确的它们在源中出现的顺序。
volatile 的一个用途是动态重新创建共享但不可变的对象,许多其他线程在其执行周期的特定点获取对该对象的引用。需要其他线程在重新创建的对象发布后开始使用它,但不需要完全同步的额外开销以及随之而来的争用和缓存刷新。
// Declaration
public class SharedLocation
static public SomeObject someObject=new SomeObject(); // default object
// Publishing code
// Note: do not simply use SharedLocation.someObject.xxx(), since although
// someObject will be internally consistent for xxx(), a subsequent
// call to yyy() might be inconsistent with xxx() if the object was
// replaced in between calls.
SharedLocation.someObject=new SomeObject(...); // new object is published
// Using code
private String getError()
SomeObject myCopy=SharedLocation.someObject; // gets current copy
...
int cod=myCopy.getErrorCode();
String txt=myCopy.getErrorText();
return (cod+" - "+txt);
// And so on, with myCopy always in a consistent state within and across calls
// Eventually we will return to the code that gets the current SomeObject.
特别是谈到您的“读-更新-写”问题。考虑以下不安全的代码:
public void updateCounter()
if(counter==1000) counter=0;
else counter++;
现在,在 updateCounter() 方法不同步的情况下,两个线程可能同时进入它。在可能发生的许多排列中,一个是线程 1 对 counter==1000 进行测试并发现它为真,然后被挂起。然后线程 2 进行相同的测试,并且也认为它是真的并被挂起。然后线程 1 恢复并将计数器设置为 0。然后线程 2 恢复并再次将计数器设置为 0,因为它错过了线程 1 的更新。即使没有如我所描述的那样发生线程切换,也可能发生这种情况,而仅仅是因为两个不同的计数器缓存副本存在于两个不同的 CPU 内核中,并且每个线程都在单独的内核上运行。就此而言,一个线程的计数器值可能是一个值,而另一个线程的计数器值可能是完全不同的值,这仅仅是因为缓存。
在这个例子中重要的是变量 counter 从主存读取到缓存中,在缓存中更新,并且仅在稍后发生内存障碍或何时发生的某个不确定点写回主存其他东西需要缓存内存。使计数器volatile
不足以保证此代码的线程安全,因为对最大值的测试和分配是离散操作,包括作为一组非原子read+increment+write
机器指令的增量,例如: /p>
MOV EAX,counter
INC EAX
MOV counter,EAX
仅当对它们执行的所有操作都是“原子”操作时,可变变量才有用,例如我的示例,其中对完全形成的对象的引用仅被读取或写入(而且,实际上,它通常只从一个点写入)。另一个示例是支持写入时复制列表的易失性数组引用,前提是该数组只能通过首先获取引用的本地副本来读取。
【讨论】:
非常感谢!带有计数器的示例很容易理解。然而,当事情变得真实时,情况就有些不同了。 “实际上,在当前硬件上,这通常会导致在获取监视器时刷新 CPU 缓存并在释放监视器时写入主内存,这两者都是昂贵的(相对而言)。 " .当您说 CPU 缓存时,它是否与每个线程本地的 Java Stacks 相同?还是线程有自己的本地版本的堆?如果我在这里很傻,请道歉。 @nishm 不一样,但它会包括所涉及线程的本地缓存。 . @MarianPaździoch:增量或减量不是读取或写入,而是读取和写入;它是读入寄存器,然后是寄存器增量,然后是写回内存。读取和写入是单独原子的,但多个这样的操作不是。 因此,根据常见问题解答,不只有锁定获取之后的操作在解锁后可见,而是所有 该线程执行的操作可见。甚至在获取锁之前执行的操作。【参考方案2】:volatile是字段修饰符,而synchronized修饰代码块和方法。因此,我们可以使用这两个关键字指定简单访问器的三种变体:
int i1; int geti1() return i1; volatile int i2; int geti2() return i2; int i3; synchronized int geti3() return i3;
geti1()
访问当前线程中i1
中当前存储的值。 线程可以有变量的本地副本,并且数据不必与其他线程中保存的数据相同。特别是,另一个线程可能在其线程中更新了i1
,但当前线程中的值可能是与更新后的值不同。事实上,Java 有“主”内存的概念,这是保存变量当前“正确”值的内存。线程可以拥有自己的变量数据副本,并且线程副本可以不同于“主”内存。所以事实上,对于i1
,“主”内存的值可能为1,对于i1
,线程1 的值可能为2如果 thread1 和 thread2 都更新了 i1 但这些更新的值尚未传播到“主”内存或其他线程。另一方面,
geti2()
有效地从“主”内存访问i2
的值。不允许 volatile 变量具有与“主”内存中当前保存的值不同的变量的本地副本。实际上,声明为 volatile 的变量必须在所有线程中同步其数据,这样每当您在任何线程中访问或更新变量时,所有其他线程都会立即看到相同的值。通常 volatile 变量比“普通”变量具有更高的访问和更新开销。通常允许线程拥有自己的数据副本是为了提高效率。volitile 和 synchronized 有两个区别。
首先同步获取并释放监视器上的锁,该锁一次只能强制一个线程执行代码块。这是同步的众所周知的方面。但是 synchronized 也会同步内存。实际上 synchronized 将整个线程内存与“主”内存同步。所以执行
geti3()
会执行以下操作:线程获取对象 this 的监视器上的锁。 线程内存刷新其所有变量,即它的所有变量都有效地从“主”内存中读取。 代码块被执行(在这种情况下,将返回值设置为 i3 的当前值,它可能刚刚从“主”内存中重置)。 (对变量的任何更改现在通常都会写入“主”内存,但对于 geti3(),我们没有任何更改。) 线程释放对象 this 的监视器上的锁。
所以其中 volatile 只在线程内存和“主”内存之间同步一个变量的值,synchronized 在线程内存和“主”内存之间同步所有变量的值,并锁定和释放一个监视器以启动。显然同步可能比 volatile 有更多开销。
http://javaexp.blogspot.com/2007/12/difference-between-volatile-and.html
【讨论】:
-1,Volatile 不获取锁,它使用底层 CPU 架构来确保写入后所有线程的可见性。 值得注意的是,在某些情况下,可能会使用锁来保证写入的原子性。例如。在不支持扩展宽度权限的 32 位平台上编写 long。英特尔通过使用 SSE2 寄存器(128 位宽)来处理 volatile long 来避免这种情况。但是,将 volatile 视为锁可能会导致代码中出现严重的错误。 锁和 volatile 变量共享的重要语义是它们都提供 Happens-Before 边(Java 1.5 和更高版本)。进入同步块、取出锁和读取 volatile 都被认为是“获取”,而释放锁、退出同步块和写入 volatile 都是“释放”的形式。【参考方案3】:tl;dr:
多线程有 3 个主要问题:
1) 比赛条件
2) 缓存/陈旧内存
3) 编译器和 CPU 优化
volatile
可以解决 2 & 3,但不能解决 1。synchronized
/显式锁可以解决 1、2 和 3。
阐述:
1) 考虑这个线程不安全的代码:
x++;
虽然看起来像是一个操作,但实际上是 3:从内存中读取 x 的当前值,将其加 1,然后将其保存回内存。如果少数线程同时尝试执行此操作,则操作的结果是未定义的。如果x
原本是1,那么在2个线程操作代码后可能是2,也可能是3,这取决于哪个线程完成了控制转移到另一个线程之前的哪部分操作。这是一种竞态条件。
在代码块上使用synchronized
使其原子 - 这意味着它使 3 个操作好像同时发生,并且没有办法让另一个线程进入中间并干扰.因此,如果x
为 1,并且 2 个线程尝试执行 x++
我们知道最终它将等于 3。因此它解决了竞争条件问题。
synchronized (this)
x++; // no problem now
将x
标记为volatile
不会使x++;
成为原子,因此它不能解决此问题。
2) 此外,线程有自己的上下文——即它们可以缓存主内存中的值。这意味着少数线程可以拥有一个变量的副本,但它们在其工作副本上进行操作,而不在其他线程之间共享该变量的新状态。
考虑在一个线程上,x = 10;
。稍后,在另一个线程中,x = 20;
。 x
值的变化可能不会出现在第一个线程中,因为另一个线程已将新值保存到其工作内存中,但尚未将其复制到主内存中。或者它确实将其复制到主内存,但第一个线程尚未更新其工作副本。所以如果现在第一个线程检查if (x == 20)
,答案将是false
。
将变量标记为volatile
基本上告诉所有线程只在主内存上进行读写操作。 synchronized
告诉每个线程在进入块时从主内存更新它们的值,并在退出块时将结果刷新回主内存。
请注意,与数据竞争不同,陈旧的内存不容易(重新)生成,因为无论如何都会刷新到主内存。
3) 编译器和 CPU 可以(在线程之间没有任何形式的同步)将所有代码视为单线程。这意味着它可以查看一些代码,这在多线程方面非常有意义,并将其视为单线程,它没有那么有意义。因此,它可以查看代码并决定,为了优化,重新排序它,甚至完全删除它的一部分,如果它不知道这段代码是为多线程工作而设计的。
考虑以下代码:
boolean b = false;
int x = 10;
void threadA()
x = 20;
b = true;
void threadB()
if (b)
System.out.println(x);
你会认为 threadB 只能打印 20(或者如果在将 b
设置为 true 之前执行了 threadB if-check,则根本不打印任何内容),因为仅在设置 x
之后才将 b
设置为 true到 20,但编译器/CPU 可能决定重新排序线程 A,在这种情况下,线程 B 也可以打印 10。将 b
标记为 volatile
确保它不会被重新排序(或在某些情况下被丢弃)。这意味着 threadB 只能打印 20 (或根本不打印)。将方法标记为同步将获得相同的结果。还将变量标记为volatile
仅确保它不会被重新排序,但它之前/之后的所有内容仍然可以重新排序,因此在某些情况下同步可能更适合。
请注意,在 Java 5 新内存模型之前,volatile 并没有解决这个问题。
【讨论】:
"虽然看起来像是一个操作,但实际上是 3:从内存中读取 x 的当前值,将其加 1,然后将其保存回内存。" - 对,因为内存中的值必须经过 CPU 电路才能添加/修改。尽管这只是变成了单个 AssemblyINC
操作,但底层 CPU 操作仍然是 3 倍,并且需要锁定以确保线程安全。好点子。虽然,INC/DEC
命令可以在程序集中进行原子标记,并且仍然是 1 个原子操作。
@Zombies 所以当我为 x++ 创建同步块时,它是把它变成标记的原子 INC/DEC 还是使用常规锁?
我不知道!我所知道的是 INC/DEC 不是原子的,因为对于 CPU,它必须加载值并读取它并写入它(到内存),就像任何其他算术运算一样。
@MaverickMeerkat - 你的 #3 示例澄清了我的问题。非凡的解释。谢谢。【参考方案4】:
synchronized
是方法级/块级访问限制修饰符。它将确保一个线程拥有临界区的锁。只有拥有锁的线程才能进入synchronized
块。如果其他线程试图访问这个临界区,它们必须等到当前所有者释放锁。
volatile
是变量访问修饰符,它强制所有线程从主内存中获取变量的最新值。访问volatile
变量不需要锁定。所有线程都可以同时访问 volatile 变量值。
使用 volatile 变量的一个很好的例子:Date
变量。
假设您已将日期变量设为volatile
。访问此变量的所有线程始终从主内存获取最新数据,以便所有线程显示真实(实际)日期值。您不需要不同的线程为同一变量显示不同的时间。所有线程都应显示正确的日期值。
查看此article 以更好地理解volatile
概念。
Lawrence Dol cleary 解释了您的 read-write-update query
。
关于您的其他查询
什么时候声明变量 volatile 比通过 synchronized 访问它们更合适?
如果您认为所有线程都应该像我为 Date 变量解释的示例那样实时获取变量的实际值,则必须使用 volatile
。
对依赖于输入的变量使用 volatile 是个好主意吗?
答案将与第一个查询中的相同。
请参阅此article 以获得更好的理解。
【讨论】:
所以读可以同时发生,所有线程都会读取最新的值,因为CPU不会将主内存缓存到CPU线程缓存中,但是写呢?写一定不能并发正确吗?第二个问题:如果一个块是同步的,但变量不是易失性的,那么一个同步块中的变量的值仍然可以被另一个代码块中的另一个线程更改对吗?以上是关于Java 中 volatile 和 synchronized 的区别的主要内容,如果未能解决你的问题,请参考以下文章
Java 中 volatile 和 synchronized 的区别