并发编程(三):Volatile关键字

一,一段代码引发可见性思考

    1,代码片段:从代码可以看出,子线程会一直在循环中阻塞,当主线程已经修改flag的值为true后,子线程并没有对flag值做同步修改。当给flag加上volatile关键字修饰后,则子线程会获取到最新的flag值,并打印出结果(不做演示)

package com.gupao;

/**
 * @author pj_zhang
 * @create 2019-09-07 20:50
 **/
public class VolatileTest {

    // 初始化表示位
    private static boolean flag = false;

    private static int i;

    public static void main(String[] args) throws InterruptedException {

        new Thread(() -> {
            while (!flag) {
                i++;
            }
            System.out.println("子线程执行完成: i = " + i);
        }, "线程_1").start();

        // 等待100毫秒,保证子线程已经加载flag值到线程高速缓存区
        Thread.sleep(100);
        // 修改flag为true
        flag = true;
        System.out.println("主线程执行完成。。。");
    }
}

二,从硬件层面了解可见性本质

    1,缓存一致性

        1.1,在计算机迭代发展中,CPU、内存和I/O设备有一个非常核心的矛盾点,即三者的处理速度的差异(CPU > 内存 > I/O)。为了提升计算性能,CPU从单核升级到多核甚至使用超线程技术最大化提高CPU的处理性能,但是后面两者的速度没有跟上CPU的速度。为了平衡三者的速度差异,最大化的利用CPU的计算性能,从硬件,操作系统,编译器等方面做了很大优化

            a,CPU增加了高速缓存

            b,操作系统增加了线程和进程,通过CPU的时间片切换最大限度的提升CPU的使用率

            c,编译器的指令优化,更合理的去利用好CPU的告诉缓存

但是每一种优化,都会带来线程安全性问题,这也是可见性的本质

        1.2,CPU高速缓存

            a,因为CPU计算速度与内存或者I/O计算速度差异,现代计算机都会增加一层读写速度尽可能接近CPU运算速度的高速缓存,以此来作为CPU和内存之间的缓冲:将线程使用到的数据复制到高速缓存中,等运算结束后再从高速缓存同步到主内存。

            b,通过CPU高速缓存确实最大化解决了CPU和主内存间的运行差异,但是同时增加了计算机系统的复杂度,带来了缓存一致性的问题。在多CPU环境下,在同一主内存中的数据可能会被多核CPU共享,如果运行在多核CPU的多线程加载到各自CPU高速缓存中的数据,并各自进行修改,则可能存在缓存不一致问题。为了解决缓存不一致问题,CPU层面提供了两种解决方式

                   * 总线锁:即对主内存加锁,各个CPU在进行数据处理时串行执行,如Synchornized那样。这种方式在一定程度上抹杀了多核CPU的优势,显然是不合适的

                   * 缓存锁:基于MESI缓存一致性协议

        1.3,缓存一致性协议 -- MESI

             a,MESI状态详解

                   * M(Modify):缓存修改,表示缓存在当前CPU高速缓存中的数据已经被修改,即缓存数据与主内存数据不一致;如果存在其他CPU同时缓存该数据,则数据状态置为I(Invalid)

                   * E(Exclusive):缓存独占,数据只缓存在当前CPU高速缓存中,并且没有被修改

                   * S(Shard):缓存共享,表示多个CPU高速缓存同时缓存当前数据

                   * I(Invalid):缓存实效,当前CPU高速缓存缓存数据状态为S(Shard),且存在其他CPU高速缓存数据已经被修改M(Modify),则设置其他CPU高速缓存中该数据状态为I(Invalid)

             b,MESI状态转换

                   * 在MESI协议中,每一个缓存的缓存控制器不仅知道当前缓存的读写操作,同时也需要监听其他CPU高速缓存的操作,进行状态变更

                   * 单个CPU从主内存缓存数据到高速缓存,此时高速缓存的数据状态为E(Exclusive)

                   * 多个CPU同时从主内存中缓存数据到高速缓存,此时高速缓存的数据状态为S(Shard)

                   * CPU对高速缓存中的数据进行修改,则数据状态修改为M(Modify)。此时,如果之前数据状态为E(Exclusive),则直接修改状态为M(Modify)(E -> M)。如果之前数据状态为S(Shard),则修改当前CPU高速缓存中该数据状态为M(Modify)(S -> M),同时发送消息到其他CPU,通知其他CPU修改高速缓存中该数据状态为I(Invalid)(S -> I)

                   * CPU读请求:高速缓存中数据状态为M(Modify),E(Exclusive),S(Shard)的数据都可以被读取,I(Invalid)状态数据需要从主内存中重新读取

                   * CPU写请求:高速缓存中数据状态为M(Modify),E(Exclusive)的数据都可以被直接写到主内存,S(Shard)状态的数据写操作,需要通知其他CPU修改高速缓存中该数据状态为I(Invalid)

         1.4,缓存一致性带来的问题

                   * 在状态修改时,各个CPU修改状态是通过传递消息进行的。如果此时一个CPU进行了数据变更,则需要通知其他CPU将高速缓存中的数据状态置为无效,并且到等到各个CPU的确认回执。在这个阶段,当前CPU一直处于阻塞状态。

                   * 为了避免阻塞带来的CPU资源浪费,在CPU中引入了Store Bufferes

    2,Store Bufferes

        1.1,Store Bufferes发送消息流程

              * CPU在写入共享数据时,直接将数据写入到Store Bufferes中,同时发送 Invalidate 消息,然后去处理其他指令(CPU去阻塞)

              * 接收到其他CPU返回的 invalidate acknowledge 后,再将Store Bufferes中的数据存储至CPU高速缓存

              * 最终再从CPU高速缓存中同步数据到主内存

        1.2,Store Bufferes存在问题

                * 加入Store Bufferes流程后,CPU取数据增加了一道流程,需要先从Store Bufferes中获取被修改的数据,如果Store Bufferes中不存在该数据,则继续到CPU高速缓存或者主内存中去取数据

                * 因为加入Store Bufferes并发送消息,再到接收到ACK回执进行数据同步完全是异步操作,数据提交时间不确定,这就会造成CPU的指令重排问题

    3,CPU指令重排问题

        1.1,从一张图开始思考;多核多线程下,下图断言的执行结果可能会是 false

             * 在上图中,对于成员变量 value = 3,在多核CPU下,该值被多个CPU加载后,在各个CPU高速缓存中数据状态为S(Shard)

             * 此时CPU0对 value 值进行修改,在CPU0中,该值状态为M(Modify)。修改完成后,CPU0需要发消息通知其他CPU将高速缓存中该值状态修改为I(Invalid)。此时,CPU0需要做两件事情:1)将 (value = 10)存储到Store BUfferes中;2)发送Invalidate消息。之后,CPU0继续向下执行,执行语句(isFinsh = true, isFinsh状态为E(Exclusive)时会直接写入到主内存),此时各个CPU还没有返回ACK回执,Stroe Bufferes中的数据并没有写入到高速缓存和主内存

             * 此时存在CPU1执行 if 判断,从主内存获取到 isFinsh 的最新值 true,接着执行 assert value == 3,但是此时由于CPU0修改的 value 值还没有同步到主内存,则对于 CPU1来讲,value值依旧为3,则执行结果为false。在显式上看,好像CPU0先执行了( isFinsh = true),然后在没有执行(value = 10)时,CPU1线程已经抢先执行,所以执行结果为false,这就是CPU的指令重排序

    4,CPU层面的内存屏障

        4.1,硬件层面优化到这种程度,就目前技术来看,已经没有了优化空间。但是还是不符合功能要求,那就需要硬件层面上添加指令处理。所以,在CPU层面上提供了Memory Barrier(内存屏障)的指令,这个指令就是用来(flush store bufferes)中的指令,软件层面可以在合适的地方加入内存屏障(volatile是加入了storeload屏障)。CPU层面提供了三种屏障:

              * 写屏障(Store Barrier):写屏障之前的已经存储到Store Buffer的数据同步到主内存

              * 读屏障(Load Barrier):读屏障之后的读操作, 都在读屏障之后执行, 配合写屏障, 使得写屏障之前的内存更新对于读屏障之后的操作都是可见的

              * 全屏障(Full Barrier): 确保屏障前的读写操作全部更新到主内存后, 再进行屏障后的读写操作

