volatile

定义

volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行

volatile变量规则只是一种标准,要求JVM实现保证volatile变量的偏序语义。结合程序顺序规则、传递性,该偏序语义通常表现为两个作用:

  • 保持可见性
  • 禁用重排序(读操作禁止重排序之后的操作,写操作禁止重排序之前的操作)

补充:

  • 程序顺序规则:如果程序中操作A在操作B之前,那么在线程中操作A将在操作B之前执行
  • 传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。
  • 对于可见性、有序性及原子性问题,通常情况下我们可以通过Synchronized关键字来解决这些个问题。
  • 不过如果对Synchronized原理有了解的话,应该知道Synchronized是一个比较重量级的操作,对系统的性能有比较大的影响,所以,如果有其他解决方案,我们通常都避免使用Synchronized来解决问题。
  • 而volatile关键字就是Java中提供的另一种解决可见性和有序性问题的方案。对于原子性,需要强调一点,也是大家容易误解的一点:对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。

保持内存可见性

内存可见性(Memory Visibility):所有线程都能看到共享内存的最新状态。

  • 问题:可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到
  • 解决原理:

修改volatile变量时会强制将修改后的值刷新的主内存中。

修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。

注:volatile并不保证变量更新的原子性

  • 失效数据
    以下是一个简单的可变整数类:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class MutableInteger {
    private int value;

    public int get(){
    return value;
    }

    public void set(int value){
    this.value = value;
    }
    }
    MutableInteger不是线程安全的,因为get和set方法都是在没有同步的情况下进行的。如果线程1调用了set方法,那么正在调用的get的线程2可能会看到更新后的value值,也可能看不到。

解决方法很简单,将value声明为volatile变量:

1
private volatile int value;
  • 神奇的volatile关键字
    神奇的volatile关键字解决了神奇的失效数据问题。

  • Java变量的读写
    Java通过几种原子操作完成工作内存和主内存的交互

  1. lock:作用于主内存,把变量标识为线程独占状态。
  2. unlock:作用于主内存,解除独占状态。
  3. read:作用主内存,把一个变量的值从主内存传输到线程的工作内存。
  4. load:作用于工作内存,把read操作传过来的变量值放入工作内存的变量副本中。
  5. use:作用工作内存,把工作内存当中的一个变量值传给执行引擎。
  6. assign:作用工作内存,把一个从执行引擎接收到的值赋值给工作内存的变量。
  7. store:作用于工作内存的变量,把工作内存的一个变量的值传送到主内存中。
  8. write:作用于主内存的变量,把store操作传来的变量的值放入主内存的变量中。
  • volatile如何保持内存可见性
    volatile的特殊规则就是:

    • read、load、use动作必须连续出现。
    • assign、store、write动作必须连续出现。

所以,使用volatile变量能够保证:

  • 每次读取前必须先从主内存刷新最新的值。

  • 每次写入后必须立即同步回主内存当中。

    也就是说,volatile关键字修饰的变量看到的随时是自己的最新值。线程1中对变量v的最新修改,对线程2是可见的。

防止重排序

后文,如果仅涉及可见性,则指明“可见性”;如果二者均涉及,则以“偏序”代称。重排序一定会带来可见性问题,因此,不会出现单独讨论重排序的场景。

  • 问题:
    在基于偏序关系的Happens-Before内存模型中,指令重排技术大大提高了程序执行效率,但同时也引入了一些问题。

一个指令重排的问题——被部分初始化的对象

  • 懒加载单例模式和竞态条件
    一个懒加载的单例模式实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
class Singleton {
private static Singleton instance;

private Singleton(){}

public static Singleton getInstance() {
if (instance == null) { // 这里存在竞态条件
instance = new Singleton();
}
return instance;
}
}

竞态条件会导致instance引用被多次赋值,使用户得到两个不同的单例。

  • DCL(Double Check Lock)和被部分初始化的对象

