【并发编程】-1. 计算机内存架构、JAVA内存模型、Volatile关键字

JAVA内存模型JMM

概述

  1. 概念Java Memory Model (JMM)JAVA内存模型是一种抽象的概念,描述的是一组规范,规范中定义了程序中各个变量(实例字段、静态字段、数组对象的组成元素)的访问方式,决定了一个线程对共享变量的写入何时对另一个线程可见;
  2. 工作流程:
    1. JVM运行程序的实体是线程 ,在线程创建时,JVM都会为其分配工作内存,用于存储线程私有的数据;
    2. JMM规定所有变量都存储在主内存中,所以当线程想操作变量时,需要先将变量从主内存中拷贝进自己的工作内存中,然后再对变量进行操作,操作完成后再将变更后的值刷写回主内存中;
    3. 结合JVM,也就是当线程操作一个对象时,会根据工作内存中引用地址去找到主内存中的真实对象,然后会讲对象拷贝到自己的工作内存中,当操作的对象较大时,会进行选择性拷贝,只拷贝自己需要操作的那部分数据;

主内存

  1. 所属区域 :属于线程共享区域 ,对JVM来说,主内存包括了堆和方法区;
  2. 存储内容:主要存储的是类的成员变量、方法中的局部变量、共享类的信息、常量、静态变量、线程创建的实例对象等共享数据都会放到主内存中,栈上分配的对象除外;
  3. 当多条线程对同一数据进行非原子性操作时,就可能出现线程安全问题

工作内存

  1. 所属区域 :属于线程私有区域,对JVM来说,工作内存包括了程序计数器、虚拟机栈和本地方法栈;
  2. 存储内容:主要存储当前方法的所有本地变量信息;
  3. 工作内存是每个线程的私有数据,线程之间无法相互访问,所以不存在线程安全问题

主内存和工作内存的关系

  1. 数据存储类型

    1. 工作内存 :对一个实例对象的成员方法来说,如果方法中含有的局部变量为boolean、byte、short、char、int、long、float、double八大基本数据类型,则这些数据将直接存储在工作内存的栈帧结构的局部变量表中,引用类型的局部变量则是存储对象的引用地址;
    2. 主内存 :存储具体的实例对象、实例对象的所有成员字段、类的相关信息、static静态变量;
  2. 数据操作方式

    1. 主内存

      public class Test {
          Integer num = new Integer(100);
          private void add(){
              num++;
          }
      }
      
    2. 工作内存

      public class Test {
          private void add(){
              Integer num = new Integer(100);
              num++;
          }
      }
      

计算机内存架构

JAVA程序是运行在操作系统上的,它的所有操作最终都是在与操作系统交互,想要理解JAVA内存模型,需要对计算机内存架构有一定的了解;

架构图

  1. 多核CPU 现在的CPU一般都为多核CPU拥有多个核心,可以支持多任务并发执行,每个线程也最终也是映射到各个CPU核心上去执行的;

    1. 超线程技术:增强核心并行运算性能,它允许一个CPU执行多个控制流,工作原理是将一颗物理CPU虚拟化为两颗逻辑CPU,我们常说的4核8线程就是通过这个技术实现的;

  2. 多级缓存

    1. 问题 :由于内存的处理速度远低于CPU,导致CPU在处理指令时大量时间花费在等待内存准备数据上,从而影响CPU性能;
    2. 解决办法 :是在寄存器和主内存之间添加L1、L2、L3多级高速缓冲区,来缓存CPU频繁访问的数据,之后寄存器再需要获取数据就可以直接从高速缓冲区中获取,不需要去访问内存;

CPU缓存一致性

由于CPU为了提升性能使用了多核与多级缓存等技术,那么各各核心、各级缓存之间就可能出现数据不一致的情况,CPU主要通过以下集中方式来保证缓存的一致性;

数据的写入

  1. 写直达 :在数据写入前判断数据是否已经在Cache中,如果数据存在,则将Cache中的数据更新,然后将数据写入内存中

    这种方式无论数据在不在 Cache 里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,影响性能;

  2. 写回 :当发生写操作时,只有在Cache不命中且数据对应的Cache中的 Cache Block 为脏标记情况下,才会将数据写到内存中;

    这种方式可以减少对主内存的写操作次数,提高性能。但是,可能会导致缓存中的数据与主内存不一致,需要额外的机制(如缓存一致性协议)来维护数据一致性。

