并发编程之 Java 内存模型 + volatile 关键字 + Happen-Before 规则

时间:2021-06-12 20:49:05

并发编程之 Java 内存模型 + volatile 关键字 + Happen-Before 规则

前言

楼主这个标题其实有一种作死的味道,为什么呢,这三个东西其实可以分开为三篇文章来写,但是,楼主认为这三个东西又都是高度相关的,应当在一个知识点中。在一次学习中去理解这些东西。才能更好的理解 Java 内存模型和 volatile 关键字还有 HB 原则。

楼主今天就尝试着在一篇文章中讲述这三个问题,最后总结。

  1. 讲并发知识前必须复习的硬件知识。
  2. Java 内存模型到底是什么玩意?
  3. Java 内存模型定义了哪些东西?
  4. Java内存模型引出的 Happen-Before 原则是什么?
  5. Happen-Before 引出的 volatile 又是什么?
  6. 总结这三者。

1. 讲并发知识前必须复习的硬件知识。

首先,因为我们需要了解 Java 虚拟机的并发,而物理硬件的并发和虚拟机的并发很相似,而且虚拟机的并发很多看着奇怪的设计都是因为物理机的设计导致的。

什么是并发?多个CPU同时执行。但请注意:只有CPU是不行的,CPU 只能计算数据,那么数据从哪里来?

答案:内存。 数据从内存中来。需要读取数据,存储计算结果。有的同学可能会说,不是有寄存器和多级缓存吗?但是那是静态随机访问内存(Static Random Access Memory),太贵了,SRAM 在设计上使用的晶体管数量较多,价格较高,且不易做成大容量,只能用很小的部分集成的CPU中成为CPU的高速缓存。而正常使用的都是都是动态随机访问内存(Dynamic Random Access Memory)。intel 的 CPU 外频 需要从北桥经过访问内存,而AMD 的没有设计北桥,他与 Intel 不同的地方在于,内存是直接与CPU通信而不通过北桥,也就是将内存控制组件集成到CPU中。理论上这样可以加速CPU和内存的传输速度。

好了,不管哪一家的CPU,都需要从内存中读取数据,并且自己都有高速缓存或者说寄存器。缓存作什么用呢?由于CPU的速度很快,内存根本跟不上CPU,因此,需要在内存和CPU直接加一层高速缓存让他们缓冲CPU的数据:将运算需要使用到的数据复制到缓存中,让运算能够快速执行,当运算结束后再从缓存同步到内存之中。这样处理器就无需等待缓慢的内存读写了。

并发编程之 Java 内存模型 + volatile 关键字 + Happen-Before 规则

但是这样引出了另一个问题:缓存一致性(Cache Coherence)。什么意思呢?

在多处理器中,每个处理器都有自己的高速缓存,而他们又共享同一个主内存(Main Memory),当多个处理器的运算任务都涉及到同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生这种情况,拿同步到主内存时以谁的缓存数据为准呢?

在早期的CPU当中,可以通过在总线上加 LOCK# 锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。

现在的 CPU 为了解决一致性问题,需要各个CPU访问(读或者写)缓存的时候遵循一些协议:MSI,MESI,MOSI,Synapse,Firefly,Dragon Protocol,这些都是缓存一致性协议。

那么,这个时候需要说一个名词:内存模型。

什么是内存模型呢?

内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的CPU 有不同的内存模型,而 Java 虚拟机屏蔽了不同CPU内存模型的差异,这就是Java 的内存模型。

那么 Java 的内存模型的结构是什么样子的呢?

并发编程之 Java 内存模型 + volatile 关键字 + Happen-Before 规则

好了,关于为什么会有内存模型这件事,我们已经说的差不多了,总体来说就是因为多个CPU的多级缓存访问同一个内存条可能会导致数据不一致。所以需要一个协议,让这些处理器在访问内存的时候遵守这些协议保证数据的一致性。

还有一个问题。CPU 的流水线执行和乱序执行

我们假设我们现在有一段代码:


int a = 1;
int b = 2;
int c = a + b;

上面的代码我们能不能不顺序动一下并且结果不变呢?可以,第一行和第二行调换没有任何问题。

实际上,CPU 有时候为了优化性能,也会对代码顺序进行调换(在保证结果的前提下),专业术语叫重排序。为什么重排序会优化性能呢?

