Java中volatile和synchronized的区别

305
我想知道在Java中将变量声明为volatile和始终在synchronized(this)块中访问变量之间的区别。根据这篇文章http://www.javamex.com/tutorials/synchronization_volatile.shtml有很多不同之处,也有一些相似之处。我特别关注以下信息:
  • volatile变量的访问永远不会阻塞:我们只是进行简单的读取或写入,因此与同步块不同,我们永远不会保持任何锁定;
  • 由于访问volatile变量永远不会持有锁定,因此它不适用于希望作为原子操作执行读取-更新-写入的情况(除非我们准备“错过更新”);
他们所说的读取-更新-写入是什么意思?写入是否也是更新,还是他们仅仅是指更新是指基于读取的写入?
最重要的是,什么时候更适合将变量声明为volatile而不是通过synchronized块访问它们?将变量设置为volatile对于依赖于输入的变量是否是一个好主意?例如,有一个名为render的变量通过渲染循环进行读取并通过按键事件设置。
4个回答

465
重要的是要理解线程安全有两个方面。
1.执行控制 2.内存可见性
第一个与控制代码何时执行(包括指令执行顺序)以及是否可以并发执行有关,而第二个与其他线程何时能够看到已经完成的内存操作有关。由于每个CPU在它和主内存之间有几个级别的缓存,因此运行在不同CPU或核心上的线程在任何给定时间可能会以不同的方式看待“内存”,因为线程被允许获取和处理主内存的私有副本。
使用synchronized防止其他线程获取相同对象的监视器(或锁),从而防止受同步保护的所有代码块在同一对象上并发执行。同步还创建了“happens-before”内存屏障,导致内存可见性约束,使得在某个线程释放锁之前完成的所有操作似乎已经在另一个随后获取相同锁的线程中发生。在实际应用中,对于当前硬件来说,这通常会导致在获取监视器时刷新CPU缓存,并在释放监视器时写入主内存,这两者都是(相对)昂贵的。

