只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

31、JMM(中)

内容来自王争 Java 编程之美

上一节,我们讲解了多线程存在的 3 个问题:CPU 缓存导致的可见性问题、指令重排导致的有序性问题、线程切换导致的原子性问题
Java 内存模型解决多线程的这 3 个问题,主要依靠 3 个关键词和 1 个规则,3 个关键词分别是:volatile、synchronized、final,1 个规则是 happens-before 规则
接下来我们就详细讲讲这 3 个关键词和 1 个规则

1、volatile 关键字

volatile 关键字可以解决可见性、有序性和部分原子性问题

1.1、解决可见性问题

volatile 翻译成中文是 "易变" 的意思,对于用 volatile 修饰的变量,在编译成机器指令时
会在写操作后面,加上一条特殊的指令:"lock addl #0x0, (%rsp)",这条指令会将 CPU 对此变量的修改,立即写入内存,并通知其他 CPU 更新缓存数据

1.2、解决有序性问题

指令重排导致有序性问题,因此 volatile 关键字通过禁止指令重排序来解决有序性问题,禁止指令重排序又分为完全禁止指令重排序和部分禁止指令重排序
完全禁止指令重排的意思是:volatile 修饰的变量的读写指令不可以跟其前面的读写指令重排,也不可以跟后面的读写指令重排
image

我们知道,指令重排是为了优化代码的执行效率,过于严格的限制指令重排,显然会降低代码的执行效率,因此 Java 内存模型将 volatile 的语义定义为:部分禁止指令重排序
部分禁止指令重排序也可以保证多线程运行的有序性,至于其原因,涉及到比较复杂的计算机体系结构的知识,因此这里就不展开证明了

部分禁止指令重排规则如下图所示:写前读后

对 volatile 修饰的变量执行 "写" 操作,Java 内存模型只禁止位于其 "前面的" 读写操作与其进行重排序,位于其 "后面的" 读写操作可以与其进行指令重排序
对 volatile 修饰的变量执行 "读" 操作,Java 内存模型只禁止位于其 "后面的" 读写操作与其进行重排序,位于其 "前面的" 读写操作可以与其进行指令重排序
image

内存屏障:StoreStore、StoreLoad、LoadLoad、LoadStore

为了能实现上述细化之后的指令重排禁止规则
Java 内存模型定义了 4 个细粒度的内存屏障(Memory Barrier),也叫做内存栅栏(Memory Fence),它们分别是:StoreStore、StoreLoad、LoadLoad、LoadStore
注意:这些内存屏障是抽象概念,底层需要依赖 CPU 提供的内存屏障指令来实现,而 Java 内存模型之所以定义抽象的内存屏障,是为了屏蔽不同 CPU 提供的内存屏障指令的差别

如果我们用 Store(x) 表示对 x 变量的写操作,Load(x) 表示对 x 变量的读操作,那么这 4 个内存屏障的作用如下图所示

  • StoreStore 内存屏障禁止屏障前面的写操作,跟屏障后面的写操作重排序
  • StoreLoad 内存屏障禁止屏障前的写操作,跟屏障后的读操作重排序
  • LoadStore 内存屏障禁止屏障前的读操作,跟屏障后的写操作重排序
  • LoadLoad 内存屏障禁止屏障前的读操作,跟屏障后的读操作重排序

image

示例代码

为了实现 Java 内存模型定义的部分禁止指令重排规则
volatile 变量写操作前面会添加 StoreStore 和 LoadStore 内存屏障,这样可以禁止 volatile 变量写操作前面的读写操作跟其重排序
volatile 变量读操作后面会添加 LoadLoad 和 LoadStore 内存屏障,这样可以禁止 volatile 变量读操作后面的读写操作跟其重排序
示例代码如下所示

<other ops>
[StoreStore]
[LoadStore]
x = 1; // x 为 volatile 变量,此句为 x 变量的写操作
<other ops>
-------------------------
<other ops>
t = x; // x 为 volatile 变量,此句为 x 变量的读操作
[LoadLoad]
[LoadStore]
<other ops>

不过这样做还是不够的,因为我们无法保证 volatile 变量写操作(也就是上述示例中的 x = 1)和 volatile 变量读操作(也就是上述示例中的 t = x)之间不被重排序
也就是说 t = x 有可能会先于 x = 1 执行,这显然是不对的