这个就有点复杂了,我们慢慢说。

我们知道,一条指令的执行可以分为很多步骤的,简单的说,可以分为以下几步:

  1. 取指 IF
  2. 译码和取寄存器操作数 ID
  3. 执行或者有效地址计算 EX
  4. 存储器返回 MEM
  5. 写回 WB

我们的汇编指令也不是一步就可以执行完毕的,在CPU 中实际工作时,他还需要分为多个步骤依次执行,每个步骤涉及到的硬件也可能不同,比如,取指时会用到 PC 寄存器和存储器,译码时会用到指令寄存器组,执行时会使用 ALU,写回时需要寄存器组。

也就是说,由于每一个步骤都可能使用不同的硬件完成,因此,CPU 工程师们就发明了流水线技术来执行指令。什么意思呢?

假如你需要洗车,那么洗车店会执行 “洗车” 这个命令,但是,洗车店会分开操作,比如冲水,打泡沫,洗刷,擦干,打蜡等,这写动作都可以由不同的员工来做,不需要一个员工依次取执行,其余的员工在那干等着,因此,每个员工都被分配一个任务,执行完就交给下一个员工,就像工厂里的流水线一样。

CPU 在执行指令的时候也是这么做的。

既然是流水线执行,那么流水线肯定不能中断,否则,一个地方中断会影响下游所有的组件执行效率,性能损失很大。

那么怎么办呢?打个比方,1冲水,2打泡沫,3洗刷,4擦干,5打蜡 本来是按照顺序执行的。如果这个时候,水没有了,那么冲水后面的动作都会收到影响,但是呢,其实我们可以让冲水先去打水,和打泡沫的换个位置,这样,我们就先打泡沫,冲水的会在这个时候取接水,等到第一辆车的泡沫打完了,冲水的就回来了,继续赶回,不影响工作。这个时候顺序就变成了:

1打泡沫 ,2冲水,3洗刷,4擦干,5打蜡.

但是工作丝毫不受影响。流水线也没有断。CPU 中的乱序执行其实也跟这个道理差不多。其最终的目的,还是为了压榨 CPU 的性能。

好了,对于今天的文章需要的硬件知识,我们已经复习的差不多了。总结一下,主要是2点:

  1. CPU 的多级缓存访问主存的时候需要配合缓存一致性协议。这个过程可以抽象为内存模型。
  2. CPU 为了性能会让指令流水线执行,并且会在单个 CPU 的执行结构不混乱的情况下乱序执行。

那么,接下来就要好好说说Java 的内存模型了。

2. Java 内存模型到底是什么玩意?

回忆下上面的内容,我们说从硬件的层面什么是内存模型?

内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的CPU 有不同的内存模型。

Java 作为跨平台语言,肯定要屏蔽不同CPU内存模型的差异,构造自己的内存模型,这就是Java 的内存模型。实际上,根源来自硬件的内存模型。

并发编程之 Java 内存模型 + volatile 关键字 + Happen-Before 规则

还是看这个图片,Java 的内存模型和硬件的内存模型几乎一样,每个线程都有自己的工作内存,类似CPU的高速缓存,而 java 的主内存相当于硬件的内存条。

Java 内存模型也是抽象了线程访问内存的过程。

JMM(Java 内存模型)规定了所有的变量都存储在主内存(这个很重要)中,包括实例字段,静态字段,和构成数据对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的。不会被共享。自然就没有竞争问题。

什么是工作内存呢?每个线程都有自己的工作内存(这个很重要),线程的工作内存保存了该线程使用到的变量和主内存副本拷贝,线程对变量的所有操作(读写)都必须在工作内存中进行。而不能直接读写主内存中的变量。不同的线程之间也无法访问对方工作内存中的变量。线程之间变量值的传递均需要通过主内存来完成。

总结一下,Java 内存模型定义了两个重要的东西,1.主内存,2.工作内存。每个线程的工作内存都是独立的,线程操作数据只能在工作内存中计算,然后刷入到主存。这是 Java 内存模型定义的线程基本工作方式。

3. Java 内存模型定义了哪些东西?

实际上,整个 Java 内存模型围绕了3个特征建立起来的。这三个特征是整个Java并发的基础。

原子性,可见性,有序性。

原子性(Atomicity)

