Java 内存模型 ( JMM )
多线程场景下需关注 , 单线程数据竞争可以通过JMM的顺序一致性来保证, 不会出现数据竞争
并发编程中, 线程间通信有两种方式
- 共享内存(隐式)
- 消息传递(显式)
而我们这里要记录的则是 Java 线程间通信使用的 共享内存, 也就是 Java 的内存是怎么样子的
Java 内存模型基础
基本概念
Java 的内存对于我们开发人员来讲, 是不可见的, 是透明的.
Java 的线程间通信使用的是 共享内存 方式进行隐式通信, 所以对于我们开发人员来讲, 这部分不可见的内容存在了很多隐患问题.
在 Java 中分为共享内存和私有内存, 而这些概念也是由 Java 本身自己来控制的, 并不真实存在
主内存: i = 1
线程A: 与 线程B:
如果线程 A 与 线程 B 间想要通信, 那么必须通过 主内存(共享内存) 来实现. Java 通过 JMM 来控制每个线程和主内存的交互, 从而来实现开发人员对内存的可见性
重排序
Java 执行过程中为了提高性能, 会对执行进行重新排序
- 编译器重排序 (不改变语义的情况下重排序)
- 指令并行重排序 (处理器多条指令并行时, 不存在数据依赖, 可以进行重排序)
- 内存系统重排序 ( 内存读写缓冲行 , 可能会重排序)
第一种为 JAVA 本身的编译器重排序, 可以通过 JMM 来进行控制, 而2,3属于CPU级重排序, Java 不能直接控制, 所以 Java 使用在生成指令时在中间插入 内存屏障指令 这种方式来实现禁止重排序.
happends - before
happends - before 规则是指, 当一个操作结束后, 结果对另一个操作可见, 其中包括
- 程序顺序 : 一个线程的每步操作
- 监视器锁释放 : 一个监视器解锁应对另一个监视器加锁可见
- volatile变量: 一个volatile域的写, 要对其他对volatile域的读可见
- 传递性 : A happends - before B , B happends - before C , 那么 A happends - before C
Java 顺序一致性内存模型
程序如果没有正确同步, 就会出现数据竞争问题, 相反, 如果程序设置了合理的正确同步, 那就一定不会出现数据竞争, 这一点就由JMM的顺序一致性来保证
如果做了线程同步, 那就一定会有JMM的顺序一致性加持, 来保证数据的一致结果, 反之则不保证数据的一致性结果.
理想状态下的数据概念模型与我们的意图完全相符, AB两条线程, 同时对数字 1 进行加1操作, 结果 为 3
Java 同步原语
计算机的屏障指令
Load 指令, 使缓存失效, 从主内存读取数据
Store 指令, 使写操作之后将内存的值刷新到主内存当中, 保证其他内存可见
volatile
-
在每个volatile写操作前插入StoreStore屏障
volatile 写
在写操作后插入StoreLoad屏障
-
在每个volatile读操作前插入LoadLoad屏障
volatile 读
在读操作后插入LoadStore屏障
StoreLoad 具备其他3个屏障的所有特性, 开销较大
在一个 volatile 变量读之后, 无论是什么操作, 不允许进行重排序;
在一个 volatile 变量写之前, 无论是什么操作, 不允许进行重排序;
在一个 volatile 变量写之后是 volatile 读, 不允许进行重排序;
对一个 volatile 变量的读/写具有可见性和原子性, 但对于 volatile 变量的运算操作不具有原子性, 比如对volatile++
volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
X86处理器仅会对写-读操作做重排序。
X86不会对读-读、读-写和写-写操作做重排序,
因此在X86处理器中会省略掉这3种操作类型对应的内存屏障
final
- final 写操作之后插入 StoreStore屏障
- final 读操作之前插入 LoadLoad屏障
对 final 域的写, 编译器和处理器遵循以下两个重排序规则
- 禁止将包含final域的写入的构造函数, 与该对象的引用赋值进行重排序
- 初次读包含final域的引用 与 读取该对象中的 final 域禁止重排序
对于以上两个规则, 是因为, JMM禁止编译器把 final域写重排序到构造函数之后(外面), 因为在final域写之后, return 该对象执行, 会插入一条 StoreStore 屏障
对于 final 域的读, JMM会进制编译器把 final 域读与之前的操作进行重排序, 在读 final 域之前会插入一个 LoadLoad 屏障,
例 : 对象A 定义两个变量为, 在我们使用
class A {
int i;
final int j = 10;
public A (int i) {
this.i = i;
}
}
A a = new A(10);
在调用 a.i 的时候, 此时有可能读不到值, 而在读 a.j 的时候则一定可以. 原因就是因为 LoadLoad 屏障的禁止重排序.
通过以上的内容, 我们知道, 当拿到一个对象的引用的时候, 在读取该对象内final域的时候, 该值一定是初始化之后的值, 而普通对象则不一定.
锁的内存语义
锁是java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息
当线程尝试释放锁时, JMM会将线程内的共享变量, 刷新到主内存当中.
当线程尝试获取锁是, JMM会将线程内的共享变量地址设为无效.从而临界区的变量必须从主内存当中重新获取.
释放锁与 volatile 写具有相同的内存语义
获取锁与 volatile 读具有相同的内存语义
Java中锁的内存语义可以通过一个锁的实现来理解, 那就是 ReentrantLock .
ReentrantLock 依赖 Java 中的 AQS同步框架 (AbstractQueuedSynchronizer)来实现. 该框架通过使用一个 volatile 变量来代替"信号"
ReentrantLock 在获取锁时, 先获取到 volatile 关键词修饰的信号 state, 在使用 CAS 将该信号量更新成已上锁的状态.在释放锁的最后, 会将这个值改为无锁状态.
CAS 会调用 cmpxchg 指令进行原子操作, 同样在调用处理器指令的时候, 会根据处理器类型来决定具体的执行指令, 如果在单处理器机器上执行时, 将直接执行 cmpxchg 指令, 而在多处理器机器上执行时, 会增加 Lock 前缀, 最终执行指令为, Lock cmpxchg.
关于 Lock 前缀, 这里简单总结一下. 在执行前增加 Lock 前缀
- 保证了对内存操作的原子性 (通过锁内存总线来实现, 这样会使所有处理器无法访问内存数据. 所以还有另外一种情况, 即所需要操作的数据在带有 Lock 前缀指令执行之前就已经被持有该缓存行的处理器锁定, 则不会通过锁总线来完成这步指令, 因为此时的数据无法被其他处理器读取, 该操作成为缓存锁定 . 但当处理器竞争程度较高, 或指令内存地址未对齐时, 仍会锁住总线)
- 禁止重排序
- 写入缓存的值刷新到主内存中
concurrent 包实现的通用化模式
- 首先, 声明 volatile 共享变量
- 然后, 使用 CAS 更新来实现线程同步
- 同时, 配合 volatile 的读/写和CAS所具有的volatile读和写的内存语义来实现线程通信
内存模型总结
- Java 内存模型
- Java 顺序一致性内存模型
- 处理器内存模型
内存模型的强弱关系
顺序一致性模型 > 语言内存模型 > 处理器内存模型
性能关系
处理器内存模型 > 语言内存模型 > 顺序一致性模型