三,JMM(Java Memory Model)-- java内存模型

    1,JMM内存模型:就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。JMM实际上就是提供了合理的禁用缓存和禁用重排序的方法。最核心的价值在于解决可见性和有序性

    2,JMM解决重排序问题

        2.1,为了提升程序的执行性能,编译器和处理器都会对指令做重排序,处理器的重排序前面已经分析。编译器的重排序值程序编写的指令在编译之后,会产生重排序来优化程序的执行性能。从源代码到最终执行的指令,可能会经过三种重排序

        2.2,1属于编译器的重排序,JMM提供了禁止特定类型的编译器重排序

        2.3,2和3属于处理器的重排序,JMM会要求编译器生成指令时,插入内存屏障来保障处理器重排序(例如volatile的storeLoad),当然并不是所有指令都会被重排序,符合Happen-Before规则的不会参与重排序

    3,JMM层面内存屏障

        * loadload():读屏障

        * storestore():写屏障

        * loadStore():读写屏障

        * storeLoad():写读屏障

    4,Volatile关键字使用了 storeload() 内存屏障,在JVM层面进行了内存屏障处理,保证了可见性和有序性

四,HappenBefore

    1,它的意思表示的是前一个操作的结果对于后续操作是可见的,所以它是一种表达多个线程之间对于内存的可见性。所以我们可以认为在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens before 关系 。 这两个操作可以是同一个线程,也可以是不同的线程

    2,能建立Happens-Before原则的操作

            * 程序的顺序规则
            * volatile规则
            * 传递性规则
            * start()规则, 线程可见
            * join()规则
            * 监视器规则(锁规则)

五,Volatile可以保证线程的有序性和可见性,但是不能保证原子性。因为在多线程环境下,存在线程在写数据时,会产生并发复盖场景

package com.gupao.concurrent;

/**
 * @author pj_zhang
 * @create 2019-09-26 22:26
 **/
public class VolatileAddTest {

    private static volatile int i = 0;

    public static void main(String[] args) {
        for (int count = 0; count < 100; count++){
            new Thread(() -> {
                i++;
            }).start();
        }
        System.out.println(i);
    }
}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章