Java中的volatile和Synchronized之间的区别


233

我想知道将变量声明为as volatile和始终synchronized(this)在Java块中访问变量之间的区别吗?

根据本文http://www.javamex.com/tutorials/synchronization_volatile.shtml可以说很多,有很多区别,但也有一些相似之处。

我对这段信息特别感兴趣:

...

  • 访问volatile变量永远不会阻塞:我们只做简单的读取或写入操作,因此与同步块不同,我们永远不会保持任何锁;
  • 因为访问易失性变量永远不会持有锁,所以它不适用于我们希望以原子操作方式进行读写更新的情况(除非我们准备“错过更新”);

读-更新-写是什么意思?写不仅是更新,还是仅表示更新是取决于读取的写?

最重要的是,何时声明变量volatile而不是通过synchronized块访问变量更合适?volatile对依赖于输入的变量使用它是一个好主意吗?例如,有一个称为的变量render可以通过渲染循环读取并由keypress事件设置吗?

Answers:


383

重要的是要了解线程安全有两个方面。

  1. 执行控制,以及
  2. 内存可见性

第一个与控制代码何时执行(包括执行指令的顺序)以及它是否可以同时执行有关,第二个与其他线程可以看到存储器中已完成操作的效果有关。由于每个CPU与主内存之间都有多个高速缓存级别,因此在不同CPU或内核上运行的线程在任何给定的时间点都可以以不同的方式查看“内存”,因为允许线程获取并使用主内存的专用副本。

使用synchronized防止任何其他线程获取同一对象的监视器(或锁),从而防止在同一对象上受同步保护的所有代码块并发执行。同步还会创建“先于先发生”的内存屏障,从而导致内存可见性约束,使得直到某个线程释放锁的点之前所做的所有操作都在另一个线程中出现,随后又在获取该锁之前获取了相同的锁。实际上,在当前硬件上,这通常会导致在获取监视器时刷新CPU高速缓存,并在释放监视器时写入主内存,这两者都是(相对)昂贵的。

使用volatile,而另一方面,将强制所有访问(读或写)到易失性可变发生到主存储器,有效地把挥发性变量out 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时线程看到的任何内容B在读取时对线程都是可见的f

- JSR 133(Java的内存模型)的常见问题解答

