Java 多线程(六)之Java内存模型

@

1. 并发编程的两个问题

在并发编程中, 需要处理两个关键问题: 线程之间如何通信及线程之间如何同步

通信指的是线程之间是以何种机制来交换信息, 在命令式编程中, 线程之间的通信机制有两种:共享内存和消息传递。在共享内存的模型中, 线程之间共享程序的公共状态, 通过读写内存中的公共状态进行隐式通信。在消息传递的并发模型中, 线程之间没有公共状态, 线程之间必须通过发送消息显示的进行通信。

同步指的是程序中用于控制不同线程之间操作发生相对顺序的机制。在共享内存的并发模型里, 同步是显示进行的。 程序员必须显示的指定某个方法或某段代码需要在线程之间互斥。

Java 采用的是共享内存模型, Java线程之间的通信总是隐式的进行, 整个通信过程对程序员完全透明。

2 CPU 缓存模型

2.1 CPU 和 主存

在计算机中, 所有的计算操作都是由 CPU 的寄存器来完成的。 CPU 指令的执行过程需要涉及数据的读取和写入操作。 CPU 通常能访问的都是计算机的主内存(通常是 RAM)。

随着制造工艺等的飞速发展, CPU 不断的发展。 但主存的发展却没有多大的突破, 因此, 差距就越来越大。

CPU和RAM

因此, 一种新类型的更快的内存-缓存,就出现了(速度越快越贵),用来弥补两者之间的差距。

2.2 CPU Cache

目前, CPU缓存模型如下所示

CPU内存模型

越靠近CPU, 速度越快。 其速度差异如下

内存和缓存的速度差异

CPU Cache 由多个 CPU Line 构成, CPU Line 被认为是最小的缓存单位。

2.3 CPU如何通过 Cache 与 主内存交互

既然有了 CPU Cache, CPU 就不直接跟内存进行交互了。 在程序运行的过程中, 会将运算所需要的数据从主内存复制到 CPU Cache 中, 这样就可以直接对 CPU Cache 进行读取和写入, 当运算结束之后, 在将结果刷新到主内存中。

通过以上的方式, CPU的吞吐能力得到极大的提高。有了 CPU Cache 之后, 整体的 CPU 和 主内存的交换架构大致如下

多核CPU和主内存的交换架构

在该架构中, 每个CPU的 CPU Cache 是自己本地的, 别的CPU无法访问。

2.4 CPU 缓存一致性问题

就如同我们在自己的程序中使用缓存时一样, CPU 引入了缓存, 提高了访问速度, 但也带来了缓存一致性的问题。

举例

对于 i++ 这个操作, 需要以下几个步骤

  1. 读取主内存值 i 到 CPU Cache 中
  2. 对 i 进行自增操作
  3. 将结果写回 CPU Cache 中
  4. 将数据刷新到缓存中

在单线程的情况下, 该操作是没有任何问题的。 但是在多线程的情况下, 变量 i 会在多个线程的本地内存中都存在副本, 如果两个线程都执行以上操作, 读取到的值刚开始都为 0, 那么在进行两次自增操作之后, 主存中的值仍然为 1。 这就是缓存一致性问题。

为了解决该问题, 聪明的前人发明了两种方法

  1. 通过总线加锁的方式
  2. 通过缓存一致性协议

总线加锁效率太低, 现在都使用的是缓存一致性协议。

最出名的就是传说中的 MESI(Modify, Exclusive, Shared, Invalid) 协议。

  • Modify:当前CPU cache拥有最新数据(最新的cache line),其他CPU拥有失效数据(cache line的状态是invalid),虽然当前CPU中的数据和主存是不一致的,但是以当前CPU的数据为准;
  • Exclusive:只有当前CPU中有数据,其他CPU中没有改数据,当前CPU的数据和主存中的数据是一致的;
  • Shared:当前CPU和其他CPU中都有共同数据,并且和主存中的数据一致;
  • Invalid:当前CPU中的数据失效,数据应该从主存中获取,其他CPU中可能有数据也可能无数据,当前CPU中的数据和主存被认为是不一致的;

