一, 现代计算机内存模型
随着技术的发展, CPU 也在按照摩尔定律快速发展, 而内存即主存 (Main Memory) 发展却十分缓慢, 所以 CPU 与主存间产生了一种因发展速度带来的矛盾, CPU 发展太快导致主存跟不上 CPU 的发展速度, 所以出现了三级缓存(不一定都是三级, 明白就行), 一种比主存读写速度更高的存储, 三级缓存的出现暂缓了这种矛盾. ok, 再远古的架构我们就先不聊了, 从三级缓存的 CPU 架构看看现代计算机的内存模型.
image
CPU 的流行架构如上图所示, 当 CPU 要 load 一个数据时, 首先从一级缓存中查找, 如果没有找到再从二级缓存中查找, 如果还是没有就从三级缓存或内存中查找.
在三级缓存架构中我们不难发现, L3 cache 和主存是共享的, 所以就存在数据一致性的保障, MESI 缓存一致性协议就作用在这里. MESI 协议规定了 CPU 从主存 (或者三级缓存) 加载或者写入数据的规则, 保证了数据的强一致性. 关于 MESI 协议和三级缓存详情请查阅另一篇博客.
二, Java 内存模型 - JMM
Java 内存模型 (Java Memory Model) 即 JMM 是一个抽象的概念, JMM 是一个抽象的概念, JMM 是一个抽象的概念, 并不是物理上的内存划分, 重要事情说三遍.
Java 内存模型 (JMM) 定义了 Java 虚拟机 (JVM) 在计算机内存 (RAM) 中的工作规范. 在硬件内存模型中, 各种 CPU 架构的实现是不尽相同的, Java 作为跨平台的语言, 为了屏蔽底层硬件差异, 定义了 Java 内存模型(JMM).JMM 作用于 JVM 和底层硬件之间, 屏蔽了下游不同硬件模型带来的差异, 为上游开发者提供了统一的使用接口. 说了这么多其实就是想说明白 JMM--JVM-- 硬件的关系. 总之一句话, JMM 是 JVM 的内存使用规范, 是一个抽象的概念.
image
如上图在 JMM 中, 内存划分为两个区域, 线程本地内存, 主内存.
本地内存: 每个线程均有自己的本地内存(Local Memory, 也称之为线程的工作内存), 本地内存是线程独占的.
主内存: 存储所有的变量. 如果一个变量被多个线程使用(被多个线程 load 到线程的本地内存中), 则该变量被称之为共享变量.
三, JVM 对 JMM 的实现
依据 JMM 规范, Java 内存模型将 JVM 分为两个部分线程栈 (Thread Stack) 和堆(Heap).
image
线程栈: 线程独占, 对其他线程不可见. 线程间通信或者共享变量需要通过 Heap. 但另外的线程拿到的也只是该变量的私有拷贝, 线程之间不能共享变量本身.
堆: 线程创建的对象都在堆区, 不管该对象是哪个线程创建的, 也不管该对象是成员变量还局部变量.(很好理解, 栈是运算速度比较快的区域, 对象一般相对较大, 栈中只需要一个变量指向堆即可.)
一个误区
image
具体各种类型的变量或者对象在 JVM 中的内存划分不是本文重点, 本文我们主要讨论 JMM.
看上图, 是不是有点疑惑, 在 JVM 中内存不是被划分为 Java 栈, 堆, 方法区, 本地方法栈程序计数器等区域吗? 为什么两个图对应不上呢? 原因很简单, 原则上来说这两个图是没有关系的.
JMM 和 JVM 不是一个层次的东西, 勉强对应起来, 主内存, 工作内存从定义上来看, 主内存主要对应于 java 堆中的示例数据部分, 而工作内存则对应于虚拟机栈的部分区域, 从更低层次来说, 主内存就直接对应物理硬件的内存, 而为了获取更好的运行速度, 虚拟机 (甚至是硬件系统本身的优化措施) 可能会让工作内存优先存储于寄存器和高速缓存器中, 因为程序运行过程中主要读写访问的是工作内存. 感觉知乎上有一个不错的 解释 .
四, JMM 主内存和本地内存交互操作
image
计算机硬件内存模型有缓存和主内存的交互协议 MESI, 同样 JMM 也规范了主内存和线程工作内存进行数据交换操作. 一共包括如上图所示的 8 中操作, 并且每个操作都是原子性的.
lock(锁定): 作用于主内存的变量, 一个变量在同一时间只能一个线程锁定. 该操作表示该线程独占锁定的变量.
unlock(解锁): 作用于主内存的变量, 表示这个变量的状态由处于锁定状态被释放, 这样其他线程才能对该变量进行锁定.
read(读取): 作用于主内存变量, 表示把一个主内存变量的值传输到线程的工作内存, 以便随后的 load 操作使用.
load(载入): 作用于线程的工作内存的变量, 表示把 read 操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的).
use(使用): 作用于线程的工作内存中的变量, 表示把工作内存中的一个变量的值传递给执行引擎, 每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作.
assign(赋值): 作用于线程的工作内存的变量, 表示把执行引擎返回的结果赋值给工作内存中的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作.
store(存储): 作用于线程的工作内存中的变量, 把工作内存中的一个变量的值传递给主内存, 以便随后的 write 操作使用.
write(写入): 作用于主内存的变量, 把 store 操作从工作内存中得到的变量的值放入主内存的变量中.
JMM 规定了以上 8 中操作需要按照如下规则进行
不允许 read 和 load,store 和 write 操作之一单独出现, 即不允许一个变量从主内存读取了但工作内存不接受, 或者从工作内存发起回写了但主内存不接受的情况出现.
不允许一个线程丢弃它的最近的 assign 操作, 即变量在工作内存中改变了之后必须把该变化同步回主内存.
不允许一个线程无原因地 (没有发生过任何 assign 操作) 把数据从线程的工作内存同步回主内存中.
一个新的变量只能在主内存中 "诞生", 不允许在工作内存中直接使用一个未被初始化 (load 或 assign) 的变量, 换句话说就是对一个变量实施 use 和 store 操作之前, 必须先执行过了 assign 和 load 操作.
一个变量在同一个时刻只允许一条线程对其进行 lock 操作, 但 lock 操作可以被同一条线程重复执行多次, 多次执行 lock 后, 只有执行相同次数的 unlock 操作, 变量才会被解锁.
如果对一个变量执行 lock 操作, 将会清空工作内存中此变量的值, 在执行引擎使用这个变量前, 需要重新执行 load 或 assign 操作初始化变量的值.
如果一个变量事先没有被 lock 操作锁定, 则不允许对它执行 unlock 操作, 也不允许去 unlock 一个被其他线程锁定住的变量.
以上 8 中规则看着也是比较生涩的, 其实如果你没看明白也没关系, 其实这些规则就是保障数据同步的一些规则. 不是很重要, 重要的在后面的 happens-before 原则.
五, 并发环境下 JMM 存在的问题
并发编程的三个特征: 原子性, 有序性, 可见性
<pre style="margin: 0px; padding: 0px; border: 0px; font-style: inherit; font-variant: inherit; font-weight: inherit; font-stretch: inherit; font-size: inherit; line-height: inherit; font-family: inherit; vertical-align: baseline; word-break: break-word;">
原子性(Atomicity): 一个操作是不可中断的, 要么全部执行成功要么全部执行失败.
可见性(Visibility): 所有线程都能看到共享内存的最新状态.(一个线程修改了一个共享变量, 其他线程能够立即看到该变量的最新值)
有序性(Ordering): 即程序执行的顺序按照代码的先后顺序执行.
</pre>
以上三个概念应该很容易理解, 在此不做过多解释.
1, 原子性
JMM 保证了四章节中的 8 个操作是原子性的, Java 语言本身对基本数据类型的变量的读取和赋值操作是原子性操作.(JVM 不对 double 和 long 类型的变量做原子性保障, 可能的原因是缓存行的大小导致的)
比如
- <pre style="margin: 0px; padding: 0px; border: 0px; font-style: inherit; font-variant: inherit; font-weight: inherit; font-stretch: inherit; font-size: inherit; line-height: inherit; font-family: inherit; vertical-align: baseline; word-break: break-word;">
- x = 1;//1 x ++;//2 y = x;//3
- </pre>
以上三行代码其实只有 x = 1 是原子性的, 这行代码只是对 x 进行赋值.
x ++; 不是原子操作, 因为这行代码包含三个操作: 加载 x 的值, 执行 ++, 然后写入新值. 单个操作是原子性的, 多个操作组合起来就不是原子性的了. 尤其是在并发环境下, 如果 x 变量是多个线程共享的, 会导致线程安全性问题.
y = x; 同理也不是原型操作, 因为需要首先加载 x 变量, 再赋值给 y.
在并发环境下, 为了保证原子性通常采用 synchronized 或者 Lock 对代码块加锁保证原子性.
2, 可见性
在 Java 中提供了一个 volatile 关键字来保证可见性. 当一个主内存中的共享变量被 volatile 关键字修饰时, 一个线程对该变量的修改会被立即刷新 (store) 到主内存, 保证其他线程看到的值一定是最新的. 可以参考
JMM 层面上 volatile 是通过 load/store 操作实现的可见性, 当然我们也可以通过 synchronized 和 Lock 通过加锁将多线程进行同步也就是串行执行来保证共享变量的可见性. 很好理解, 当两个线程都需要操作一个共享变量, 后到的线程需要等到先到的线程执行完才能继续执行, 变相的保证了数据的可见性.
当然在可见性层面, 加锁相对于 volatile 是比较重量级的一个操作.
3, 有序性
happens-before 原则
<pre style="margin: 0px; padding: 0px; border: 0px; font-style: inherit; font-variant: inherit; font-weight: inherit; font-stretch: inherit; font-size: inherit; line-height: inherit; font-family: inherit; vertical-align: baseline; word-break: break-word;">
程序次序规则: 一个线程内, 按照代码顺序, 书写在前面的操作先行发生于后面的操作.
锁定规则: 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作.(先释放锁, 才能加锁)
volatile 变量规则: 对同一个变量的写操作先行发生于后面对这个变量的读操作.
传递规则: 如果操作 A 先行发生于操作 B, 而操作 B 又先行发生于 C, 则 A 先行发生于操作 C.
线程启动规则: Thread 对象的 start()方法先行发生于此线程的每一个动作.
线程终结规则: 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生.
线程终结规则: 线程中所有的操作都先行发生于线程的终结检测, 我们可以通过 Thread.join()方法结束, Thread.isAlive()的返回值手段检测到线程已经终止执行.
对象终结规则: 一个对象的初始化完成先行发生于他的 finalize()方法的开始.
</pre>
happens-before 是干嘛的呢? 我理解的 happens-before 原则就是一个对人来说显而易见的东西. 但是程序并不能理解这么些东西.
happens-before 与可见性
happens-before 通过以上 8 中规则保证可见性, 如果一个操作 A happens-before 另一个操作 B, 那么操作 A 的结果是对操作 B 可见的. 不难理解.
happens-before 与重排序
两个操作如果存在 happens-before 关系, 并不意味着一定是有序进行的, 因为 JVM 存在指令重排优化, 如果 JVM 认为两个操作重排序有利于性能提升并且重排序后的操作和未重排结果一致, 将进行指令重排序. 当然 JVM 层面的重排序发生于编译期, 运行时的指令重排是处理器决定的.
Java 语言通过 volatile 关键字通过向主内存加入内存屏障实现禁止指令重排.
如有错误的地方还请留言指正.
- https://www.jianshu.com/p/8a58d8335270
- http://ifeve.com/java-memory-model-6/
- https://www.jianshu.com/p/76959115d486
来源: http://www.jianshu.com/p/fbdb71ba1818