什么是原子性,其实这个原子性和事务处理中的原子性定义基本是一样的。指的是一个操作是不可中断的,不可分割的。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

我们大致可以认为基本数据类型的访问读写是具备原子性的(但是,如果你在32位虚拟机上计算 long 和 double 就不一样了),因为 java 虚拟机规范中,对 long 和 double 的操作没有强制定义要原子性的,但是强烈建议使用原子性的。因此,大部分商用的虚拟机基本都实现了原子性。

如果用户需要操作一个更到的范围保证原子性,那么,Java 内存模型提供了 lock 和 unlock (这是8种内存操操作中的2种)操作来满足这种需求,但是没有提供给程序员这两个操作,提供了更抽象的 monitorenter 和 moniterexit 两个字节码指令,也就是 synchronized 关键字。因此在 synchronized 块之间的操作都是原子性的。

可见性(Visibility)

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改,Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值,这种依赖主内存作为传递媒介的方式来实习那可见性的。无论是普通变量还是 volatile 变量都是如此。他们的区别在于:volatile 的特殊规则保证了新值能立即同步到主内存,以及每次是使用前都能从主内存刷新,因此,可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

除了 volatile 之外, synchronized 和 final 也能实现可见性。同步块的可见性是由 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存种(执行 store, write 操作)

有序性(Ordering)

有序性这个问题我们在最上面说硬件的时候说过,CPU 会调整指令顺序,同样的 Java 虚拟机同样也会调整字节码顺序,但这种调整在单线程里时感知不到的,除非在多线程程序中,这种调整会带来一些意想不到的错误。

Java 提过了两个关键字来保证多个线程之间操作的有序性,volatile 关键字本身就包含了禁止重排序的语义,而 synchronized 则是由 “一个变量同一时刻只允许一条线程对其进行 lock 操作”这个规则获得的。这条规则决定了同一个锁的两个同步块只能串行的进入。

好了,介绍完了 JMM 的三种基本特征。不知道大家有没有发现,volatile 保证了可见性和有序性,synchronized 则3个特性都保证了,堪称万能。而且 synchronized 使用方便。但是,仍然要警惕他对性能的影响。

4. Java内存模型引出的 Happen-Before 原则是什么?

说到有序性,注意,我们说有序性可以通过 volatile 和 synchronized 来实现,但是我们不可能所有的代码都靠这两个关键字。实际上,Java 语言已对重排序或者说有序性做了规定,这些规定在虚拟机优化的时候是不能违背的。

  1. 程序次序原则:一个线程内,按照程序代码顺序,书写在前面的操作先发生于书写在后面的操作。
  2. volatile 规则:volatile 变量的写,先发生于读,这保证了 volatile 变量的可见性。
  3. 锁规则:解锁(unlock) 必然发生在随后的加锁(lock)前。
  4. 传递性:A先于B,B先于C,那么A必然先于C。
  5. 线程的 start 方法先于他的每一个动作。
  6. 线程的所有操作先于线程的终结。
  7. 线程的中断(interrupt())先于被中断的代码。
  8. 对象的构造函数,结束先于 finalize 方法。

5. Happen-Before 引出的 volatile 又是什么?

我们在前面,说了很多的 volatile 关键字,可见这个关键字非常的重要,但似乎他的使用频率比 synchronized
少多了,我们知道了这个关键字可以做什么呢?

volatile 可以实现线程的可见性,还可以实现线程的有序性。但是不能实现原子性。

我们还是直接写一段代码吧!

package cn.think.in.java.two;

/**
 * volatile 不能保证原子性,只能遵守 hp 原则 保证单线程的有序性和可见性。
 */
public class MultitudeTest {

  static volatile int i = 0;

  static class PlusTask implements Runnable {

    @Override
    public void run() {
      for (int j = 0; j < 10000; j++) {
// plusI();
        i++;
      }
    }
  }

  public static void main(String[] args) throws InterruptedException {
    Thread[] threads = new Thread[10];
    for (int j = 0; j < 10; j++) {
      threads[j] = new Thread(new PlusTask());
      threads[j].start();
    }

    for (int j = 0; j < 10; j++) {
      threads[j].join();
    }

    System.out.println(i);
  }

// static synchronized void plusI() {
// i++;
// }

}

