jmm(java内存模型)和volatile详解

1. 可见性问题

现代计算机的cpu都是多核cpu,鉴于内存(RAM)的速度对于cpu来说还不够快。因此多核cpu涉及出了缓存的概念进一步加速cpu的速度。在cpu一侧引入缓存会带来一致性的问题,而我们java同时也受到了这个的影响。

cpu缓存

考虑一下下面代码的执行结果有是什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class TaskRunner {

private static int number = 0;
private static boolean ready = false;

private static class Reader extends Thread {

@Override
public void run() {
while (!ready) {
Thread.yield();
}

System.out.println(number);
}
}

public static void main(String[] args) {
new Reader().start();
number = 42;
ready = true;
}
}

正常预期来说结果会是42,但是也有可能结果输出为0。这是怎么回事呢?

假设我们cpu有两个核心,核心A运行主线程,核心B运行Reader线程。核心A更新了number之后,其实只是在cpu核心A缓存中进行修改,并且未同步到主内存(RAM)和cpu核心B缓存。因此当核心B运行Reader线程读取number时,只是读的核心B缓存中的值,并非Number的最新值。而最新值cpu会选择合适的时机把缓存中的值统一刷新到主存(RAM)中去。

因此这意味着主线程更新了number之后,reader线程对number的最新值能否看到是有不确定性的。

2. 指令重排

更糟糕的是,我们的代码可能不会按照我们预期的顺序执行,这样也会导致我们看到的结果是0,而不是42。

1
2
number = 42; 
ready = true;

这里有可能先给ready赋值,再给number赋值。出现这样指令重排的原因是为了提高性能而做的优化。因为在大多数情况下,改变赋值的顺序,并不影响最终结果。但是多线程竞争下就可能出现问题。具体来说不同的组件都可能使用指令重排的优化,比如:

  • 处理器可能以任何可能的顺序从cpu核心缓存刷新数据到主存中。
  • 处理器可能使用乱序执行的优化技术。
  • jit编译器也有可能重排指令。

3. volatile

为了保证多线程状态下,变量的可见性能如预期的那样,我们可以使用volatile关键字修饰变量。这样cpu和运行时环境jvm都知道被volatile修饰的变量都不应该被重排、已经应该把所有修改及时刷新到主存中去。因此可以总结一下volatile:

  • 保证可见性
  • 禁止指令重排
  • 不保证原子性(特殊情况:boolean和int赋值可以保证原子性)

有必要说下为什么volatile为什么不保证原子性。

原子性意味着不可分割,完整性,即某个操作中间不可以被加塞或者分隔,要么同时成功,要么同时失败。

我们想想一下这样一个方法。

1
2
3
4
5
public static volatile int num =0;

public void plus(){
num++
}

当多个线程同时运行plus函数时,num的总数会小于调用plus的次数。为什么会这样,因为在字节码层面,num++会被编译器翻译成多行指令,因此在执行时会被打断,无法保持原子性。

4. Happens-Before

因为jvm会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。
Happens-Before的规则包括:

  1. 程序顺序规则
  2. 锁定规则
  3. volatile变量规则
  4. 线程启动规则
  5. 线程结束规则
  6. 中断规则
  7. 终结器规则
  8. 传递性规则

5. 内存屏障原理

内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由jvm来生成内存屏障的指令。硬件层的内存屏障分为两种:

  • Load Barrier 读屏障

    对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载新数据;

  • Store Barrier写屏障。

    对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

内存屏障有两个作用:

  1. 阻止屏障两侧的指令重排序;
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。