Java-第十六部分-JUC-内存模型、CAS、volatile和一些概念

313 阅读17分钟

JUC全文

Java内存模型

  • Java线程内存模型,Java memory model
  • 处理器、高速缓存、主内存的交互关系

缓存与主内存的数据,根据缓存一致性协议进行同步,在读写数据时需要遵守协议 image.png

  • 内存模型的目的,定义程序中的各种变量的访问规则,关注在虚拟机中把变量存储到内存和从内存中取出变量值的底层细节

关注实例变量、静态变量、构成数组对象的元素,不包括局部变量和方法参数,这些线程私有,不存在竞争的问题

  • 模型规则
  1. 规定所有变量(不包括局部变量和方法参数)都存储在主内存
  2. 每条线程有自己的工作内存,允许线程的工作内存中保存该线程使用的变量的主内存副本(一个堆中对象被访问到的某个字段的复制,并不会复制整个对象)
  3. 线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接操作主内存数据
  4. 不同的线程之间无法互相访问工作内存,线程间变量值传递需要通过主内存完成
  • 线程、主内存、工作内存关系,butongCPU对应不同缓存的部分 image.png

内存间的操作

image.png

  • 主内存与工作内存的交互操作
  1. lock,作用于主内存变量,把一个变量标识为一条线程独占的状态
  2. unlock,作用于主内存变量,把一个处于锁状态的变量释放,释放后的变量才能被其他线程锁定
  3. read,作用于主内存变量,把一个变量的值从主内存传输到线程的工作内存,便于后续的load
  4. load,作用于主内存变量,把read来的变量值,放入工作内存的变量副本
  5. use,作用于主内存变量,把工作内存的一个变量值传递给执行引擎,当jvm遇到一个需要使用变量的值的字节码指令时,会执行该操作
  6. assign,作用于主内存变量,把执行引擎接收到的值赋给工作内存的变量,当jvm遇到一个给变量赋值的字节码指令时,会执行该操作
  7. store,作用于主内存变量,把工作内存中的一个变量的值传输到主内存,便于后续write操作
  8. write,作用于主内存变量,把store从工作内存中得到的变量的值,放入主内存的变量的值
  • 把一个变量从主内存拷贝到工作内存,需要readload;把变量从工作内存同步到主内存,需要storewrite

要求上面两组操作,按操作顺序执行,中间可以插入其他步骤

  • 主内存与工作内存的交互规则
  1. 不允许readloadstorewrite中的两组操作的某一个操作单独出现,不允许一个变量从内存中读取了但工作内存不接受,或工作内存发起回写,但主内存不接受
  2. 不允许一个线程丢弃最近的assign操作,即变量在工作内存中最后的改变后,必须回写到主内存
  3. 不允许一个线程无原因(没发生过assign操作),把数据从线程的工作内存同步到主内存
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(loadassign)的变量,对一个变量执行usestore前,必须进行赋值
  5. 一个变量同一时刻只能被一个线程lock,一个线程可以对一个变量进行多次lock,但是释放需要同等次数的unlock
  6. 如果对一个变量执行lock,那么会清空工作内存中此变量的所有值,在执行引擎使用这个变量前,需要重新执行loadassign操作初始化变量的值
  7. 一个变量没有被lock锁定前,不允许执行unlock操作,也不允许unlock一个被其他线程lock的值
  8. 对一个变量执行unlock之前,必须把此变量同步到主内存中,执行storewrite后,才能unlock

针对long和double的特殊规则

  • long和double的非原子性协定
  1. 允许没有被volatile修饰的64位变量的读写操作划分为两次32位的操作,极少情况下,会读到半个变量的数值
  2. 虚拟机自行决定load、store、read和write这四个操作的原子性
  • 一般不会遇到特殊情况,视为原子操作

CAS

  • 无锁、自旋锁、乐观锁、轻量级锁、compareAndSetcompareAndSwap

