JMM(Java Memory Model),Java 内存模型, 它是一种 Java 虚拟机需要遵守的规范, 定义了线程间如何在内存中正确地交互. JDK5 以后的 JMM 规范在 JSR-133 中详细列出.
1. 内存模型
1.1 为什么需要内存模型
多线程编程的困难在于很难对程序进行调式, 如果控制不好, 就会产生意料之外的结果. 对于传统的单核 CPU 来说, 由于是并发执行, 即同一时刻只有一个线程在执行, 所以一般不会出现数据的访问冲突. 这也不是绝对的, 单核多线程场景下, 如果允许抢占式调度, 仍存在线程安全问题. 当前的处理器架构大多是多核 + 多级缓存 + 主存的模式, 这样在多线程场景下就存在数据竞争从而造成缓存不一致的问题. 另外 CPU 可能会对程序进行优化, 进行指令重排序, 只要重排后程序的语义没有发生变化, 指令重排就是有可能发生的(编译器和 JVM 也存在指令重排), 但这有时会让多线程执行的结果出乎意料.
现代处理器架构
1.2 什么是内存模型
对处理器来说, 内存模型定义了充分必要条件, 以知道其他处理器对内存的写入对当前处理器可见, 而当前处理器的写入对其他处理器可见. 一些处理器使用强内存模型, 即所有处理器在任何给定的内存位置上始终能看到完全相同的值, 但这也不是绝对的, 某些时候也需要使用特殊指令 (称为内存屏障) 来完成. 其他处理器使用弱内存模型, 需要内存屏障来刷新或使本地处理器缓存失效, 以便查看其他处理器的写操作或使此处理器的写操作对其他处理器可见. 这些内存屏障通常在 lock 和 unlock 时执行; 对于使用高级语言的程序员来说, 它们是不可见的. 处理器的设计趋势是鼓励使用弱内存模型, 因为它们的规范具有更强的可伸缩性.
1.3 其他语言有内存模型吗
大多数其他编程语言 (如 C 和 C ++) 的设计并未直接支持多线程. 这些语言针对编译器和体系结构中发生的各种重排序提供的保护很大程度上取决于所使用的线程库(例如 pthread), 所使用的编译器以及运行代码的平台所提供的保证.
2.Java 内存模型
2.1 简介
Java 内存模型是建立在内存模型之上的, 它回答了当读取一个确定的字段时什么样的值是可见的. 它将一个 Java 程序分解成若干动作 (actions) 并且为这些动作分配一个顺序. 如果分配的这些顺序中能在对一个字段的写操作 (write actions) 和读操作 (read actions) 间形成一个叫 happens-before 的关系, 那么 Java 内存模型保证了读操作将返回一个正确的值.
JMM 规定所有实例域, 静态域和数组元素存储在 JVM 内存模型的的堆中, 堆内存在线程间是共享的. 局部变量和异常处理器参数不会共享, 他们不存在内存可见性问题. 每个线程创建时 JVM 都会为其创建一个工作内存(栈空间), 工作内存是每个线程的私有数据区域, 线程对变量的操作必须在工作内存中进行, 首先要将变量从主内存拷贝到自己的工作内存空间, 然后对变量进行操作, 操作完成后再将变量写回主内存 `, 不能直接操作主内存中的变量, 各个线程中的工作内存中存储着主内存中的变量副本拷贝, 因此线程间的通信必须通过主内存来完成.
2.2 代码优化问题
上面一段代码, 模拟了两个线程. 期望可能是 thread1 执行一次 count++,thread2 修改 flag 的值, 然后 thread1 退出循环. 但是在未做同步控制的情况下多线程的执行情况是无法预料的. 还存在一个很重要的问题, 那就是编译器优化(这里编译器可以是 Java 编译器如 JIT,JVM,CPU).
对于 thread1, 没有对 flag 的写操作, 所以编译器认为 flag 的值总是 true, 就将 flag 直接改为 true 来提高程序运行速度, 这种优化是被允许的, 因为对于它本身而言没有改变程序语义.
对于 thread2, 没有要求对 flag 的值要刷回主存, 编译器就可能优化为忽略对 flag 的写指令, 因为不刷回主存的值改变只有线程自己可见.
2.3 指令重排序问题
对上图中三条指令, 我们期望是顺序执行, 但某些编译器为了提高速度, 很可能对指令重排序变成下面一种执行顺序.
再来看看下面的例子
处理器 A | 处理器 B |
---|---|
a = 1; // 写操作 A1 | b =2; // 写操作 B1 |
x = b; // 读操作 A2 | y = a; // 读操作 B2 |
初始状态 a = b = 0 | 结果 x = y = 0 |
之所以会出现以上结果, 是因为处理器对写读指令进行了重排序, 如将顺序 A1 -> A2 重排成 A2 -> A1. 对写读的重排序在 x86 架构下是被允许的. 下图是不同架构下支持的重排序类型, 这解释了为什么相同的程序在不同的架构系统下会产生不同的结果, 因为编译器可能对你的代码进行了不同的重排序.
另外重排序需要考虑到数据之间的依赖性, 比如下面 3 条指令, 3 是不会排到指令 1 之前的, 因为指令 3 依赖于指令 1 的数据 x.
- int x = 1; //1
- int y = 2; //2
- y = x * x; //3
2.4 可见性问题
观察以上代码, 写线程在自己的工作内存中改变了 x 的值却并未来得及刷回主内存, 这样读线程读取到的值仍然是旧值, 读线程此时对写线程的操作不可见. Java 为此提供了 volatile 关键字解决方案: 只要用 volatile 修饰变量 x, 对 x 进行原子操作后, x 的值将立马刷回主内存, 这样保证了读线程对写线程的可见性.
2.5 原子性问题
Java 中 long 型占 8 字节, 也就是 64 位, 如果在 64 位操作系统中执行以上代码不存在原子性问题, 对 foo 的写操作一步完成, 但是在 32 位操作系统中这种写操作就失去了原子性. 32 位操作系统中对 foo 的写操作分两步进行 - 分别对高 32 位和低 32 位进行写操作. 在这种情况下就可能产生如下结果
2.6 Happens-before 规则
Happens-before 表示动作上的偏序关系, 官方文档对于该规则的定义如下
大致翻译一下就是:
两个动作可以由 happends-before 关系排序, 如果一个动作 happens-before 另一个动作, 那么第一个动作的执行结果对后一个动作可见. 两个操作之间存在 happens-before 关系, 并不意味着必须要按照 happens-before 关系指定的顺序来执行. 如果重排序之后的执行结果, 与按 happens-before 关系来执行的结果一致, 那么这种重排序并不非法. 例如, 在线程构造的对象的每个字段中写入默认值不需要在该线程的开始之前发生, 只要没有读取操作就会观察到该事实. 另外, 当两个动作存在于不同的线程中时, 也存在这种关系, 此种情况下两者之间会存在一个消息传递机制.
happens-before 的 8 条规则如下:
程序顺序规则: 一个线程中的每个操作, happens-before 于该线程中的任意后续操作.
监视器锁规则: 对一个锁的解锁, happens-before 于随后对这个锁的加锁.
volatile 变量规则: 对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读.
传递性: 如果 A happens-before B, 且 B happens-before C, 那么 A happens-before C.
start()规则: 如果线程 A 执行操作 ThreadB.start()(启动线程 B), 那么 A 线程的 ThreadB.start()操作 happens-before 于线程 B 中的任意操作.
join()规则: 如果线程 A 执行操作 ThreadB.join()并成功返回, 那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回.
程序中断规则: 对线程 interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生.
对象 finalize 规则: 一个对象的初始化完成 (构造函数执行结束) 先行于发生它的 finalize()方法的开始
2.7 实现
字段域 | 方法域 |
---|---|
final | synchronized(method/block) |
volatile | java.util.concurrent.* |
- volatile
- public class VolatileFieldsVisivility{
- int a = 0;
- volatile int x = 0;
- public void writeThread(){
- a = 1; //1
- x = 1; //2
- }
- public void readThread(){
- int r2 = x; //3
- int d1 = a; //4
- }
- }
假设写线程执行完后, 问读线程读变量 a 的值是 1 还是 0 还是不确定? 答案是确定的 1, 即使变量 a 未用 volatile 修饰. 由上面给出的 happens-before 规则可推得: 1 happens-before 2, 2 happens-before 3 , 3 happens-before 4 --> 1 happens-before 4(传递性), 即读线程读 a 的时候一定能看到写线程的执行结果, 简短来说就是当一个线程对 volatile 修饰的变量写入, 并且读取时也是此变量时在他之前的所有写操作被保证对其他线程是可见的. 值得注意的是, 写读操作必须是原子性的, 如果被 volatile 修饰的是 long 或者 double, 那么这个 64 位的变量不能被拆分存储. 也就是说 volatile 保证了可见性和有序性, 但不保证原子性.
由于篇幅过长, 其他方式的实现我将在其他文章中单独抽出来分析.
3. 总结
Java 内存模型就是 Java 语言在内存模型上的实现, 它是为了保证多线程场景下的原子性, 可见性和有序性提出的规范. Java 语言提供了 volatile,synchronized,final 关键字和 java.util.concurrent.* 并发编程包来实现这些规范, 这些提供给程序员的原语和包屏蔽了和底层交互的细节, 让程序员可以更方便快捷地编程.
来源: http://www.jianshu.com/p/4ca90c38b777