缓存一致性问题

  1. 问题 :现在的多核CPU,由于L1/L2 Cache是多个核心各自独有的,而且CPU为了考虑性能,数据写入采用的是写回策略,这就可能导致多个核心缓存中数据不一致的情况,从而造成结果错误;

    1. 例子: 如上图,假设,A读取了内存中的 i 变量,并执行了i++语句,由于使用了写回策略;
      • A就会先把执行结果i = 1写入到 L1/L2 Cache 中,然后把L1/L2 Cache中对应的 Block 标记为脏的,此时数据并没有被同步到内存中的,因为写回策略,只有在 A 中的这个 Cache Block 要被替换的时候,数据才会写入到内存里,这就出现了内存中的数据和A中Cache数据不一致的情况;
      • 如果这时旁边的 B 从内存读取 i 变量的值,则读到的将会是错误的值;
      • 这个就是所谓的缓存一致性问题,A 号核心和 B 号核心的缓存,在这个时候是不一致,从而会导致执行结果的错误;
  2. 解决思路:解决这个问题就需要对两个不同核心中的缓存数据进行同步,一般需要满足两点:

    1. 写传播 :某个CPU核心里的Cache数据更新是,需要传播到其他核心的Cache中;

    2. 事物串行化 :在某个CPU里对数据的操作顺序,必须在其他核心看起来顺序是一样的;

      1. 没有事物串行化存在的问题 :A把 i 的值改为100,此时在同一时间 B 把 i 值改为200,这两个修改都会传播到C、D,此时就可能出现C、D收到A、B两个数据更改的顺序不同,也就会导致各个Cache中的数据不一致;所以,我们要保证 C 、 D能看到相同顺序的数据变化,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串行化;


      2. 实现技术

      • CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
      • 需要引入锁的概念呢,相当于只有获取到锁才能进行对应的数据更新;

总线嗅探

  1. 作用:可以实现写传播;
  2. 实现 :当A修改了Cache中的i变量时,会通过总线将这个事件广播通知给其他核心,CPU的所有核心,都会监听总线上的广播事件,检查Cache是否有相同变量,如果有则会更新自己的Cache
  3. 缺点:不能保证事物串行化,需要频繁发送广播事件,加重了总线带宽压力;

MESI协议

  1. 概念将数据用四种状态来标记 M(Modified|已修改) E(Exclusive|独占) S(Shared|共享) I(Invalidated|已失效)

    1. M(Modified)已修改 :表示Cache中的数据已经被更新过,但还没写入到内存中;
    2. E(Exclusive)独占 :表示数据值存储在一个CPU核心中,不需要考虑缓存一致性问题;
    3. S(Shared)共享 :表示数据存储在多个CPU核心中,当我们需要更新数据时,需要向其他核心发送广播请求,要求其他核心将Cache中对应的数据标记为已失效状态,然后再更新;
    4. I(Invalidated)已失效 :表示Cache中的数据已经失效,不可读取该状态数据;
  2. 相互之间的转化

    1. A从内存中读取变量 i 的值,会通过总线发消息通知其他CPU核心,如果其他CPU核心中没有缓存该数据,则A的Cache中 i 变量的标记为独占状态

    2. 之后,若其他核心中任意一个也要读取 i 变量,假设是B ,则会通过总线发送广播消息,给其他核心,由于A中已经读取了i 变量,所以会把数据返回给B,并将核心中的 i 变量标记为共享状态

    3. 如果A这时要修改变量 i 的值,并且Cache中变量 i 的标记为共享状态,则会通过总线发送广播消息,将各个核心中的 i 变量标记为失效状态 ,然后将A的Cache中 i 变量标记为修改状态

    4. 若此时A继续修改 i 变量的值,则不需要通知其他核心,直接修改即可

    5. B需要读取i 数据时,发现Cache中变量 i 的标记为失效 ,会发出读取数据的请求,A在收到B读取数据的请求后会将变量 i 同步到内存中,并将状态设为共享,这是B就可以读取到最新数据;

  3. 带来的优点:当数据标记为修改或者独占时,修改更新数据不需要发送广播消息,在一定程度上减小了总线带宽压力;

操作系统与JMM之间的关系

  1. JMM只是一组抽象的概念,是一组规则,它的内存划分:工作内存(线程私有)、主内存(线程共享)对于计算机硬件来说并不存在;
  2. JMM的数据操作对应到底层也是操作主内存、操作高速缓存来操作数据的,而多核CPU有是通过MESI协议来达到数据一致性的,所以,JAVA内存模型底层其实也还是通过MESI一致性来保证的数据一次性

JMM原理

指令重排序

为了提高性能,编译器和处理器通常会对指令进行重排序,有如下三种:

