并发编程-(volatile)可见性&有序性

并发编程-(volatile)可见性&有序性

【可见性】:就是两个线程对同一个变量进行修改线程a修改后,线程b没有读取到修改后的数据,相似于数据库中的脏读。java

【有序性】:在java内存模型中,容许编译器和处理器对指令进行从新排序,在单线程的时候不影响,可是在多线程的时候,就会影响执行结果的正确性。数据库

【volatile】:java提供的一种解决上面的这两种问题的方式,底层是cpu提供的#Lock指令。编程

可见性

模拟两个线程,两个线程对同一个数据进行改变,看看线程a改变了数据,线程b是否能够觉察,运行下面的代码,咱们发现,main线程对flag进行修改,并不能中止咱们执行while循环的线程,这就是线程可见性问题。缓存

    private static boolean flag=false;
    public static void main(String[] args) throws InterruptedException {
        Thread thread=new Thread(()->{
            int i=0;
            while (!flag){
                i++;
            }
        });
        thread.start();
        System.out.println("thread began execute");
        Thread.sleep(100);
        flag=true;
    }
}

如何解决线程可见性问题呢

1.实际上咱们只要增长volatile便可,能够这样认为,加了这个关键字后,当其中一个线程修改了一个变量后,其余的线程对应也能够看见修改后的内容,其实jvm中有作深度优化(活性失败),在底层优化后while(!flag)就变成了【whild(true)】,volatile就是禁止这样优化,从而让程序正常的运行。安全

 

2.咱们可让线程睡一下,实际上即便是0睡0毫米,cup也会进行切换,切换后,咱们的数值就会从新加载,起到正常运行的目的多线程

3. 能够打印 i 的数值,这实际上是一个 io 的操做,底层有synchronize的锁,变量也会从新加载。架构

 

可见性是什么致使的

咱们都知道cup是核心的资源,咱们不能让cpu资源的浪费,为了充分利用cpu资源的,咱们想出了一系列方案,可是在这一系列方案去优化cpu的使用率的时候,咱们就碰到了可见性的问题。这些优化的方案为: 并发

  • 给CPU增长高速缓存
  • 给操做系统中增长进程和线程这样的东西
  • 编译器层面:上面提到的jvm的深度优化就是一个例子

CPU的高速缓存

由于cpu的处理速度和内存读写的速度差距较大,那就形成了cpu读取内存数据的时候消耗很长时间,那能不能像咱们作分布式开发的时候的那样,把数据缓存在Redis中,从而减小性能的消耗,减小时间呢,是的cpu的高速缓存就是相似这样作的,cpu的高速缓存就是介于CPU处理器和内存之间的临时数据交换的缓冲区。他的流程就是当cpu读取数据的时候,当缓存中没有数据,才去内存中读取。实际上在cpu中设计了三层缓存,每个cpu中都有三个缓存,L1和L2是cpu独立存在的,其中L2缓存和L3是共享的,L1缓存中分为数据缓存(L1D)和指令缓存(L1I)jvm

咱们知道了缓存行的存在,就明白了为何上面的代码main修改了变量flag,可是执行i++的线程却不能感应到的缘由了,这就是缓存一致性问题,那么如何解决?这里引入一个概念缓存行分布式

 缓存行

cpu的缓存是由多个缓存行组成的,cpu每一次读取数据的时候都只会加载一段数据,缓存行是cpu和内存交互的最小工做单元,在x86的架构中,每一个缓存行是64个字节,因此cpu在读取数据的时候,一次就读取64个字节。由于在高速缓存的时候,涉及到一个伪共享的问题,那什么是伪共享?

伪共享

如今主流的缓存行都是64Bytes大小的,试想,若是两个线程同时修改同一个缓存行中的变量,那势必会形成互相竞争,缓存行一次读取了【ABCDEFGH,】线程1要修改A的数值,线程2要修改B的内容,当线程1修改了A的数值线程0的缓存行就会失效,当线程2修改B的数值的时候,那线程a的缓存行就会失效,这样就耗费了性能,这就是伪共享。那么怎么解决这个问题,那就要用到【对齐填充】了。

对齐填充

既然他每次都cpu每次都要读取64Bytes,可是这64个Bytes中可能加载了伪共享状况的发生,那咱们能不能每次只加载一个数据,其他都给他自动填充成别的数据,这样时候就避免了伪共享的发生,其实对齐填充就是这样作的,当咱们只要操纵【ABCDEFGH】中的A的时候,那咱们就第一个数据就只读取A,其他的位数就这样A*******

一个例子说明对齐填充的性能

