内存屏障与volatile

内存屏障

由于现代操作系统都是多处理器操作系统,每个处理器都会有自己的缓存,可能存再不同处理器缓存不一致的问题,而且由于操作系统可能存在重排序,导致读取到错误的数据,因此,操作系统提供了一些内存屏障以解决这种问题:

LoadLoad屏障

  • 对于Load1; LoadLoad; Load2 ,操作系统保证在Load2及后续的读操作读取之前,Load1已经读取。

StoreStore屏障

  • 对于Store1; StoreStore; Store2 ,操作系统保证在Store2及后续的写操作写入之前,Store1已经写入。

LoadStore屏障

  • 对于Load1; LoadStore; Store2,操作系统保证在Store2及后续写入操作执行前,Load1已经读取。

StoreLoad屏障

  • 对于Store1; StoreLoad; Load2 ,操作系统保证在Load2及后续读取操作执行前,Store1已经写入,开销较大,但是同时具备其他三种屏障的效果。

volatile型变量

当我们声明某个变量为volatile时,这个变量便具有了线程可见性。volatile通过在读写操作前后添加内存屏障,完成了数据的及时可见性,java并发编程实战上给出了一个volatile行为理解代码:

    public class SynchronizedInteger {
        private long value;

        public synchronized int get() {
            return value;
        }

        public synchronized void set(long value) {
            this.value = value;
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

我们可以具体分析下这段代码都做了什么事。

在get/set value之前,所有对value做的操作都已生效,这点由synchronized的happends-before语义来保证。

synchronized用于方法时,锁定的当前对象,因此get和set都像是具有了原子语义一样,即使它是long型变量。 
简而言之,volatile具有以下特性 
- 可见性,对一个volatile的读,一定能看到读之前最后的写入。 
- 原子性,对volatile变量的读写具有原子性,但是类似volatile++这种复合操作不具有原子性,

如下示例:

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;                   //1
        flag = true;               //2
    }

    public void reader() {
        if (flag) {                //3
            int i =  a;           //4
            ……
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

假设我们在同一个线程A顺序执行writer()和reader()方法 
对于volatile型变量,由一下规则保证: 
- 在flag写入之前的所有写,均对flag可见,即1 happens before 2 
- 在flag读取之后的所写,均在flag之后执行,即3 happens before 4 
- volatile变量自身的读写具有happens before规则,即2 happens before 3 
- 根据happens before的传递性,1 happens before 4

volatile写-读的内存语义

  • 当写入一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,从主内存中读取所有的共享变量。

以上面示例程序为例,假设线程A执行writer()方法,线程B执行reader()方法,初始时两个线程的本地内存中的flag和a都是初始状态。 
- A线程写入volatile,在A写入之前所有的写操作都已经完成。 
- B线程读取volatile,在B读取的时候,由于volatile型变量特性,B会强制刷新内存,使得所有对A可见的变量对B可见。

看起来像是A向B发了一条消息,使得A写入volatile之前的所有写操作都对B可见

实现规则

JMM对读写的重排序有以下规则,纵轴为操作1,横轴为操作2:

\ 普通读/写 volatile读 volatile写
普通读/写     NO
volatile读 NO NO NO
volatile写   NO NO

从表中可以看出, 
- 在第一个操作为volatile读的时候,其后的所有变量读写操作都不会重排序到前面。 
- 在第二个操作为volatile读的时候,其之前的所有volatile读写操作都已完成, 
- 在第一个操作为volatile写的时候,其后的volatile变量读写操作都不会重排序到前面。 
- 在第二个操作为volatile写的时候,其之前的所有变量的读写操作都已完成。

可以得出以下内存屏障表格

\ 普通读 普通写 volatile读 volatile写
普通读       LoadStore
普通写       StoreStore
volatile读 LoadLoad LoadStore LoadLoad LoadStore
volatile写     StoreLoad StoreStore

根据JMM规则,结合内存屏障的相关分析,可以得出以下保守策略: 
- volatile读之前,会添加LoadLoad内存屏障。 
- volatile读之后,会添加LoadStore内存屏障。 
- volatile写之前,会添加StoreStore内存屏障。 
- volatile写之后,会添加StoreLoad型内存屏障。 
(此处存疑,内存屏障之间存在包含关系?)

再回头看之前的测试代码:

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;                   //1
        //StoreStore,a对flag可见
        flag = true;               //2
        //StoreLoad,flag和a对后续可见
    }

    public void reader() {
        //LoadLoad,flag和a可见
        if (flag) {                //3
        //LoadStore,flag和a可见
            int i =  a;           //4
            ……
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

再来看一个经典的double check的问题:

public static Singleton instance;
public static Singleton getInstance()
{
  if (instance == null)              //1
  {                                  //2
    synchronized(Singleton.class) {  //3
      if (instance == null)          //4
        instance = new Singleton();  //5
    }
  }
  return instance;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

对于以上经典写法,相信大家都不陌生,但是对java这种,缺存在问题。

对于第5步,会完成两件事:

  1. 在内存中创建对象
  2. 分配内存,将指针指向这块区域

很遗憾,这两个动作的顺序并不能保证,那么当先完成2,后完成1的时候会发生什么事呢?假设A、B两个线程按照下面顺序执行:

  1. A线程获取锁,并完成初始化instance的动作2,完成1之前发生线程切换。
  2. B线程判断instance != null,返回instance,并做动作。
  3. A线程获取时间片,完成初始化动作1.

很明显,在动作2的时候,会拿到一个未初始化完成的对象,很可能会导致程序异常。

如果使用volatile呢? 
在jdk1.5及以后,对volatile修饰的引用的初始化,能够保证1和2动作的顺序,从而避免了这个问题。

猜你喜欢

转载自blog.csdn.net/zhangyunsheng11/article/details/81052250