Java基础-关于volatile的三共识

OK,文章开头我先给定一个内味:volatile是一个特征修饰符(type specifier)volatile的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。 [百度百科].

接下来就让我们炮打司令部—三共识:

🧞‍♂️ 1可见性

可见性简单解释:线程A修改了内存某值,对于其他线程B、C、D…而言,如果其中线程B也在对这个值进行相应操作,那线程B就应知道自己拿到的值不是最新的,那他就应该去重新读最新的这个值,然后完成自己的操作。

要讲清可见性,我们得先来一点计算机常识:计算机组成原理中内存是分多层次的、Register、L1、L2、L3…,来提升性能。
在多核CPU的情况如下图,数据一般依次经过内存->L3->L2->L1->寄存器->ALU。在ALU中完成计算,再次写回内存。
在这里插入图片描述
而在Cache三级缓存中我们又划分了Cacheline作为Cache的基本读取单位,Cacheline是为在性能和时间间折中出现:

缓存行越大,局部性空间效率越高,但读取时间慢;
缓存行越小,局部性空间效率越低,但读取时间快;
取一一个折中值,目前多用:64字节

接下来就是volatile可见性实现的核心部分:讲解依据下图,

  1. 假设ThreadA、B被CPU调度,分别由同一L3下的两核执行。线程A、B的任务都是对数据x、y进行操作。此时同时开始执行任务。首先x、y作为一个cacheline被读入L3中,然后这个cacheline分别读入各核的L2、L1,最后由ALU运算。在这里需注意被volitale显示修饰的数据本质上是禁止编译器将该数据优化到寄存器中(因为这个数据可能被其他线程修改)
    1-2
  2. 若线程A先完成对数据x、y的修改,并将新值写回内存。此时触发MESI(缓存一致性)机制,机制会将其他核的缓存中的x、y所在的cacheline置为无效,因为数据没有被存入寄存器中,所有执行线程B任务的核要去cache中取值。 但是此时x、y所在的cacheline是无效的,所有核处理器会重新读取内存中的最新值,完成线程B的任务。
    1-3
    到这里volatile 可见性实现可以总结为:1.通过对cache-line的操作实现,2.显示的禁止编译器将该数据优化到寄存器中。
可见性问题下的—伪共享

这一部分是针对可见性带来的问题的优化:
先来整一段代码:

class volatile {
    public static long COUNT = 1_0000_0000L;

    private static class T{
        public volatile long x=0l;
    }

    public static T[] arr=new T[2];
    
    static  {
        arr[0] = new T();
        arr[1] = new T();
    }

    public static void main(String[] args)throws Exception {
        CountDownLatch latch=new CountDownLatch(2);
        //t1线程对静态数组中的arr[0].x进行循环赋值
        Thread tA=new Thread(()->{
            for (long i=0;i<COUNT;i++){
                arr[0].x=i;
            }
            latch.countDown();
        });
       //t2线程对静态数组中的arr[0].x进行循环赋值
        Thread tB=new Thread(()->{
            for (long i=0;i<COUNT;i++){
                arr[1].x=i;
            }
            latch.countDown();
        });
        final long start =System.currentTimeMillis();
        tA.start();
        tB.start();
        latch.await();
        System.out.println("time cinsuming:"+(System.currentTimeMillis() - start)/1000.000 +"S");
    }
}

执行代码我们发现平均运算时间在:2.5s左右,计算机性能一般情况下。
在这里插入图片描述
对代码进行一下改良呢:

public class volatile {
    public static long COUNT = 1_0000_0000L;
    //变化之处在这,对静态内部类T的成员变量进行了改变
    private static class T{
        public volatile long p1,p2,p3,p4,p5,p6,p7;
        public volatile long x=0l;
        public volatile long p9,p10,p11,p12,p13,p14,p15;
    }

    public static T[] arr=new T[2];

    static  {
        arr[0] = new T();
        arr[1] = new T();
    }

    public static void main(String[] args)throws Exception {
        CountDownLatch latch=new CountDownLatch(2);

        Thread tA=new Thread(()->{
            for (long i=0;i<COUNT;i++){
                arr[0].x=i;
            }
            latch.countDown();
        });

        Thread tB=new Thread(()->{
            for (long i=0;i<COUNT;i++){
                arr[1].x=i;
            }
            latch.countDown();
        });
        final long start =System.currentTimeMillis();
        tA.start();
        tB.start();
        latch.await();
        System.out.println("time cinsuming:"+(System.currentTimeMillis() - start)/1000.000 +"S");
    }
}

此时我们看看平均运算耗时:在0.6s左右。
在这里插入图片描述

🌊 那仅仅是两行代码怎么能提升这么多运算效率呢?这就是一种伪共享,或者是一种伪对齐思路。还是用图唠叨:

因为数组在进行内存分配时是连续分配的,所以T[0]和T[1]是相连的,那么就将图中的x代表T[0].xy代表T[1].x;
1.ThreadA只对x进行操作,ThreadB只对y进行操作。但是读取内存时是按照一定单位读取的,所以相连的x,y就当作是一个cacheline读入了cache中,
2.某时ThreadA先把x给修改了,并写回内存。触发ThreadB中的cacheline无效,可是ThreadB核读到的就是y的新值,根本就不需要在读呀。但是莫得办法,ThreadB核还是得重新去读内存。时间就在这浪费
在这里插入图片描述
那为什么加了两行代码,在x前后加7个long就解决了呢?
出现上面现象是因为x,y出现在了同一cacheline上,那么让他们不在一起,个去个的核处理不就行了。
在这里插入图片描述

🆗,因为我们知道一般cacheline是64字节,而long是8字节。64/8=8,那我们就在数据前后都加上7的long,这样不管cpu怎么读64字节,x、y都不可能在同一cacheline出现,所以这就节省了很多时间。向上面的情况,当然一般来说只需要在前或者在后加上相应字节进行填充就好,不一定需要前后都加。

上面这个问题就是:“总线风暴”的一个体现

由于volatile的MESI缓存一致性协议需要不断的从主内存嗅探和CAS不断循环无效交互导致总线带宽达到峰值。

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~ 👐🙌👐

🧞‍♂️2原子性?

看完可见性我们可以脱口而出这个结论:volatile这个东西没有原子性。
在这一啪中,最闪👀的就是—++问题

++失效问题

先来一段code:

class Volatile_Atomicity {
    public static volatile int x;

    public static void main(String[] args)throws Exception {
        CountDownLatch latch=new CountDownLatch(2);
        new Thread(()->{
            for (int i=0;i<20000;i++){
                x++;
            }
            latch.countDown();
        }).start();

        new Thread(()->{
            for (int i=0;i<20000;i++){
                x++;
            }
            latch.countDown();
        }).start();
        latch.await();
        System.out.println("瞅瞅x值:"+x);
    }
}

当这个阈值 i 较小时不明显,调大以后就比较明显,因为线程2还没启动起来,有可能线程1跑完了:
在这里插入图片描述

给出解释 :
尽管volitale保证了可见性,但是对于多个线程同时对某一变量进行i++或者++i操作时,仍然无法保证其运算得到正常的结果,本质原因还是i++ 和 ++i不是原子操作。结合可见性图解,当线程1和线程2进行 i++ 操作时,
首先,i++并不是原子操作,操作是拆分为3个步的:

1.把数据从主内存加载到缓存。
2.在缓存中命中数据,传到ALU执行i++操作。
3.将i的新值刷新到主内存。

那么进行如下过程,则会发生线程安全问题:
线程A,线程B都取到最新i值,并都执行到步骤2,都已经从cache中命中数据,传到了工作的内存,此时不管哪个线程先完成,置其他核cacheline无效,都达不到最终效果,因为我需要读的数,我读到了,不用再去cache中找。这时明明是执行了两次的 ++ 操作,但内存中的值还是 i+1,只是刷新两次而以。这是一种极端情况,因为一般cpu运算都特别快,但出问题都是在调度线程的核自以为读到最新值并传入工作的内存时,出现该线程的不正常停止或其他核运算更快,导致主内存值已刷新。

最后:自增操作不是原子性操作,而且volatile不能保证对变量的任何操作都是原子性的。

那安全性呢?❓

不是说volatile是缩水的synchronized吗?难道不具有安全性了吗?
这个问题我就在简单介绍有序性后再做讨论。

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~ 👐🙌👐

🧞‍♀️3有序性

volatile是禁止指令重排序优化。由于编译器优化,在实际执行的时候可能与我们编写的顺序不同。编译器只保证程序执行结果与源代码相同,却不保证实际指令的顺序与源代码相同。这在单线程看起来没什么问题,然而一旦引入多线程,这种乱序就可能导致严重问题。
先来一段简单代码:

class Test8_22 {
     static class T{
         int x=8;
     }
    
    public static void main(String[] args) {
        T t=new T();
    }
}

我们编译以后,看看字节码文件(主要看main方法中的new):

  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 7 L0
    NEW Test8_22$T                              //1.内存申请T对象大小的空间
    DUP
    INVOKESPECIAL Test8_22$T.<init> ()V          //2.完成初始化
    ASTORE 1                                     //3.将内存中的地址,赋予引用
   L1
    LINENUMBER 8 L1
    RETURN
   L2
    LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
    LOCALVARIABLE t LTest8_22$T; L1 L2 1
    MAXSTACK = 2
    MAXLOCALS = 2

所以如上注释new操作一般是分三步的,可能第2步不胜了解,那就是在第一步完成后,类成员int x只是分配了一个4字节的地址,他的值还是0,并没有赋值。只有在完成第2步后进行初始化,并调用构造方法。所以第1步还是半初始化状态。
但是这还仅仅是到了字节码,离真正的底层还有:源码->编译器优化重排序->指令级并行重排序->内存系统重排序->最后执行的指令序列。
并且越往下走,指令的优化就愈加重要,所以我们new的这三步,到最后可能什么顺序都有,最可怕的就是在单列模式下重排序成了:3-1-2。
所以我们一般将单列模式写成双重检查模式(double check):