为了解决这个问题,我们可以选择在 volatile 变量写操作后面添加 StoreLoad 内存屏障,当然也可以选择在 volatile 变量读操作前面添加 StoreLoad 内存屏障
因为大部分情况下,读操作远多于写操作,所以为了尽量减少内存屏障对性能的影响,Java 内存模型选择在 volatile 写操作后面添加 StoreLoad 内存屏障,示例代码如下所示

<other ops>
[StoreStore]
[LoadStore]
x = 1; // x 为 volatile 变量,此句为 x 变量的写操作
[StoreLoad]
<other ops>
t = x; // x 为 volatile 变量,此句为 x 变量的读操作
[LoadLoad]
[LoadStore]
<other ops>

X86 CPU
只支持:写读重排序,StoreLoad 翻译为 "lock addl #0x0, (%rsp)"
不支持:写写重排序,读读重排序、读写重排序,StoreStore、LoadLoad、LoadStore 翻译为空操作指令

刚刚我们讲到的 StoreStore、StoreLoad、LoadLoad、LoadStore 都是抽象的内存屏障,当具体实现时,依赖具体 CPU 提供的内存屏障指令
对于常用到 X86 CPU 来说,其不支持读写(前一个操作是读操作,后一个操作是写操作)重排序、读读(前一个操作是读操作,后一个操作是读操作)重排序、写写(前一个操作是写操作,后一个操作也是写操作)重排序,只支持写读(前一个操作是写操作,后一个操作是读操作)重排序
因此 JVM 运行在 X86 CPU 上时,Java 内存模型会将 StoreStore、LoadLoad、LoadStore 翻译为空操作指令,将 StoreLoad 翻译为 "lock addl #0x0, (%rsp)"
也就是说,"lock addl #0x0, (%rsp)" 这条指令既能保证 volatile 变量的可见性,还能禁止指令的重排