编译器指令优化的重排

  1. 编译器在不改变程序语义的前提下,重新安排语句的执行顺序;

    1. 不改变语义即代码之间不存在依赖,依赖可分为两种:数据依赖 int a = 1; int b = a;条件依赖 boolean f = ture; if(f){};
  2. 例子 :如下代码我们的预期应该是得到x=0、y=0这个结果,但实际上可能出现x=2、y=1这种结果;

    java 复制代码
    // 主存的共享变量
    int a = 0;
    int b = 0;
    
    //代码的顺序
    //线程A                   线程B
    代码1:int x = a;         代码3:int y = b;
    代码2:b = 1;             代码4:a = 2;
    
    //经过重排序后最终执行可能出现的顺序
    //线程A                   线程B
    代码2:b = 1;         代码4:a = 2;
    代码1:int x = a;     代码3:int y = b;  

指令并行的重排

  1. 现代处理器一般都的是采用指令级并行技术,会将多条不相互依赖(即后一个执行的语句,无需依赖前面语句的执行结果)的指令重叠执行,如CPU的流水线技术
CPU流水线
  1. 指令执行一般分为如下步骤:

    1. IF取指:CPU 会根据程序计数器里的存储地址,从内存或缓存中取出待执行的指令,将其加载到指令寄存器中;

    2. ID译码和取寄存器操作数:指令译码单元将指令解析成操作码和操作数,并确定执行指令所需的资源;

    3. EX执行或者有效地址计算:处理器根据指令的操作码执行相应的操作,可能涉及算术逻辑运算、内存访问或控制流操作等;

    4. MEM存储器访问:如果指令涉及内存操作,处理器会在这个阶段进行内存读取或写入操作;

    5. WB写回:将执行阶段得到的结果写回到寄存器文件或内存中;

  2. 为了提高硬件的利用率,CPU执行指令会采用流水线技术来工作;图中可以看出,当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样能提升CPU性能;

  3. 存在的问题:当流水线出现中断,所有的硬件设备都会进入一轮停顿期;

    1. 比如说需要执行i = a + b; j = c + d;不存在指令重排序时他们的执行顺序:1.读取a、2.读取b、3.执行a+b、4.保存结果到i、5.读取c、6.读取d、7.执行c+d、8.保存结果到j;
    2. 我们可以知道3、4两个步骤依赖于1、2两步,当1、2两步卡顿时,会导致3进行等待,则4也需要进行等待,最终拖慢整个流水线的执行,5、6、7、8同理;
    3. 解决的办法:我们可以知道第3步与5、6不存在依赖关系,所以CPU可以通过指令重排序的方式将5、6步的执行提前,执行顺序变为1、2、5、3、6、4、7、8,这时下相当于给具有依赖关系的语句中间插入无关语句,从而尽量保证前面的指令执行完成,减少停顿的可能;
  4. 作用:根据上述,我们可以知道,指令重排序的作用时减少CPU在流水线执行时的停顿;

  5. 带来的问题:对于单线程而言,由于指令重排是在保证串行语义执行的一致性的情况下进行的,但对于多线程环境就可能导致程序乱序执行的问题;

内存系统的重排

  1. 处理器缓存的存在,可能导致内存与缓存数据的同步存在时间差,导致加载load和存储store操作看上去可能是在乱序操作;

三大特性

JMM主要是围绕程序执行的原子性、有序性、可见性来开展的,通过这三大特性来保证数据的并发安全;

原子性

  1. 概念:指操作的原子性,指一组操作要么全部成功要么全部失败;
  2. 对于32位系统而言,byte、short、int、float、boolean、char等基本数据类型的读写操作是原子操作,而lone、double存储大小为64bit,一次读写操作需要分两次读取数据,就可能出现数据被两个线程分两次读取的情况;

可见性

  1. 概念:当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值;
  2. 由上述指令重排序可知,编译器还是处理器的重排现象,在多线程环境下会导致乱序执行的情况出现,也可能导致可见性问题;

有序性

  1. 概念:指代码按照我们编写的顺序从上往下依次执行;
  2. 在多线程环境下,对于某个线程来说,他的所有操作都视为有序的,但如果从一个线程中观察另外一个线程,所有操作都是无序的,原因是指令重排序和主内存与工作内存之间的同步存在延迟;

JMM如何保证三大特性

  1. 原子性 :除了基本数据类型的操作本身就保证原子性外,对于方法或代码块级别的原子性操作可以使用synchronize关键字或Lock锁接口实现类来保证程序执行的原子性;
  2. 可见性 :工作内存和主内存同步延迟现象导致的可见性问题,可以通过加锁或者volatile关键字解决;
  3. 有序性 :通过加锁或者volatile关键字解决,volatile可以通过禁止指令重排序来保证有序性;
  4. JMM内部还定义一套happens-before原则来保证多线程环境下两个操作间的原子性、可见性以及有序性

