Java内存模型
大约 4 分钟
1. 多线程安全
1.1 为什么会导致线程不安全
- 可见性
- 描述:线程对共享变量修改,其他线程应立刻可见
- 问题:CPU1在执行时先将共享变量加载到自己的高速缓存中,此时CPU2的修改对于CPU1不可见
- 解决:
volatile
和synchronized
和各种Lock
- 原子性
- 描述:操作要么全部执行成功,要么全部失败
- 问题:由CPU分时复用引起。比如线程1在执行完之后,还没来得及将CPU缓存写入内存就被CPU2执行
- 解决:
synchronized
和各种Lock
- 有序性
- 描述:程序按代码的先后顺序执行
- 问题:编译程序为了优化可能会指令重排(Instruction Reorder),Java代码到执行过程会经过编译器优化重排序、指令级并行重排序、内存系统重排序三个过程
- 解决:
volatile
可以禁止指令进行重排序优化
1.2 Java解决并发安全的方案
Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法,并提供了 volatile
、synchronized
和 final
三个关键字来禁用缓存和编译优化,并提供了Happens-Before 规则来解决并发安全问题
2. Java内存模型
2.1 介绍
Java 线程之间的通信由 Java 内存模型(JMM) 控制。JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的副本
- 主内存:所有线程创建的实例对象都存放在主内存
- 本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
如图,线程AB想要通信就必须由线程A将变量同步到主内存,线程B再从主内存拿到这个变量。JMM为主内存和本地内存的同步定义了8种同步操作:
- 锁定(lock): 作用于主内存,将变量标记为一个线程独享变量
- 解锁(unlock): 作用于主内存,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定
- 读取(read):作用于主内存,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
- 载入(load):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中
- 使用(use):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令
- 赋值(assign):作用于工作内存,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
- 存储(store):作用于工作内存,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用
- 写入(write):作用于主内存,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中
2.2 Happens-Before原则
在分布式环境中,通过一系列规则来定义逻辑时钟的变化,从而能通过逻辑时钟来对分布式系统中的事件的先后顺序进行判断。逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 Happens-Before 关系
Happens-Before为了尽可能减少对编译器和处理器的限制,允许不改变程序结果的重排序,而禁止改变结果的重排序。
对于这段代码:
int userNum = getUserNum(); // 1
int teacherNum = getTeacherNum(); // 2
int totalNum = userNum + teacherNum; // 3
对于1和2,显然不会改变程序的执行结果,JMM允许对其进行重排序;但3需要1和2的执行结果,也就是1,2happens-before3,所以这里不允许重排序