class Singleton{
    private volatile static Singleton instance = null;
    
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();//加了volatile的变量严格按顺序执行,不会先执行3将一个null的值给引用对象。
            }
        }
        return instance;
    }
}

通过volatile的修饰去避免当重排序成了3-1-2的时候:threadA先执行了 instance = new Singleton();但是是先执行的3指令,将一个null赋给了引用对象。这样等ThreadA走完同步代码块,ThreadB进来instance==null发现是ture那么ThreadB又会去new一个对象,破坏了单列模式。

更明显的无序性案列在网上有许多。譬如《 volatile看完你就明白了》—无序性的例子这一节就罗列出了不少好案列。文章中的代码想跑出相应的结果可能需要挺多次尝试。

那volatile是怎么做到的呢?—内存屏障
在这里插入图片描述
这种内存屏障(JVM级别,不是CPU级别)将显示的告诉后面的编译器禁止指令重排序。

内存屏障插入策略:
1)在每个volatile写操作前插入一个StoreStore屏障。
2)在每个volatile写操作后插入一个StoreLoad屏障。
3)在每个volatile读操作后插入一个LoadLoad屏障。
4)在每个volatile读操作后插入一个LoadStore屏障。

这是一种JMM规范的实现,同时volatile的禁止重排序并不局限于两个volatile 的属性操作不能重排序,而且是volatile 属性操作和它周围的普通属性的操作也不能重排序,禁止规则如下:

1)当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
2)当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
3)当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

依据上面可知volatile提供了happens-before 保证,总的来说就是:对volatile 变量v的写入happens-before 所有其他线程后续对v的读操作。

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~ 👐🙌👐

🖋安全性探讨和总结

安全性探讨:
那安全性用法呢?这就附上:
依据可见性--------可以成为信号量在线程中进行通讯,但是这中信号量得安全性使用有局限性:

class Test8_22 {
       public static volatile boolean flag=true;
        public static void main(String[] args) throws Exception {
            CountDownLatch latch=new CountDownLatch(2);
            new Thread(()->{
                for (int i=0;i<20000;i++){
                    reversal();
                }
                latch.countDown();
            }).start();
            new Thread(()->{
                for (int i=0;i<20000;i++){
                    reversal();
                }
                latch.countDown();
            }).start();
            latch.await();
            System.out.println(flag);
        }
        public static void reversal(){
            flag=!flag;///每一次取反,都依赖上一次
        }
    }

进行过偶数次取反,应该还是true的,但返回的是错误的false。
在这里插入图片描述

因为这种volatile信号量的操作,每一次都依赖于前一次,但volatile不具原子性导致特别容易出错。
那他怎么做信号量?所以我们要打破这种修改依赖上一次的行为。如下:

class Test8_22 {
       public static volatile boolean flag=true;
        public static void main(String[] args) throws Exception {
            CountDownLatch latch=new CountDownLatch(2);
            new Thread(()->{
                for (int i=0;i<20000;i++){
                    reversal();
                }
                latch.countDown();
            }).start();
            new Thread(()->{
                for (int i=0;i<20000;i++){
                    reversal();
                }
                latch.countDown();
            }).start();
            latch.await();
            System.out.println(flag);
        }
        public static void reversal(){
            flag=true;//取值单一指向。
        }
    }

这样是安全的,flag变量是取值无须参考上一次的。可是这样的信号量效率是极低的。不过利用这个点还是可以来一个简单的触发器:

volatile boolean flag = false;

//线程A:
{
do some things;
flag = true; //当完成时,或者达到条件时        
}
    
 
//线程B:
{
while(!flag ){
sleep();//减少空循环次数,提高效率;若实时性要求高,可以适当减少sleep时间
} 
do some things;   
}

依据有序性--------这里最常见的就是双重检查单例

总结:

  1. volatile 具有可见性,任何一个线程对其的修改将立马对其他线程可见。
  2. volatile修饰属性不会被线程缓存(禁止加载到寄存器),始终从主存中读取。
  3. volatile 具有有序性,volatile修饰的属性( 并且只能作用于属性),这样计算机底层不会对这个属性做指令重排序。
  4. volatile提供了happens-before 保证,对volatile 变量v的写入happens-before 所有其他线程后续对v的读操作。
  5. volatile属性的读写操作都是无锁的,它不能替代synchronized, 因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
  6. volatile 修饰符适用于两种场景:第一种是利用可见性,修饰属性被多个线程共享,当有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如boolean flag。 利用这样特点可作为触发器,实现轻量级同步。第二种就是利用有序性,实现双重检查单例模式。

OK,文章结尾再来品一品☕内味:volatile是一个特征修饰符(type specifier)volatile的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略(有序性),且要求每次直接读值(可见性)。

Fine任务完成,🌊向三连长致敬了。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~ 👐🙌👐

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~ 👐🙌👐
参考资料:
可见性小节—《马士兵教育-多线程与高并发》视频
总结------------《java并发核心》mooc视频课

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值