// 对齐填充
public class ValuePadding {
    protected long p1, p2, p3, p4, p5, p6, p7;
    protected volatile long value = 0L;
    protected long p9, p10, p11, p12, p13, p14;
    protected long p15;
}
public class CacheLineExample  implements Runnable {
    public final static long ITERATIONS = 500L * 1000L * 100L;
    private int arrayIndex = 0;
    private static ValuePadding[] longs;

    public CacheLineExample(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }

    @Override
    public void run() {
        long i = ITERATIONS + 1;
        while (0 != --i) {
            longs[arrayIndex].value = 0L;
        }
    }

    static void runTest(int NUM_THREADS) throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS];

        longs = new ValuePadding[NUM_THREADS];

        for (int i = 0; i < longs.length; i++) {
            longs[i] = new ValuePadding();
        }
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new CacheLineExample(i));
        }

        for (Thread t : threads) {
            t.start();
        }
        for (Thread t : threads) {
            t.join();
        }
    }

}
public class Test {
    public static void main(final String[] args) throws Exception {
        for (int i = 1; i < 10; i++) {
            System.gc();
            final long start = System.currentTimeMillis();
            CacheLineExample.runTest(i);
            System.out.println(i + " Threads, duration = " + (System.currentTimeMillis() - start));
        }
    }

}

对齐填充

 不对齐填充(把CacheLineExample中的对齐填充的类替换便可

// 不对齐填充
public class ValueNoPadding {
    //8字节
    protected volatile long value = 0L;
}

tips:咱们发现对齐填充明显比不对齐填充运行的要快得多,在java8中咱们使用@Contended 去避免伪共享的问题,可是前提是你必须设置jvm的参数

缓存一致性问题

上面说了给cpu加上缓存是一种解决方案,可是这就又致使了缓存一致性问题【可见性】,缓存不一致问题,正如上述讲的demo,main修改了flag没有影响到别的线程的数据,其实咱们能够进行锁的方式解决,就是当个人某一个线程在修改内存的数据的时候,阻塞别的线程,或者,咱们是否是能够只对某个缓存行进行加锁呢,这样别的线程就不用阻塞,从而提升了性能,那就引出了两个概念。

  • 总线锁:其实在线程访问内存的时候还有要通过一个线程总线,其实咱们能够给这个总线加锁,可是这就想当于阻塞其余线程。
  • 缓存锁:咱们知道cpu是一个缓存行一个缓存行对数据进行读取,那么咱们就能够对緩存行进行加锁,从而提升控制的粒度,也不会对其余的线程阻塞,那么缓存锁是如何保障一致性的呢,那么就要引入一个概念, 缓存一致性协议MESI.

 MESL(表示缓存的4中状态)

cpu读:【M、E、S】都可被读取

cpu写:【M、E】,当状态为S时必须先把其余cpu缓存中的状态设为失效

  •  M  (Modify:修改):当咱们对某个变量进行修改,那状态就变成了 M,该缓存行有效,数据被修改了,和内存中的数据不一致数据只存在于本缓存行中
  •  E(Exclusive:互斥):也是就这一个数据只存在当前cpu对其余不可见那么状态就是 E,该缓存行有效,数据和内存中的数据一致,数据只存在于本缓存行中
  •  S(Shared:分享):和其余cpu共享一个数值时,状态就是 S ,该缓存行有效,数据和内存中的数据一致,数据同时存在于其余缓存中
  •     I   (Invalid:无效):当共享一个变量时,cpuA把一个数据修改,那么就要把别的cpu中的状态变为  I 该缓存行数据无效

如何使用mesl呢

这里要引入一个东西叫作snoopy协议,咱们都知道有线程总线这样一个概念,当其余cpu发起任务时候,会有一个状态在缓存总线上,这个snoopy就是来监听其余cpu的状态的对不一样状态作出不一样的反应,你能够理解缓存、cpu、snoopy是一个一个总线上的一个个的总体。好比说cpuA要修改flag=1那么他先修改本身的高速缓存,同时同步到主内存的的时候,会发送一个失效的指令到缓存总线中,这个时候其余的的线程中的flag状态失效,而后他们就会去内存中从新获取数据。这就达到了缓存一致性的目的。这里,咱们就知道当咱们声明 volatile关键字的时候,底层就会有一个汇编指令【#lock】去对cpu加锁多是总线锁、也多是缓存锁取决于你的cpu

【#lock】:能够解决总线锁、缓存锁、以及内存屏障的问题

 

有序性

有时候代码可能不是按照正常顺序执行的,在多线程状况下,咱们就能看出,例以下面的代码:正常执行结果多是(1,0)、(0,1)或(1,1) 然而可能有第三种可能那就是(0,0)出现这种状况就是颠倒执行了顺序。像我下面图中最后的执行场景。然而这种状况很难发生,你须要运行下面的代码好久,我是循环了200多万次才复现的这个场景。那么究竟是什么致使这种指令重排序的问题发生的呢?

 

public class seq  {
    private  static int x = 0, y = 0;
    private  static int a = 0, b = 0;


    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            String result = "第" + i + "次(" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.out.println(result);
                break;
            } else {
            }
        }
    }

}

 

