• 【Java并发专题之一】Java内存模型


    一、计算机内存模型

    针对计算机机器而言,操作系统、JVM程序等其他所有程序都需要遵循内存模型规范。
    1、CPU技术发展
    1.1 CPU缓存的出现
    CPU的发展快于内存条,CPU的运算速度越来越快,内存条的读写速度无法适应CPU的速度,那么就在CPU和内存条之间加上高速缓存来适配;


    (1)缓存有L1-一级缓存、L2-二级缓存、L3-三级缓存等多级缓存;

    #查看核cpu0,index0的缓存大小
    $ cat /sys/devices/system/cpu/cpu0/cache/index0/size
    32K
    #查看核cpu0,index0的缓存类型
    $ cat /sys/devices/system/cpu/cpu0/cache/index0/type
    Data
    #查看核cpu0/index0,cpu3/index3的缓存等级
    $ cat /sys/devices/system/cpu/cpu0/cache/index0/level
    1
    $ cat /sys/devices/system/cpu/cpu3/cache/index3/level
    3
    #查看核cpu0/index0缓存行大小,理解缓存行的概念,netty高效率就在于此
    $ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
    64

    (2)越靠近CPU,读写速度越快,容量越小,制造成本越高;越靠近内存条,读写速度越慢,容量越大,制造成本越低;

    (3)每一级缓存中所储存的全部数据都是下一级缓存的一部分;
    (4)当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。
    (5)单核CPU只含有一套L1,L2,L3缓存;如果CPU含有多个核心,即多核CPU,则每个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存。

     图示

    无缓存:

    单核:

    多核:

    数据访问冲突分析:
    (a)单核单线程、多核单线程:cpu核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题。
    (b)单核CPU,多线程:进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突。
    (c)多核CPU,多线程:每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。

     

    1.2 为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理,称为处理器优化。

    1.3 现代编程语言的编译器(Java虚拟机的即时编译器-JIT)为优化而重新安排语句的执行顺序,称为指令重排,带来的效果和处理器优化是一样的。

    小结:计算机为了实现最大限度的并行提高效率,所以采用重排序,其实以上都属于重排序:

    (1)编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
    (2)指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
    (3)内存读写的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

    (2)和(3)都属于处理器重排序.

    2、多核CPU技术发展对并发编程带来的风险

    以上的CPU缓存技术以及重排序对于单核CPU而言,达到了提高执行速度的目的,不会发生数据安全问题。但是随着技术的发展,出现了多核CPU,那么这些新技术反而成了导致数据安全的始作俑者。

    并发编程为了保证数据的安全,需要满足以下三个特性:

    (a)原子性:是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行,强调的是:当前线程的操作不被其他线程干扰;
    (b)可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值,强调的是:当前线程的数据修改对其他核上的线程可见;
    (c)有序性:即程序执行的顺序按照代码的先后顺序执行,强调的是禁止对存在数据依赖的操作重排序。

    2.1 多核CPU架构下,在CPU和主存之间增加缓存,在多线程场景下会存在缓存一致性问题,见上面图分析。

    举一个代码案例:

    A1操作将修改的a的值写入处理器A的缓冲区,写缓冲区A对处理器B不可见,那么处理器B执行B2操作获取的数据不是最新数据;同理B1操作也会影响A2的结果。


    2.2 多核CPU架构下,多线程场景处理器优化会导致原子问题。

    举例说明:

    经典的i++问题:这个操作设计读-->改-->写操作,如果核CPU0中线程执行到改动作后就中断了,数据2未写入到内存,核CPU1中线程此时再读取i的值会读到错误的数据。


    2.3 多核CPU架构下,多线程场景下编译器指令重排会导致有序性问题。

    指令重排,在不改变代码执行结果的前提下,如果不存在数据依赖的情况,改变代码执行顺序。

    例1

    pi = 3.14    //语句A
    r = 2        //语句B
    s = pi * r * r//语句C

    可能执行顺序是:A-->B-->C,也可能是B-->A-->C,最终结果不会改变。但是C-->A-->B就不可以,因为C操作依赖A和B。

    例2

    //线程1:
    context = loadContext();   //语句1
    inited = true;             //语句2
     
    //线程2:
    while(!inited ){
      sleep()
    }
    doSomethingwithconfig(context);

     语句1和语句2不存在依赖,线程1可能先执行语句2,这时线程2执行 while(!inited ) 会结束循环,认为已经完成初始化,开始执行 doSomethingwithconfig(context);  那么就出错了。

    3、针对并发问题提出内存模型

      为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。它解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。

    4、内存模型规范和具体实现手段有哪些?

    4.1 针对原子性问题:总线加锁

    (1)总线加锁

    什么是总线?
      总线是处理器与主存以及处理器与处理器之间进行通信的媒介,有两种基本的互联结构:SMP(symmetric multiprocessing 对称多处理)和NUMA(nonuniform memory access 非一致内存访问)。

    SMP系统结构


    SMP系统结构普通,容易构建,很多小型服务器采用这种结构。
    处理器和存储器之间采用总线互联,处理器和存储器都有负责发送和监听总线广播的信息的总线控制单元。
    但是同一时刻只能有一个处理器(或存储控制器)在总线上广播,所有的处理器都可以监听。很容易看出,对总线的使用是SMP结构的瓶颈。

    NUMP系统结构


    NUMP系统结构中,一系列节点通过点对点网络互联,像一个小型互联网,每个节点包含一个或多个处理器和一个本地存储器。
    一个节点的本地存储对于其他节点是可见的,所有节点的本地存储一起形成了一个可以被所有处理器共享的全局存储器。
    可以看出,NUMP的本地存储是共享的,而不是私有的,这点和SMP是不同的。
    NUMP的问题是网络比总线复制,需要更加复杂的协议,处理器访问自己节点的存储器速度快于访问其他节点的存储器。
    NUMP的扩展性很好,所以目前很多大中型的服务器在采用NUMP结构。

    解决原子性问题机制:
    在早期的CPU当中,是可以通过在总线上加LOCK#锁的形式来解决原子性问题。对于共享变量无法在CPU中缓存或者数据占据多个缓存行。
    因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从其内存读取变量,然后进行相应的操作。这样就解决了原子性的问题

    缺点:
    但是由于在锁住总线期间,其他CPU无法访问内存,会导致效率低下。

    4.2 针对可见性问题:软件层面的缓存一致性协议和硬件层面的内存屏障。

    (1)缓存一致性协议

    缓存一致性协议,代表性的是Intel的MESI协议,适合以总线为互连机构的多处理器系统
    当一个CPU修改缓存中共享变量,如果其他CPU也存在该变量的副本,会发出信号通知这些CPU将该变量的缓存行置为无效状态;
    当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

    (1)要求每个cache行有个状态位,有四种状态:

    修改态-M(Modified):这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
    专有态-E(Exclusive):这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中。
    共享态-S(Shared):这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中。
    无效态-I(Invalid):这行数据无效。

    (2)MESI协议状态转换规则

    各cache控制器除负责响应自己CPU的内存读写操作(包括读/写命中与未命中)外,还要负责监听总线上的其它CPU的内存读写活动(包括读监听命中与写监听命中)并对自己的cache予以相应处理。所有这些处理过程要维护cache一致性,必须符合MESI协议状态转换规则。

    1、该I无效行在 自身Cache读未命中 将被相应内存块填充以建立新行时,读监听命中,说明其它Cache正在读同地址的内存块,以建立新行。故为多Cache共享行,应为S状态,并应继续发出读监听广播,使其它Cache的类似情况效仿。
    2、该I无效行在 自身Cache读未命中 将被相应内存块填充以建立新行时,未有读监听命中,为本Cache专有,故新建行应为E状态。
    3、该I无效行在 自身Cache写未命中 时,将先读入相应内存块填充新行后,再进行写修改,与原内存正本的数据不一致,故新建行为M状态。
    4、该S共享行 写监听命中,说明别的Cache由于写命中修改了同此地址的行,根据写无效原则,此共享行应改变为无效(I)状态。
    5、该S共享行 读命中,状态不变。
    6、该S共享行 读监听命中,说明其它Cache正在读同地址内存块,以建立新行,此时该共享行状态不必改变,但应继续发读监听广播,供它者监听。
    7、该S共享行 写命中,其中某字被改写,与内存正本不一致,故应改为M状态,且应发出共享行写命中监听广播,使其它Cache同地址行作废(同 4)。
    8、该E专有行 读监听命中,说明别的Cache正在读同地址的内存正本,以建立新行,故其状态应改为S状态,并发出读监听广播,以使同此情况及1效仿之。
    9、该E专有行 读命中 不必改变状态。
    10、该E专有行 写监听命中,说明别的Cache由于写未命中而访问同地址的内存正本,该E态行内容即将过时,故应作废。
    11、该E专有行 写命中,只改变状态为M态即可,无须他者监听。
    12、该M修改行 写命中 状态不变。
    13、该M修改行 读命中 状态不变。
    14、该M修改行 读监听命中,应将该行最新数据写回内存正本后变为S状态。并发出读监听广播,供他者监听。
    15、该M修改行 写监听命中,说明别的Cache由于写未命中而访问了同地址的内存块(同3),将实行先读后修改,此时本地M修改行应抢先写回主存,然后作废,以保证别的Cache读出整行而未被修改数据的正确性。
    16、该M修改行 整行写监听命中,说明别的Cache由于写未命中而访问了同地址的内存块,将实行先读后整行的修改,此时本地M修改行不必写回主存,只作废即可。

    缺点:无法保证实时性,可能会有极短时间的脏读问题。

    传统的MESI协议中有两个操作的执行成本比较大。一个是将某个Cache Line标记为Invalid状态,另一个是当某Cache Line当前状态为Invalid时写入新的数据。CPU通过使用Store Buffer和Invalidate Queue组件来降低这类操作的延时。

    1、当一个CPU进行写入时,首先会给其它CPU发送Invalid消息,然后把当前写入的数据写入到Store Buffer中,然后异步在某个时刻真正的写入到Cache中。
    2、当前CPU核如果要读Cache中的数据,需要先扫描Store Buffer之后再读取Cache。
    3、但是此时其它CPU核是看不到当前核的Store Buffer中的数据的,要等到Store Buffer中的数据被刷到了Cache之后才会触发失效操作。
    4、而当一个CPU核收到Invalid消息时,会把消息写入自身的Invalidate Queue中,随后异步将其设为Invalid状态。
    5和Store Buffer不同的是,当前CPU核心使用Cache时并不扫描Invalidate Queue部分,所以可能会有极短时间的脏读问题。

    (2)内存屏障(Memory Barrier)或内存栅栏(Memory Fence)

    内存屏障,也称内存栅栏,内存栅障,屏障指令等, 是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。
    内存屏障是一种标准,各CPU厂商按标准提供支持指令。程序在编译的时候,在适当的位置加入屏障指令,CPU就执行屏障指令。

    (1)两个操作
    Store:将处理器缓存的数据刷新到内存中。
    Load:将内存存储的数据拷贝到处理器的缓存中。
    (2)常见处理器允许的重排序类型的列表,“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序

    4.3 针对有序性问题:as-if-serial语义。
    (1)不管怎么重排序程序的执行结果不能被改变。
    (2)编译器,runtime 和处理器都必须遵守as-if-serial 语义。
    (3)禁止对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。

     二、Java内存模型(Java Memory Model ,JMM)

      针对Java虚拟机而言,Java语言编写的jar、class程序需要符合JMM规范。
      Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
      C/C++直接使用物理硬件和操作系统的内存模型,在不同操作系统和硬件平台下表现不同,比如有些c/c++程序可能在windows平台运行正常,而在linux平台却运行有问题。

    1、JMM规范

      (1)Java内存模型里设计了主内存和工作内存,JMM规范约束了工作内存和主内存之间数据交互的行为;
      主内存和工作内存属于抽象设计,和JVM中的堆、栈、方法区,以及物理机中的缓存、物理内存没有严格的对应关系;
      (2)JVM内存分堆、栈、方法区,线程的局部变量存在于自己的线程栈里,不会共享,而对象实例存在于堆里、类实例存在于方法区,这些属于线程共享变量;
      (3)JMM规定了所有的共享变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存;
      (4)不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
      (5)java虚拟机中主内存和工作内存交互,就是一个变量如何从主内存传输到工作内存中,如何把修改后的变量从工作内存同步回主内存。


      具体交互有8个操作:

    lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线成独占这个变量
    unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定
    read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用
    load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)
    use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作
    assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作
    store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用
    write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中

      8个操作遵循规则:

    (a)不允许read和load、store和write操作之一单独出现,也就是不允许从主内存读取了变量的值但是工作内存不接收的情况,或者不允许从工作内存将变量的值回写到主内存但是主内存不接收的情况
    (b)不允许一个线程丢弃最近的assign操作,也就是不允许线程在自己的工作线程中修改了变量的值却不同步/回写到主内存
    (c)不允许一个线程回写没有修改的变量到主内存,也就是如果线程工作内存中变量没有发生过任何assign操作,是不允许将该变量的值回写到主内存
    (d)变量只能在主内存中产生,不允许在工作内存中直接使用一个未被初始化的变量,也就是没有执行load或者assign操作。也就是说在执行use、store之前必须对相同的变量执行了load、assign操作
    (e)一个变量在同一时刻只能被一个线程对其进行lock操作,也就是说一个线程一旦对一个变量加锁后,在该线程没有释放掉锁之前,其他线程是不能对其加锁的,但是同一个线程对一个变量加锁后,可以继续加锁,同时在释放锁的时候释放锁次数必须和加锁次数相同。
    (f)对变量执行lock操作,就会清空工作空间该变量的值,执行引擎使用这个变量之前,需要重新load或者assign操作初始化变量的值
    (g)不允许对没有lock的变量执行unlock操作,如果一个变量没有被lock操作,那也不能对其执行unlock操作,当然一个线程也不能对被其他线程lock的变量执行unlock操作
    (h)对一个变量执行unlock之前,必须先把变量同步回主内存中,也就是执行store和write操作

     2、JMM的实现

    2.1、原子性:
    实现了CAS算法的Atomic原子类型、synchronized(monitorenter和 monitorexit)、Lock

    2.2、可见性:synchronized、Lock、volatile(内存屏障)、final

    Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:

    2.3、有序性
    (1)数据依赖
    如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

    (2)happens-before
    happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

    (3)实现方式:synchronized、volatile、Lock

    小结:

     

    synchronized和锁保证三个特性,都是通过保证同一时间只会有一个线程执行目标代码段来实现的。

    参考:

    Java内存模型
    内存模型之基础概述
    内存模型之内部原理

    Java线程安全特性与问题

  • 相关阅读:
    筛选IPV4地址
    linux查看磁盘空间大小df du fdisk stat命令
    编写shell脚本sum求1100累加和
    postman通过Cookies登录博客园
    Linux中mount挂载命令简洁使用方法
    linux如何查询文件及文件夹大小
    postman接口测试中添加不同的断言
    设计模式之状态模式
    Docker安装SQL Server
    架构漫谈读书笔记
  • 原文地址:https://www.cnblogs.com/cac2020/p/12030710.html
Copyright © 2020-2023  润新知