<other ops>
x = 1; // x 为 volatile 变量,此句为 x 变量的写操作
[lock addl #0x0, (%rsp)]
<other ops>
-------------------
<other ops>
t = x; // x 为 volatile 变量,此句为 x 变量的读操作
<other ops>

从上述代码可见,对于常用的 X86 CPU 来说,Java 内存模型只会在 volatile 变量写操作后添加一条额外的指令,volatile 变量的读操作前后不会添加任何额外的指令,因此如果变量读多写少,那么添加 volatile 几乎不影响性能,这也是 volatile 性能比 synchronized 锁性能更好的原因

更多

实际上,StoreStore、StoreLoad、LoadStore、LoadLoad 这 4 个内存屏障,除了可以禁止硬件层面的指令重排序之外,还可以用来禁止编译优化(JIT 编译)导致的指令重排序
因此尽管在硬件层面,在 X86 CPU 上,这 4 个内存屏障中只有 StoreLoad 有用,但是在编译层面,这 4 个内存屏障都有用
不同的 CPU 支持不同类型的重排序(读写、读读、写读、写写),因此做为跨平台的编程语言,Java 并不能只保留 StoreLoad 这一种抽象的内存屏障

还有,不同 CPU 提供的内存屏障指令也不同,也并非会跟这 4 种内存屏障一一对应
在具体将 Java 定义的抽象的内存屏障翻译为 CPU 内存屏障指令时,会做相应的调整,比如将 StoreStore、LoadStore 两个重新的内存屏障翻译为一个 CPU 内存屏障指令等等

指令重排问题代码

了解了 volatile 如何解决指令重排问题之后,我们再来看下上一节给出的指令重排问题代码
实际上问题代码在 X86 CPU 上运行并不会出现问题,永远都是打印正确的 value 值 2,这是因为在 X86 CPU 上只有写读重排序,而问题代码中,value = 2 和 ready = true 是写写结构,并不会重排序
但是代码给出正确执行结果,仅限于运行在 X86 CPU,如果代码运行在其他类型的 CPU 上,就无法保证仍然给出正确的执行结果
因此我们需要在 ready 变量前面加上 volatile 修饰,依次来禁止 volatile 变量写入操作(ready = true)前面的写操作(value = 2)跟它重排序

public class Demo {
private static volatile boolean ready = false;
private static int value = 1;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (!ready) {
}
System.out.println(value);
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
value = 2; // 写操作
ready = true; // 写操作
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}

1.3、解决部分原子性问题

上节提到两类原子性问题,一类是 64 位 long 和 double 类型数据的读写的原子性问题,另一类是自增语句(例如 count++)的原子性问题
volatile 可以解决第一类原子性问题,但是无法解决第二类原子性问题

在 32 位计算机上,读写 64 位的 long 或 double 类型数据,会执行两次内存读写操作
如果我们使用 volatile 关键词修饰 long 或 double 类型变量
那么编译器将代码编译成机器指令时,会在两次读或写之前添加锁定总线的指令,在完成两次读或写之后再释放总线
这样就可以保证 64 位 long 或 double 类型数据在 32 位计算机上读写的原子性
不过现在大部分计算机都已经是 64 位的了,因此我们也就不需要为 long 或 double 类型变量添加 volatile 关键字

现在,我们再讨论一下,为什么 volatile 没法解决自增语句的原子性问题?
volatile 可以解决可见性问题,当两个线程同时对 volatile 修饰的变量进行自增操作时,一个线程对变量的修改,会立刻写入内存,并让另一个线程的 CPU 缓存失效
另一个线程从内存读取新的值进行自增,这不就解决自增语句的原子性问题了吗?

实际上自增语句的原子性问题,不是出在 "CPU 缓存" 上,而是出在 "寄存器" 上
上一节我们讲到,自增语句可以分解为 3 个指令:首先是读取数据到寄存器,然后在寄存器上执行自增操作,最后是将寄存器的数据写入内存
假设线程 t1 和线程 t2 分别运行在 CPU A 和 CPU B 上,当线程 t1 和线程 t2 都将变量值读取到寄存器之后,尽管变量被 volatile 修饰
线程 t1 对变量的修改,能够立即写入内存,并且同步给线程 t2 所使用的 "CPU 缓存",但并不会同步更新线程 t2 所使用的 "寄存器"
线程 t2 中的 "寄存器" 保存的仍然是老值,对老值自增一,然后写入内存,就会导致覆盖掉线程 t1 更新之后的结果

总结一下,volatile 只能解决 long、double 读写的原子性问题,对于更大范围的原子性问题的解决,volatile 无能为力,锁才真正可以,也就是接下来要讲的 synchronized 关键字

2、synchronized 关键字

synchronized 也可以解决可见性、有序性、原子性问题,只不过它的解决方式比较简单粗暴
让原本并发执行的代码串行执行,并且每次加锁和释放锁,都会同步 CPU 缓存和内存中的数据,关于 synchronized 锁,我们留在后续章节中详细讲解

3、final 关键字

在《设计模式之美》中,我们讲到单例模式,单例模式有多种实现方式,其中一种如下代码所示,你觉得以下代码是否存在问题呢?

  • instance 标记为 volatile
  • seq 标记为 final
public class Singleton {
private static Singleton instance;
private int seq;
private Singleton(int seq) {
this.seq = seq;
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(11);
}
}
}
return instance;
}
// 省略其他方法, 比如 seq 的 getter 方法
}

上述代码存在问题,问题主要出在 instance = new Singleton(11) 这条语句上,跟自增语句类似,这条语句也并非原子操作,它可以分解为以下 3 个操作

  • STEP 1:为对象分配内存空间
  • SETP 2:初始化对象(Singleton 构造函数里的语句),写操作
  • SETP 3:将内存空间的地址赋值给 instance,写操作

STEP2 和 STEP3 写写操作,尽管 X86 CPU 只允许写读重排序,STEP 2 和 STEP 3 在硬件层面并不会被重排序,但是编译器仍然有可能将两者重排序
也就是说,在对象没有初始化完成之前,对象对应的内存地址就已经赋值给了 instance,此时其他线程判断 instance 不等于 null,然后就拿 instance 去使用
这时,其他线程就有可能用到未初始化的 Singleton 对象

