• 深入理解并发三大特性


    并发三大特性

    可见性、原子性和有序性问题

    可见性

    当一个线程修改了共享变量的值,其他线程能够看到修改的值。Java 内存模型是通过在变量 修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介 的方法来实现可见性的。

    如何保证可见性?

    • 通过 volatile 关键字保证可见性。
    • 通过 内存屏障保证可见性。
    • 通过 synchronized 关键字保证可见性。
    • 通过 Lock保证可见性。
    • 通过 final 关键字保证可见性

    深入理解一下可见性

    可见性问题深入分析

      //  storeLoad  JVM内存屏障  ---->  (汇编层面指令)  lock; addl $0,0(%%rsp)
        // lock前缀指令不是内存屏障的指令,但是有内存屏障的效果   缓存失效
        private  boolean flag = true;
        private int count = 0;
    
        public void refresh() {
            flag = false;
            System.out.println(Thread.currentThread().getName() + "修改flag:" + flag);
        }
    
        public void load() {
            System.out.println(Thread.currentThread().getName() + "开始执行.....");
            while (flag) {
                //TODO  业务逻辑
                count++;
    
            }
            System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
        }
    
        public static void main(String[] args) throws InterruptedException {
            MainTest test = new MainTest();
    
            // 线程threadA模拟数据加载场景
            Thread threadA = new Thread(() -> test.load(), "threadA");
            threadA.start();
    
            // 让threadA执行一会儿
            Thread.sleep(1000);
            // 线程threadB通过flag控制threadA的执行时间
            Thread threadB = new Thread(() -> test.refresh(), "threadB");
            threadB.start();
    
        }
    
    
        public static void shortWait(long interval) {
            long start = System.nanoTime();
            long end;
            do {
                end = System.nanoTime();
            } while (start + interval >= end);
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

    先看一个例子,这里我们使用线程a,b 分别启动,线程 B去改线程a里循环变量,让其停下来。

    首先运行一下,可以看到线程a没有停下来,一直在运行。

    在这里插入图片描述

    思考

    这里为什么x编程b更改了变量值,线程a没有停下来,线程 B的这个变量一直是true呢?

    原因是在 Java 中,每个线程都有独立的变量空间,也就是我们所说的本地线程空间,也就是缓存。

    在 Java 中,这里需要引入一个 JMM 内存模型,java中规定线程中所有的变量都需要从内存中读取,拷贝到线程本地变量(这个变量值是内存变量的副本),然后运算之后再写回到主内存中。

    Java内存模型(JMM)

    Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各 种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效 果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可 以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。JMM 描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私 有数据区域的访问方式,JMM是围绕原子性、有序性、可见性展开的。

    在这里插入图片描述

    这里我们画一下线程 A,B 的变量执行流程图

    在这里插入图片描述
    这里具体步骤如下

    • 1 通过read指令从内存读取flag 变量
    • 2 load 加载到线程本地 也就是变量副本
    • 3 cpu 使用use这个flag变量将其改为false
    • 4 然后assign 到线程本地
    • 5 store 变量 write 到主内存

    但此时 线程 A没有停止下来,因为线程 A 并没有重新从内存读取最新的变量。

    内存交互操作

    关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、 如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

    • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
    • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放 后的变量才可以被其他线程锁定。
    • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存 中,以便随后的load动作使用
    • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放 入工作内存的变量副本中。
    • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引 擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
    • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给 工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
    • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主 内存中,以便随后的write的操作。
    • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值 传送到主内存的变量中。
      在这里插入图片描述

    Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

    • 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操 作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。 但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
    • 不允许read和load、store和write操作之一单独出现 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须 同步到主内存中。
    • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回 主内存中。
    • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化 (load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过 了assign和load操作。
    • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条 线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解 锁。lock和unlock必须成对出现
    • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用 这个变量前需要重新执行load或assign操作初始化变量的值
    • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许 去unlock一个被其他线程锁定的变量。
    • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和 write操作)。

    如何让其重新读取呢?

    这里大概有这么几种办法

    1 使用 volatile 关键字

     private volatile boolean flag = true;
    
    • 1

    2 使用 Integer 不使用 int

    private Integer count = 0;
    
    • 1

    Integer value是 通过 final 关键字保证可见性 , final可以保证可见性。

    3 在 while 循环中输出一行东西

     public void load() {
            System.out.println(Thread.currentThread().getName() + "开始执行.....");
            while (flag) {
                //TODO  业务逻辑
                count++;
                //输出一行东西
                System.out.println(" -----  ");
    
            }
            System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    4 在while循环中睡眠

    public void load() {
           System.out.println(Thread.currentThread().getName() + "开始执行.....");
           while (flag) {
               //TODO  业务逻辑
               count++;
    
               try {
                   Thread.sleep(1);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
    
           }
           System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
       }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    5 在循环中手动加内存内存屏障

      UnsafeFactory.getUnsafe().storeFence();
    
    • 1

    6 让线程 A 交出cpu执行权,重新调度

     Thread.yield();
    
    • 1

    7 LockSupport.unpark 线程放弃调度

    
     LockSupport.unpark(Thread.currentThread());
    
    • 1
    • 2

    8 释放缓存(释放硬件缓存,让缓存失效 重新从内存获取)

      shortWait(1000000); //1ms
    
    • 1

    由于死循环一直在执行 导致硬件缓存中的变量一直在使用,没有失效。

    以上8 中方法可以让其从内存重新读取变量值,也就是说保证可见性总的来说大概有 2 种方式

    • 1 jvm层面 storeLoad内存屏障 ===> x86 lock替代了mfence
    • 2 上下文切换 Thread.yield();

    在 Java 层面 有这么几种方式

    • 1 线程切换
    • 2 内存屏障
    • 3 加锁保证原子性可见性有序性
    • 4 final 不可变性

    硬件层面的可见性

    CPU高速缓存(Cache Memory)

    CPU缓存即高速缓冲存储器,是位于CPU与主内存间的一种容量较小但速度很高的存储 器。由于CPU的速度远高于主内存,CPU直接从内存中存取数据要等待一定时间周期,Cache中 保存着CPU刚用过或循环使用的一部分数据,当CPU再次使用该部分数据时可从Cache中直接调 用,减少CPU的等待时间,提高了系统的效率。

    多CPU多核缓存架构

    物理CPU:物理CPU就是插在主机上的真实的CPU硬件,在Linux下可以数不同的physical id 来确认主 机的物理CPU个数。
    核心数:我们常常会听说多核处理器,其中的核指的就是核心数。在Linux下可以通过cores来确认主机 的物理CPU的核心数。
    逻辑CPU:逻辑CPU跟超线程技术有联系,假如物理CPU不支持超线程的,那么逻辑CPU的数量等于核 心数的数量;如果物理CPU支持超线程,那么逻辑CPU的数目是核心数数目的两倍。在Linux下可以通 过 processors 的数目来确认逻辑CPU的数量。 现代CPU为了提升执行效率,减少CPU与内存的交互,一般在CPU上集成了多级缓存架 构,常见的为三级缓存结构。

    在这里插入图片描述
    在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就是局部性原理。

    时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。 比如循环、递归、方法的反复调用等。
    空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。 比如顺序执行的代码、连续创建的两个对象、数组等。

    缓存一致性(Cache coherence)

    计算机体系结构中,缓存一致性是共享资源数据的一致性,这些数据最终存储在多个本地缓 存中。当系统中的客户机维护公共内存资源的缓存时,可能会出现数据不一致的问题,这在多处 理系统中的cpu中。

    在这里插入图片描述
    在共享内存多处理器系统中,每个处理器都有一个单独的缓存内存,共享数据可能有多个副 本:一个副本在主内存中,一个副本在请求它的每个处理器的本地缓存中。当数据的一个副本发 生更改时,其他副本必须反映该更改。缓存一致性是确保共享操作数(数据)值的变化能够及时地 在整个系统中传播的规程。

    缓存一致性的问题

    在这里插入图片描述

    这里我们两个线程再cpu去执行一端代码,x初始值是 5

    执行过程中 2 个线程将值读到缓存中此时值是 5

    在这里插入图片描述

    这里此时第三个线程来读取,此时x 值可能是 5 8 10

    出现这种情况有可能是t1 没回写 此时是 10 ,t2 没回写是 8 两个都没回写 是 5 。

    为什么会出现这种情况,原因是cpu缓存不一致

    如果缓存一致那么我们的结果是 13 也就是我们所需要的正确值。

    解决缓存不一致
    一致性机制

    确保一致性的两种最常见的机制是窥探机制(snooping )和基于目录的机制(directory- based)

    如果有足够的带宽可用,基于协议的窥探往往会更快,因为 所有事务都是所有处理器看到的请求/响应。其缺点是窥探是不可扩展的。每个请求都必须广播 到系统中的所有节点,这意味着随着系统变大,(逻辑或物理)总线的大小及其提供的带宽也必须 增加。另一方面,目录往往有更长的延迟(3跳 请求/转发/响应),但使用更少的带宽,因为消息 是点对点的,而不是广播的。由于这个原因,许多较大的系统(>64处理器)使用这种类型的缓存 一致性。

    总线仲裁机制

    在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都 是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包 括读事务(Read Transaction)和写事务(WriteTransaction)。读事务从内存传送数据到处 理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。 这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会 禁止其他的处理器和I/O设备执行内存的读/写。
    在这里插入图片描述
    假设处理器A,B和C同时向总线发起总线事务,这时总线仲裁(Bus Arbitration)会对竞 争做出裁决,这里假设总线在仲裁后判定处理器A在竞争中获胜(总线仲裁会确保所有处理器都 能公平的访问内存)。此时处理器A继续它的总线事务,而其他两个处理器则要等待处理器A的 总线事务完成后才能再次执行内存访问。假设在处理器A执行总线事务期间(不管这个总线事务 是读事务还是写事务),处理器D向总线发起了总线事务,此时处理器D的请求会被总线禁止。

    总线的这种工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间 点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。

    总线锁定

    总线锁定就是使用处理器提供的一个 LOCK#信号,当其中一个处理器在总线上输出此信号 时,其它处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

    缓存锁定

    由于总线锁定阻止了被阻塞处理器和所有内存之间的通信,而输出LOCK#信号的CPU可能 只需要锁住特定的一块内存区域,因此总线锁定开销较大。

    缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那 么当它执行锁操作回写到内存时,处理器不会在总线上声言LOCK#信号(总线锁定信号),而 是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制 会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的 数据时,会使缓存行无效。
    缓存锁定不能使用的特殊情况:

    • 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会 调用总线锁定。
    • 有些处理器不支持缓存锁定。
    总线窥探(Bus Snooping)

    现在cpu里使用总线窥探机制

    工作原理当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到所有其他具有 该数据副本的缓存中。这种更改传播可以防止系统违反缓存一致性。数据变更的通知可以通过总 线窥探来完成。所有的窥探者都在监视总线上的每一个事务。如果一个修改共享缓存块的事务出 现在总线上,所有的窥探者都会检查他们的缓存是否有共享块的相同副本。如果缓存中有共享块 的副本,则相应的窥探者执行一个动作以确保缓存一致性。这个动作可以是刷新缓存块或使缓存 块失效。它还涉及到缓存块状态的改变,这取决于缓存一致性协议(cache coherence protocol)。

    根据管理写操作的本地副本的方式,有两种窥探协议:

    Write-invalidate 当处理器写入一个共享缓存块时,其他缓存中的所有共享副本都会通过总线窥探失效。这种 方法确保处理器只能读写一个数据的一个副本。其他缓存中的所有其他副本都无效。这是最常用 的窥探协议。MSI、MESI、MOSI、MOESI和MESIF协议属于该类型。

    Write-update 当处理器写入一个共享缓存块时,其他缓存的所有共享副本都会通过总线窥探更新。这个方 法将写数据广播到总线上的所有缓存中。它比write-invalidate协议引起更大的总线流量。这就 是为什么这种方法不常见。Dragon和firefly协议属于此类别。

    一致性协议(Coherence protocol)

    一致性协议在多处理器系统中应用于高速缓存一致性。为了保持一致性,人们设计了各种模 型和协议,如MSI、MESI(又名Illinois)、MOSI、MOESI、MERSI、MESIF、write-once、 Synapse、Berkeley、Firefly和Dragon协议。

    MESI协议 MESI协议是一个基于写失效的缓存一致性协议,是支持回写(write-back)缓存的最常用 协议。

    MESI 缓存行大小 64 字节

    缓存行有4种不同的状态

    已修改Modified (M) : 缓存行是脏的(dirty),与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必 须回写到主存,状态变为共享(S).
    独占Exclusive (E) : 缓存行只在当前缓存中,但是干净的–缓存数据同于主存数据。当别的缓存读取它时,状态变为 共享;当前写数据时,变为已修改状态。
    共享Shared (S) : 缓存行也存在于其它缓存中且是未修改的。缓存行可以在任意时刻抛弃。
    无效Invalid (I) : 当块标记为 M (已修改), 在其他缓存中的数据副本被标记为I(无效)

    如果变量被一个线程加载到缓存中,此时消息总线会嗅探到,并且标记缓存行是独占状态
    在这里插入图片描述

    此时另一个线程也加载变量到缓存,嗅探且标记为共享状态 S

    在这里插入图片描述

    同样如果都被cpu修改并且回写到缓存中,会被总线窥探到会变为 M 修改状态,立即刷会主存,减少其他cpu加载减少等待时间,会告诉其他处理器,缓存中的变量会变成无效状态,必须重新从内存去获取。

    在这里插入图片描述

    如果说两个cpu同时计算后被嗅探到更改,同时会写到缓存,再回写到主内存,此时会发生覆盖,也就是数据丢失。

    只能保证缓存一致性,不能保证原子性,volatile 就是如此,如++请求。

    缓存一致性失效
    • 跨cpu核处理

    如果多个核的线程在操作同一个缓存行中的不同变量数据,那么就会出现频繁的缓存失效,即使 在代码层面看这两个线程操作的数据之间完全没有关系。这种不合理的资源竞争情况就是伪共享 (False Sharing)。

    • 不支持嗅探机制的cpu

    保证可见性,如使用总线裁决,锁总线等方式,只不过这种属于串行操作了。

    • 跨页表,跨总线宽度

    如 32 位机器 执行64 位操作

    JMM的内存可见性保证

    一般用锁或者 volatile 去保证,按程序类型,Java程序的内存可见性保证可以分为下列3类:

    单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会 共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。

    x=8
    y=5
    z=x+3

    在不影响结果的情况下指令重排序,下面会比上面 少load一个指令,x load 之后可以立马进行计算,而上面需要load 2 次。

    y=5
    x=8
    z=x+3

    但在多线程情况下就可能会出现问题,无法保证可见性问题,结果就无法保证。

    as-if-serial语义

    不管怎么重排序(编译器和处理器为了提高并行度),(单线 程)程序的执行结果不能被改变。

    double pi = 3.14; // A 
     double r = 1.0; // B 
    double area = pi * r * r; // C
    
    • 1
    • 2
    • 3

    A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序 列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之 间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。

    正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的 执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点, JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。

    未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行 时读取到的值,要么是之前某个线程写入的值,要么是默认值未同步程序在JMM中的执 行时,整体上是无序的,其执行结果无法预知。

    JMM不保证未同步程序的执行结果与 该程序在顺序一致性模型中的执行结果一致。

    最经典的问题就是我们的 dcl 双重校验的单例

    在这里插入图片描述

    如果没有指令重排序,那么此单例对象,初始化不会有问题,但指令重排序,在多线程情况下,2,3 步骤发生重排序

    即使同步的多线程程序JMM不保证未同步程序的执行结果与 该程序在顺序一致性模型中的执行结果一致。
    在这里插入图片描述

    在t1线程初始化未结束时,已经指向分配的内存空间
    此时t2线程进来,发现内存不是 Null 就会返回一个未初始化完成的对象,此时就出现问题。

    解决

    • 1 禁止指令重排序
    • 2 线程t1 内存地址对t2可见,使用 volatile 关键字

    使用 volatile 后,进入同步代码块后,在线程t2 load 读取时,会保证 t1 初始化store 写对所有处理器可见,线程t1发现内存地址不是null,直接返回,线程 t2继续初始化,然后返回对象地址。

    happens-before原则

    happens-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的 主要依据,保证了多线程环境下的可见性。

    happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依 靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。

    happens-before原则定义如下:

      1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作 可见,而且第一个操作的执行顺序排在第二个操作之前。
      1. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则 制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果 一致,那么这种重排序并不非法。

    如:

    i = 1; //线程A执行 
     j = i ; //线程B执行
    
    • 1
    • 2

    j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以 确定线程B执行后j = 1 一定成立。

    如果他们不存在happens-before原则,那么j = 1 不一定成 立。这就是happens-before原则的威力。

    happens-before原则规则
    • 1.程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操 作;
    • 2.锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;
    • 3.volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
    • 4.传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A 先行发生于操作C;
    • 5.线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
    • 6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件 的发生;
    • 7.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
    • 8.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
    八条是原生Java满足Happens-before关系的规则

    程序次序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理 器会对指令进行重排序。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的 结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确 性。

    锁定规则:这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状 态,那么必须先执行unlock操作后面才能进行lock操作。

    volatile变量规则:这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就 是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是 happens-before读操作的。

    传递规则:提现了happens-before原则具有传递性,即A happens-before B , B happens- before C,那么A happens-before C

    线程启动规则:假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对 共享变量的修改在接下来线程B开始执行后确保对线程B可见。

    线程终结规则:假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B 在终止之前对共享变量的修改在线程A等待返回后可见。

    volatile的内存语义

    volatile的特性可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最 后的写入。

    原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复 合操作不具有原子性(基于这点,我们通过会认为volatile不具备原子性)。volatile仅 仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个 临界区代码的执行具有原子性。 64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。

    有序性:对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指 令重排序来保障有序性。

    在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许 volatile变量与普通变量重排序。

    为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组 决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保 volatile的写-读和锁的释放-获取具有相同的内存语义。

    volatile写-读的内存语义 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到 主内存。

    当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来 将从主内存中读取共享变量。

    volatile可见性实现原理 JMM内存交互层面实现 volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修 改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证volatile变量操作对多线程 的可见性。

    指令重排序

    Java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况 的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。

    指令重排序的意义:JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机 器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。 在编译器与CPU处理器中都能执行指令重排优化操作

    在这里插入图片描述

    JVM 中 是如何保证可见性的

    Java中使用volatile来确定重排序规则

    在这里插入图片描述
    volatile禁止重排序场景:

    1. 第二个操作是volatile写,不管第一个操作是什么都不会重排序
    2. 第一个操作是volatile读,不管第二个操作是什么都不会重排序
    3. 第一个操作是volatile写,第二个操作是volatile读,也不会发生重排序

    JMM内存屏障插入策略

    1. 在每个volatile写操作的前面插入一个StoreStore屏障
    2. 在每个volatile写操作的后面插入一个StoreLoad屏障
    3. 在每个volatile读操作的后面插入一个LoadLoad屏障
    4. 在每个volatile读操作的后面插入一个LoadStore屏障

    在这里插入图片描述

    不同处理器架构上支持所有的内存屏障指令

    x86处理器不会对读-读、读-写和写-写操作做重排序, 会省略掉这3种操作类型对应的内存 屏障。仅会对写-读操作做重排序,所以volatile写-读操作只需要在volatile写后插入 StoreLoad屏障

    在这里插入图片描述
    x86架构上仅支持store Load 内存屏障

    x86机器上 有三种方式可以实现storeLoad

    mfence
    cpuid 锁消息总线
    locked insn lock前缀指令

    基本上x86都是用locke 前缀指令,性能比较好。

    JVM层面的内存屏障

    在JSR规范中定义了4种内存屏障:

    • LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数 据被访问前,保证Load1要读取的数据被读取完毕。

    • LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前, 保证Load1要读取的数据被读取完毕。

    • StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前, 保证Store1的写入操作对其它处理器可见。

    • StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行 前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。

    在大多数处理器的 实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能 由于x86只有store load可能会重排序,所以只有JSR的StoreLoad屏障对应它的mfence或 lock前缀指令,其他屏障对应空操作

    硬件层内存屏障

    硬件层提供了一系列的内存屏障 memory barrier / memory fence(Intel的提法)来提供 一致性的能力。

    拿X86平台来说,有几种主要的内存屏障:

    1. lfence,是一种Load Barrier 读屏障
    2. sfence, 是一种Store Barrier 写屏障
    3. mfence, 是一种全能型的屏障,具备lfence和sfence的能力
    4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。

    Lock会对 CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

    内存屏障有两个能力:

      1. 阻止屏障两边的指令重排序
      1. 刷新处理器缓存/冲刷处理器缓存 对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主 内存加载数据;

    对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写 回到主内存。 Lock前缀实现了类似的能力,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁 后会把高速缓存中的数据刷新回主内存。在Lock锁住总线的时候,其他CPU的读写请求都会被 阻塞,直到锁释放。

    不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由 JVM来为不同的平台生成相应的机器码。

    原子性

    一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。

    在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。

    不采取任 何的原子性保障措施的自增操作并不是原子性的。

    如何保证原子性通过 synchronized 关键字保证原子性。
    通过 Lock保证原子性。 通过 CAS保证原子性。

    有序性

    即程序执行的顺序按照代码的先后顺序执行。
    JVM 存在指令重排,所以存在有序性问题。

    如何保证有序性通过 volatile 关键字保证可见性。

    • 通过 内存屏障保证可见性。
    • 通过 synchronized关键字保证有序性。
    • 通过 Lock保证有序性。

    最后

    并发三大特性里,最难理解的是可见性,可见性是为有序性服务的。

    我们常说的并发场景下有三个重要特性:原子性、可见性、有序性。只有在满足了这三个特性,才能保证并发程序正确执行,否则就会出现各种各样的问题。

    原子性, CAS 和 Atomic* 类,可以保证简单操作的原子性,对于一些复杂的操作,可以使用synchronized 或各种锁来实现。

    可见性,指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

    有序性,程序执行的顺序按照代码的先后顺序执行,禁止进行指令重排序。看似理所当然的事情,其实并不是这样,指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。但是在多线程环境下,有些代码的顺序改变,有可能引发逻辑上的不正确。

    而 volatile 做实现了两个特性,可见性和有序性。所以说在多线程环境中,需要保证这两个特性的功能,可以使用 volatile 关键字。

    对于我们 Java开发来说,了解 JMM 内存模型以及volatile是如何保证可见性以及内存屏障就可以了。

  • 相关阅读:
    算法分析与设计CH3:Growth of Functions
    【Linux问题】This account is currently not available.
    热烈庆祝瑞森半导体成立10周年
    文盘Rust -- 本地库引发的依赖冲突
    网络安全(黑客)-0基础小白自学
    尚硅谷(SpringCloudAlibaba微服务分布式)学习代码Eureka部分
    「建模学习」听说3D建模很难,原来不是学不会,而是缺少这个
    资料总结分享:临床重要数据库
    【Python 3】函数
    ACM MM 2022视频理解挑战赛视频分类赛道冠军AutoX团队技术分享
  • 原文地址:https://blog.csdn.net/weixin_38361347/article/details/127398751