自旋,cas过程中重复判断的阶段do...while的过程

  • 存在的问题,原子性问题ABA问题

incrementAndGet

  • integer.incrementAndGet();内部采用CAS,实际上线程是没有阻塞的
while (true) { //防止其他线程修改过,导致老值不匹配,需要循环处理,直到处理成功
    int oldVal = integer.get(); //老值
    int newVal = oldVal + 1; //新值
    //老值跟当前内存的值相等时,才更新新值
    if(integer.compareAndSet(oldVal, newVal)) {
        //并跳出循环
        break;
    }
}
  • incrementAndGet内部实现 image.png

原子性问题

  • 原子操作,一步到位;非原子操作,分几步完成
  • 如果CAS底层是非原子操作,在第一个线程的compareset之间,有一个线程对该值进行修改,第一个线程将另一个线程的值进行覆盖
  • c++ image.png
  • 汇编,核心为lock cmpxchgq,将cmpxchgqlock锁住,内部就是一条命令,具有原子性,并发执行,串行化
  1. lock默认为缓存行锁,当一个缓存行锁比较大,超过64个字节,锁住的数据跨几个缓存行,直接加总线锁
  2. 缓存行,暂时保存系统内存数据的单位 image.png
  • LOCK_IF_MP检查mp是否为多核cpu,如果是,返回lock指令 image.png

ABA锁

  • 初始值为A,一个线程需要将A改成B,在过程中修改前,一个新线程将A改成C,又将C改成A,该线程可以修改成功,但是过程中,有其他线程已经修改
  • 用版本号解决,在比较的时候同时比较版本号,在设置值的时候要更新版本\
  • AtomicStampedReference
AtomicStampedReference<Integer> iasr = new AtomicStampedReference<>(0,0);
void sadd() {
    iasr.compareAndSet(0,1,0,1);
}
  • 需要比较版本号 image.png

分段CAS

  • LongAdder,采用分段CAS,不同线程对不同的cell进行cas++,最终求和
  • 根据线程访问的多少,自动扩容和减少数组
LongAdder longAdder = new LongAdder();
void ladd() {
    longAdder.increment();
}
  • base的cas操作失败,调用longAccumulate,为cells扩容 image.png
  • 获取值 image.png image.png

volatile

  • 使用volatile的两个条件,前提是保证对该变量的原子性操作
  1. 对变量的写操作不依赖于当前值
  2. 该变量没有包含在具有其他变量的不变式中
  • 不能保证对变量操作的原子性,能保证有序性/可见性
  • 轻量级同步保护,每次被线程访问时,都强迫线程从共享内存中重读该变量的值;当成员变量发生改变时,强迫线程将变化值写到共享内存,线程不能保存变量的私有复制,直接与共享内存交互
  • 禁止指令重排序,当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行

x = y必须先获取y的值,才能将该值赋给x

  • 指令关键字,确保本条指令不会被编译器优化,要求直接读取这个变量值,不能从缓存或寄存器等位置读取
  • Java允许线程保存共享成员变量的私有复制,只有当线程进入或者离开同步代码块时,才会与共享变量的原始值进行比较,在优化情况下,如果对一个变量写多个值,编译器会将其优化成最后一条有效
x = 1;
x = 2;
x = 3;
x = 4; //仅有这条真正写入主存中

和synchronizd的区别

  • volatile线程同步的轻量级实现,性能强于synchronizd
  • volatile只能修饰变量,synchronizd修饰方法及代码块
  • 多线程同时访问时,volatile没有使用锁,不会阻塞线程,synchronizd使用排他锁,会发生阻塞
  • volatile解决共享资源在多线程间的一致性,synchronizd解决某个代码块的原子性操作