JMM中的happens-before原则

线程与内存的交互

  1. 交互类型 :在JAVA程序在执行过程中,实际就是OS在调度JVM的线程执行,执行过程就是与内存的交互操作,而内存的交互操作有8种;
    1. lock锁定:作用于主内存的变量,将一个变量标识为线程独占状态;
    2. unlock解锁:作用于主内存的变量,将一个锁定状态的变量释放出来;
    3. read读取:作用于主内存的变量,将一个变量的值从主内存传输到线程的工作内存中;
    4. load载入:作用于工作内存的变量,将read操作从主内存中传输的值放入工作内存中;
    5. use使用:作用于工作内存的变量,将工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到的变量值,就会使用到这个命令;
    6. assign赋值:作用于工作内存的变量,把一个从执行引擎中接受到的值放入工作内存的变量副本中;
    7. store存储:作用于主内存的变量,把一个工作内存的一个变量值传送到主内存中;
    8. write写入:作用于主内存的变量,把store操作传送的值放入主内存的变量中;
  2. JMM指定的交互规则
    1. 不允许read和load、store和write操作之一单独出现;即:使用了read必须load,使用了store必须write;
    2. 不允许线程丢弃他最近的assign操作,即工作变量的数据改变后,必须告知主内存;
    3. 不允许线程将没有assign操作的数据同步回主内存;
    4. 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过assignload操作;
    5. 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁;
    6. 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
    7. 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量;
    8. 对一个变量进行unlock操作之前,必须把此变量同步回主内存;

JMM中的happens-before原则

  1. 程序顺序原则:即在一个线程内必须保证语义串行性,也就是按照代码顺序执行;
  2. 锁规则 :解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前;
  3. volatile规则volatile变量的写,先发生于读,这保证了volatile变量的可见性;简单的理解就是:volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值;
  4. 线程启动规则 :线程的start()方法先于它的每一个动作,即如果线程A,在执行线程B的start方法前修改了共享变量的值,那么当线程B执行start方法时,线程A变更过的共享变量,对线程B可见;
  5. 传递性优先级规则:A先于B,B先于C,那么A必然先于C;
  6. 线程终止规则 :线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见;
  7. 线程中断规则 :对线程interrupt()方法的调用,先发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断;
  8. 对象终结规则:对象的构造函数执行,结束先于finalize()方法;

Volatile关键字

volatileJAVA提供的轻量级的同步工具,它可以保证可见性和做到禁止指令重排序做到有序性,但不能保证原子性;

内存屏障(Memory Barrier)

  1. 存屏障是一个CPU指令 ,作用是保证特定操作的执行顺序和保证某些变量的内存可见性;
    1. 保证执行顺序 :具体的就是在指令直接插入MemoryBarrier内存屏障,相当于告诉编译器和CPU不管什么指令都不能与这条MemoryBarrier进行指令重排序,也就是通过插入内存屏障,禁止在内存屏障前后的指令执行重排序;
    2. 保证可见性 :强制刷出各种CPU缓存数据,使CPU上的任何线程都能读到这些数据的最新版本;
  2. 内存屏障的类型
    1. LoadLoad Barriers:确保Load1指令数据的装载,发生于Load2及后续所有装载指令的数据装载之前;
      1. 指令示例:Load1; LoadLoad; Load2;
    2. StoreStore Barriers:确保Store1数据的存储对其他处理器可见(刷新到内存中)并发生于Store2及后续所有存储指令的数据写入之前。
      1. 指令示例:Store1; StoreStore; Store2;
    3. LoadStore Barriers:确保Load1指令数据的装载,发生于Store2及后续所有存储指令的数据写入之前。
      1. 指令示例:Load1; LoadStore; Store2;
    4. StoreLoad Barriers:确保Store1数据的存储对其他处理器可见(刷新到内存中),并发生于Load2及后续所有装载指令的数据装载之前;StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载)完成之后,才执行该屏障之后的内存访问指令;
      1. 指令示例:Store1; StoreLoad; Load2;

内存可见

  1. volatile可以保证,一个线程对volatile所修饰的变量进行更改操作后,总能对其他线程可见;
  2. 当一个变量被volatile修饰,发生写操作时JMM会把该线程工作内存中的共享变量值刷新到主内存中;当读取操作时,JMM会把该线程对应的工作内存置为无效,要求该线程从主内存中重新读取该变量的值,也是通过内存屏障实现的

