内存位置访问无效 midas.dll_多线程系列-(三)原子性、mesi协议、内存屏障与双重检查...

betterfishXL的文章 - 知乎 https:// zhuanlan.zhihu.com/p/55 767485

前言

  1. 关于原子性、可见性、有序性的问题与解决方法?
  2. 关于扩展MESI协议、内存屏障、JMM内存模型与HappenBefore原则
  3. 获取单例对象的双重检查
  4. 枚举类为什么是线程安全的?

1.关于原子性、可见性、有序性的问题与解决方法

原子性、可见性、有序性、这三个问题的解决本质上都是通过jmm的happenBefore的内存模型来实现的。happenBefore内存模型底层如何实现呢?答案是通过CPU指令,以常见的x86(不确定)服务器为例,
禁止指令重排序是通过mfence指令实现内存屏障而实现的禁止指令重排与storeBuffer等刷新来保证可见性。但是不同架构的机器下,进制重排以及内存屏障的指令是不同的,所以jmm封装了一条happenBefore原则来让程序员使用,我们写代码只需要关注是否符合原则就可以实现自己想要的结果。

1.原子性:比如简单的i++,i++不是一个原子操作,假如单线程下执行i++,没有问题,但是多线程下执行i++可能会导致i++的值有问题
    解决方法:
             1.sync加锁:监视器锁法则-对锁M解锁之前的所有操作Happens-Before对锁M加锁之后的所有操作

2.可见性:比如通过boolean值作为线程循环中止条件的问题,一个线程的对flag值进行修改,另一个线程不一定会及时的接收到。
    解决方法:
             1.sync加锁:监视器锁法则-对锁M解锁之前的所有操作Happens-Before对锁M加锁之后的所有操作。
             2.通过Voliatile关键字:volatile变量法则。包含volalite的代码汇编会发现lock,通过lock指令来让jvm底层实现类似于mfence的命令,mfence包含读屏障与写屏障。会将将寄存器中(storeBuffer与invideQueue)的指令强制更新到cache中,再通过mesi协议保证线程对共享变量的更新对另一线程可见。
3.有序性:

    问题:线程的CPU到主内存之间是有多级缓存的,当线程A修改了变量值不一定会及时刷新到主内存中,此时线程B去访问变量值则可能拿到旧值
    解决方法:
        1.通过Voliatile关键字:1.volatile变量法则,Lock保证修改及时可见。2.通过happenBefore对volatile的扩展,比如volatile写之前的操作对volatile写操作不能重排序(此处应有双重检查)
        2.锁:监视器锁法则-对锁M解锁之前的所有操作Happens-Before对锁M加锁之后的所有操作。

2.扩展

1.jmm内存模型与happenbefore原则

1.JMM内存模型:
        JAVA内存模型是java封装的一套多线程间相互交流通信的模型,他定义了每个线程与主内存相连,且每个线程都有自己的本地缓存。我的理解是他是一套通用的模型,比如我们把mesi模型简化一下就可以得到java内存模型,
        不同硬件的线程与内存通信的方式是不同的,jmm内存模型就是屏蔽了这种差异,对于java使用人员来说,只需要关注JMM内存模型即可。同样的他为了解决在JMM中线程A在更新本地内存变量对线程B不可见的问题,提出了一套
        happenBefore原则来解决这些问题。比如我们完全抛弃mesi模型、底层的读写屏障基于JMM来理解volatile变量法则。线程A读取了volatile int x=0,线程B也读取了volatile int x=0,线程A此时修改了x=1,那么根据规则,A需要将x=1值更新到内存中。假如此时当B进行x=2的赋值时,B中的x是无效的,B需要重新读取x=1到内存中,然后再赋值x=2。
