Java中的 Volatile关键字理解

210 阅读5分钟

volatile 有什么作用?

volatile 的主要作用有两点:

  • 保证变量的内存可见性
  • 禁止指令重排序

什么叫内存可见性?

在Java多线程通信主要通过:

  • 通过共享内存数据
  • 通过消息通知机制 那么jvm 是如何通过共享内存通信呢? A、B线程分别先从主内存中复制一份到自己的本地内存,修改变量数据后,再刷新到主内存中去。如图:
graph TB
线程A((线程A))
线程B((线程B))
主内存(主内存)
线程A内存(线程A  内存副本)
线程B内存(线程B  内存副本)

主内存 --> 线程A内存
主内存 --> 线程B内存

线程A内存 --> 线程A
线程B内存 --> 线程B

线程A内存 --刷新--> 主内存
线程B内存 --刷新--> 主内存

通过修改变量达到隐性通信,但是现实往往不尽人意,按照预期下面代码A,B线程将交替打印:

package com.example.lib_java;

public class MyTest {
    static  boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        AThread a = new AThread();
        BThread b = new BThread();
        a.start();
        Thread.sleep(500);//确保 b线程在 a线程之后执行
        b.start();
    }

    static class AThread extends Thread{
        @Override
        public void run() {
           for(;;){
               if(MyTest.flag){
                   System.out.println("thread A print");
                   MyTest.flag = false;
               }
           }
        }
    }
    static class BThread extends Thread{
        @Override
        public void run() {
            for(;;){
                if(!MyTest.flag){
                    System.out.println("thread B print");
                    MyTest.flag = true;
                }
            }
        }
    }
}

运行代码,我们发现并没有交替打印,这是因为 子线程修改数据后不会立刻刷新到主内存中,其他线程也读不到最新的修改的数据。当我们将 flag 变量加上 volatile后static volatile boolean flag = true;,我们发现可以交替打印了,这又是怎么回事呢?

使用 volatile修饰共享变量后,每个线程要操作变量时会从主内存中将变量拷贝到本地内存作为副本,当线程操作变量副本并写回主内存后,会通过 CPU 总线嗅探机制告知其他线程该变量副本已经失效,需要重新从主内存中读取。 volatile 保证了不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改后的变量写回主内存时,其他线程能立即看到最新值。 这就是volatile的内存可见性。

禁止指令重排序

我们知道java 会将我们的编写的代码 分为一个个指令,为了提高效率指令可能会重排。在多线程下这种性质可能会带来一些意想不到的后果,例如在我们常见的单例模式中:

public class Singleton {
    private volatile static Singleton instance;
    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这里提出两个问题:

  • 已经有synchronized同步块,为什么还要用volatile?
  • 根据上面volatile具有内存可见性,为什么还要用synchronized保证线程安全?

关于第一问题注意instance = new Singleton();这行代码,它的执行可以分解为第三个步骤:(1)为instance实例分配内存。(2)执行Singleton构造函数来初始化instance。(3)将instance指向分配的内存。

但在JDK1.5前,上边的(2)(3)无法保证按顺序执行,如果按(1)(3)(2)顺序,假如A线程执行完(3),(2)未执行就被切换到B线程,因为步骤(3)已经在A线程执行,则B线程直接取走了认为非空instance,这就导致双重检查锁定的判断失效。

在JDK1.5后,只要这样声明instance实:private volatile static Singleton instance;即添加volatile修饰符后,就是所谓的禁止指令重排,从而确保了程序一定是按照(1)(2)(3)执行的。

因此volatile是必须要使用的。

关于第二个问题实际上问的是:为什么volatile不能保证原子性?

为什么volatile不能保证原子性?

程序的原子性指:整个程序中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。 原子性在一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。及时在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。

volatile无法保证线程同步,下面代码验证一下:

package com.example.lib_java;
public class MyTest {
    static volatile int count  = 0;
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(new MyRunnable());
            thread.start();
        }
        Thread.sleep(2000);
        System.out.println("count is  "+ count);
    }

    static class MyRunnable implements Runnable{

        @Override
        public void run() {
            for (int i = 0; i < 10_000; i++) {
                count++;
            }
        }
    }
}

执行上述代码,我们开启了5个线程,分别只增10000次,按照预期最后输出应该是50000,但是每次的输出都是不一样的,这说明volatile并不能起到同步线程的作用(即具有原子性)。将代码改为:

package com.example.lib_java;

import java.util.concurrent.atomic.AtomicInteger;

public class MyTest {
    static AtomicInteger count  = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(new MyRunnable());
            thread.start();
        }
        Thread.sleep(2000);
        System.out.println("count is  "+ count.get());
    }

    static class MyRunnable implements Runnable{

        @Override
        public void run() {
            for (int i = 0; i < 10_000; i++) {
                count.incrementAndGet();
            }
        }
    }
}

我们发现可以输出预期值了。这是因为AtomicInteger具有原子性,具体参见《面试必问的CAS,你懂了吗? - 知乎 (zhihu.com)》。 为什么volatile没有原子性呢?

  • 简单的说,修改volatile变量分为四步:
  • 1)读取volatile变量到local
  • 2)修改变量值
  • 3)local值写回
  • 4)插入内存屏障,即lock指令,让其他线程可见
  • 这样就很容易看出来,前三步都是不安全的,取值和写回之间,不能保证没有其他线程修改。原子性需要锁来保证。

好了,有关volatile 就说到这里了。