为什么在同步块中要使用volatile?

53

我在Java中看到一些示例,在代码块上进行同步以更改某些变量,而该变量最初被声明为volatile。我在一个单例类的示例中看到了这个,他们将唯一实例声明为volatile,并且在初始化该实例的代码块上同步...我的问题是为什么我们要声明它为volatile,同时对它进行同步?难道其中一个不足以代替另一个吗?

public class SomeClass {
    volatile static Object uniqueInstance = null;

    public static Object getInstance() {
        if (uniqueInstance == null) {
            synchronized (someClass.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new SomeClass();
                }
            }
        }
        return uniqueInstance;
    }
}

提前感谢。


2
"volatile static uniqueInstance = null;" 是什么? - Marian Paździoch
5个回答

25

在这种情况下,如果第一个检查在同步块中执行(但它不在),仅使用同步就足够了(如果变量不是volatile,则一个线程可能无法看到另一个线程所做的更改)。仅使用volatile是不够的,因为您需要原子地执行多个操作。但要小心!您在这里拥有的是所谓的双重检查锁定 - 一种常见的习惯用法,不幸的是不能可靠工作。我认为自Java 1.6以来已经改变了,但仍然可能存在风险。

编辑:当变量是volatile时,自JDK 5以来,此代码可以正常工作(而不是我之前写的JDK 6),但它在JDK 1.4或更早版本下将无法按预期工作。


3
你说如果第一个检查是在同步块内部,那么同步就足够了……但是我在进入同步块后再次进行了相同的检查,所以下一个线程肯定会看到变量的更新值。 - Dorgham
6
提醒未来的读者:上面链接的文章已经过时,正如编辑所述,该技术在 JDK 5 上可以很好地工作,但JDK 5已发布了近10年。 - Steve Pomeroy
3
第二个空值检查将始终查看已更新的值。那么这是否意味着volatile仅用于在效率上使第一个空值检查失败?(没有volatile会变慢但仍然正确吗?) - Weishi Z
3
当一个同步方法退出时,它会自动与后续为同一对象调用的任何同步方法建立 happen-before 关系。这保证了对对象状态的更改对所有线程都是可见的。我认为这也适用于同步块。如果是这样的话,被阻塞的线程应该能看到“uniqueInstance”值的更新。 - user3386493
1
@WeishiZeng 是的,这只是一种优化。 - Michał Kosmulski
显示剩余6条评论

7

这种方法采用了双重检查锁定,注意if(uniqueInstance == null)不在同步块中。

如果uniqueInstance不是易失性的,在同步块之外执行的线程可能会将其“初始化”,其中部分对象对于除正在执行同步块的线程之外的其他线程是不可见的。在这种情况下,易失性使其成为全有或全无的操作。

如果没有同步块,可能会出现同时有2个线程到达此点的情况。

if(uniqueInstance == null) {
      uniqueInstance = new someClass(); <---- here

您创建了2个SomeClass对象,这违背了其目的。

严格来说,您不需要使用volatile,该方法可以是:

public static someClass getInstance() {
    synchronized(FullDictionary.class) {
         if(uniqueInstance == null) {
             uniqueInstance = new someClass();
          }
         return uniqueInstance;
    }
}

但是这会导致执行getInstance()的每个线程都需要进行同步和序列化。


1
正如我所说,如果没有使用volatile关键字,一个线程可能会看到部分构造的对象。因此,如果uniqueInstance不是volatile的话,它将永远不会进入同步块,但如果另一个线程正在同步块的中间,则可能返回一个部分构造的对象。也就是说,你有1个线程没有进入同步块,而另一个线程正在同步块的中间,这可能导致返回垃圾数据。 - nos
我的问题是没有使用volatile,实际上第一个if(uniqueInstance == null) {可能会被通过,即使它已经被初始化,但是在进入同步块之后,由于已经同步,错判将被修正,因此if(uniqueInstance == null)将为false,不会创建新的实例,对吧?所以即使没有使用volatile,代码仍然可以按预期工作(只创建并返回一个相同的实例)。但确实会导致性能较差?我是正确的吗? - JaskeyLam
1
@nos 我和 @Jaskey 一样有同样的疑问。"只有在构造函数返回时,新对象才会分配给变量。而 uniqueInstance 的默认值是 null。因此,在synchronized块内,如何将部分初始化的对象分配给uniqueInstance - Weishi Z
1
@nos 谢谢回复!是的,这已经接近我的问题了。关于步骤#2和#3的顺序,您知道在哪里可以找到文档吗?uniqueInstance = new someClass(); 在我的理解中,只有当构造函数返回时,对象才会分配给uniqueInstance。(根据我对此SO的理解http://stackoverflow.com/questions/7187842/will-long-running-constructors-create-half-initialized-objects) - Weishi Z
@nos 我已经阅读了https://docs.oracle.com/javase/specs/jls/se8/jls8.pdf上的内存模型,但没有找到直接的答案。您能否看一下这个问题?(http://stackoverflow.com/questions/7187842/will-long-running-constructors-create-half-initialized-objects)所有答案基本上都是说:“只有在构造函数返回时,对象才被分配给变量。”这意味着步骤#3在#2之前。他们都错了吗? - Weishi Z
显示剩余5条评论

6

这篇文章解释了volatile的概念。

这个概念也在经典著作Java并发编程实践中有所提及。

主要思想是并发不仅涉及到共享状态的保护,还包括线程之间该状态的可见性:这就是volatile的作用。(这个更大的契约由Java内存模型定义。)


0
您可以在不使用同步块的情况下进行同步操作。 在其中使用volatile变量并非必需... volatile从主存更新一个变量,而synchronized则更新所有已从主存访问的共享变量。 因此,您可以根据需要使用它。

-2

我在这里说两句

首先,快速解释一下这段代码的直觉

if(uniqueInstance == null) {
        synchronized(someClass.class) {
            if(uniqueInstance == null) {
                uniqueInstance = new someClass();
            }
        }
    }

它两次检查uniqueInstance == null的原因是为了减少调用相对较慢的同步块的开销。所谓的双重检查锁定。

其次,使用synchronized的原因很容易理解,它使得同步块内的两个操作成为原子操作。

最后,volatile修饰符确保所有线程看到相同的副本,因此同步块外的第一次检查将以与同步块“同步”的方式查看uniqueInstance的值。如果没有volatile修饰符,则一个线程可以将一个值分配给uniqueInstance,但另一个线程可能不会在第一次检查时看到它。(尽管第二次检查会看到它)


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