底层

  • 当共享变量用volatile修饰后就会帮在总线开启一个MESI缓存协议
  • volatile修饰的共享变量进行写操作的时候会多出Lock前缀(相当于内存屏障)的指令,会锁定这块内存区域的缓存(缓存行锁定)并写回到主内存,通过独占内存、使其他处理器缓存失效,达到了指令重排序无法越过内存屏障的作用
  1. 将当前处理器缓存行数据刷写到系统主内存
  2. 这个写回主内存的操作会使其他CPU缓存的该共享变量内存地址的数据(缓存行)无效
  • 为了保证在从工作内存刷新回主内存这个阶段主内存数据的安全性,在store前会使用内存模型当中的lock操作来锁定当前主内存中的共享变量,当主内存变量在write操作后才会将当前lock释放掉,别的线程必须从主内存中获取新的值
  • 当同时有两个线程对volatile进行写操作时,一个线程抢到了lock,为变量上锁,当操作完成后,通知另一个线程缓存无效,需要从共享内存中重新读取
  • 将锁加在store前,锁的力度更小,解决了并发问题,不影响并发读,只在更新主内存数据进行上锁,性能更好

不能保证操作原子性

  • 操作volatile变量++的步骤
mov 0xc(%r10),%r8d; 读取变量到工作内存,类似操作数栈栈顶
inc %r8d; Increment 增加变量的值,或者修改变量的值
mov %r8d, 0xc(%r10) ; 写入到工作内存中的变量中
lock addl $0x0,(%rsp) ; 更新到主内存中,在store之间上锁,进行store and write
  • lock addl $0x0,(%rsp) ;这个操作真正实现了volatile的可见性,使用了StoreLoad Barrier,利用MESI协议,让该主内存的变量的其他缓存行无效
  • 在一个线程完成了load操作后,将值存储到操作数栈栈顶,进行下一步操作前,另一个线程已经完成了修改操作,此时该线程的操作数栈栈顶的值已是过期值,而当完成++操作后,监听到另一个线程的修改指令,将该值无效后,这次++操作便浪费了
  • 内存屏障
  1. 一旦你完成写入,任何访问这个字段的线程关于该值的缓存都会无效,再次使用时将执行read and load获得最新的值
  2. 在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,保证执行的有序性,禁止重排序

概念

CPU缓存结构

  • 越往上访问速度越快、成本更高,所以空间也越小。越往下访问速度越慢、成本越低,空间也就越大 image.png
  • 解决CPU运算速度与内存读写速度不匹配的矛盾,在CPU和内存之间,引入了L1高速缓存、L2高速缓存、L3高速缓存,每一级缓存中所存储的数据全部都是下一级缓存中的一部分,当CPU需要数据时,就从缓存中获取,从而加快读写速度,提高CPU利用率、提升整体效率
  1. L1高速缓存:也叫一级缓存。一般内置在内核旁边,是与CPU结合最为紧密的CPU缓存。一次访问只需要2~4个时钟周期
  2. L2高速缓存:也叫二级缓存。空间比L1缓存大,速度比L1缓存略慢。一次访问约需要10多个时钟周期
  3. L3高速缓存:也叫三级缓存。部分单CPU多核心的才会有的缓存,介于多核和内存之间。存储空间已达Mb级别,一次访问约需要数十个时钟周期。
  • 当CPU要读取一个数据时,首先从L1缓存查找,命中则返回;若未命中,再从L2缓存中查找,如果还没有则从L3缓存查找(如果有L3缓存的话)。如果还是没有,则从内存中查找,并将读取到的数据逐级放入缓存

总线锁和缓存行锁

  • 总线,一组信号线,负责连接各个硬件部分,传输信息
  • 总线锁,锁住总线。通过处理器发出lock指令,总线接受到指令后,其他处理器的请求就会被阻塞,直到此处理器执行完成。这样,处理器就可以独占共享内存的使用,并行执行串行化

总线锁存在较大的缺点,一旦某个处理器获取总线锁,其他处理器都只能阻塞等待,多处理器的优势就无法发挥

  • 缓存锁,不需锁定总线,只需要锁定被缓存的共享对象(实际为缓存行)即可,接受到lock指令,通过缓存一致性协议,维护本处理器内部缓存和其他处理器缓存的一致性