2.happenBefore原则
    根据Java内存模型中的规定,可以总结出以下几条happens-before规则。Happens-before的前后两个操作不会被重排序且后者对前者的内存可见。

        程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。

        监视器锁法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。

        volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。

        线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。

        线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。

        中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。

        终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。

        传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C

    HappenBefore后续还对happenbefore进行了关于volatile和final语义的扩展

        voliatile的扩展:普通读写与voliatile写读之间不可以重排序。voliatile写读之间不可以重排序等。比如可以保证单例双重校验的问题。

        对final的扩展:对final语义的扩展保证一个对象的构建方法结束前,所有final成员变量都必须完成初始化(的前提是没有this引用溢出)

2.MESI协议与volatile底层原理

1.MESI模型与失效流程,
        为什么需要内存屏障 - betterfishXL的文章 - 知乎  https://zhuanlan.zhihu.com/p/55767485
            由来:
                MESI模型,CPU为了增加数据处理的销量,在主内存和CPU之间加了多级CACHE。CPU读取数据和修改数据都是先经过CACHE再经过CPU,CACHE一般有三层

                CPU1        CPU2

                cache1      cache1
                cache2      cache2
                cache3      cache3
                     BUS内存总线
                主内存主内存主内存主内存

                当模型定成这样的时候,会引发一个问题,假如CPU1和2的cache中同时存在a=0变量,在cpu1修改a=1,数据传入cache,还未传入主内存时,
                cpu2此时从自己cache中获取a执行其他操作。会导致a在c1与c2中数据不一致,为了解决这种问题,采用了mesi协议。
            流程:
                初始流程:
                1.CPUA在执行a=1之前检查a状态是否为共享,假如a状态为共享,则发送“无效标志”给CPUB
                2.CPUB收到无效标志,将a对应的cacheLine设置为无效,并将“无效返回”返回给CPUA
                3.CPUA收到无效返回,将数据写入cache,并更改a对应cacheLine状态
                更新流程:cpu与cache之间读写效率还是不够快,为了加快速度,在cpu与cache之间引入了,storeBuffer寄存器和Invaild queue寄存器,一个用于写数据一个用于接收发送无效标志
                CPU1        CPU2
                store       store
                cache1      cache1
                cache2      cache2
                cache3      cache3
                invaild que invaild que
                     BUS内存总线
                主内存主内存主内存主内存

                1.CPUA在执行a=1之前检查a状态是否为共享,假如a状态为共享,则发送“无效标志”给CPUB
                2.CPUB的Invaild queue收到无效标志,并立即返回“无效返回”(cacheLine不会立即设置)
                3.CPUA收到无效返回,将数据写入StoreBuffer(a值不会立刻更新到cacheLine中)

            问题:
                cpu写数据时只需要将数据写入storebuffer中,即可继续执行下边的指令,不用等store写入cache则可继续执行其他指令,这样会导致更改的数据在store时,另一个cpu对应的cacheLine收不到失效消息。

                读写屏障就是一种操作,操作的目的是将cpu写入寄存器中的指令强制写入到cache中,以达到线程A修改数据,线程B立即可见的问题。
                在原有的流程上增加了其他流程,比如当执行把数据写入cacheLine的操作时,必须把数据写入cacheLine

    2.内存屏障概念:

            在A等于0的情况下

            cpu:0           cpu:1
            A=1;            while(X){
                内存屏障            }
            X=true;         sout(A)

            此时:
            内存屏障主要是针对于cpu可能产生的指令重排序,以及缓存一致性这两个问题,cpu内部所采用的一种机制。主要有两个特点:
                1.禁止指令重排序,比如说可以保证写指令执行之前,比如例子中CPU0可能将X=true与A=1进行指令重排。则此时cpu2的1可能会输出A=0。假如加了内存屏障可以保证A=1在x=true之前执行。
                2.保证可见性,解决缓存一致性问题。通过读写屏障,清空本地的invalidate queue,保证之前的所有load都已经生效;写屏障,清空本地的store buffer,使得之前的所有store操作都生效。
            内存屏障可以被分为以下几种类型:
                LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
                StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
                LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
                StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
                x86架构下 StoreLoad屏障是通过mfence指令来实现。LoadLoad屏障是通过lfense, StoreStore屏障是通过mfence指令

    3.voliatile如何解决可见性的问题。
            包含volalite的代码汇编会发现lock,通过lock指令来实现类似于mfence的命令,mfence包含读屏障与写屏障。保证禁止指令重排序,保证可见性。

    4.as-if-serial语义:
            As-if-serial语义的意思是,所有的动作(Action)5都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。 比如,为了保证这一语义,重排序不会发生在有数据依赖的操作之中。