我们启动了10个线程分别对一个 int 变量进行 ++ 操作,注意,++ 符号不是原子的。然后,主线程等待在这10个线程上,执行结束后打印 int 值。你会发现,无论怎么运行都到不了10000,因为他不是原子的。怎么理解呢?

i++ 等于 i = i + 1;

虚拟机首先读取 i 的值,然后在 i 的基础上加1,请注意,volatile 保证了线程读取的值是最新的,当线程读取 i 的时候,该值确实是最新的,但是有10个线程都去读了,他们读到的都是最新的,并且同时加1,这些操作不违法 volatile 的定义。最终出现错误,可以说是我们使用不当。

楼主也在测试代码中加入了一个同步方法,同步方法能够保证原子性。当for循环中执行的不是i++,而是 plusI 方法,那么结果就会准确了。

那么,什么时候用 volatile 呢?

运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
我们程序的情况就是,运算结果依赖 i 当前的值,如果改为 原子操作: i = j,那么结果就会是正确的 9999.

比如下面这个程序就是使用 volatile 的范例:

package cn.think.in.java.two;

/**
 * java 内存模型:
 * 单线程下会重排序。
 * 下面这段程序再 -server 模式下会优化代码(重排序),导致永远死循环。
 */
public class JMMDemo {

  // static boolean ready;
  static volatile boolean ready;
  static int num;

  static class ReaderThread extends Thread {

    public void run() {
      while (!ready) {
      }
      System.out.println(num);

    }
  }

  public static void main(String[] args) throws InterruptedException {
    new ReaderThread().start();
    Thread.sleep(1000);
    num = 32;
    ready = true;
    Thread.sleep(1000);
    Thread.yield();
  }

}

这段程序很有意思,我们使用 volatile 变量来控制流程,最终的正确结果是32,但是请注意,如果你没有使用 volatile 关键字,并且虚拟机启动的时候加入了 -server参数,这段程序将永远不会结束,因为他会被 JIT 优化并且另一个线程永远无法看到变量的修改(JIT 会忽略他认为无效的代码)。当然,当你修改为 volatile 就没有任何问题了。

通过上面的代码,我们知道了,volatile 确实不能保证原子性,但是能保证有序性和可见性。那么是怎么实现的呢?

怎么保证有序性呢?实际上,在操作 volatile 关键字变量前后的汇编代码中,会有一个 lock 前缀,根据 intel IA32 手册,lock 的作用是 使得 本 CPU 的Cache 写入了内存,该写入动作也会引起别的CPU或者别的内核无效化其Cache,别的CPU需要重新获取Cache。这样就实现了可见性。可见底层还是使用的 CPU 的指令。

如何实现有序性呢?同样是lock 指令,这个指令还相当于一个内存屏障(大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障),指的是,重排序时不能把后面的指令重排序到内存屏障之前的位置。只有一个CPU访问内存时,并不需要内存屏障;但如果有两个或者更多CPU访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证了。

因此请不要随意使用 volatile 变量,这会导致 JIT 无法优化代码,并且会插入很多的内存屏障指令,降低性能。

6. 总结

首先 JMM 是抽象化了硬件的内存模型(使用了多级缓存导致出现缓存一致性协议),屏蔽了各个 CPU 和操作系统的差异。

Java 内存模型指的是:在特定的协议下对内存的访问过程。也就是线程的工作内存和主存直接的操作顺序。

JMM 主要围绕着原子性,可见性,有序性来设置规范。

synchronized 可以实现这3个功能,而 volatile 只能实现可见性和有序性。final 也能是实现可见性。

Happen-Before 原则规定了哪些是虚拟机不能重排序的,其中包括了锁的规定,volatile 变量的读与写规定。

而 volatile 我们也说了,不能保证原子性,所以使用的时候需要注意。volatile 底层的实现还是 CPU 的 lock 指令,通过刷新其余的CPU 的Cache 保证可见性,通过内存栅栏保证了有序性。

总的来说,这3个概念可以说息息相关。他们之间互相依赖。所以楼主放在了一篇来写,但这可能会导致有所疏漏,但不妨碍我们了解整个的概念。可以说,JMM 是所有并发编程的基础,如果不了解 JMM,根本不可能高效并发。

当然,我们这篇文章还是不够底层,并没有剖析 JVM 内部是怎么实现的,今天已经很晚了,有机会,我们一起进入 JVM 源码查看他们的底层实现。

good luck!!!!