volatile关键字

简介

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,但是它并不容易被正确、完整地理解,以至于许多程序员都习惯去避免使用它,遇到需要处理多线程数据竞争问题的时候一律使用synchronized来进行同步。了解volatile变量的语义对后面理解多线程操作的其他特性很有意义

volatile两个最最关键的特点:可见性与有序性

两个特性

可见性:这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如,线程A修改一个普通变量的值,然后向主内存进行回写,这之间是有时间差的,另外一条线程B在线程A回写完成了之后再对主内存进行读取操作,新变量值才会对线程B可见,A回写完之前新变量值是对B不可见。事实上volatile变量的值在传递时仍然需要通过主内存,但是每次读取变量前都需要将工作内存的值同步进主内存,故看起来像是“可见”。

关于volatile变量的可见性,经常会误以为下面的描述是正确的:“volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反映到其他线程之中。换句话说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是线程安全的”。这句话的论据部分并没有错,但是由其论据并不能得出“基于volatile变量的运算在并发下是线程安全的”这样的结论。volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的

一个经典的例子就是自增运算

// 使用IntelliJ IDEA请注意,在IDEA中运行这段程序,会由于IDE自动创建一条名为Monitor Ctrl-Break的线程(从名字看应该是监控Ctrl-Break中断信号的)而导致while循环无法结束,改为大于2或者用Thread::join()方法代替可以解决该问题
public class VolatileTest {

    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        // 等待所有累加线程都结束
        while (Thread.activeCount() > 1)
            Thread.yield();

        System.out.println(race);
    }
}

这段代码发起了20个线程,每个线程对race变量进行10000次自增操作,如果这段代码能够正确并发的话,最后输出的结果应该是200000。读者运行完这段代码之后,并不会获得期望的结果,而且会发现每次运行程序,输出的结果都不一样,都是一个小于200000的数字

问题就出在自增运算不是原子操作,race++应为race = race + 1,可以看成有三个操作

  • 首先线程A取出race的值,此时volatile可以保证race的值是正确的,假设为99
  • 然后线程A计算race+1=100,这个计算时间中race可能被线程B改为100了
  • 然后线程A将100赋值给race同步到内存中,最终race被线程A和B都自增了一次结果应该为101但是为100

实际上这样来分析并发问题仍然是不严谨的,因为分解的三个操作仍然不是原子操作,下面还有字节码指令,一条字节码指令在解释执行时,解释器要运行许多行代码才能实现它的语义。如果是编译执行,一条字节码指令也可能转化成若干条本地机器码指令。但是考虑到阅读的方便性而且已经能说明问题,就直接这样解释了

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性:

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束(变量不依赖其他变量,比如race=val+1依赖了val)

而在像下面这段代码所示的这类场景中就很适合使用volatile变量来控制并发,执行dowork时flag变量的值必然已经回退到主内存中了,当shutdown()方法被调用时,能保证所有线程中执行的doWork()方法都立即停下来

volatile boolean flag;

public void shutdown() {
    flag = true;
}

public void doWork() {
    while (!flag) {
        // 代码的业务逻辑
    }
}

有序性:禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在同一个线程的方法执行过程中无法感知到这点,所以容易认为是有序执行的,这就是Java内存模型中描述的所谓“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)

这话还是不好理解的,举个例子

public class Test {
    static class Node {
        private int num;
        public Node() {
            num = 1;
        }
    }
    static boolean flag = false;
    static Node node;

    public static void main(String[] args) {
		// ThreadA负责生成node然后设置flag为true告诉其他线程可使用node了
        new Thread(() -> {
            node = new Node();

            flag = true;
        }).start();
        
        //ThreadB负责等待flag为true使用node
        new Thread(() -> {
            while (!flag) {
            }
            System.out.println(node.num);
        }).start();
    }
}

乍一看这代码是没有问题的,然而事实上A线程是有可能先执行flag=true然后再生成node的,那么B线程就会报空指针异常,为什么呢?因为A线程的代码并不一定按顺序执行,因为指令重排序优化(这里的优化应该在机器级别考虑)导致flag=true提前执行。然而又前面说过它们进行优化是会保证代码结果相同的,这又是否矛盾?事实上它们保证结果相同仅仅是基于单线程的,也就是保证一个线程内的结果相同,而多个线程的结果是无法预测的,在并发下上述代码便可能存在问题,要避免这种问题只需要将flag变量设置为volatile类型的即可

volatile还有个经典的在单例模式的使用例子,可参考博客单例模式懒汉式4双重判断

有volatile修饰的变量,多了一条指令“lock addl $0x0,(%esp)”,这个操作的作用相当于一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个处理器访问内存时,并不需要内存屏障;但如果有两个或更多处理器访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了

事实上这个操作也实现了volatile的可见性,addl $0x0,(%esp)这个操作是指将esp寄存器的值加0,这显然是个无意义的操作,那么作用是什么?这里的关键在于lock前缀,它的作用是将本处理器的缓存写入了内存,类似于前面讲的store和write,那么可以知道每次读取volatile变量时会将工作内存的值同步到主内存,这就实现了每次读取的变量一定是最新值,即变量的可见性

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值