3 Java 内存模型(JMM)
Java 虚拟机规范中试图定义一种 Java 内存模型来屏蔽掉各种硬件和操作系统的内存访问差异, 以实现让 Java 程序在各种平台下都能达到一致性的内存访问效果
3.1 主内存与工作内存
Java 内存模型的主要目标是定义
程序中各个变量的访问规则
,
即在虚拟机中将变量存储到内存和从内存中取出变量值这样的底层细节
此处的变量与 Java 编程中所说的变量略有区别, 它包括了实例域, 静态域和构成数组对象的元素, 但不包括局部变量与方法参数, 因为后者是线程私有的, 不存在竞争问题
为了获得比较好的执行效率, JMM 并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互, 也没有限制即时编译器调整代码执行顺序这类权限
JMM 规定了
所有的变量都存储在主内存(Main Memory)
每条线程有自己的工作内存(Working Memory)
保存了该线程使用到的变量的主内存副本拷贝 (线程所访问对象的引用或者对象中某个在线程访问到的字段, 不会是整个对象的拷贝!), 线程对变量的所有操作(读, 赋值等) 都必须在工作内存中进行, 不能直接读写主内存中的变量 volatile 变量依然有工作内存的拷贝, 只是他特殊的操作顺序性规定, 所以看起来如同直接在主内存读写
不同线程之间无法直接访问对方工作内存中的变量, 线程间变量值的传递均要通过主内存
线程主内存工作内存三者的交互关系
jvm 模型与 jmm 不是同一层次的内存划分, 基本是没有关系的, 硬要对应起来, 从变量, 内存, 工作内存的定义来看,
主内存 === java 堆中的对象实例数据部分
工作内存 === 虚拟机栈中的部分区域
更低层次来说
主内存直接对应于物理硬件的内存
为了更好的运行速度, 虚拟机 (甚至硬件系统的本身的优化措施) 可能会让工作内存优先存储于寄存器和高速缓存器中, 因为程序运行时主要访问读写的是工作内存
3.2 内存间交互操作
一个变量如何从主内存拷贝到工作内存
如何从工作内存同步回主内存的实现细节
JMM 定义了以下 8 种操作来完成, 都是原子性不可再分的(对 double 和 Long 类型问题之后说明)
lock(锁定)
作用于主内存变量, 把一个变量标识为一条线程独占的状态
unlock(解锁)
作用于主内存变量, 把一个处于锁定状态的变量释放, 释放后的变量才可以被其它线程锁定
unlock 之前必须将变量值同步回主内存
read(读取)
作用于主内存变量, 把一个变量的值从主内存传输到工作内存, 以便随后的 load 使用
load(载入)
作用于工作内存变量, 它把 read 从主内存中得到的变量值放入工作内存的变量副本
use(使用)
作用于工作内存变量, 把工作内存中一个变量的值传递给执行引擎, 每当虚拟机遇到一个需要使用到的变量的值得字节码指令时将会执行这个操作
assign(赋值)
作用于工作内存变量, 把一个从执行引擎接收到的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
store(存储)
作用于工作内存变量, 把工作内存中一个变量的值传送到主内存, 以便随后的 write 操作使用
write(写入)
作用于主内存变量, 把 store 操作从工作内存中得到的值放入主内存的变量中
把一个变量从主内存复制到工作内存
就要顺序执行 read 和 load
把变量从工作内存同步回主内存
就要顺序地执行 store 和 write 操作
JMM 只要求上述两个操作必须按序执行, 而没有保证连续执行
也就是说 read/load 之间 store/write 之间可以插入其它指令
如对主内存中的变量 a,b 访问时, 一种可能出现的顺序是 read a->readb->loadb->load a
JMM 规定执行上述八种基础操作时必须满足如下规则
不允许 read/loadstore/write 操作之一单独出现
即不允许一个变量从主内存读取了但工作内存不接收, 或从工作内存发起回写但主内存不接收
不允许一个线程丢弃它的最近的 assign
即变量在工作内存中改变 (为工作内存变量赋值) 后必须把该变化同步回主内存
新变量只能在主内存诞生, 不允许在工作内存直接使用一个未被初始化 (load 或 assign) 的变量
换话说就是一个变量在实施 use,store 之前, 必须先执行过 assign 和 load
如果一个变量事先没有被 load 锁定, 则不允许对它执行 unlock, 也不允许去 unlock 一个被其它线程锁定的变量
对一个变量执行 unloack 前, 必须把此变量同步回主内存中(执行 store,write)
volatile 变量可以被看作是一种 " 轻量的 synchronized
可以说是 JVM 提供的最轻量级的同步机制
当一个变量定义为 volatile 后
保证此变量对所有线程的可见性, 这里的可见性是指
1 原子性
一次只允许一个线程持有某锁, 一次只有一个线程能使用共享数据
2 可见性
当一条线程修改了这个变量的值, 新值对于其它线程是可以立即得知的, 变量值在线程间传递均需要通过主内存来完成, 如: 线程 A 修改一个普通变量的值, 然后向主内存进行回写, 另外一条线程 B 在线程 A 回写完成了之后再从主内存进行读取操作, 新变量的值才会对线程 B 可见必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程可见
对域中的值做赋值和返回的操作通常是原子性的, 但递增 / 减并不是
volatile 对所有线程是立即可见的, 对 volatile 变量所有的写操作都能立即返回到其它线程之中, 换句话说, volatile 变量在各个线程中是一致的, 但并非基于 volatile 变量的运算在并发下是安全的
volatile 变量在各线程的工作内存中不存在一致性问题(在各个线程的工作内存中 volatile 变量也可以存在不一致, 但由于
每次使用之前都要先刷新, 执行引擎看不到不一致的情况
因此可以认为不存在一致性问题), 但 Java 里的运算并非原子操作, 导致 volatile 变量的运算在并发下一样是不安全的
- public class Atomicity {
- int i;
- void f(){
- i++;
- }
- void g(){
- i +=3;
- }
- }
编译后文件
- void f();
- 0 aload_0 [this]
- 1 dup
- 2 getfield concurrency.Atomicity.i : int [17]
- 5 iconst_1
- 6 iadd
- 7 putfield concurrency.Atomicity.i : int [17]
- // Method descriptor #8 ()V
- // Stack: 3, Locals: 1
- void g();
- 0 aload_0 [this]
- 1 dup
- 2 getfield concurrency.Atomicity.i : int [17]
- 5 iconst_3
- 6 iadd
- 7 putfield concurrency.Atomicity.i : int [17]
- }
每个操作都产生了一个 get 和 put , 之间还有一些其他的指令
因此在获取和修改之间, 另一个线程可能会修改这个域
所以, 这些操作不是原子性的
再看下面这个例子是否符合上面的描述
- public class AtomicityTest implements Runnable {
- private int i = 0;
- public int getValue() {
- return i;
- }
- private synchronized void evenIncrement() {
- i++;
- i++;
- }
- public void run() {
- while(true)
- evenIncrement();
- }
- public static void main(String[] args) {
- ExecutorService exec = Executors.newCachedThreadPool();
- AtomicityTest at = new AtomicityTest();
- exec.execute(at);
- while(true) {
- int val = at.getValue();
- if(val % 2 != 0) {
- System.out.println(val);
- System.exit(0);
- }
- }
- }
- }
- output:
- 1
该程序将找到奇数值并终止
尽管 return i 原子性, 但缺少同步使得其数值可以在处于不稳定的中间状态时被读取
由于 i 不是 volatile , 存在可视性问题
getValue() 和 evenIncrement() 必须 synchronized
对于基本类型的读 / 写操作被认为是安全的原子性操作
但当对象处于不稳定状态时, 仍旧很有可能使用原子性操作来访问他们
最明智的做法是遵循同步的规则
volatile 变量只保证可见性
在不符合以下条件规则的运算场景中, 仍需要通过加锁 (使用 synchronized 或 JUC 中的原子类) 来保证原子性
运算结果不依赖变量的当前值, 或者能确保只有单一的线程修改变量的值
变量不需要与其它的状态变量共同参与不可变类约束
基本上, 若一个域可能会被多个任务同时访问 or 这些任务中至少有一个是写任务, 那就该将此域设为 volatile
当一个域定义为 volatile 后, 将具备
1. 保证此变量对所有的线程的可见性, 当一个线程修改了这个变量的值, volatile 保证了新值能立即同步到主内存, 其它线程每次使用前立即从主内存刷新
但普通变量做不到这点, 普通变量的值在线程间传递均需要通过主内存来完成
2. 禁止指令重排序有 volatile 修饰的变量, 赋值后多执行了一个 load addl $0x0, (%esp)操作, 这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置)
这些操作的目的是用线程中的局部变量维护对该域的精确同步
指令重排序
有序性: 即程序执行的顺序按照代码的先后顺序执行
举个例子
- int i = 0;
- boolean flag = false;
- i = 1; // 语句 1
- flag = true; // 语句 2
从代码顺序上看, 语句 1 在 2 前, JVM 在真正执行这段代码的时候会保证语句 1 一定会在语句 2 前面执行吗? 不一定, 为什么呢? 这里可能会发生指令重排序(Instruction Reorder)
使用 volatile 变量的第二个语义是禁止指令重排序优化,
普通变量仅保证该方法执行过程所有依赖赋值结果的地方能获取到正确结果
而不保证变量赋值操作的顺序与代码执行顺序一致
因为在一个线程的方法执行过程中无法感知到这一点, 这也就是 JMM 中描述的所谓的
线程内表现为串行的语义(Within-Thread As-If-Serial Sematics)
- Map configOptions;
- char[] configText;
- // 此变量必须定义为 volatile
- volatile boolean initialized = false;
- // 假设以下代码在线程 A 中执行
- // 模拟读取配置信息, 当读取完成后
- // 将 initialized 设置为 true 来通知其它线程配置可用
- configOptions = new HashMap();
- configText = readConfigFile(fileName);
- processConfigOptions(configText, configOptions);
- initialized = true;
- // 假设以下代码在线程 B 中执行
- // 等线程 A 待 initialized 为 true, 代表线程 A 已经把配置信息初始化完成
- while(!initialized) {
- sleep();
- }
- // 使用线程 A 中初始化好的配置信息
- doSomethingWithConfig();
如果定义 initialized 时没有使用 volatile, 就可能会由于指令重排序优化, 导致位于线程 A 中最后一行的代码 initialized = true 被提前执行, 这样在线程 B 中使用配置信息的代码就可能出现错误, 而 volatile 关键字则可以完美避免
volatile 变量读操作性能消耗与普通变量几乎无差, 但写操作则可能会稍慢, 因为它需要在代码中插入许多内存屏障指令来保证处理器不发生乱序执行不过即便如此, 大多数场景下 volatile 的总开销仍然要比锁小, 我们在 volatile 与锁之中选择的唯一依据仅仅是 volatile 的语义能否满足使用场景的需求
一般来说, 处理器为了提高程序运行效率, 可能会对输入代码进行优化, 它不保证程序中各个语句的执行先后顺序同代码中的顺序一致, 但是它会保证程序最终执行结果和代码顺序执行的结果是一致的
比如上面的代码中, 语句 1/2 谁先执行对最终的程序结果并无影响, 就有可能在执行过程中, 语句 2 先执行而 1 后虽然处理器会对指令进行重排序, 但是它会保证程序最终结果会和代码顺序执行结果相同, 靠什么保证? 数据依赖性
编译器和处理器在重排序时, 会遵守数据依赖性, 编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序
举例
- double pi = 3.14; //A
- double r = 1.0; //B
- double area = pi * r * r; //C
三个操作的数据依赖关系
A 和 C 之间存在数据依赖关系, 同时 B 和 C 之间也存在数据依赖关系
因此在最终执行的指令序列中, C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面, 程序的结果将会被改变)
但 A 和 B 之间没有数据依赖关系, 编译器和处理器可以重排序 A 和 B 之间的执行顺序
该程序的两种执行顺序
这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作, 在单线程程序中, 对存在控制依赖的操作重排序, 不会改变执行结果
但在多线程程序中, 对存在控制依赖的操作重排序, 可能会改变程序的执行结果这是就需要内存屏障来保证可见性了
3. 内存屏障
3.1 分类
Load Barrier 读屏障
Store Barrier 写屏障
3.2 作用
1. 阻止屏障两侧的指令重排序
2. 强制把写缓冲区 / 高速缓存中的脏数据等写回主内存, 让缓存中相应的数据失效
对于 Load Barrier 来说, 在指令前插入 Load Barrier, 可以让高速缓存中的数据失效, 强制从新从主内存加载数据
对于 Store Barrier 来说, 在指令后插入 Store Barrier, 能让写入缓存中的最新数据更新写入主内存, 让其他线程可见
java 的内存屏障实际上也是上述两种的组合, 完成一系列的屏障和数据同步功能
LoadLoad 屏障: 对于这样的语句 Load1; LoadLoad; Load2, 在 Load2 及后续读取操作要读取的数据被访问前, 保证 Load1 要读取的数据被读取完毕
StoreStore 屏障: 对于这样的语句 Store1; StoreStore; Store2, 在 Store2 及后续写入操作执行前, 保证 Store1 的写入操作对其它处理器可见
LoadStore 屏障: 对于这样的语句 Load1; LoadStore; Store2, 在 Store2 及后续写入操作被刷出前, 保证 Load1 要读取的数据被读取完毕
StoreLoad 屏障: 对于这样的语句 Store1; StoreLoad; Load2, 在 Load2 及后续所有读取操作执行前, 保证 Store1 的写入对所有处理器可见它的开销是四种屏障中最大的在大多数处理器的实现中, 这个屏障是个万能屏障, 兼具其它三种内存屏障的功能
volatile 的内存屏障策略非常严格保守
在每个 volatile 写操作前插入 StoreStore 屏障, 在写操作后插入 StoreLoad 屏障
在每个 volatile 读操作前插入 LoadLoad 屏障, 在读操作后插入 LoadStore 屏障
由于内存屏障的作用, 避免了 volatile 变量和其它指令重排序线程之间实现了通信, 使得 volatile 表现出了锁的特性
3.3 下面的操作都是原子不可再分的
1) lock: 作用于主内存的变量, 它把一个变量标识为一个线程独占的状态
2) unlock: 作用于主内存的变量, 他把一个处于锁定状态的变量释放出来, 释放后的变量才可以被其他线程锁定
3) read: 作用于主内存变量, 他把一个变量的值从主内存传输到线程的工作内存, 以便随后的 load 操作使用
4) load: 作用于工作内存的变量, 他把 read 操作从主内存中得到的变量值放入工作内存的变量副本中
5) use: 作用于工作内存的变量, 他把工作内存中一个变量的值传递给执行引擎, 每当虚拟机遇到一个需要使用到变量的值得字节码指令时将会执行这个操作
6) assign: 作用于工作内存的变量, 他把一个从执行引擎接收到的值付给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
7) store: 作用于工作内存的变量, 他把工作内存中一个变量的值传送到主内存中, 以便随后 write 使用
8) write: 作用于主内存的变量, 他把 store 操作从工作内存中得到的变量的值放入主内存的变量中
volatile 性能
volatile 的读性能消耗与普通变量几乎相同, 但是写操作稍慢, 因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行
来源: http://www.jianshu.com/p/9511ea71ccab