为了解决这个问题,可以使用synchronized关键字将getInstance方法改为同步方法;但这样串行化的单例是不能忍的。所以我猿族前辈设计了 DCL(Double Check Lock,双重检查锁)机制,使得大部分请求都不会进入阻塞代码块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Singleton {
private static Singleton instance;

public int f1 = 1; // 触发部分初始化问题
public int f2 = 2;

private Singleton(){}

public static Singleton getInstance() {
if (instance == null) { // 当instance不为null时,可能指向一个“被部分初始化的对象”
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
}
}
}
return instance;
}
}

“看起来”非常完美:既减少了阻塞,又避免了竞态条件。不错,但实际上仍然存在一个问题——当instance不为null时,仍可能指向一个"被部分初始化的对象"

如果Singleton没有字段,自然也不会有部分初始化之说。因此,这里添加了两个字段,已触发部分初始化问题。

问题出在这行简单的赋值语句:

1
instance = new Singleton();

它并不是一个原子操作。事实上,它可以”抽象“为下面几条JVM指令:

1
2
3
memory = allocate();	//1:分配对象的内存空间
initInstance(memory); //2:初始化对象(对f1、f2初始化)
instance = memory; //3:设置instance指向刚分配的内存地址

上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM可以以“优化”为目的对它们进行重排序,经过重排序后如下:

1
2
3
memory = allocate();	//1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址(此时对象还未初始化)
ctorInstance(memory); //2:初始化对象

可以看到指令重排之后,操作 3 排在了操作 2 之前,即引用instance指向内存memory时,这段崭新的内存还没有初始化——即,引用instance指向了一个”被部分初始化的对象”。此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if条件判为false,方法返回instance引用,用户得到了没有完成初始化的“半个”单例。
解决这个该问题,只需要将instance声明为volatile变量:

1
private static volatile Singleton instance;

也就是说,在只有DCL没有volatile的懒加载单例模式中,仍然存在着并发陷阱。我确实不会拿到两个不同的单例了,但我会拿到“半个”单例(未完成初始化)
然而,许多面试书籍中,涉及懒加载的单例模式最多深入到DCL,却只字不提volatile。这“看似聪明”的机制,曾经被我广大初入Java世界的猿胞大加吹捧——我在大四实习面试跟谁学的时候,也得意洋洋的从饱汉、饿汉讲到Double Check,现在看来真是傻逼。对于考查并发的面试官而言,单例模式的实现就是一个很好的切入点,看似考查设计模式,其实期望你从设计模式答到并发和内存模型。

  • volatile如何防止指令重排
    volatile关键字通过“内存屏障”来防止指令被重排序。

不同CPU架构对内存屏障的实现不同,则JVM对volatile的实现也不同。一种最简单的实现方式是:

  • 在每个volatile写操作的后面插入一个Full Barriers
  • 在每个volatile读操作的前面插入一个Full Barriers
    勘误:不同CPU架构对内存屏障的实现不同,但是这里的实现怎么看都不对。特改为一种正确实现,建议阅读本文开头指向的新文章;以下是原文。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。然而,对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。

下面是基于保守策略的JMM内存屏障插入策略:

在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。

  • 如果存在这种重排序问题,那么synchronized代码块内部不是也可能出现相同的问题吗?

即这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
class Singleton {
...
if (instance == null) { // 可能发生不期望的指令重排
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
System.out.println(instance.toString()); //程序顺序规则发挥效力的地方
}
}
}
...
}

难道调用instance.toString()方法时,instance也可能未完成初始化吗?

首先还请放宽心,synchronized代码块内部虽然会重排序,但不会在代码块的范围内导致线程安全问题。

  • Happens-Before内存模型和程序顺序规则
    程序顺序规则:如果程序中操作A在操作B之前,那么线程中操作A将在操作B之前执行

