volatile关键字理解

一、volatile关键字的意义

volatile关键字是Java虚拟机提供的最轻量级的线程间同步机制,我们很容易在书籍或网络上了解到volatile关键字的作用,主要有两点:

1、当一个变量被volatile修饰时,将保证此变量对所有线程的可见性。 这里的“可见性”指的是,当对一个变量修改后,新值对于其他线程是立即可见的,注意这里的立即可见,并不是说其他线程能监听到变量值修改,而是说修改后的值能立即同步到主内存中(稍后介绍Java内存模型),保证其他线程能读取到的一定是最新值。

2、使用volatile修饰的变量能禁止指令重排优化。 什么是指令重排优化呢,Java源代码最终会编译成计算机能识别的机器码指令,为了提高执行效率,编译器和处理器可能会对指令进行优化重新排序,导致实际上指令执行的顺序可能会和源代码中想表达的顺序不一致。

到此如果能轻松的理解上面两句话的,可以跳过本文。如果不能理解,下面我们将通过代码来验证上面的定义,在此之前,先简单介绍下Java的内存模型和相关概念,有助于更好的理解上述内容。

二、Java内存模型

Java内存分为主内存和工作内存。所有变量都存在主内存中,每个线程都有自己的工作内存(可理解为缓存,实际上底层实现大概就是寄存器或高速缓存),线程的工作内存中将会拷贝主内存中变量的副本,所有的读写等操作都在工作内存中完成,线程的工作内存不能被其他线程访问。

线程、工作内存、主内存的关系如下图:

volatile关键字理解_第1张图片

这里介绍下Java内存操作的三个基本概念: 原子性、可见性、有序性。这里只是简单介绍,有兴趣自行查阅资料。

原子性,可以理解为对某一块内存的操作不可再细分,也就是中间不能再插入其他操作。符合原子性的操作称为原子操作,Java内存模型定义下列8种原子操作:
lock/unlock:将主内存的变量锁定/解锁为一条线程独占状态,例如使用synchronized关键字标识的代码块。
read、load:read将主内存中的变量值传输到线程工作内存中,load将得到的变量值存入工作线程的变量副本。
use、assign:使用和赋值。
store、write:store将工作内存中的副本变量值传送到主内存,随后write操作写入主内存。
例如有一个变量A和B,表达式A = B完整的操作,可能会是read A、read B、load B、load A、use B‘、assign A’(=B‘)、store A’、write A。这里只是辅助理解,实际操作可能更为复杂,读、写操作中间是可以插入其他操作的。

可见性,上面已经给出过解释,就是当修改一个共享变量时,其他线程能立即“主动”获得最新值,也就是保证拿到的一定是最新的值。

有序性,在单个线程内,所有的操作都是有序的,程序会按照代码的顺序执行。但是多个线程并发时,因为有指令重排序,所以会影响指令的执行顺序。

三、代码举例

1、volatile可见性

首先“可见性”很容易被我们误解为“volatile修饰的变量是线程安全的”,我们用下面的代码验证下:

public static volatile int count = 0;

public static void add() {
    count++;
}

public static void main(String[] args) {
    int defCount = Thread.activeCount();
    // 10个子线程
    for (int i = 0; i < 10; i++) {
        new Thread(() -> {
            for (int j = 0; j < 1000; j++) {
                add();
            }
        }).start();
    }
    // 等待子线程执行完毕
    while (Thread.activeCount() > defCount) {
        Thread.yield();
    }
    System.out.println("end: " + count);
}

执行结果大概率是小于10000的,通过命令“javap -c 类名.class”查看字节码,结果如下:

public static void add();
    Code:
       0: getstatic     #2                  // Field count:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field count:I
       8: return

可以看出一条“count++”语句,并不是原子操作,它最终编译成了4条字节码,并且一条字节码指令在执行时还有可能被解释成多条机器指令。因此可见,只要不是原子操作,volatile关键字修饰的变量,并不是线程安全的。那么,volatile关键字的“可见性”到底有什么用,这里直接说结论。

一般来说,Java基本数据类型的读写是原子操作(64位的long、double是分两次读写例外),也就是说多线程环境下,只要满足以下一种以上情况,即可满足线程安全,否则只能通过synchronized或者Lock加锁满足原子性。
第一个,变量操作不依赖其他变量(包括自己),这时候不会依赖主内存中的值,只有纯写入操作。
第二个,只有一个线程会改变变量值,不会存在资源竞争。

因此,关于可见性,volatile关键字的经典用法如下:

volatile boolean isFinished = false;

//  线程A执行
public void finish() {
    isFinished = true;
}

//  线程B执行
public void loop() {
    while (!isFinished) {
        // do something
    }
}

这里线程A中写入变量isFinished的值,线程B立即能获取到最新的值,因此说变量isFinished对线程B可见。

2、关于指令重排优化

以下场景比较常见,在一个线程初始化配置,另一个线程去等待初始化完成并使用配置,我们首先不对“isInitial”使用volatile关键字,运行以下代码:

// 参数
static Map mConfigs = null;
static boolean isInitial = false;
static int times = 0;
public static void test() {
    for (;;) {
        System.out.println("测试次数:" + times++);
        int defCount = Thread.activeCount();
        //  线程A
        new Thread(() -> {
            //  等待B初始化完成
            while (!isInitial) {
                Thread.yield();
            }
            
            if(null == mConfigs) {
                System.out.println("发生指令重排:isInitial = true, mConfigs == null");
                System.exit(1);
            }
        }).start();
        
        //  线程B
        new Thread(() -> {
            // 模拟读取配置、文件等
            mConfigs = new HashMap();
            isInitial = true;
        }).start();
        
        //  等待线程A、B执行完毕
        while (Thread.activeCount() > defCount) {
            Thread.yield();
        }
        
        isInitial = false;
        mConfigs = null;
    }
}

理论上isInitial = true之后,模拟配置的变量mConfigs不可能为空,但是执行结果如下图:

........
测试次数:27731
测试次数:27732
发生指令重排:isInitial = true, mConfigs == null

Process finished with exit code 1

出现这个结果的原因,就是因为指令重排序优化,使“isInitial = true”这条代码对应的机器码指令提前执行。举个例子,假设有两条指令T1、T2,正常的执行顺序应该是T1 -> T2,但是执行器认为T1和T2无依赖关系并且先后顺序不会影响结果,比如“i + 2 + 3”和“(i + 3) + 2”,因此优化后实际顺序可能是T2 -> T1。

而关键字volatile的作用,是给变量加了一个内存屏障,使重排序优化时不能把后面的指令重排序到前面的位置,这样保证了一致性。(有兴趣可以尝试给上述代码中的“isInitial”加上volatile关键字,理论上电脑炸了也不会停下来)

例子,单例模式经典写法:

public class SingleTon {
    // 使用volatile
    private volatile static SingleTon instance;

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

四、小结

作为最轻量级的同步机制,volatile的总体性能是好于synchronized的,因为volatile使用内存屏障来保证写入顺序正确,所以仅仅是写入操作相对较普通变量耗时,读取操作基本不受影响,而synchronized使用monitorenter和monitorexit两条指令无论读、写都会lock内存保证安全,因此理论上读写性能都会受影响。所以在满足场景需求的前提下,优先使用volatile关键字解决并发问题。

最后,如有错误欢迎指出,以免误导它人。

你可能感兴趣的:(Java,java,jvm,面试)