4.双重检查的懒汉式单例写法:

线程安全单例实现有多种,一般比较基础的包含懒汉式和饿汉式,懒汉式为要用到对象的再考虑生成、饿汉式为直接生成单例对象,需要的时候再返回(假如用static修饰饿汉式的方法,则因为类加载机制,会保证线程安全),不细讲,主要将一下懒汉式情况下的双重检查锁。

1.普通懒汉式(在高并发下多个线程进入①判断会导致生成多个对象)

public class SingleDemo {
    private static SingleDemo demo=null;
    private SingleDemo(){}
    public static SingleDemo getsDemo(){
       //①
        if(demo==null){
           demo=new SingleDemo ();
       }
           return demo;
    }
}

2.加锁懒汉式(给getsDemo的方法加锁来解决懒汉式)缺点:每次线程获取对象的时候都要试着去获取锁与释放锁,效率太低

public class SingleDemo {
    private static SingleDemo demo=null;
    private SingleDemo(){}
    public static synchronized SingleDemo getsDemo(){
       //①
        if(demo==null){
           demo=new SingleDemo ();
       }
           return demo;
    }
}

3.双重检查懒汉式(当创建好对象后,不用每次都尝试会获取锁)

public class SingleDemo {
    private static SingleDemo demo=null;
    private SingleDemo(){}
    public static SingleDemo getsDemo(){
     //①
      if(demo==null){
        synchronized(SingleDemo.class){
          if(demo==null){
           //②
           demo=new SingleDemo ();
          }
         }
       }
        return demo;
    }
}

缺点:

当执行②步骤的方法时,new一个对象主要分三步:1.在堆中划分内存 2.在内存中创建对象 3.将demo的引用指向堆中的内存地址。

jvm在代码中的顺序没有依赖关系的时候是会进行执行重排的。我们可以分析下123的依赖关系为2依赖于1,3依赖于1,则此时有可能会将123重排序为132,当线程执行3但为执行2时,此时demo的引用不为null,但新对象仍未建立。此时要有新线程执行到①判断demo不为null,则直接引用demo可能会出错。

解决方法:

将private static SingleDemo demo=null;修改为private static volatile SingleDemo demo=null。使用volatile关键字,可以利用内存屏障禁止指令重排序。,(在volatile写操作之前的任何操作都是不可重排序的)

4.扩展:枚举类为什么是线程安全的?

参考: http://www. hollischuang.com/archiv es/197

枚举类反编译的话,可以看到对枚举类变量的赋值是在static代码块中操作的,类加载机制可以保证线程安全。

枚举类反编译之后的代码如下:

public final class T extends Enum
{
    private T(String s, int i)
    {
        super(s, i);
    }
    public static T[] values()
    {
        T at[];
        int i;
        T at1[];
        System.arraycopy(at = ENUM$VALUES, 0, at1 = new T[i = at.length], 0, i);
        return at1;
    }

    public static T valueOf(String s)
    {
        return (T)Enum.valueOf(demo/T, s);
    }

    public static final T SPRING;
    public static final T SUMMER;
    public static final T AUTUMN;
    public static final T WINTER;
    private static final T ENUM$VALUES[];
    static
    {
        SPRING = new T("SPRING", 0);
        SUMMER = new T("SUMMER", 1);
        AUTUMN = new T("AUTUMN", 2);
        WINTER = new T("WINTER", 3);
        ENUM$VALUES = (new T[] {
            SPRING, SUMMER, AUTUMN, WINTER
        });
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值