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

前言

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

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

  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的数据:将运算须要使用到的数据复制到缓存中,让运算可以快速执行,当运算结束后再从缓存同步到内存之中。这样处理器就无需等待缓慢的内存读写了。多线程

CPU 和缓存

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

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

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

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

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

什么是内存模型呢?

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

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

Java 内存模型(Java Memory Model)

好了,关于为何会有内存模型这件事,咱们已经说的差很少了,整体来讲就是由于多个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 内存模型(Java Memory Model)

仍是看这个图片,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!!!!

相关文章
相关标签/搜索