MESI 协议为每个 CPU Line 提供状态, 并根据不同状态的操作做出不同的响应。

CacheLine

在 MESI 协议中, 有如下操作

  • Local Read(LR):读本地cache中的数据
  • Local Write(LW):将数据写到本地cache
  • Remote Read(RR):其他核心发生read
  • Remote Write(RW):其他核心发生write
    MESI

3 Java内存模型(JMM)

3.1 Java内存模型(JMM)

Java 虚拟机规范提供了一种Java 内存模型来屏蔽掉各种硬件和操作系统的内存访问差异, 以实现让 Java 程序在各种平台下都能达到一致性的内存访问效果。
JMM

从架构上看, 跟之前提到的物理硬件内存模型有很大的相似度, 但是差别挺大。

  • 主内存: 所有的变量都存储在主内存中(类似于物理硬件的主内存, 不过该内存只是虚拟机内存的一部分)
  • 工作内存: 工作内存中保存了被该线程用到的变量的主内存副本拷贝(取决于虚拟机的实现, 可能复制的只是对象的引用, 对象的某个字段等), 线程对变量的操作(读写等)都必须在工作内存中运行, 而不能直接读写主内存中的变量

不同的线程之间无法访问对方工作内存中的变量, 线程之间变量的传递必须通过主内存进行

3.2 内存间交互操作

变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存, 由以下8种原子操作来完成。

  1. lock: 作用于主内存变量, 它把一个变量标识为一条线程独占的状态
  2. unlock: 作用于主内存的变量, 它把一个处于加锁的变量释放出来, 释放后的变量才可以被其他线程锁定
  3. read: 作用于主内存变量, 它把一个变量的值从主内存传输到线程的工作内存, 一般随后的 load 操作
  4. load: 作用于工作内存的变量, 它把 read 操作从主内存宏得到的值写入工作内存的变量副本中
  5. use: 作用于工作内存的变量, 把工作内存的变量传递给执行引擎, 每当虚拟机遇到一个需要使用到变量值的字节码指令时就会执行该操作
  6. assign: 作用于工作区内存的变量, 它把执行引擎接收到的值赋值给工作内存的变量, 当虚拟机遇到给一个给变量赋值的指令时就会执行这个操作
  7. store: 作用于工作内存变量, 把工作内存中变量的值传送到主内存中, 以便随后的 write 操作
  8. write: 作用于主内存变量, 它把 store 操作从工作内存中得到的变量值放入主内存变量中

Java模型还对这些操作进行了更加细致的限定, 加上 volatile 的一些特殊规定, 就可以确定 Java 程序中哪些内存访问操作在并发下是安全的。

3.3 重排序

重排序是编译器和处理器为了优化程序性能而对指令序列进行重重排序的一种手段。重排序的目的是在不改变程序执行结果的情况下, 尽可能提高并行度。 有以下几种重排序:

  1. 编译器优化的重排序。 在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现在处理器采用了指令级并行技术(ILP)来将多条指令重叠执行。 如果不存在数据依赖性, 处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。 由于处理器使用缓存和读写缓冲区, 这使得记载和存储操作看上去可能是乱序执行的。

从源代码到最终实际执行的指令序列, 经历的3种重排序

源代码到最终执行

1属于编译器重排序, 2和3属于处理器重排序。

3.3.1 数据依赖性

如果两个操作访问同一个变量, 且这两个操作中有一个为写操作, 此时这两个操作之间就存在数据依赖性

名称 代码示例 说明
写后读 a=1;
b=a;
写一个变量之后, 在读这个位置
写后写 a=1;
a=2;
写一个变量之后, 再写一个变量
读后写 a=b;
b=1;
读一个变量之后, 再写这个变量