当然,我们可以使用前面讲到的 volatile 解决这个问题,我们只需要将 instance 标记为 volatile,这样 instance 的写操作(STEP 3)就不能与其前面的读写操作重排序了,也就不存在 STEP 2 和 STEP 3 重排序导致的问题了

如果说 Singleton 类中的 seq 的值设置以后就不再修改,那么我们还可以使用 final 关键字来禁止重排序,如下所示
当然,这种做法的前提是 seq 本身就是不可变的,如果 seq 可变,那就只能用 volatile 关键字了

public class Singleton {
private static Singleton instance;
private final int seq;
private Singleton(int seq) {
this.seq = seq;
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(11);
}
}
}
return instance;
}
}

原来 final 本来只限制变量是否可变,现在 Java 内存模型对 final 的语义做了增强
禁止编译器将构造函数中对 final 变量的写操作,重排序到对象引用之后,也就是禁止 STEP 2 和 STEP 3 重排序
Java 内存模型之所以单独对 final 的语义进行增强,是因为被 final 修饰变量原本是不可变的
但在多线程环境下,一个线程可能看到 final 变量的两个不同的值,违背了 final 关键词的语义
因此 Java 内存模型对 final 变量语义进行了增强,起码保证在一个线程内所有关键字的语义不被违背

4、happens-before 规则

JMM 最最最核心的概念:Happens-before 原则

前面讲了 3 个关键字,我们再来讲 1 个规则:happens-before(先行发生) 规则,happens-before 对解决多线程存在的问题,没有实质性作用,而是专门给程序员看的
程序员可以依照 happens-before 规则,检查自己编写的代码在多线程下的执行顺序,是否符合自己的预期

happens-before 规则有如下 8 个,我们依次来看下

  1. 单线程规则:在单线程中,前面的操作先于后面的操作执行(这个我们稍后重点解释)
  2. 锁规则:一个线程释放锁先于另一个线程获取锁,这里的锁指的是同一把锁(这个很好理解,不需要过多解释)
  3. volatile 规则:在时间序上,如果对一个 volatile 变量的写操作,先于后面的对这个变量的读操作执行,那么 volatile 读操作必定能读到 volatile 写操作的结果
    也就是说,如果 x 为 volatile 变量,在 t1 时刻执行了 x = 1,在 t2 时刻执行了 y = x,t1 小于 t2,那么 y 肯定等于 1
    不管编译器或者 CPU 会如何优化指令的执行顺序,都能保证这个结果
  4. 线程启动规则:如果线程 A 在执行过程中,启动了线程 B,那么线程 A 对共享变量的修改对线程 B 可见
  5. 线程终结规则:如果线程 A 在执行过程中,通过 Thread.join() 等待线程 B 终止,那么线程 B 对共享变量的修改,在线程 B 终止之后,对线程 A 可见
  6. 线程中断规则:线程 A 对线程 B 调用 interrupt() 方法,先行发生于线程 B 的代码检测到中断事件的发生
  7. 对象终结规则:一个对象的初始化完成,先行发生于调用它的 finalize() 方法
  8. 传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C

我们重点解释一下第一条规则,这条规则只用于单线程,你可能会说,单线程里的操作不是可以重排序吗?这条规则不就不对了吗?
规则的意思是,你可以按照它说的这种顺序来分析程序的执行结果,但是编译器可以偷偷重排序,只要他不会让你感知到就可以(也就是运行结果跟你认为的前面的操作先于后面的操作执行得到的结果一致)
对于其他规则也是一样的,只要保证执行结果跟 happens-before 定义的规则的执行结果一样,编译器或 CPU 就可以随意重排序

这也印征了我们前面提到的,程序员如果想知道代码的运行结果,只需要按照 happens-before 规则来分析就可以,但真正的代码的执行顺序,并不一定跟 happens-before 规则一致,只要结果一致即可

5、课后思考题

对于指令重排序问题代码,我们通过给 ready 变量添加 volatile 关键字来解决了重排序问题,那么我们是否可以通过给 value 变量添加 volatile 关键字来解决重排序问题呢?
不可以,因为当对 volatile 修饰的变量执行写操作时,Java 内存模型只禁止位于其前面的读写操作与其进行重排序,不禁止位于其后面的读写操作与其进行指令重排序

posted @   lidongdongdong~  阅读(99)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开