什么致使了指令重排序

store buffer】:咱们能够想象他是相似于一个队列,上面说了我们的MESL当cpu修改某个数值的时候,其实先把这个消息告诉store buffer,而后他去通知别的cpu,说这个数值已经失效,接着其余cpu回复当前cpu收到后,当前cpu才修改他的缓存中的数值和内存中的数值,这个store buffer在必定的程度上提升了性能,由于当cpu把他要修改内存中的一个数值的信息告诉其余cpu的时候,其余cpu须要回复当前cpu后,当前cup才能对数据进行操做,那在等待的时候可能会有一段时间当前cpu闲置,这个store buffer 就能够去帮助当前cpu去通知消息,那样当前cpu就能够作后面的事情了,等到这个store buffer 返回消息后,他就能够对内存中的数据进行修改了

咱们来看这段代码,最终结果为什是b=1,用这个来分析致使指令重排序的缘由

 

用一个图来阐述问题

 其实上面问题就是cpuA他给了【store buffer】了这个数值而后是其余线程返回了这个数据,那试想是否能够直接从store buffer中获取这个数据呢?是的,这个过程叫【store forwarding】,cpu能够直接从本身的store buffer中读取数据,可是这又会致使问题,这里不进行展开,推荐去一个博客内容

 tips:store buffer 和【store forwarding】已经很大程度的让cpu资源利用最大化,可是有个问题,那就是【store buffer的大小是有限制的若是什么都放入这个里面cpu可能会阻塞的!,那又引入一个东西【Invalidate Queue】

 Invalidate Queue(失效队列)

 实际上就是上面的图中的第二步,cpuA等到别的cpu返回消息后才对本身的信息进行修改,那么是否能够在cpuB中放置一个队列同样的东西,去接受cpuA的消息,而后立马返回收到的信息呢?是这样的。Invalidate Queue】就是一个队列,接受其余cpu的失效内容,而后立马返回一个收到的信息【 invalidate ack】而后本身慢慢处理,这样就能够增长增长一些效率,让store buffer更快的执行。可是咱们想这里可能又致使了另一个问题,就是实际上,他只是放在本身的队列中,而没有真的对这个消息进行处理,或者说没有来的急对这个消息进行处理,可是线程别收到他的假消息,因而继续操做这个数据,因此可能又引出了指令重排序的问题。记住咱们这些都是在说有序性的缘由,这一切均可以使用【volatile】解决。那实在的怎么解决呢?这个时候要再介绍一个名词【内存屏障】

 

 内存屏障

  • 写屏障:全部的写操做必须在写屏障前面完成
  • 读屏障:全部的读操做必须在读屏障前完成
  • 和全屏障:读写操做必须在全屏障以前完成

内存屏障你就想象他是一个标记,他会锁定内存的系统,去确保执行顺序。他底层仍是经过#Lock指令,#Lock指令是一个全屏障。这里记住#Lock指令在不一样的系统中实现内存屏障的指令不一样,只不过在X86中是#Lcok,其余的架构中,咱们不去深究。

 总结

可见性:咱们想充分利用cpu的资源,因此提出使用cpu高速缓存去缓存一些数据,去减小cpu的执行时间加强执行效率,因此每一个cpu都有本身的【缓存行】,这里牵扯到一个【伪共享】的问题,消耗了cpu的性能,而后使用了【对齐填充】解决了这个问题。咱们想到可使用加锁的方式对数据安全进行保障,而后又引入了【总线锁、和缓存行锁】去解决这个问题,这些锁是基于【mesl】协议去作的,咱们使用【snoopy】去辅助各个线程去监听别的线程的【mesl】协议的状态,咱们知道若是一个变量被volatile所修饰的话,在每次数据变化以后,值都会被强制刷入主存。而其余处理器的缓存因为遵照了mesl协议,也会把这个变量的值从主存加载到本身的缓存中,这就解决了可见性问题。

有序性:【store buffer】辅助cpu去通知各个线程本身的状态,可是其实直接从【store buffer】中获取当前本身已经修改的内存比通知别的线程更能提升性能,因此咱们就引入了【store forwarding】,然而store forwarding 的内存大小有限,因此咱们引入了【 Invalidate Queue】去充当一个失效队列,cpu在接收到内容后,就直接返回本身的结果,可是有可能他急于返回内存,并无处理,那就形成了可能在后面的流程须要用到这个值,然而没有操做的结果,因此就致使了【指令重排序】,那只能交给开发本身解决,这个时候用volatile关键字就能够调用cpu提供的【内存屏障】,从而解决有序性问题。