如果对以上的操作并行重排序, 则会改变程序执行的结果。因此, 编译器和处理器在重排序时, 会遵循数据依赖性, 编译器和处理器不会改变存在数据依赖性的两个操作的执行顺序。

此处说的仅仅是单线程的数据依赖性, 多线程的不考虑。

3.3.2 as-if-serial

即不管程序怎么重排序, (单线程)程序的执行结果不能被改变。 编译器、runtime和处理器必须遵循 as-if-serial 语义。

double pi=3.14;         // A
double r=1.0;           // B
double area = pi*r*r;   // C    

在此代码中, A和B都跟C存在数据依赖性, 但是 A 和 B 之间没有依赖性。 因此, C 不能被排到 A或B 之前。 但对 A 和 B, 这两者可以随意排序。

3.3.3 程序顺序规则

在以上圆形面积的计算中, 有如下三个 happens-before 关系

  1. A happens-before B
  2. B happens-before C
  3. A happens-before C

其中第三条是根据前面两条传递性推倒出来的。

A happens-before B 并不是要求 A 一定要在 B 之前执行, 而是要求A的执行结果对B可见。 但这里的A的执行结果不需要对B可见, 在这种情况下, JMM 会认为这种重排序是合法的, JMM 允许此类重排序。

3.4 happens-before原则

happens-before 是用来阐述操作之间的可见性。 即在JMM中, 如果一个操作执行的结果需要对另一个操作可见, 则这两个操作之间必须存在 happens-before 关系。
happens-before

happens-before 规则

  1. 程序顺序规则(单线程): 一个线程中的每个操作, happens-before 于该线程中的后续操作。
  2. 监视器规则: 对一个锁的解锁, happens-before 于对该锁的加锁
  3. volatile规则:对一个 volatile 域的写, happens-before 于随后对这个域的读
  4. 传递性: 如果 A happens-before B, 且 B happens-before C, 则 A happens-before C。
  5. 线程启动规则: 如果线程A执行操作ThreadB.start()(线程B启动), 那么A线程的 Thread.start() 操作 happens-before 于线程B的任意操作。
  6. 线程终止规则: 如果线程 A 执行操作 ThreadB.join() 并成功返回, 那么编程B中的任意操作 happens-before 于线程A从ThreadB.join()操作成功返回。
  7. 程序中断规则: 对线程interrupt()的方法的调用 happens-before 于被中断线程代码检测到中断事件的发生。
  8. 对象终结规则: 一个对象的初始化完成, happens-before 于发生它的 finalize() 方法的开始。

3.4 原子性、可见性和有序性

JMM 是围绕着在并发过程中如何处理原子性、可见性和有序性这个三个特征来建立的。

3.4.1 原子性

Java 中对以上的八种操作是原子性的。 对应起来就是对基本数据类型的读取/赋值操作都是原子性的, 引用类型的读取和赋值也是如此。

举几个例子

赋值操作

a=10

该操作需要使用 assign 操作, 可能需要 store 和 write 操作。 这些过程都是原子操作。

可有通过

  1. synchronized关键字
  2. JUC所提供的显式锁Lock

来实现原子性

3.4.1 可见性

指的是一个线程中修改了共享变量, 其他的线程就能够立即知道这个修改。 JMM 可以通过以下三种方式来保证可见性

  1. volatile关键字
  2. synchronized关键字
  3. JUC所提供的显式锁Lock

3.4.2 有序性

Java 中天然的有序性可以概括总结为一句话:如果本线程内观察, 所有的操作都是有序的; 如果在一个线程内观察另一个线程, 所有的操作都是无序的。 前半句指的是 as-if-serial 语义, 后半句指的是“指令重排”和“线程内存与主内存同步延迟”的线程。

有序性的保证:

  1. volatile: 禁止指令重排
  2. synchronized: 一个变量再同一时刻, 只允许一条线程对其进行 lock 操作。
  3. Lock: 同 synchronized
posted @ 2018-12-11 15:35  阿进的写字台  阅读(1135)  评论(0编辑  收藏  举报