轻量级的同步机制——volatile语义详解(可见性保证+禁止指令重排)

1.关于volatile

volatile是java语言中的关键字,用来修饰会被多线程访问的共享变量,是JVM提供的轻量级的同步机制,相比同步代码块或者重入锁有更好的性能。它主要有两重语义,一是保证多个线程对共享变量访问的可见性,二防止指令重排序。缓存

2.语义一:内存可见性

2.1 一个例子

public class TestVolatile {

    public static void main(String[] args) throws InterruptedException {

        ThreadDemo threadDemo = new ThreadDemo();
        new Thread(threadDemo).start();
        threadDemo.flag = false;
        System.out.println("已将flag置为" + threadDemo.flag);

    }

    static class ThreadDemo implements Runnable {

        boolean flag = true;

        @Override
        public void run() {
            System.out.println("Flag=" + flag);
        }

    }
}

当你屡次执行代码时,有必定概率会出现这种结果
安全

在主线程将子线程实例的flag置为false后,子线程中的flag居然仍是true。这是怎么回事?这就是多线程的内存可见性问题。对于一个没有volatile修饰的的共享变量,当一个线程对其进行了修改,另外一线程并不必定能立刻看见这个被修改后的值。为何会出现这种状况呢?这就要从java的内存模型谈起。多线程

2.2 java的内存模型(JMM)

java的内存模型定义了线程和主内存之间的抽象关系,它的内容主要包括:并发

  • 主要由多线程共享的主内存和各线程私有的工做内存组成(工做内存是个抽象概念,并不真实存在,是对缓冲区,cpu寄存器等的抽象)
  • 变量都存储于主内存中,可是线程的工做内存中保存着要使用的变量在主内存中的副本。
  • 线程对变量的操做必须在工做内存中进行,不一样的线程没法直接访问对方的工做内存,相互通讯必须通过主内存。

线程,主内存,工做内存三者的交互关系如图所示
app

看看JMM模型会给咱们在多线程环境下的读写带来什么样的问题。jvm

  • 当一个线程(线程1)对共享变量进行修改时,修改的并非主内存中的变量,而是该线程对应的工做内存中该变量的一个副本。
  • 当主内存中的变量值已经被修改,另外一个线程(线程2)读取的却仍是本身工做内存中的旧值。

这时就出现了共享变量在多线程环境下的可见性问题。若是把线程的工做内存看成主内存的缓存,这个问题的本质就在于如何解决缓存失效问题。那么JMM中是如何解决可见性问题的?这就不得不提到happens-before规则。ide

2.3 happens-before规则

happens-before规则又叫先行发生规则。它定义了java内存模型中两项操做的偏序关系,更确切的说,它定义了操做可见性之间的偏序关系。好比A操做 happens-before B操做,并不意味这A操做必定在B操做以前,而是A操做的影响能被操做B观察到,这个影响包括改变了内存中共享变量的值,发送消息等。那么JMM定义了哪些happens-before规则?工具

  • 1.程序顺序规则:一个线程中的每一个操做,happens-before于该线程中的任意后续操做。
  • 2.监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁。
  • 3.volatile变量规则:对于一个volatile 变量的写,happens-before于任意后续对这个volatile变量的读。
    这里对于咱们而言重要的是第三点。即对于一个volatile变量,写操做happens-before于读操做,也就是说,一个线程对volatile变量作了修改,另外一个线程能立刻读到这个被修改后的值。
    这样就能解决共享变量在多线程环境下的可见性问题了。结合JMM模型,咱们能够继续探讨下volatile是如何作到这点的。

2.4 volatile解决内存可见性问题的原理

当一个变量被修饰为volatile后,对其的读写就会显得比较特别性能

  • 1.写一个volatile变量时,JMM首先修改工做内存中的变量值,并刷新到主内存中
    如图所示

  • 2.读一个变量时,JMM会把该线程对应的本地内存置为无效,并从主内存中读取共享变量。
    如图所示

对volatile变量的读写,能够说都是直接对主内存进行的操做,这样虽然会牺牲一些性能,可是解决了“缓存一致性问题”,使得变量在多线程间的可见性获得了很好的保证。

3. 语义二:禁止指令重排

3.1 为何会有指令重排

为了优化程序性能,编译器和处理器会对java编译后的字节码和机器指令进行重排序,通俗的说代码的执行顺序和咱们在程序中定义的顺序会有些不一样,只要不改变单线程环境下的执行结果就行。可是在多线程环境下,这么作却可能出现并发问题。好比下面的例子。

3.2 线程不安全的双重检查单例模式

运行这段代码咱们可能会获得一个匪夷所思的结果:咱们得到的单例对象是未初始化的。为何会出现这种状况?由于指令重排。首先要明确一点,同步代码块中的代码也是可以被指令重排的。而后来看问题的关键

INSTANCE = new Singleton();

虽然在代码中只有一行,编译出的字节码指令能够用以下三行表示

  • 1.为对象分配内存空间
  • 2.初始化对象
  • 3.将INSTANCE变量指向刚分配的内存地址
    因为步骤2,3交换不会改变单线程环境下的执行结果,故而这种重排序是被容许的。也就是咱们在初始化对象以前就把INSTANCE变量指向了该对象。而若是这时另外一个线程恰好执行到代码所示的2处
if (INSTANCE == null)

那么这时候有意思的事情就发生了:虽然INSTANCE指向了一个未被初始化的对象,可是它确实不为null了,因此这个判断会返回false,以后它将return一个未被初始化的单例对象!整个过程的执行流程以下图所示

因为重排序是编译器和CPU自动进行的,那么有什么办法能禁止这种重排序操做吗?很简单,给
INSTANCE变量加个volatile关键字就行,这样编译器就会根据必定的规则禁止对volatile变量的读写操做重排序了。而编译出的字节码,也会在合适的地方插入内存屏障,好比volatile写操做以前和以后会分别插入一个StoreStore屏障和StoreLoad屏障,禁止CPU对指令的重排序越过这些屏障。

4. volatile的其余特性

对volatile变量的读写具备原子性,可是其余操做并不必定具备原子性,一个简单的例子就是i++。因为该操做并不具备原子性,故而即便该变量被volatile修饰,多线程环境下也不能保证线程安全。

5.总结

volatile是jvm提供的轻量级同步工具。被volatile修饰的共享变量在多线程环境下能够得到可见行保证。其次它还能禁止指令重排。因为对volatile的写-读与锁的释放-获取具备相同的内存语义,故某些时候能够代替锁来得到更好的性能。可是和锁不同,它不能保证任什么时候候都是线程安全的。

相关文章
相关标签/搜索