Java 多线程内存模型跟 cpu 缓存模型类似,基于 cpu 缓存模型来建立,Java 线程内存模型是标准化的,屏蔽掉了底层不同计算机的区别
模型:线程与主内存之间由 JMM 控制,主内存存放共享变量,同时在线程内部的工作内存中也保存一份共享变量副本,修改副本刷新到主内存中
1、数据原子操作
- read(读取):从主内存读取数据
- load(载入):将主内存读取到的数据写入工作内存
- use(使用):从工作内存读取数据来计算
- assign(赋值):将计算好的值重新赋值到工作内存中
- store(存储):将工作内存数据写入主内存
- write(写入):将 store 过去的变量值赋值给主内存中的变量
- lock(锁定):将主内存变量加锁,标识为线程独占状态
- unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量
2、JMM 缓存不一致问题
- 缓存一致性协议(MESI)
- 多个 cpu 从主内存读取同一个数据到各自的高速缓存,当其中某个 cpu 修改了缓存里的数据,该数据会马上同步回主内存
- 其他 cpu 通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效
- 缓存加锁
- 缓存锁的核心机制是基于缓存一致性协议来实现的,一个处理器的缓存回写到内存会导致其他处理器缓存无效,IA-32 和 Intel 64 处理器使用 MESI 实现缓存一致性协议
3、Volatile 可见性底层实现原理
- Volatile 缓存可见性实现原理
- 底层实现主要通过汇编 lock 前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存
- IA-32 和 Intel 64 架构 lock 指令解释
- 会将当前处理器缓存行的数据立即写回到系统内存
- 这个写回内存的操作会引起其他 CPU 里缓存了该内存地址的数据无效(MESI 协议)
- 提供内存屏障功能,使 lock 前后指令不能重排序
4、指令重排序
- 并发编程三大特性:可见性、原子性、有序性
- volatile 保证可见性和有序性,但不保证原子性,保证原子性需要加 synchronized 锁机制
- 指令重排序:在不影响单线程程序执行结果的前提下,计算机为了最大限度的发挥机器性能,会对机器指令重排序优化
- 源代码 -> 编译器优化重排序 -> 指令级并行重排序 -> 内存系统重排序 -> 最终执行的指令序列
- 重排序会遵循 as-if-serial 与 happens-before 原则
- as-if-serial 语义:不管怎么重排序,单线程程序的执行结果不能被改变
- happens-before 原则
- 程序顺序原则:在一个线程内必须保证语义串行性,也就是说按照代码顺序执行
- 锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,对于一个锁解锁后再加锁,那么加锁的动作必须在解锁动作之后
- volatile 规则:volatile 变量的写,先发生于读,每次被线程访问时,必须重主内存中进行读,变化时必须把最新的结果写回主内存
- 线程启动原则:线程 start() 方法先于它的每个动作,线程执行前的共享变量修改都可见
- 传递性:A 先于 B,B 先于 C,那么 A 必先于 C
- 线程终止规则:线程所有操作先于线程的终结,Thread.join() 方法作用是等待当前执行的线程终止
- 双重检测锁 DCL 对象半初始化问题
- 双重检测锁创建单例的时候会出现半初始化,底层先赋值再调用初始化方法,其他线程拿到之后就不是空的,但是没有进行初始化
- 解决方法:将目标属性声明为 volatile
5、内存屏障
- JVM 规范定义的内存屏障
- LoadLoad:保证 load1 的读取在 load2 及后续读取操作之前执行
- StoreStore:在 store2 及其后的写操作执行前,保证 store1 的写操作已刷新到主内存
- LoadStore:在 store2 及其后的写操作执行前,保证 load1 的读操作已读取结束
- StoreLoad:保证 store1 写操作已刷新到主内存之后,load2 及其后的读操作才能执行
- JVM 规定 volatile 需要实现的内存屏障
- a = 2; // volatile 写,a 为 volatile 变量
- StoreStore 屏障
- a = 1; // volatile 写
- StoreLoad 屏障
- b = a; // volatile 读
- LoadLoad 屏障
- LoadStore 屏障