Java并发基础 - volatile篇

举报
码农参上 发表于 2022/04/24 09:10:38 2022/04/24
【摘要】 在上一篇文章中,我们介绍了synchronized关键字的使用和部分原理,下面我们再来看看在并发编程中另一个非常重要的关键字volatile。为了直观的体会volatile的作用,下面先看一段代码:public class VolatileTest { private static boolean flag=false; public void setFlag(){ ...

在上一篇文章中,我们介绍了synchronized关键字的使用和部分原理,下面我们再来看看在并发编程中另一个非常重要的关键字volatile。为了直观的体会volatile的作用,下面先看一段代码:

public class VolatileTest {
    private static boolean flag=false;

    public void setFlag(){
        this.flag=true;
        System.out.println(Thread.currentThread().getName()+" change flag to true");
    }

    public void getFlag(){
        while(!flag){
        }
        System.out.println(Thread.currentThread().getName()+" get flag status change to true");
    }

    public static void main(String[] args) {
        VolatileTest test=new VolatileTest();
        new Thread(test::getFlag).start();
        
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        new Thread(test::setFlag).start();
    }
}

例子中使用两个线程来对boolean类型的flag进行修改和读取。讲道理当执行getFlag方法的线程检测到flag变为true时,应该退出循环并打印语句。但是看一下执行结果,会发现只打印了setFlag方法中的语句,并且程序一直没有执行结束。

下面,我们在flag加上volatile关键字修饰,再执行一次上面的代码:

 private static volatile boolean flag=false;

可以看到,这时getFlag方法的线程检测到了flag的变化,并正常结束了程序。结合上面的例子,我们发现,当一个线程写数据,另一个线程读数据时,会存在数据不一致性的问题,而volatile的出现正好解决了这个问题。那么volatile究竟做了什么工作呢,这个时候就要引入java内存模型(JMM)来一探究竟了。

如上图中所示,java中运行的线程是不能直接读写主内存的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中。主内存是多个线程共享的,单线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成。

JMM控制中,又将对数据原子操作分为以下8个类别:

  • read(读取):从主内存读取数据
  • load(载入):将主内存读取的数据写入工作内存
  • use(使用):从工作内存读取数值来计算
  • assign(赋值):将计算好的值重新赋值到工作内存中
  • store(存储):将工作内存数据写入主内存
  • write(写入):将store过去的变量值赋值给主内存中的变量
  • lock(锁定):将主内存变量加锁,标识为线程独占状态
  • unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量

那么,我们之前举的例子就可以用下面的图来表示:

很明显,线程1无法跳出循环的原因是它读取的一直是自己工作内存中的flag,而没有获取到主内存中更新后的值。

为了解决缓存一致性问题,曾经使用过总线加锁的解决方案。具体来说,就是CPU从主内存读取数据到缓存,会在总线上进行数据加锁,这样其他CPU就没法去读写这个数据,直到这个CPU使用完数据释放锁之后其他CPU才能读取该数据。

但是这样一来,由于加锁的粒度太大,会造成阻塞时间过长,严重降低CPU的使用性能。因此在此基础上,行成了我们现在使用的MESI缓存一致性协议:

简单来说,就是多个CPU从主内存读取同一个数据到各自的缓存,当其中某个CPU修改了缓存里的数据,该数据会马上同步回主内存,其他CPU通过总线嗅探机制可以感知到数据的变化而将自己缓存里的数据失效。

总结一下,就是在读操作时,不做任何事情,把内存中的数据读到缓存中。而在写操作时,发出信号通知其他的CPU将该变量置为无效,其他的CPU要访问这个变量的时候,只能从内存中获取。

给测试类配置启动参数,打印汇编指令到控制台:

-server -Xcomp -XX:+UnlockDiagnosticVMOptions 
-XX:+PrintAssembly 
-XX:CompileCommand=compileonly,*VolatileTest.setFlag

可以看出,在执行到修改flag的语句时,首先加入lock这一个前缀指令,实现了对缓存行的锁定。简单来说,就是:

lock
flag=true write回主内存
unlock

只有在执行写write操作时候才会加锁,相对总线对数据加锁,极大的降低了锁的粒度,只要不是在write过程中其他线程依然可以读取主内存中的数据,从而提高了CPU性能

除此之外,volatile还能够实现指令的有序性。保证有序性是因为有时候会出现代码实际执行的顺序并不是我们输入的代码的顺序,那么为什么会出现这种情况呢,这里就有必要引入一下指令重排序:编译器为了优化程序的性能,会重新对字节码指令排序。

指令重排序的基础是,编译器认为运行的结果一定是正常的。在单线程下,指令重排序对程序的帮助一定是正向的,可以很好的优化程序的性能,但是在多线程下,有可能因为指令重排序出现一些问题。volatile实现有序性保证了以下两点:

  • volatile之前的代码不能调整到它的后面
  • volatile之后的代码不能调整到它的前面

总结

最后,结合上一篇文章,我们总结一下synchronizedvolatile的特点以及区别:

  • 使用上的区别:volatile只能修饰变量,synchronized能修饰方法和语句块
  • 对原子性的保证:synchronized可以保证原子性,volatile不能保证原子性
  • 对可见性的保证:都可以保证可见性,但实现原理不同。volatile对变量加了locksynchronized使用monitorentermonitorexit
  • 对有序性的保证:volatile能保证有序,synchronized虽然也可以保证有序性,但是代价变大(重量级),并发退化到串行执行
  • 除此之外:synchronized会引起阻塞,而volatile则不会引起阻塞

最后

觉得对您有所帮助,小伙伴们可以点个赞啊,非常感谢~
公众号『码农参上』,一个热爱分享的公众号,有趣、深入、直接,与你聊聊技术。欢迎来加Hydra好友 (vx: DrHydra9),围观朋友圈,做个点赞之交啊。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。