前面说过,只有在Happens-Before内存模型中才会出现这样的指令重排序问题。Happens-Before内存模型维护了几种Happens-Before规则,·程序顺序规则·最基本的规则。
程序顺序规则的目标对象是一段程序代码中的两个操作A、B其保证此处的指令重排不会破坏操作A、B在代码中的先后顺序,但与不同代码甚至不同线程中的顺序无关。

因此,在synchronized代码块内部,instance = new Singleton()仍然会指令重排序,但重排序之后的所有指令,仍然能够保证在instance.toString()之前执行。进一步的,单线程中,if ( instance == null )能保证在synchronized代码块之前执行;但多线程中,线程1中的if ( instance == null )却与线程2中的synchronized代码块之间没有偏序关系,因此线程2中synchronized代码块内部的指令重排对于线程1是不期望的,导致了此处的并发陷阱。

类似的Happens-Before规则还有 volatile变量规则、监视器锁规则等。程序猿可以借助(Piggyback)现有的Happens-Before规则来保持内存可见性和防止指令重排。

  • 注意点
    上面简单讲解了volatile关键字的作用和原理,但对volatile的使用过程中很容易出现的一个问题是:

错把volatile变量当做原子变量。

出现这种误解的原因,主要是volatile关键字使变量的读、写具有了“原子性”。然而这种”原子性”仅限于变量(包括引用)的读和写,无法涵盖变量上的任何操作,即:

  • 基本类型的自增(如count++)等操作不是原子的。
  • 对象的任何非原子成员调用(包括成员变量和成员方法)不是原子的。
    如果希望上述操作也具有原子性,那么只能采取锁、原子变量更多的措施。

严格来说,volatile的读、写也不是原子的,但从可见性的角度上看,与原子性表现一致。

volatile使用建议

相对于synchronized块的代码锁,volatile应该是提供了一个轻量级的针对共享变量的锁,当我们在多个线程间使用共享变量进行通信的时候需要考虑将共享变量用volatile来修饰。

volatile是一种稍弱的同步机制,在访问volatile变量时不会执行加锁操作,也就不会执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
使用建议:在两个或者更多的线程需要访问的成员变量上使用volatile。当要访问的变量已在synchronized代码块中,或者为常量时,没必要使用volatile。
由于使用volatile屏蔽掉了JVM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字。

volatile和synchronized区别

  • 1.volatile不会进行加锁操作:
    volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
    1. volatile变量作用类似于同步变量读写操作:
      从内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。
  • 3.volatile不如synchronized安全:
    在代码中如果过度依赖volatile变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些。
    1. volatile无法同时保证内存可见性和原子性:
      加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。

当且仅当满足以下所有条件时,才应该使用volatile变量:

  1. 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  2. 该变量没有包含在具有其他变量的不变式中。

总结:在需要同步的时候,第一选择应该是synchronized关键字,这是最安全的方式,尝试其他任何方式都是有风险的。尤其在、jdK1.5之后,对synchronized同步机制做了很多优化,如:自适应的自旋锁、锁粗化、锁消除、轻量级锁等,使得它的性能明显有了很大的提升。

内存屏障

内存屏障(Memory Barrier)与内存栅栏(Memory Fence)是同一个概念,不同的叫法。

通过volatile标记,可以解决编译器层面的可见性与重排序问题。而内存屏障则解决了硬件层面的可见性与重排序问题。

  • 标准
    先简单了解两个指令:

Store:将处理器缓存的数据刷新到内存中。
Load:将内存存储的数据拷贝到处理器的缓存中。

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore Barriers Store1;StoreStore;Store2 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore Barriers Load1;LoadStore;Store2 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad Barriers Store1;StoreLoad;Load2 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。

然而,除了mfence,不同的CPU架构对内存屏障的实现方式与实现程度非常不一样。相对来说,Intel CPU的强内存模型比DEC Alpha的弱复杂内存模型(缓存不仅分层了,还分区了)更简单。x86架构是在多线程编程中最常见的,下面讨论x86架构中内存屏障的实现。

参考文章

评论