因此,现在两种形式的内存屏障(在当前的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进行了测试,发现它为true,然后被挂起。然后线程2进行了相同的测试,并且也看到它是正确的并被挂起。然后线程1恢复并将计数器设置为0。然后线程2恢复并再次将计数器设置为0,因为它错过了线程1的更新。即使未发生线程切换,也可能发生这种情况,这仅仅是因为两个不同的CPU内核中存在两个不同的计数器缓存副本,并且每个线程都在一个单独的内核上运行。为此,一个线程可能由于缓存而在一个值上具有计数器,而另一个线程可能在某个完全不同的值上具有计数器。

在此示例中重要的是,变量计数器是从主内存中读取到缓存中,在缓存中进行更新,并且仅在出现内存障碍或需要缓存内存用于其他内容时,才在某个不确定的时间点写回到主内存中。volatile对于该代码的线程安全而言,使计数器不足是因为对最大值的测试和分配是离散操作,包括增量(一组非原子read+increment+write机器指令),例如:

MOV EAX,counter
INC EAX
MOV counter,EAX

易变变量仅在对其执行的所有操作都是“原子的” 时才有用,例如在我的示例中,仅读取或写入对完全形成的对象的引用(实际上,通常仅从单个点写入)。另一个示例是支持写时复制列表的易失性数组引用,条件是仅首先通过对引用进行本地复制才能读取该数组。


5
非常感谢!带有计数器的示例很容易理解。但是,当事情变成现实时,情况会有所不同。
阿不思·邓布利多

“实际上,在当前硬件上,这通常会导致在获取监视器时刷新CPU缓存,并在释放监视器时写入主内存,这两者都很昂贵(相对而言)。” 。当您说CPU缓存时,它是否与每个线程本地的Java堆栈相同?还是线程有自己的本地堆版本?抱歉,如果我在这里很傻。
NishM

1
@nishm是不一样的,但是它将包括所涉及线程的本地缓存。。
劳伦斯·多尔

1
@MarianPaździoch:递增或递减不是读取写入,而是读取写入;它是读入寄存器,然后是寄存器递增,然后是写回存储器。读取和写入单独的原子,但多个这样的操作都没有。
劳伦斯·多尔

2
因此,根据常见问题,并不只发了行动,因为获取锁被解锁后可见,但所有由线程所做的动作可见。甚至在获取锁之前执行的操作。
Lii

97

volatile字段修饰符,而sync则修饰代码块方法。因此,我们可以使用这两个关键字来指定简单访问器的三种变体:

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

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

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

geti1()访问当前存储在i1当前线程中的值。线程可以具有变量的本地副本,并且数据不必与其他线程中保存的数据相同,特别是另一个线程可能已经i1在其线程中进行了更新,但是当前线程中的值可能与此不同更新值。实际上,Java的想法是“主”内存,这是保存变量当前“正确”值的内存。线程可以拥有自己的变量数据副本,并且线程副本可以与“主”内存不同。所以,事实上,可能的是“主”存储器具有的值1i1,对线程1到具有值2i1线程2如果线程1线程2都更新了i1,但这些更新后的值尚未传播到“主”内存或其他线程,则值为3i1

另一方面,geti2()有效地访问i2“主”内存中的值。易失性变量不允许具有与“主”内存中当前保存的值不同的变量的本地副本。实际上,声明为volatile的变量必须在所有线程之间同步其数据,这样,无论何时在任何线程中访问或更新该变量,其他所有线程都会立即看到相同的值。通常,易失性变量比“普通”变量具有更高的访问权限和更新开销。通常,允许线程拥有自己的数据副本是为了提高效率。

波动和同步之间有两个区别。

首先,synchronized获取并释放监视器上的锁,这些锁一次只能强制一个线程执行一个代码块。这是同步的众所周知的方面。但是同步也可以同步内存。实际上,同步将整个线程内存与“主”内存同步。因此执行geti3()以下操作:

  1. 线程获取监视器this对象上的锁。
  2. 线程内存刷新其所有变量,即,其所有变量均有效地从“主”内存中读取。
  3. 执行代码块(在这种情况下,将返回值设置为i3的当前值,该值可能刚从“主”存储器中复位)。
  4. (对变量的任何更改现在通常都将写出到“主”内存中,但是对于geti3(),我们没有任何更改。)
  5. 线程为对象this释放监视器的锁。

因此,在volatile仅使线程存储器和“主”存储器之间的一个变量的值同步的情况下,已同步则使线程存储器和“主”存储器之间的所有变量的值同步,并锁定并释放监视器以进行引导。显然,同步可能比易失性具有更多的开销。

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


35
-1,Volatile不获取锁,它使用底层CPU架构来确保写入后所有线程的可见性。
Michael Barker

值得注意的是,在某些情况下,可以使用锁来保证写入的原子性。例如,在不支持扩展宽度权限的32位平台上写长篇文章。英特尔通过使用SSE2寄存器(128位宽)来处理易失性长型来避免这种情况。但是,将volatile视为锁可能会导致代码中出现讨厌的错误。
Michael Barker

2
锁易失变量共享的重要语义是,它们都提供“发生前边缘”(Java 1.5及更高版本)。进入同步块,取出锁并从volatile中读取均被视为“获取”,释放锁,退出同步块并写入volatile均是“释放”的形式。
Michael Barker

20

synchronized是方法级别/块级别访问限制修饰符。它将确保一个线程拥有关键部分的锁。只有拥有锁的线程才能进入synchronized块。如果其他线程试图访问此关键部分,则必须等到当前所有者释放该锁。

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

一个使用volatile变量的好例子:Datevariable。

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

在此处输入图片说明

请看一下这篇文章,以更好地理解volatile概念。

劳伦斯·多尔(Lawrence Dol)cleary向您解释了read-write-update query

关于您的其他查询

什么时候将变量声明为volatile比通过同步访问变量更合适?

volatile如果您认为所有线程都应该实时获取变量的实际值,则必须使用,就像我为Date变量解释的示例一样。

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

答案将与第一个查询相同。

请参阅本文以获得更好的理解。


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

11

tl; dr

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

1)比赛条件

2)缓存/过时的内存

3)编译器和CPU优​​化

volatile可以解决2和3,但不能解决1。synchronized/ explicit锁可以解决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
}

标记xvolatile不会使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,则根本不打印任何内容),因为b只有在x设置为20 之后才设置为true ,但是编译器/ CPU可能决定重新排序的ThreadA,在这种情况下也threadB可以打印10标记bvolatile确保它不会被重新排序(或在某些情况下被丢弃)。这意味着threadB只能打印20(或什么也不打印)。将方法标记为已同步将获得相同的结果。另外,将变量标记为volatile只能确保它不会被重新排序,但是仍然可以对它之前/之后的所有内容进行重新排序,因此在某些情况下同步可能更适合。

请注意,在Java 5 New Memory Model之前,volatile无法解决此问题。


1
“虽然看起来像一个操作,但实际上是3:从内存中读取x的当前值,将x的值加1,然后将其保存回内存。” -正确,因为来自存储器的值必须经过CPU电路才能进行添加/修改。即使这只是变成一个Assembly INC操作,底层的CPU操作仍然是3倍,并且需要锁定才能保证线程安全。好点子。虽然,这些INC/DEC命令可以在汇编中被原子标记,并且仍然是1原子操作。
僵尸

@Zombies,所以当我为x ++创建同步块时,它是否将其转换为标记的原子INC / DEC或使用常规锁?
David Refaeli

我不知道!我所知道的是INC / DEC不是原子的,因为对于CPU,它必须像其他任何算术运算一样加载值并读取并写入(到内存)。
僵尸
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.