架构师必须掌握的JMM理论和技术

架构师必须掌握的JMM理论和技术

经验文章nimo972025-04-11 18:38:1414A+A-

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 思考为什么

  1. 如果让一个线程总是占用 CPU 是不合理的,任务调度器会让线程分时使用 CPU
  2. 编译器以及硬件层面都会做层层优化,提升性能
    • 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 解决

点击这里复制本文地址 以上内容由nimo97整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!
qrcode

尼墨宝库 © All Rights Reserved.  蜀ICP备2024111239号-7