Java并发编程之关键字Volatile的深入解析

volatile是研究Java并发编程绕不过去的一个关键字,先说结论:

volatile的作用:

1.保证被修饰变量的可见性

2.保证程序一定程度上的有序性

3.不能保证原子性

下面,我们将从理论以及实际的案例来逐个解析上面的三个结论

一、可见性

什么是可见性?

举个例子,小明和小红去看电影,刚开始两个人都还没买电影票,小红就先去买了两张电影票,没有告诉小明。小明以为小红没买,所以也去买了两张电影票,因为他们只有两个人,所以他们只能用两张票,这就是小明和小红他俩电影票的数量的可见性。

在讲解之前,我们简单的了解一下JVM当中运行时数据区的结构

1BE70FFA-2F8F-E973-FF72-47C5A656F42A.png

堆内存:存放的就是对象,所以它也是JVM当中内存最大的一区域

线程私有区:线程中的栈会去从堆当中获取变量的值来进行操作,正是因为是私有化的,所以两个线程之间的数据是不会共享的

元空间:存放静态变量以及常量还有被虚拟机加载的类信息

同理,我们可以将小明和小红看作java当中的两个线程1和2,共有一个变量

public class volatileTest {
    public static boolean flag = false;

    public static void main(String[] args) {
        try {
            new Thread(() -> {
                System.out.println("线程1开始");
                //线程1当中取反值,当flag为true时才会跳出循环
                while (!flag) {
                }
                System.out.println("线程1结束");
            }).start();
            Thread.sleep(100);
            new Thread(() -> {
                System.out.println("线程2开始");
                //线程2给flag赋值
                flag = true;
                System.out.println("线程2结束");
            }).start();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

该代码的运行结果如下:

AB3CFD2F-6BAF-EDDE-AE2A-8DCB00BA2C7B.png

可以很清楚的看到,只有线程2是跑完了的,但是明明线程2已经给flag赋值,线程1并没有停止循环,这就是flag这个变量没有可见性,导致线程1一直不停止

解决的方法有两种

第一:让每个线程空余时间就去堆同步数据(显然不合理)

第二:使用volatile关键字去修饰变量flag

让我们加上volatile试试:

8FE17D1C-1D69-06C4-44F0-D437381161E0.png

这回线程1总算是成功停止了,由此我们可得,volatile是可以让变量具有可见性的。

学习编程不能只知道如何去使用,而是要知道原理,这样才会有更多的薪资

那么volatile的底层是如何实现的呢?

如上面jvm运行数据区的图所示,所有的变量都是存在了堆当中,而每个线程都是拿到他们的副本进行计算和修改,volatile干了啥事呢,如下图所示

BBB60D18-C0C2-79DE-7D85-53C5C0118C0A.png

这里我们介绍一个新的概念,叫总线(各位可以把它理解成进行连接线程和堆内存,在计算机的硬件当中,也是有总线的,了解的朋友可以把它用相同概念理解一下)。

当一个被volatile修饰的变量,在某一个线程当中被修改时,总线会监听到这个变动,并且会让其他线程中的这个变量失效,简而言之,当线程2当中堆flag进行了修改,则会导致线程1当中的flag失效,就是把这个线程1当中的flag删了。当线程1中没有flag了,它会重新去获取flag,这个时候,就会使我们的变量flag具有了可见性。

现在我们已经知道了,volatile的实行原理,那么它的底层是如何实现的?

众所周知,java语言加载时 -> class ->汇编语言 -> 机器语言,因为volatile是个关键字,所以它的底层是一种汇编语法,被volatile修饰的变量其实就是给它加了个一个lock前缀指令。

也就是说,当面试官问到我们,如何手写一个volatile时,我们可以说在编译的层面,添加一个lock前缀指令相当于一个内存屏障,它本身会提供三个功能

1)它会强制堆缓存的修改操作立即写入主存

2)如果是写操作,它会导致其他CPU中对应的缓存行无效

3)它会确保指令重排序时不会吧其它的指令排到内存屏障之前的位置,也不会之前的操作拍到内存屏障之后

前面两点很好理解,并且我们也进行了进一步的认证,第三点可能有朋友不太明白,这就引出了我们下一个论点,volatile可以保证一定的有序性

二、有序性

我们看下面三行代码

int i=1;
int j=2;
i =i++;

在我们的理解当中,程序时自上而下运行的,先是第一行,再是第二行等,然而事实上,jvm可能会对代码进行重排序,比如它可能就会让上面的这三行代码变成下面的状态

int i=1;        
i =i++;
int j =2;

为什么会进行重排序,目的是让代码执行的速度更快,当然它也不是随便乱排的,排序的规则是根据代码的依赖性进行的判断,简而言之就是在不影响结果的情况下进行排序,感兴趣的朋友可以自行去了解一下

这是java本身对程序保证的有序性,在不影响运行结果的情况下进行重排序,但是仅限于单线程的情况下,在多线程的情况中,并不能有效地保证程序的有序性

下图为手写的一个单例模式,不做过多的赘述,左边为代码,右边为翻译的字节码文件

303BF29C-73E3-4987-905F-2B29A820028D.png

通过上图可以很清晰的看出,new OnlyObject这个操作重点分为了四步,

第一步:创建这个对象

第二步:调用这个类的构造方法

第三步:添加指向(就是从私有线程当中执行堆)

第四步:加载

由于java对程序的重排序,会使第二步和第三步进行调换位置,在单线程当中不会有任何问题,而在多线程当中就有问题了

看下图代码

CB0C3949-1428-BC8B-1BEE-D422EC71BD5D.png

当线程1已经完成添加指向时,在堆当中其实已经分配了一个值,但是这时并没有调用构造方法,所以导致此时这个对象只是一个半成品对象,里面并不是我们想要的值。这时线程2走进来,他发现object并不为空,所以直接返回了,此时的程序跟我们的业务并不相符,所以我们需要使用volatile来保证我们的有序性。

以上都是本人的拙见,有错误的地方还请大家帮忙指出,谢谢各位

收藏 (0)
评论列表
正在载入评论列表...
我是有底线的
为您推荐
    暂时没有数据