禁止指令重排序

  1. volatile通过在修饰变量访问前后添加内存屏障,来静止指令重排序,从而保证有序性;

  2. 例子:说明volatile禁止指令重排序,synchronized不禁止;

    1. 以下是一个双重锁检测的代码:

      java 复制代码
      public class Singleton{
        private static Singleton singleton;
        
        private Singleton(){}
        
        public static Singleton getInstance(){
           if(singleton == null){
                synchronized(Singleton.class){
                      if(singleton == null){
                            singleton = new Singleton();
                     }
                }
            }
            return singleton;
        }
      }
    2. singleton没有被volatile修饰的时候是可能获取到null值,出现线程不安全的情况,,原因如下:

    3. 但当如果singleton变量加上volatile后,会禁止new这个操作被其他线程打断,从而保证线程安全;

具体实现

  1. 具体表现 :假设有五个相互之间不存在依赖性的指令操作A、B、C、D、E,现在对B、C、D加上内存屏障就变成了A、内存屏障( B、C、D)内存屏障、E;此时依旧可以发生重排序,但会将(B、C、D)看作一个整体,再去与A、E排序,其内部也可以重排序;
  2. 实现方式volatile并没有直接使用操作系统的内存屏障指令,而是使用的JVM内存屏障字节码指令,JVM的内存屏障字节码指令会间接使用操作系统的内存屏障指令,也就是JVM对操作系统的内存屏障指令做了层封装,具体的定义位于HotSport源码的bytecodeInterpreter.cpp文件中;
  3. 实现原理 :通过内存屏障的StoreLoad读+写屏障实现
    1. 当多个线程读取共享变量时,触发读屏障,读屏障中会记录哪些线程读取了这个变量
    2. 当有一条线程写会数据时,就会触发写屏障,此时写屏障会根据前面读屏障记录下来的线程,去通知所有还未刷回的线程,重新再来获取一次最新的值;(保证了可见性)
    3. 硬件层面
      1. volatile修改的高速缓存数据,写回到机器内存时,会通过缓存一致性协议和总线嗅探技术,通知其他处理器来检查这个变量有没有在自己的缓存中,如果缓存了该变量的数据则将该数据置为无效,后续再需要使用次变量时,重新从内存中读取;

不满足原子性

  1. 假设现在有两个线程T1、T2此时内存中i = 1,现在两个线程都执行i++操作,大致操作如下:
    1. T1-读值、T1-计算、T1-写值;
    2. T2-读值、T2-计算、T2-写值;
  2. 他们的每一步操作都是原子的,但整体不是,这就会导致当两条线程并行时,语句的执行可能变为(T1-读值、T2-读值、T2-计算、T2-写值、T1-计算、T1-写值),最终的结果为i = 2不符合预期的i = 3
  3. 现在假设使用了volatile来修饰变量,假设还是上面的执行流程,当T2写值时,会触发内存屏障,此时会要求还未刷回数据的T1线程重新获取一次主存数据,也就是i = 2,再经过计算后写入主存,最终结果为i = 3符合预期;
    1. 从步骤三来看,似乎volatile解决线程安全问题,但其实步骤三是需要建立在T1、T2处与同一核心的情况;对于多核CPUT1、T2线程可以绑定不同核心,从而达到并行执行的效果,此时就可能出现T1、T2i++操作,在同一时刻并发执行,接着出现T1、T2同时将i==2这个结果,刷写回主内存情况,从而导致线程安全问题;
相关推荐
新手村领路人4 分钟前
macos m2 百度paddleocr文字识别 python
开发语言·python·macos
一碗谦谦粉4 分钟前
Spring之spring的单例bean是线程安全的吗
java·spring
liuliuliuliuyujie5 分钟前
学生管理系统
java
ItKevin爱java8 分钟前
java八股文面试题
java·开发语言
※※冰馨※※8 分钟前
Visual Studo 2019 无法启动
开发语言·c#
宁波阿成19 分钟前
基于jeecgboot-vue3的Flowable流程-集成仿钉钉流程(一)大题思路
开发语言·javascript·钉钉
喜欢猪猪20 分钟前
两个线程之间是如何通信的呢?
java·开发语言
co松柏24 分钟前
在非 antd pro 项目中使用 umi OpenAPI
java·前端
java66666888825 分钟前
Spring Boot中的数据加密与解密
java·spring boot·后端
小白在路上~29 分钟前
51单片机嵌入式开发:STC89C52操作GPIO口LED灯
c语言·开发语言·单片机·嵌入式硬件·51单片机·dsp开发