Java之volatile,及其对可见性、有序性、原子性的影响

引入

假如我现在有这样一个需求,有多个线程对一个数值一直进行某个操作(比如+1操作),另有一个线程需要在一定的时机,终止这些线程的操作,如何简单实现。

	private static int count = 0;
    private static boolean start = true;

    public static void main(String[] args) {
        for (int i = 0; i < 4; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println("开启" + Thread.currentThread().getName());
                    while (start) {
                        count++;
                    }

                    System.out.println("线程关闭了");
                }
            }, "操作线程" + i).start();
        }
        
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                    System.out.println(count);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                start = false;

                System.out.println("线程正在关闭。。。");
            }
        }, "控制线程").start();
    }

上面代码乍一看确实没有什么问题,然而当你拿去运行一下就会发现,程序并没有被“控制线程”中断,而是卡在这里不动了,如图:
控制线程无效
怎么回事?我的“控制线程”已经把开关(start)关了啊,为什么操作线程还没有停止?如果你实在想不明白呢,那就去看看JMM吧,那里或许能找到你想要的答案。
说白了,控制线程对变量start的改变,在操作线程中不可见。怎么解决这个不可见的问题呢?有一个很简单的办法,用volatile关键字修饰start,你可以去试一试是否可行。
volatile是怎么做到的呢?接下来对其进行简单讲解。

内存语义

1.被volatile修饰的变量值对所有线程都是可见的,换句话说,一个变量被volatile修饰后,如有线程将其值改变,其它线程就会立刻感知。
2.禁止指令重排序优化,赋值操作后会有一道内存屏障,防止指令重排导致的多线程下结果的不可知性。

保证可见性

至于什么是可见性,出门右拐——JMM
对于上面我们提到的那个例子,就是volatile保证可见性的体现。我们知道JMM中定义的内存模型将内存分为了主内存和工作内存,也就是在多线程的情况下,一个普通变量可能会有很多份存储,而其中一块存储中的变量值修改,是不一定会使得其它也随之修改的,要解决这一问题,volatile就借用了操作系统的缓存一致性协议
使用缓存一致性协议,使CPU缓存中的数据与计算机系统中的数据保持一致,也使得工作内存中的数据与主内存中的数据保持一致。

保证一定的有序性

至于什么是有序性,出门右拐——JMM
了解过JMM应该知道代码并不一定会按照我们编写时的顺序运行,系统会进行指令重排优化,但在多线程的情况下,很多时候是不希望系统进行指令重排的,为了达到这一目的就出现了“内存屏障”的概念。
什么是内存屏障呢?内存屏障(Memory Barrier)也称内存栅栏,它是CPU中的一个指令,主要有保证操作的执行顺序和变量的可见性的作用。如果在指令间插入一个内存屏障,那么其它指令将无法和这条指令进行重排序。如果CPU缓存中的变量值改变,内存屏障也会将其强制刷出到内存中,保证其它线程可以得到最新版本的变量值。
由此可见,volatile关键字主要就是通过内存屏障来实现其内存语义的,下面我们举一个例子看一下volatile在解决指令重排所起到的作用。

	private static int count = 0;
    private static boolean start = true;
    private static int a, b, x, y;
    private static Set<String> results = new HashSet<>();

    public static void main(String[] args) throws InterruptedException {
        while (start) {
            start = false;
            a = 0; b = 0; x = 0; y = 0;
            Thread t1 = new Thread(
                    new Runnable() {
                        @Override
                        public void run() {
                            x = a;
                            b = 1;
                        }
                    }
            );

            Thread t2 = new Thread(
                    new Runnable() {
                        @Override
                        public void run() {
                            y = b;
                            a = 1;
                        }
                    }
            );

            t1.start(); t2.start();
            t1.join(); t2.join();

            String result = x + "," + y;
            count++;
            results.add(result);

            if (x == 0 || y == 0) {
                start = true;
            } else {
                System.out.println(count);
            }
        }

        System.out.println(results);

如果说上面这个例子不会发生指令重排,那么这个循环一定是一个死循环,因为按照代码的顺序,x和y必有一个为0。
但实际上,你运行一下上面的代码,你会发现程序最终跳出了循环,结果如下:
指令重排后跳出循环
但如果声明变量a, b, x, y时,加上关键字volatile,那么这个循环就真的变成死循环了,因为volatile强行保证了指令的有序性。

不能保证原子性

至于什么是原子性,出门右拐——JMM
volatile不能保证原子性,举一个例子就能看出来:

private static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            Thread t = new Thread(
                    new Runnable() {
                        @Override
                        public void run() {
                            for (int i = 0; i < 100; i++) {
                                count++;
                            }
                        }
                    }
            );
            t.start();
        }

        Thread.sleep(2000);
        System.out.println(count);
    }

按照我们正常思维,这段代码执行结果count的值应该是10000,但实际运行就会发现,即便是加了volatile关键字,count的值也只能小于或等于10000,比如:
count值不恒等于10000
为什么会这样呢?volatile关键字虽然可以保证可见性,但终究count++这个操作并非原子操作,可以将其分为3步(取出count值到缓存,执行加1操作,将count值写回内存),在第一步取count值确实可以保证所取到的值一定是正确的,但第二步进行加1时,可能其它线程已经改变了count的值,此时操作栈顶中的值就变成了过期的数据,但当前线程仍旧会执行第三步,将这个已过期的值通过putstatic指令同步回主内存。(这是从《深入理解Java虚拟机》中读出来的意思,我总是觉得count值变成了过期数据,当前线程也应该能嗅探到count已经失效,应该不会同步回主内存,这个还需要以后深入研究)

volatile的内存屏障

Java的内存屏障一般有4种:
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
注意:在大多数处理器的实现中,StoreLoad屏障是个万能屏障,兼具其它三种内存屏障的功能,所以它的开销也是最大的。

volatile是怎样使用这些内存屏障的呢?
1.在每个volatile写操作的前面插入一个StoreStore屏障,禁止了前面的普通写操作和后面的volatile写操作重排序;
2.在每个volatile写操作的后面插入一个StoreLoad屏障,禁止了前面的volatile写操作和后面的volatile读/写操作重排序;
3.在每个volatile读操作的后面插入一个LoadLoad屏障,禁止了前面的volatile读操作和后面的普通读操作重排序;
4在每个volatile读操作的后面插入一个LoadStore屏障,禁止了前面的volatile读操作和后面的普通写操作重排序。
来张图更清晰一些:
volatile与内存屏障
上面说的是一般情况,在有的时候JMM可能还会做一些优化,省略加一些不必要的屏障。
上面说的是volatile为我们加内存屏障,那么我们能不能以代码的方式自己加内存屏障呢?的确是有的,可以借助Unsafe类来添加内存屏障,比如Unsafe.getUnsafe().loadFence()。

总线风暴

说了这么多,有人也许会想,volatile能禁止指令重排,还能保证可见性,比普通变量好多了,我能不能所有的变量都使用volatile修饰啊?
volatile虽好,但是也不可乱用,因为其嗅探机制(总线嗅探各个缓存中变量值有没有被改变,以保证其他线程的可见性)非常消耗总线资源,有可能引起总线风暴。
volatile引起总线风暴是怎么一回事?由于volatile的缓存一致性协议需要不断的从主内存嗅探和cas不断循环无效交互导致总线带宽达到峰值。
所以有很多时候,往往使用synchronize或者Lock来代替volatile,尽管性能可能有所下降。

【个人笔记】转载请注明出处!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值