从 PC 内存架构到 Java 内存模型
你知道 Java 内存模型 JMM 吗? 那你知道它的三大特性吗?
Java 是如何解决指令重排问题的?
既然 CPU 有缓存一致性协议(MESI), 为什么 JMM 还需要 volatile 关键字?
带着问题, 尤其是面试问题的学习才是最高效的. 加油, 奥利给!
文章收录在 GitHub https://github.com/Jstarfish/JavaKeeper ,N 线互联网开发必备技能兵器谱
前两天看到同学和我显摆他们公司配的电脑多好多好, 我默默打开了自己的电脑, 酷睿 i7-4770, 也不是不够用嘛, 4 核 8 线程的 CPU, 也是杠杠的.
扯这玩意干啥, Em~~~~
介绍 Java 内存模型之前, 先温习下计算机硬件内存模型
硬件内存架构
计算机在执行程序的时候, 每条指令都是在 CPU 中执行的, 而执行的时候, 又免不了要和数据打交道. 而计算机上面的数据, 是存放在主存当中的, 也就是计算机的物理内存.
计算机硬件架构简易图:
我们以多核 CPU 为例, 每个 CPU 核都包含一组 「CPU 寄存器」, 这些寄存器本质上是在 CPU 内存中. CPU 在这些寄存器上执行操作的速度要比在主内存 (RAM) 中执行的速度快得多.
因为 CPU 速率高, 内存速率慢, 为了让存储体系可以跟上 CPU 的速度, 所以中间又加上 Cache 层, 就是我们说的 「CPU 高速缓存」.
CPU 多级缓存
由于 CPU 的运算速度远远超越了 1 级缓存的数据 I\O 能力, CPU 厂商又引入了多级的缓存结构. 通常 L1,L2 是每个 CPU 核有一个, L3 是多个核共用一个.
Cache Line
Cache 又是由很多个「缓存行」(Cache line) 组成的. Cache line 是 Cache 和 RAM 交换数据的最小单位.
Cache 存储数据是固定大小为单位的, 称为一个 Cache entry, 这个单位称为 Cache line 或 Cache block. 给定 Cache 容量大小和 Cache line size 的情况下, 它能存储的条目个数 (number of cache entries) 就是固定的. 因为 Cache 是固定大小的, 所以它从主内存获取数据也是固定大小. 对于 X86 来讲, 是 64Bytes. 对于 ARM 来讲, 较旧的架构的 Cache line 是 32Bytes, 但一次内存访存只访问一半的数据也不太合适, 所以它经常是一次填两个 Cache line, 叫做 double fill.
缓存的工作原理
这里的缓存的工作原理和我们项目中用 Memcached,Redis 做常用数据的缓存层是一个道理.
当 CPU 要读取一个数据时, 首先从缓存中查找, 如果找到就立即读取并送给 CPU 处理; 如果没有找到, 就去内存中读取并送给 CPU 处理, 同时把这个数据所在的数据块 (就是我们上边说的 Cache block) 调入缓存中, 即把临近的共 64 Byte 的数据也一同载入, 因为临近的数据在将来被访问的可能性更大, 可以使得以后对整块数据的读取都从缓存中进行, 不必再调用内存.
这就增加了 CPU 读取缓存的命中率 (Cache hit) 了.
计算机层级存储
计算机存储系统是有层次结构的, 类似一个金字塔, 顶层的寄存器读写速度较高, 但是空间较小. 底层的读写速度较低, 但是空间较大
缓存一致性
既然每个核中都有单独的缓存, 那我的 4 核 8 线程 CPU 处理主内存数据的时候, 不就会出现数据不一致问题了吗?
为了解决这个问题, 先后有过两种方法: 总线锁机制和缓存锁机制.
总线锁就是使用 CPU 提供的一个 LOCK# 信号, 当一个处理器在总线上输出此信号, 其他处理器的请求将被阻塞, 那么该处理器就可以独占共享锁. 这样就保证了数据一致性.
但是总线锁开销太大, 我们需要控制锁的粒度, 所以又有了缓存锁, 核心就是 "缓存一致性协议", 不同的 CPU 硬件厂商实现方式稍有不同, 有 MSI,MESI,MOSI 等.
代码乱序执行优化
为了使得处理器内部的运算单元尽量被充分利用, 提高运算效率, 处理器可能会对输入的代码进行「乱序执行」(Out-Of-Order Execution), 处理器会在计算之后将乱序执行的结果重组, 乱序优化可以保证在单线程下该执行结果与顺序执行的结果是一致的 **, 但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致.
乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化. 在单核时代, 处理器保证做出的优化不会导致执行结果远离预期目标, 但在多核环境下却并非如此.
多核环境下, 如果存在一个核的计算任务依赖另一个核的计算任务的中间结果, 而且对相关数据读写没做任何防护措施, 那么其顺序性并不能靠代码的先后顺序来保证, 处理器最终得出的结果和我们逻辑得到的结果可能会大不相同.
编译器指令重排
除了上述由处理器和缓存引起的乱序之外, 现代编译器同样提供了乱序优化. 之所以出现编译器乱序优化其根本原因在于处理器每次只能分析一小块指令, 但编译器却能在很大范围内进行代码分析, 从而做出更优的策略, 充分利用处理器的乱序执行功能.
内存屏障
尽管我们看到乱序执行初始目的是为了提高效率, 但是它看来其好像在这多核时代不尽人意, 其中的某些 "自作聪明" 的优化导致多线程程序产生各种各样的意外. 因此有必要存在一种机制来消除乱序执行带来的坏影响, 也就是说应该允许程序员显式的告诉处理器对某些地方禁止乱序执行. 这种机制就是所谓内存屏障. 不同架构的处理器在其指令集中提供了不同的指令来发起内存屏障, 对应在编程语言当中就是提供特殊的关键字来调用处理器相关的指令, JMM 里我们再探讨.
Java 内存模型
Java 内存模型即 Java Memory Model, 简称 JMM.
这里的内存模型可不是 JVM 里的运行时数据区.
「内存模型」可以理解为在特定操作协议下, 对特定的内存或高速缓存进行读写访问的过程抽象.
不同架构的物理计算机可以有不一样的内存模型, Java 虚拟机也有自己的内存模型.
Java 虚拟机规范中试图定义一种「 Java 内存模型」来屏蔽掉各种硬件和操作系统的内存访问差异, 以实现让 Java 程序在各种平台下都能达到一致的内存访问效果, 不必因为不同平台上的物理机的内存模型的差异, 对各平台定制化开发程序.
Java 内存模型的主要目标是定义程序中各个变量的访问规则, 即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节. 这里的变量与我们写 Java 代码中的变量不同, 它包括了实例字段, 静态字段和构成数组对象的元素, 但不包括局部变量和方法参数, 因为他们是线程私有的, 不会被共享.
JMM 组成
主内存: Java 内存模型规定了所有变量都存储在主内存 (Main Memory) 中(此处的主内存与物理硬件的主内存 RAM 名字一样, 两者可以互相类比, 但此处仅是虚拟机内存的一部分).
工作内存: 每条线程都有自己的工作内存(Working Memory, 又称本地内存, 可与 CPU 高速缓存类比), 线程的工作内存中保存了该线程使用到的主内存中的共享变量的副本拷贝. 线程对变量的所有操作都必须在工作内存进行, 而不能直接读写主内存中的变量. 工作内存是 JMM 的一个抽象概念, 并不真实存在.
JMM 与 JVM 内存结构
JMM 与 Java 内存区域中的堆, 栈, 方法区等并不是同一个层次的内存划分, 两者基本没有关系. 如果一定要勉强对应, 那从变量, 主内存, 工作内存的定义看, 主内存主要对应 Java 堆中的对象实例数据部分, 工作内存则对应虚拟机栈的部分区域(与上图对应着看哈).
JMM 与计算机内存结构
Java 内存模型和硬件内存体系结构也没有什么关系. 硬件内存体系结构不区分栈和堆. 在硬件上, 线程栈和堆都位于主内存中. 线程栈和堆的一部分有时可能出现在高速缓存和 CPU 寄存器中. 如下图所示:
当对象和变量可以存储在计算机中不同的内存区域时, 这就可能会出现某些问题. 两个主要问题是:
线程更新 (写) 到共享变量的可见性
读取, 检查和写入共享变量时的竞争条件
可见性问题(Visibility of Shared Objects)
如果两个或多个线程共享一个对象, 则一个线程对共享对象的更新可能对其他线程不可见(当然可以用 Java 提供的关键字 volatile).
假设共享对象最初存储在主内存中. 在 CPU 1 上运行的线程将共享对象读入它的 CPU 缓存后修改, 但是还没来得及即刷新回主内存, 这时其他 CPU 上运行的线程就不会看到共享对象的更改. 这样, 每个线程都可能以自己的线程结束, 就出现了可见性问题, 如下
竞争条件(Race Conditions)
这个其实就是我们常说的原子问题.
如果两个或多个线程共享一个对象, 并且多个线程更新该共享对象中的变量, 则可能出现竞争条件.
想象一下, 如果线程 A 将一个共享对象的变量读入到它的 CPU 缓存中. 此时, 线程 B 执行相同的操作, 但是进入不同的 CPU 缓存. 现在线程 A 执行 +1 操作, 线程 B 也这样做. 现在该变量增加了两次, 在每个 CPU 缓存中一次.
如果这些增量是按顺序执行的, 则变量结果会是 3, 并将原始值 + 2 写回主内存. 但是, 这两个增量是同时执行的, 没有适当的同步. 不管将哪个线程的结构写回主内存, 更新后的值只比原始值高 1, 显然是有问题的. 如下(当然可以用 Java 提供的关键字 Synchronized)
JMM 特性
JMM 就是用来解决如上问题的. JMM 是围绕着并发过程中如何处理可见性, 原子性和有序性这 3 个 特征建立起来的
可见性: 可见性是指当一个线程修改了共享变量的值, 其他线程能够立即得知这个修改. Java 中的 volatile,synchronzied,final 都可以实现可见性
原子性: 即一个操作或者多个操作, 要么全部执行并且执行的过程不会被任何因素打断, 要么就都不执行. 即使在多个线程一起执行的时候, 一个操作一旦开始, 就不会被其他线程所干扰.
有序性:
计算机在执行程序时, 为了提高性能, 编译器和处理器常常会对指令做重排, 一般分为以下 3 种
单线程环境里确保程序最终执行结果和代码顺序执行的结果一致;
处理器在进行重排序时必须要考虑指令之间的数据依赖性;
多线程环境中线程交替执行, 由于编译器优化重排的存在, 两个线程中使用的变量能否保证一致性是无法确定的, 结果无法预测
内存之间的交互操作
关于主内存和工作内存之间具体的交互协议, 即一个变量如何从主内存拷贝到工作内存, 如何从工作内存同步回主内存之类的实现细节, Java 内存模型中定义了 8 种 操作来完成, 虚拟机实现必须保证每一种操作都是原子的, 不可再拆分的(double 和 long 类型例外)
lock(锁定): 作用于主内存的变量, 它把一个变量标识为一条线程独占的状态.
unlock(解锁): 作用于主内存的变量, 它把一个处于锁定状态的变量释放出来, 释放后的变量才可以被其他线程锁定.
read(读取): 作用于主内存的变量, 它把一个变量的值从主内存传输到线程的工作内存中, 以便随后的 load 动作使用.
load(载入): 作用于工作内存的变量, 它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中.
use(使用): 作用于工作内存的变量, 它把工作内存中一个变量的值传递给执行引擎, 每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作.
assign(赋值): 作用于工作内存的变量, 它把一个从执行引擎接收到的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作.
store(存储): 作用于工作内存的变量, 它把工作内存中一个变量的值传送到主内存中, 以便随后的 write 操作使用.
write(写入): 作用于主内存的变量, 它把 store 操作从工作内存中得到的变量的值放入主内存的变量中.
如果需要把一个变量从主内存复制到工作内存, 那就要顺序地执行 read 和 load 操作, 如果要把变量从工作内存同步回主内存, 就要顺序地执行 store 和 write 操作. 注意, Java 内存模型只要求上述两个操作必须按顺序执行, 而没有保证是连续执行. 也就是说 read 与 load 之间, store 与 write 之间是可插入其他指令的, 如对主内存中的变量 a,b 进行访问时, 一种可能出现顺序是 read a,read b,load b,load a.
除此之外, Java 内存模型还规定了在执行上述 8 种基本操作时必须满足如下规则
不允许 read 和 load,store 和 write 操作之一单独出现, 即不允许一个变量从主内存读取了但工作内存不接受, 或者从工作内存发起回写了但主内存不接受的情况出现.
不允许一个线程丢弃它的最近的 assign 操作, 即变量在工作内存中改变了之后必须把该变化同步回主内存.
不允许一个线程无原因地 (没有发生过任何 assign 操作) 把数据从线程的工作内存同步回主内存.
一个新的变量只能在主内存中 "诞生", 不允许在工作内存中直接使用一个未被初始化 (load 或 assign) 的变量, 换句话说, 就是对一个变量实施 use,store 操作之前, 必须先执行过了 assign 和 load 操作.
一个变量在同一时刻只允许一条线程对其进行 lock 操作, 但 lock 操作可以被同一条线程重复执行多次, 多次执行 lock 后, 只有执行相同次数的 unlock 操作, 变量才会被解锁.
如果对一个变量执行 lock 操作, 那将会清空工作内存中此变量的值, 在执行引擎使用这个变量前, 需要重新执行 load 或 assign 操作初始化变量的值.
如果一个变量事先没有被 lock 操作锁定, 那就不允许对它执行 unlock 操作, 也不允许去 unlock 一个被其他线程锁定住的变量.
对一个变量执行 unlock 操作之前, 必须先把此变量同步回主内存中(执行 store,write 操作).
long 和 double 型变量的特殊规则
Java 内存模型要求 lock,unlock,read,load,assign,use,store,write 这 8 个操作都具有原子性, 但对于 64 位的数据类型( long 或 double), 在模型中定义了一条相对宽松的规定, 允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行, 即允许虚拟机实现选择可以不保证 64 位数据类型的 load,store,read,write 这 4 个操作的原子性, 即 long 和 double 的非原子性协定.
如果多线程的情况下 double 或 long 类型并未声明为 volatile, 可能会出现 "半个变量" 的数值, 也就是既非原值, 也非修改后的值.
虽然 Java 规范允许上面的实现, 但商用虚拟机中基本都采用了原子性的操作, 因此在日常使用中几乎不会出现读取到 "半个变量" 的情况, so, 这个了解下就行.
先行发生原则
先行发生 (happens-before) 是 Java 内存模型中定义的两项操作之间的偏序关系, 如果操作 A 先行发生于操作 B, 那么 A 的结果对 B 可见. happens-before 关系的分析需要分为单线程和多线程的情况:
单线程下的 happens-before 字节码的先后顺序天然包含 happens-before 关系: 因为单线程内共享一份工作内存, 不存在数据一致性的问题. 在程序控制流路径中靠前的字节码 happens-before 靠后的字节码, 即靠前的字节码执行完之后操作结果对靠后的字节码可见. 然而, 这并不意味着前者一定在后者之前执行. 实际上, 如果后者不依赖前者的运行结果, 那么它们可能会被重排序.
多线程下的 happens-before 多线程由于每个线程有共享变量的副本, 如果没有对共享变量做同步处理, 线程 1 更新执行操作 A 共享变量的值之后, 线程 2 开始执行操作 B, 此时操作 A 产生的结果对操作 B 不一定可见.
为了方便程序开发, Java 内存模型实现了下述的先行发生关系:
程序次序规则: 一个线程内, 按照代码顺序, 书写在前面的操作先行发生于书写在后面的操作.
管程锁定规则: 一个 unLock 操作先行发生于后面对同一个锁的 lock 操作.
volatile 变量规则: 对一个变量的写操作 happens-before 后面对这个变量的读操作.
传递规则: 如果操作 A 先行发生于操作 B, 而操作 B 又先行发生于操作 C, 则可以得出操作 A 先行发生于操作 C.
线程启动规则: Thread 对象的 start()方法先行发生于此线程的每个一个动作.
线程中断规则: 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生.
线程终结规则: 线程中所有的操作都先行发生于线程的终止检测, 我们可以通过 Thread.join()方法结束, Thread.isAlive()的返回值手段检测到线程已经终止执行.
对象终结规则: 一个对象的初始化完成先行发生于它的 finalize()方法的开始
内存屏障
上边的一系列操作保证了数据一致性, Java 中如何保证底层操作的有序性和可见性? 可以通过内存屏障.
内存屏障是被插入两个 CPU 指令之间的一种指令, 用来禁止处理器指令发生重排序(像屏障一样), 从而保障有序性的. 另外, 为了达到屏障的效果, 它也会使处理器写入, 读取值之前, 将主内存的值写入高速缓存, 清空无效队列, 从而保障可见性.
- eg:
- Store1;
- Store2;
- Load1;
- StoreLoad; // 内存屏障
- Store3;
- Load2;
- Load3;
对于上面的一组 CPU 指令(Store 表示写入指令, Load 表示读取指令),StoreLoad 屏障之前的 Store 指令无法与 StoreLoad 屏障之后的 Load 指令进行交换位置, 即重排序. 但是 StoreLoad 屏障之前和之后的指令是可以互换位置的, 即 Store1 可以和 Store2 互换, Load2 可以和 Load3 互换.
常见的 4 种屏障
LoadLoad 屏障: 对于这样的语句 Load1; LoadLoad; Load2, 在 Load2 及后续读取操作要读取的数据被访问前, 保证 Load1 要读取的数据被读取完毕.
StoreStore 屏障: 对于这样的语句 Store1; StoreStore; Store2, 在 Store2 及后续写入操作执行前, 保证 Store1 的写入操作对其它处理器可见.
LoadStore 屏障: 对于这样的语句 Load1; LoadStore; Store2, 在 Store2 及后续写入操作被执行前, 保证 Load1 要读取的数据被读取完毕.
StoreLoad 屏障: 对于这样的语句 Store1; StoreLoad; Load2, 在 Load2 及后续所有读取操作执行前, 保证 Store1 的写入对所有处理器可见. 它的开销是四种屏障中最大的(冲刷写缓冲器, 清空无效化队列). 在大多数处理器的实现中, 这个屏障也被称为全能屏障, 兼具其它三种内存屏障的功能.
Java 中对内存屏障的使用在一般的代码中不太容易见到, 常见的有 volatile 和 synchronized 关键字修饰的代码块, 还可以通过 Unsafe 这个类来使用内存屏障.(下一章扯扯这些)
Java 内存模型就是通过定义的这些来解决可见性, 原子性和有序性的.
参考
《深入理解 Java 虚拟机》第二版
http://rsim.cs.uiuc.edu/Pubs/popl05.pdf
http://ifeve.com/wp-content/uploads/2014/03/JSR133 中文版. PDF
来源: https://www.cnblogs.com/lazyegg/p/12530128.html