相比总线锁,会提高cpu利用率

缓存行

  • 最大化CPU利用率,采用一次获取一整块的内存数据,放入缓存
  • CPU缓存中可分配、操作的最小存储单元
  • 与CPU架构有关,通常有32字节、64字节、128字节不等。目前64位架构下,64字节最为常用
  • 并不是所有数据都会被缓存,比如一些较大的数据,缓存行无法容下,那么就只能每次都去主内存中读取

MESI协议

  • 开启总线嗅探机制,保证了每个缓存中使用的共享变量的副本是一致的,修饰缓存行的状态
  • 核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,并将该变量同步回主内存,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取
  1. Modified(修改):该缓存行仅出现在此cpu缓存中,缓存已被修改,和内存中不一致,等待同步至内存
  2. Exclusive(独占):该缓存行仅出现在此cpu缓存中,缓存和内存中保持一致
  3. Shared(共享):该缓存行可能出现在多个cpu缓存中,且多个cpu缓存的缓存行和内存中的数据一致
  4. Invalid(失效):由于其他cpu修改了缓存行,导致本cpu中的缓存行失效
  • 每个缓存行不仅知道自己的读写操作,而且也监听其它缓存行的读写操作。每个缓存行的状态根据本cpu和其它cpu的读写操作在4个状态间进行迁移
  1. 当缓存行处于Modified状态时,会时刻监听其他cpu对该缓存行对应主内存地址的读取操作,一旦监听到,将本cpu的缓存行写回内存,本缓存行标记为Shared状态
  2. 当缓存行处于Exclusive状态时,会时刻监听其他cpu对该缓存行对应主内存地址的读取操作,一旦监听到,将本cpu的缓存行标记为Shared状态
  3. 当缓存行处于Shared状态时,会时刻监听某一个cpu对使缓存行失效的指令(即其他cpu的写入操作),一旦监听到,将本cpu的缓存行标记为Invalid状态(某一个cpu进入Modified状态)
  4. 当缓存行处于Invalid状态时,从内存中读取,否则直接从缓存读取
  • 当某个cpu修改缓存行数据时,其他的cpu通过监听机制获悉共享缓存行的数据被修改,会使其共享缓存行失效。本cpu会将修改后的缓存行写回到主内存中。此时其他的cpu如果需要此缓存行共享数据,则从主内存中重新加载,并放入缓存,以此完成了缓存一致性

伪共享问题

  • 多核多线程并发场景下,多核要操作的不同变量处于同一缓存行,某cpu更新缓存行中数据,并将其写回缓存,同时其他处理器会使该缓存行失效,如需使用,还需从内存中重新加载
  • 解决方案
  1. 缓存行填充/对齐,填充成一个最小单位
  2. jdk8后注解@Contended,通过对对象头内存布局的优化,将那些可能会被同一个线程几乎同时写的字段分组到一起,避免形成竞争,来达到避免伪共享的目的

三大特性

  1. 原子性,一个整体的、不可中断的操作
  2. 有序性,保证程序执行的顺序,volatile通过lock保证指令重排序无法越过内存屏障,该操作前的指令必须执行完毕,lock指令执行结束后,才能执行后续指令
  3. 可见性,一条线程修改了变量的值,其他线程立刻可以得知;普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性
  • 指令重排序,处理器在保证最后结果正确的前提下,正确处理指令依赖情况,允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理;在Java内存模型中,允许编译器和处理器对指令进行重排序,影响到多线程并发执行的正确性

happens-before

  • 不等价于时间上先发生,类似操作执行的优先级
  • 先行发生原则,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,如果不能从此原则推出,那么就可以进行重排序
  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()方法的开始
  • jvm,只会对不存在数据依赖性的指令进行重排序,保证对最后的结果不产生影响