使用volatile可以强制所有对volatile变量的访问(读或写)发生在主内存中,有效地使volatile变量不进入CPU缓存。这对于某些操作可能是有用的,其中仅需要正确的变量可见性而访问顺序并不重要。使用volatile还会更改对longdouble的处理方式,要求对它们的访问是原子性的;在一些(旧的)硬件上,这可能需要锁定,但在现代64位硬件上不需要。在Java 5+的新(JSR-133)内存模型下,volatile的语义已被加强,几乎与同步相同,具有内存可见性和指令排序方面的强度(请参见http://www.cs.umd.edu/users/pugh/java/memoryModel/jsr-133-faq.html#volatile)。就可见性而言,对volatile字段的每个访问都像半个同步。

在新的内存模型下,易失变量仍然不能相互重排序。不同之处在于,现在不再那么容易重新排序普通字段访问。写入易失字段具有与监视器释放相同的内存效果,从易失字段读取具有与监视器获取相同的内存效果。实际上,由于新的内存模型对易失字段访问与其他字段访问(易失或非易失)的重排序施加了更严格的限制,因此当线程A写入易失字段f时对线程A可见的任何内容,在线程B读取f时也将对线程B可见。-- JSR 133 (Java Memory Model) FAQ 因此,现在在当前JMM下,两种形式的内存屏障都会导致指令重排序屏障,从而防止编译器或运行时跨越屏障重新排序指令。在旧的JMM中,易失变量无法防止重排序。这可能很重要,因为除了内存屏障之外,唯一强制执行的限制是对于任何特定的线程,代码的净效果与以源代码中出现的顺序精确执行指令的效果相同。
易失变量的一个用途是为共享但不可变对象重新创建对象,并在其执行周期的特定点上许多其他线程引用该对象。我们需要其他线程在发布后开始使用重新创建的对象,但不需要完全同步的额外开销及其伴随的争用和缓存刷新。
// Declaration
public class SharedLocation {
    static public volatile SomeObject someObject=new SomeObject(); // default object
    }

// Publishing code
SharedLocation.someObject=new SomeObject(...); // new object is published

// Using 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.
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进行计数器==1000的测试并发现其为真,然后被暂停。然后线程2进行相同的测试,也看到它为真,并被暂停。然后线程1恢复并将计数器设置为0。然后线程2恢复并再次将计数器设置为0,因为它错过了线程1的更新。即使线程切换没有如我所描述的那样发生,这也可能发生,但只是因为两个不同的CPU核心中存在两个不同的缓存副本,并且线程分别在单独的核心上运行。说起来,一个线程可以具有一个值的计数器,而另一个线程可以具有某些完全不同的值,仅因为缓存。

在这个例子中重要的是,变量counter从主内存读取到缓存中,在缓存中更新,只有在稍后出现内存屏障或需要缓存内存用于其他用途时才写回主内存。将计数器设置为volatile对于此代码的线程安全性是不足够的,因为最大值测试和赋值是离散操作,包括增量,这是一组非原子的读取+增量+写入机器指令,类似于:

MOV EAX,counter
INC EAX
MOV counter,EAX

易失变量只有在所有对它们执行的操作都是“原子”的时候才有用,比如我的例子中,对完全形成的对象的引用仅被读取或写入(实际上通常只从一个点写入)。另一个例子是易失数组引用支持写时复制列表,前提是该数组仅通过首先取本地副本引用来读取。

7
非常感谢!使用计数器的例子很容易理解。不过,当情况变得真实时,会有一些不同。 - Albus Dumbledore
1
@nishm 这不完全相同,但它将包括涉及的线程的本地缓存。 - Lawrence Dol
3
“增量”或“减量”不是一个读取写入操作,它是一个读取写入操作;它首先是将数据读入寄存器,然后对寄存器进行增量操作,最后将结果写回内存。读取和写入操作各自是原子性的,但多个这样的操作不是原子性的。 - Lawrence Dol
2
所以,根据常见问题解答,解锁后不仅会使自锁获取后的操作可见,而且会使该线程执行的所有操作都可见。即使是在锁获取之前执行的操作也是如此。 - Lii
1
@TiStrga:哎呀,是的。我最初是这样暗示的,认为这很明显——现在我已经添加了一个明确的声明。 - Lawrence Dol
显示剩余8条评论

114

volatile is a field modifier, while synchronized modifies code blocks and methods. So we can specify three variations of a simple accessor using those two keywords:

    int i1;
    int geti1() {return i1;}

    volatile int i2;
    int geti2() {return i2;}

    int i3;
    synchronized int geti3() {return i3;}

geti1() accesses the value currently stored in i1 in the current thread. Threads can have local copies of variables, and the data does not have to be the same as the data held in other threads.In particular, another thread may have updated i1 in it's thread, but the value in the current thread could be different from that updated value. In fact Java has the idea of a "main" memory, and this is the memory that holds the current "correct" value for variables. Threads can have their own copy of data for variables, and the thread copy can be different from the "main" memory. So in fact, it is possible for the "main" memory to have a value of 1 for i1, for thread1 to have a value of 2 for i1 and for thread2 to have a value of 3 for i1 if thread1 and thread2 have both updated i1 but those updated value has not yet been propagated to "main" memory or other threads.

On the other hand, geti2() effectively accesses the value of i2 from "main" memory. A volatile variable is not allowed to have a local copy of a variable that is different from the value currently held in "main" memory. Effectively, a variable declared volatile must have it's data synchronized across all threads, so that whenever you access or update the variable in any thread, all other threads immediately see the same value. Generally volatile variables have a higher access and update overhead than "plain" variables. Generally threads are allowed to have their own copy of data is for better efficiency.

There are two differences between volitile and synchronized.

Firstly synchronized obtains and releases locks on monitors which can force only one thread at a time to execute a code block. That's the fairly well known aspect to synchronized. But synchronized also synchronizes memory. In fact synchronized synchronizes the whole of thread memory with "main" memory. So executing geti3() does the following:

  1. The thread acquires the lock on the monitor for object this .
  2. The thread memory flushes all its variables, i.e. it has all of its variables effectively read from "main" memory .
  3. The code block is executed (in this case setting the return value to the current value of i3, which may have just been reset from "main" memory).
  4. (Any changes to variables would normally now be written out to "main" memory, but for geti3() we have no changes.)
  5. The thread releases the lock on the monitor for object this.

So where volatile only synchronizes the value of one variable between thread memory and "main" memory, synchronized synchronizes the value of all variables between thread memory and "main" memory, and locks and releases a monitor to boot. Clearly synchronized is likely to have more overhead than volatile.

http://javaexp.blogspot.com/2007/12/difference-between-volatile-and.html


46
Volatile不会获取锁,而是使用底层CPU架构在写入后确保所有线程之间的可见性。 - Michael Barker
值得注意的是,有些情况下可能需要使用锁来保证写操作的原子性。例如,在不支持扩展宽度写入的32位平台上写入长整型。Intel通过使用SSE2寄存器(128位宽)来处理易失性长整型来避免这种情况。然而,将易失性视为锁可能会导致代码中出现严重的错误。 - Michael Barker
3
锁和易失变量都提供 Happens-Before 边缘(Java 1.5及以上版本)。进入同步块、取锁以及从易失变量中读取,都被视为“获取”操作,释放锁、退出同步块以及写入易失变量都是“释放”操作。 - Michael Barker

62

多线程存在三个主要问题:

  1. 竞态条件

  2. 缓存/过期内存

  3. 编译器和CPU优化

volatile可以解决2和3,但无法解决1。synchronized/显式锁可以解决1、2和3。

详细说明:

  1. 考虑以下线程不安全的代码:

x++;

虽然它看起来像是一条操作,但实际上有3个步骤:从内存中读取x的当前值,将其加1,再保存回内存。如果多个线程同时尝试执行这个操作,则操作的结果未定义。如果x最初为1,则在两个线程操作该代码后,它可能是2,也可能是3,具体取决于哪个线程在控制传递到另一个线程之前完成了操作的哪个部分。这是一种竞态条件的形式。

在代码块上使用synchronized使其成为原子操作-这意味着它们好像三个操作同时发生,而且没有其他线程可以在中间干扰。因此,如果x为1,并且2个线程尝试执行x++,我们知道最终它将等于3。因此,它解决了竞态条件问题。

synchronized (this) {
   x++; // no problem now
}

x标记为volatile无法使x++;操作具有原子性,因此它不能解决这个问题。

  1. 另外,线程有自己的上下文——即它们可以缓存来自主内存的值。这意味着几个线程可以拥有变量的副本,但它们在操作其工作副本时不与其他线程共享变量的新状态。

考虑在一个线程中,x = 10;。稍后,在另一个线程中,x = 20;。由于另一个线程已将新值保存到其工作内存中,但尚未将其复制到主内存中,或者已将其复制到主内存中,但第一个线程尚未更新其工作副本,因此第一个线程中对if (x == 20)的检查将返回false

将变量标记为volatile基本上告诉所有线程只在主内存上执行读写操作。 synchronized指示每个线程在进入块时从主内存更新其值,并在退出块时将结果刷新回主内存。

请注意,与数据竞争不同,过时的内存不太容易(再)生成,因为对主内存的刷新总是会发生。

  1. 编译器和CPU可以(在线程之间没有任何形式的同步的情况下)将所有代码视为单线程。这意味着它可以查看一些非常重要的多线程方面的代码,并将其视为单线程,其中它并不那么重要。因此,如果编译器不知道该代码被设计为在多个线程上工作,则它可以重新排序甚至完全删除某些代码部分以进行优化。

考虑以下代码:

boolean b = false;
int x = 10;

void threadA() {
    x = 20;
    b = true;
}

void threadB() {
    if (b) {
        System.out.println(x);
    }
}
你会认为线程B只能打印20(或者在将b设置为真之前执行线程B的if检查,可能不打印任何内容),因为b只有在x设置为20后才被设置为真,但编译器/CPU可以决定重新排序线程A,在这种情况下线程B也可以打印10。将b标记为volatile可以确保它不会被重新排序(或在某些情况下被丢弃)。这意味着线程B只能打印20(或根本不打印)。将方法标记为同步也可以实现相同的结果。另外,将变量标记为volatile只能确保它不会被重新排序,但是它之前/之后的所有内容仍然可以被重新排序,因此在某些情况下同步可能更合适。
请注意,在Java 5新内存模型之前,volatile无法解决这个问题。

1
虽然看起来像是一个操作,但实际上它包含了三个步骤:从内存中读取当前的x值,将其加1,再保存回内存。这是因为内存中的值必须通过CPU电路才能被添加/修改。即使这只转化为单个汇编INC操作,底层的CPU操作仍然是三倍的,并需要锁定以确保线程安全。这是一个很好的观点。尽管INC / DEC命令可以在汇编中被原子标记并仍然是1个原子操作。 - Zombies
@Zombies,当我为x++创建同步块时,它会将其转换为标记的原子INC/DEC,还是使用常规锁定? - Maverick Meerkat
我不知道!但我知道INC/DEC不是原子操作,因为对于CPU来说,它必须加载值并读取它,然后再写入(到内存),就像任何其他算术操作一样。 - Zombies
@MaverickMeerkat - 你的第三个例子解答了我的问题。非常清晰明了的解释。谢谢。 - RamPrakash

26

同步

synchronized 是用于保护方法或代码块的关键字。通过将方法设置为同步,您可以实现两个目标。

  1. 在同一对象上执行的两个 synchronized 方法永远不会同时运行
  2. 对象状态的更改对其他线程可见

volatile 是变量访问修饰符,它强制所有线程从主内存获取变量的最新值。所有线程都可以同时访问 volatile 变量值,而无需任何锁定。

使用 volatile 变量的一个很好的例子是 Date 变量。

假设您已经将 Date 变量设置为 volatile。您不需要不同的线程为同一变量显示不同的时间。访问此变量的所有线程始终从主内存获取最新数据,以便所有线程显示真实(实际)的日期值。

Lawrence Dol 清楚地解释了您的 读取-写入-更新查询

关于您的其他查询

何时更适合声明变量为 volatile 而不是通过 synchronized 访问它们?

如果您认为所有线程都应该实时获取变量的实际值,就必须使用volatile,就像上面提到的数据示例一样。

对于依赖于输入的变量,使用volatile是个好主意吗?

答案与第一个查询相同。


因此,读取可以同时进行,所有线程都将读取最新值,因为CPU不会将主内存缓存到CPU线程缓存中,但是写入呢?写入必须不是并发正确的吗?第二个问题:如果一个块被同步,但变量不是易失性的,那么在同步块中变量的值仍然可以被另一个代码块中的另一个线程更改,对吗? - the_prole

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