1. 内容简介
1.1 JMM 研究的到底是什么?
A. Java 内存结构,如栈、堆?
B. JVM 调优?
C. JVM 垃圾回收机制?
D. 多线程下 Java 代码的执行顺序,共享变量的读写?
1.2 学习目标
- 多线程下,读写共享变量会有哪些问题
- 解决这些问题的钥匙 - Java 内存模型
- 解决这些问题的手段 - 掌握同步方法
- 更多安全问题与解决方法
2. 多线程读写共享变量
2.1 Does your computer execute the program you wrote?
2.2 澄清两个事实
- 你写的代码,未必是实际运行的代码
- 代码的编写顺序,未必是实际执行顺序
2.3 问题演示
以下问题演示,需要的条件:共享变量 + 有读、至少一个写
问题 - 永远的循环
问题 - 加加减减
问题 - 第四种可能
2.4 问题揭秘
永远的循环 - 揭秘
改写一下代码方便测试
先用 -XX:+PrintCompilation 来查看即时编译情况
- % 的含义
- On-Stack-Replacement(OSR)
再尝试用 -Xint 强制解释执行
加加减减 - 揭秘
同样改写一下测试代码
这回用一下 ASM 工具,可以看到源码第 8 行的 balance += 5 的字节码如下
而第 12 行的 balance -= 5 字节码如下
方法替换为伪码后
可能的执行序列如下
第四种可能 - 揭秘
可能的执行序列如下
那么 case 4 的实际执行序列是?
可能是编译器调整了指令执行顺序
压测方能暴露问题
2.5 思考为什么
- 如果让一个线程总是占用 CPU 是不合理的,任务调度器会让线程分时使用 CPU
- 编译器以及硬件层面都会做层层优化,提升性能
- Compiler/JIT 优化
- Processor 流水线优化
- Cache 优化
2.6 编译器优化
例 1
例 2
例 3
2.7 Processor 优化
流水线在 CPU 的一个时钟周期内会执行多个指令的不同部分
非流水线操作
假设有三条指令
每条指令执行花费 300ps 时间,最后将结果存入寄存器需要 20ps
一秒能运行的指令数为
流水线操作
仔细分析就会发现,可以把每个指令细分为三个阶段
增加一些寄存器,缓存每一阶段的结果,这样就可以在执行 指令1-C 阶段时,同时执行 指令2-B 以及 指令3-A
一秒能运行的指令数为
execute Out of Order
- 在按序执行中,一旦遇到指令依赖的情况,流水线就会停滞
- 如果采用乱序执行,就可以跳到下一个非依赖指令并发布它。这样,执行单元就可以总是处于工作状态,把时间浪费减到最少-
例如: //3 会被重排到 //2 之前,以减少 //2 引起的流水线停滞
2.8 缓存优化
MESI 协议
- Modified - 要向其它 CPU 发送 cache line 无效消息,并等待 ack
- Exclusive - 独占、即将要执行修改
- Shared - 共享、一般读取时的初始状态
- Invalid - 一旦发现数据无效,需要重新加载数据
例子
a == b == 0
证明可能是缓存引起的
2.9 我们面对的问题
对于程序员而言,我们不应当关注究竟是
- 编译器优化
- Processor 优化
- 缓存优化
否则,就好像打开了潘多拉魔盒
3. JMM 内存模型
3.1 什么是 JMM
多线程下,共享变量的读写顺序是头等大事,内存模型就是多线程下对共享变量的一组读写规则
- 共享变量值是否在线程间同步
- 代码可能的执行顺序
- 需要关注的操作就有两种 Load、Store
- Load 就是从缓存读取到寄存器中,如果一级缓存中没有,就会层层读取二级、三级缓存,最后才是Memory
- Store 就是从寄存器运算结果写入缓存,不会直接写入 Memory,当 Cache line 将被 eject 时,会writeback 到 Memory
3.2 JMM 规范
规则 1 - Race Condition
在多线程下,没有依赖关系的代码,在执行共享变量读写操作(至少有一个线程写)时,并不能保证以编写顺序(Program Order)执行,这称为发生了竞态条件(Race Condition)
例如
竞争是为了更好的性能 -Data Race Free
规则 2 - Synchronization Order
同步动作 - 一个线程中代码的执行顺序
若要保证多线程下,每个线程的执行顺序(Synchronization Order)按编写顺序(Program Order)执行,那么必须使用 Synchronization Actions 来保证,这些 SA 有
- lock,unlock - synchronized, ReentrantLock
- volatile 方式读写变量 - 保证可见性,防止重排序
- VarHandle 方式读写变量
例如
用 volatile 修饰共享变量 y,
线程 1 执行
线程 2 执行
最终的结果就不可能是 r1==1 而 r2==0
SO 并不是阻止多线程切换
并不是说 //1 与 //2 处之间不能切换到线程 2,只是即使切换到了线程 2,因为线程 2 不能拿到 LOCK 锁导致被阻塞,执行权又会轮到线程 1
思考:如果线程 2 执行的代码不使用同一个 LOCK 对象呢?
volatile 只用了一半算 SO 吗?
执行下面的测试用例
//1 //2 处的顺序可以保证(只写了 volatile 变量),但 //3 //4 处的顺序却不能保证(只读了 volatile 变量),仍会出现 r1==r2==0 的问题
有时会很迷惑人,例如下面的例子
这回 //1 //2 (只写了 volatile 变量)//3 //4 处(只读了 volatile 变量)的顺序均能保证了,绝不会出现r1==r2==1 的情况
此外将用例 2 中两个变量均用 volatile 修饰就不会出现 r1==r2==0 的问题,因此也把全部都用 volatile 修饰称为total order,部分变量用 volatile 修饰称为 partial order
规则 3 - Happens-Before
线程切换时代码的顺序和可见性
若是变量读写时发生线程切换(例如,线程 1 写入 x,切换至线程 2,线程 2 读取 x)在这些边界的处理上如果有action1 先于 action 2 发生,那么代码可以按确定的顺序执行,这称之为 Happens-Before Order 规则
Happens-Before Order 也称之为 Partial Order
用公式表达为
含义为:如果 action1 先于 action2 发生,那么 action1 之前的共享变量的修改对于 action2 可见,且代码按 PO顺序执行
具体规则
其中 代表线程,而 x 未加说明,是普通共享变量,使用 volatile 会单独说明
规则 4 - Causality
Causality 即因果律:代码之间如存在依赖关系,即使没有加 SA 操作,代码的执行顺序也是可以预见的
回顾一下
多线程下,没有依赖关系的代码,在共享变量读写操作(至少有一个线程写)时,并不能保证以编写顺序(Program Order)执行,这称为发生了竞态条件(Race Condition)
如果有一定的依赖关系呢?
比如
x 的值来自于 y,y 的值来自于 x,而二者的初始值都是 0,因此没有可能有其他结果
规则 5 - 安全发布
若要安全构造对象,并将其共享使用,需要用 final 或 volatile 修饰其成员变量,并避免 this 溢出情况
静态成员变量可以安全地发布
例如
需要将它作为全局使用
两个线程,一个创建,一个使用
可能会看到未构造完整的对象
4. 同步动作
前面没有详细展开从规则 2 之后的讲解,是因为要理解规则,还需理解底层原理,即内存屏障
4.1 内存屏障
共有四种内存屏障,具体实现与 CPU 架构相关,不必钻研太深,只需知道它们的效果
LoadLoad
LoadStore
- 防止 B 的 Store 被重排到 A 的 Load 之前
StoreStore
- 防止 A 的 Store 被重排到 B 的 Store 之后
- 意义:在 B 修改为 true 之前,其它线程别想看到 A 的修改
- 有点类似于 sql 中更新后,commit 之前,其它事务不能看到这些更新(B 的赋值会触发 commit 并撤除屏障)
StoreLoad(*)
意义:屏障前的改动都同步到主存 1 ,屏障后的 Load 获取主存最新数据
- 防止屏障前所有的写操作,被重排序到屏障后的任何的读操作,可以认为此 store -> load 是连续的
- 有点类似于 git 中先 commit,再远程 poll,而且这个动作是原子的
如何记忆使用
- LoadLoad + LoadStore = Acquire 即让同一线程内读操作之后的读写上不去,第一个 Load 能读到主存最新
- LoadStore + StoreStore = Release 即让同一线程内写操作之前的读写下不来,后一个 Store 能将改动都写入主存
- StoreLoad 最为特殊,还能用在线程切换时,对变量的写操作 + 读操作做同步,只要是对同一变量先写后读,那么屏障就能生效
4.2 volatile
1. 本质
事实上对 volatile 而言 Store-Load,与 LoadLoad 屏障最为有用,简化起见以后的分析省略部分其他屏障
单一变量的赋值原子性
控制了可能的执行路径:线程内按屏障有序,线程切换时按 HB 有序
可见性:线程切换时若发生了 写 ->读 则变量可见,顺带影响普通变量可见
2. visibility
即使是多次读取同一变量,所得结果不合理
初始
分析所有可能性
使用 volatile 修饰 x 即可,测试用例如下
给 x 上加入 volatile,会阻止编译器对代码的优化,并加入的 StoreLoad 屏障会保证红色线程的写入,对后续蓝色线程的读取可见
3. partial ordering
volatile 修饰 y
volatile 修饰 x - 行不行?
4. total ordering
volatile 仅修饰 y - 不符合最后写最先读
检查最后结果是否可能 r1 r2 都为 0
5. 源码体现
凡是需要 cas 操作的地方
代码片段 1 - AtomicInteger
代码片段 2 -
AbstractQueuedSynchronizer
代码片段 3 - ConcurrentHashMap
1.7 没有实现懒惰初始化
1.8 实现懒惰初始化
4.3 synchronized
1. 本质
2. Atomicity
synchronized - 正确同步
synchronized - 未正确同步
3. 优化
synchronized 关键字自带
- 重量级
- 当有竞争时,仍会向系统申请 Monitor 互斥锁
- 轻量级锁
- 如果线程加锁、解锁时间上刚好是错开的,这时候就可以使用轻量级锁,只是使用 cas 尝试将对象头替换为该线程的锁记录地址,如果 cas 失败,会锁重入或触发重量级锁升级
- 偏向锁
- 打个比方,轻量级锁就好比用课本占座,线程每次占座前还得比较一下,课本是不是自己的(cas),频繁 cas 性能也会受到影响
- 而偏向锁就好比座位上已经刻好了线程的名字,线程【专用】这个座位,比 cas 更为轻量
- 但是一旦其他线程访问偏向对象,那么比较麻烦,需要把座位上的名字擦去,这称之为偏向锁撤销,锁也升级为轻量级锁
- 偏向锁撤销也属于昂贵的操作,怎么减少呢,JVM 会记录这一类对象被撤销的次数,如果超过了 20 这个阈值,下次新线程访问偏向对象时,就不用撤销了,而是刻上新线程的名字,这称为重偏向
- 如果撤销次数进一步增加,超过 40 这个阈值,JVM 会认为这一类对象不适合采用偏向锁,会对它们禁用偏向锁,下次新建对象会直接加轻量级锁
4. 无锁 vs 有锁
- synchronized 更为重量,申请锁、锁重入都要发起系统调用,频繁调用性能会受影响
- synchronized 如果无法获取锁时,线程会陷入阻塞,引起的线程上下文切换成本高
- 虽然做了一系列优化,但轻量级锁、偏向锁都是针对无数据竞争场景的
- 如果数据的原子操作时间较长,仍应该让线程阻塞,无锁适合的是短频快的共享数据修改操作主要用于计数器、停止标记、或是阻塞前的有限尝试
5. 更多安全问题
5.1 单个变量读写原子性
32 位操作系统
字 4个字节 long double 8个字节
64 位操作系统
字 8个字节
单个变量的读写原子性
- 64 位系统 vs 32 位系统
- 如果需要保证 long 和 double 在 32 位系统中原子性,需要用 volatile 修饰
- JMM9 之前
- JMM9 32 位系统下 double 和 long 的问题,double 没有问题,long 在 -server -XX:+UnlockExperimentalVMOptions -XX:-AlwaysAtomicAccesses 才有问题
Long 类型变量测试
Object alignment
32 字 4 个字节
64 字 8 个字节
你或许听说过对象对齐,它的一个主要目的就是为了单个变量读写的原子性,可以使用 jol 工具查看 java 对象的内存布局
测试类
开启对象头压缩(默认)输出
不开启对象头压缩 -XX:-UseCompressedOops 输出
5.2 字分裂(扩展)
前面也看到了,Java 能够保证单个共享变量读写是原子的,类似的数组元素的读写,也会提供这样的保证
如果上述效果不能保证,则称之为发生了字分裂现象,java 中没有字分裂 2 ,但 Java 中某些实现会有类似字分裂现象,例如 BitSet、Unsafe 读写等
数组元素读写测试
BitSet 读写测试
Unsafe 直接操作内存
输出
来个压测
某次结果
5.3 安全发布
当创建了一个对象,并把它赋值给共享变量时,这个存在线程安全问题
构造也不安全
原因分析,看字节码
使用 final 改进
使用 volatile 改进
age 有 volatile 修饰,注意位置必须在最后
DCL 安全单例‘
试用今天所学知识,分析为什么上述写法不正确
有问题的情况
使用 volatile 解决