这篇文章要梳理的概念会比较多, 我在第一次接触这些概念时理解了很久, 反反复复, 用了很长时间才弄明白个大概. 我想在这篇文章把概念说清楚, 每个概念本身都有很多外延的内容, 外延的内容我会不断学习后逐渐到文中.
JMM(Java Memory Model:java 内存模型)
这是一个 java 技术规范, java 的强大之一是它的多线程支持. java 多线程执行期间是如何使用内存的呢? JMM 就是这样一个规范, 它描述了多线程执行时的内存使用方式.
硬件层面, CPU 的指令执行速度远快于内存存取, 为了缓解这种速度差, 在 CPU 和内存之间, 往往会有很多级速度比内存快的寄存器, 存储一些 CPU 频繁访问的变量, 这比直接去频繁访问内存要快很多.
Java 在多线程方面, 也会充分利用以上物理特性, 为每个线程构建私有的工作内存. JMM 规范要求, 线程对变量的读写, 需要从主存拷贝变量副本到工作内存中, 以提高执行性能, 再在合适的时机同步回主存, 以使其他线程可见.
盗用一张网络图片
上面提到, 线程会在工作内存操作线程副本, 合适时机同步回内存. 这里就会有一个同步问题, 这是外延内容, 以后补充.
这里我们只需要了解: 线程有自己的工作内存, 对变量的读写是需要从主存拷贝进工作内存的.
如果是一个共享变量, 多线程都在并发访问, 这时候会有一个问题: 就是线程 T1 时序上先改了共享变量 a, 把值变成了 a=1, 原始值比如 a=100
线程 T2 时序上在此后读取 a,T2 读取到的 a 值不一定等于 1. 注意这里是不一定, 不是必然. 就是说 T1 虽然改了共享变量的值, 其它线程比如 T2 不一定能看到这个值. 因为 T2 读取也是工作在自己的工作内存上, T1 虽然做了修改, 而这个也是发生在 T1 的工作内存上, 只有 T1 把变量 a 的值同步回主存, T2 再从主存拷贝 a 的值到自己的工作内存, 才能保证 T2 能看到 T1 的修改.
这就是 JMM 规范下的一个变量在多线程情况下的可见性问题, 下面要讲解的 Volatile 关键字是一个解决可见性问题的一个关键字.
Volatile
volatile 是 java 语言当中的一个关键字. 它用于声明变量, 声明变量后, 起到的作用是: 保障变量在多线程环境下的可见性.
什么意思呢? 保障变量在多线程环境下的可见性.
在讲述 JMM 时, 我们最后说了一个问题, JMM 规范下, 一个线程修改了某个共享变量的值, 另外一个线程即使在它之后执行, 也不一定能看到这个变量被修改过后的值. 我用以下代码说明:
共享变量 boolean ready = false; // 初始值 false
线程 T1 先执行以下代码
- while(!ready){
- // wait
- }
- // ready, do something
线程 T2 后执行以下代码
- ready = true;
- //do other thing
看完以上代码, 我们可能以为线程 T1 启动后, 一直检测 ready 的值, 如果是 false, 就一直 while 循环, 直到线程 T2 启动, 把 ready 的值改为 true, 线程 T1 才能退出循环.
其实真正执行以上代码, 你会发现, T1 始终不能发现 ready 的值变更, 会一直在 while 中死循环. 这就是可见性问题, T1 没有发现这个变量变更了.
即使把以上共享变量换成数组, 如
共享变量 boolean [] ready = new boolean[3]; // 初始值 false
线程 T1 先执行以下代码
- while(!ready[1]){
- // wait
- }
- // ready, do something
线程 T2 后执行以下代码
- ready[1] = true;
- //do other thing
它的效果也是一样的. 这时候, 你给 ready 变量, 加上 volatile 后, 再试试
volatile boolean ready = false; 或
volatile boolean [] ready = new boolean[3];
你会发现, T2 一执行, T1 马上跳出了循环. 这就是 volatile 的作用, 它保障了被修饰变量的可见性, T2 一旦做过变更, T1 就能看到.
那么究竟 volatile 底层又是如何保障的呢, 这属于外延内容, 我后续补充.
重排序
先说说什么是重排序, 看下面这段代码.
- int a = 1;
- char b = 'b';
- byte c = 2;
这三行代码很简单, 就是简单的声明了 3 个变量. 你可能会认为, 这三行代码的执行顺序就如写代码的顺序一样, 按 a b c 的顺序进行, 实际上是不一定的. java 语言是允许重排序的, 也就是不按照 abc 的顺序执行, 可能是 cba,bac 都有可能. 前提是不改变执行结果, 数据之间不存在依赖.
如果
- int a = 1;
- int b = a;
像上面这种 b 对 a 有数据依赖的, 是不会被重排序的, 执行顺序必然是 a 在 b 之前.
重排序发生在以下几个阶段:
编译器优化的重排序. 编译器在不改变单线程程序语义放入前提下, 可以重新安排语句的执行顺序.
指令级并行的重排序. 现代处理器采用了指令级并行技术来将多条指令重叠执行. 如果不存在数据依赖性, 处理器可以改变语句对应机器指令的执行顺序.
内存系统的重排序. 由于处理器使用缓存和读写缓冲区, 这使得加载和存储操作看上去可能是在乱序执行.
为什么会重排序, 上面这几个阶段里大概提到了, 提高并行效率, 编译器认为重排序后执行更优, 指令级重排序并行效率更好等等. 在单个线程的视角看来, 重排序不存在任何问题, 重排序不改变执行结果, 如下例:
- int a = 1;
- int b = 2;
- int c = a + b;
c 因为对 a 和 b 有数据依赖, 因此 c 不会被重排序, 但是 a ,b 的执行可能被重排序. 但在单个线程下, 这种重排序不存在任何问题, 不论先初始化 a, 还是先初始化 b,c 的值都是 3. 但是在多线程情况下, 重排序就可能带来问题, 如下例:
线程 T1 执行:
- a = 1; // 共享变量 int a
- b = true; // 共享变量 boolean b
线程 T2 执行:
- if (b){
- int c = a;
- System.out.println(c);
- }
假如某个并发时刻, T2 检测到 b 变量已经是 true 值了, 并且变量都对 T2 可见. c 赋值得到的一定是 1 吗?
答案是不一定, 原因就是重排序问题的存在, 在多线程环境下, 会造成问题. T1 线程如果 a 和 b 变量的赋值被重排序了, b 先于 a 发生, 这个重排序对 T1 线程本身不存在什么问题, 之前我们已经讨论过. 但是在 T2 这个线程看来, 这个执行就有问题了, 因为在 T2 看来, 如果没有重排序, b 值变为 true 之前, a 已经被赋值 1 了. 而重排序使得这个推断变得不确定, b 有可能先执行, a 还没来的及执行, 此时线程 T2 已经看到 b 变更, 然后去获取 a 的值, 自然不等于 1.
happen-before 原则
因为有以上重排序问题, 会导致并发执行的问题, 那么有没有方法解决呢?
happen-before 原则, 就是用来解决这个问题的一个原则说明, 它告诉我们的开发者, 你放心的写并发代码, 但是你要遵循我告诉你的原则, 你就能避免以上重排序导致的问题.
这个原则是什么呢?
1, 程序次序规则: 在一个单独的线程中, 按照程序代码的执行流顺序,(时间上)先执行的操作 happen-before(时间上)后执行的操作.
2, 管理锁定规则: 一个 unlock 操作 happen-before 后面 (时间上的先后顺序, 下同) 对同一个锁的 lock 操作.
3,volatile 变量规则: 对一个 volatile 变量的写操作 happen-before 后面对该变量的读操作.
4, 线程启动规则: Thread 对象的 start()方法 happen-before 此线程的每一个动作.
5, 线程终止规则: 线程的所有操作都 happen-before 对此线程的终止检测, 可以通过 Thread.join()方法结束, Thread.isAlive()的返回值等手段检测到线程已经终止执行.
6, 线程中断规则: 对线程 interrupt()方法的调用 happen-before 发生于被中断线程的代码检测到中断时事件的发生.
7, 对象终结规则: 一个对象的初始化完成 (构造函数执行结束)happen-before 它的 finalize() 方法的开始.
8, 传递性: 如果操作 A happen-before 操作 B, 操作 B happen-before 操作 C, 那么可以得出 A happen-before 操作 C.
我们一条条看,
第一条
单线程情况下, happen-before 原则告诉你, 你放心的认为代码写在前面的就是先执行就 ok 了, 不会有任何问题的.(当然实际并非如此, 因为有指令重排序嘛, 有的虽然写在前面, 但是未必先执行, 但是单线程情况下, 这并不会给实际造成任何问题, 写在前面的代码造成的影响一定对后面的代码可见)
happen-before 有一种记法, hb(a,b) , 表示 a 比 b 先行发生.
单线程情况下, 写在前面的代码都比写在后面的代码先行发生
- int a = 1;
- int b= 2;
- hb(a,b)
第二条
看如下代码
线程 T1:
- a = 1;
- lock.unlock();
- b = ture;
线程 T2:
- if (b){
- lock.lock();
- int c = a;
- System.out.println(c);
- lock.unlock();
- }
此前在讲重排序的时候说过这个问题, 说 c 有可能读取到的 a 值不一定是 1. 因为重排序, 导致 a 的赋值语句可能没执行. 但是现在在
b 赋值之前加了解锁操作, 线程 T2 在读取到 b 值变更后, 做了加锁操作. 这时候就是第二条原则生效的时候, 它告诉我们, 假如在时间上 T1 的 lock.unlock()先执行了, T2 的 lock.lock()后执行, 那么 T1 unlock 之前的所有变更, a = 1 这个变更, T2 是一定可见的, 即 T2 在 lock 后, c 拿到的值一定是 a 被赋值 1 的值.
因为 a = 1 和 lock.unlock() 有 hb 关系 hb(a=1 , lock.unlock() )
第二条原则 hb(unlock, lock), 而 hb(lock , c = a ), 因此 c 在被赋值 a 时, a=1 一定会先行发生.
第三条
volatile 关键字修饰的变量的写先行发生与对这个变量的读, 如下
线程 T1:
- a = 1;
- vv = 33;//volatile
- b = ture;
线程 T2:
- if (b){
- int ff = vv;// vv is volatile
- int c = a;
- System.out.println(c);
- }
与前面的锁原则一样, 这次是 volatile 变量 写 happen-before 读. 线程 T2 在读取 a 变量前先读取以下 vv 这个 volatile 变量. 因为第三条原则的存在, 只要 T1 在时间上执行了 vv 写操作, T2 在执行 vv 读操作后, a=1 的赋值一定可以被 T2 读到.
第四条, 第五条, 第六条
线程 T1 start 方法, 先行发生于 T1 即将做的所有操作.
如, 在某个线程中启动 thread1
- a = 1;
- thread1.start();
如上, a =1 先行发生 thread.start(), 而第四条规则又说, start 方法先行发生该线程即将做的所有操作, 那么 a =1 , 也必将先行发生于 thread1 的任何操作. 所以 thread1 启动后, 是一定可以读取到 a 的值为 1 的
五, 六条类似, 线程终止前的所有操作先行发生于终止方法的返回. 这就保障了一个线程结束后, 其他线程一定能感知到线程所做的所有变更.
第七条
对象被垃圾回收调用 finalize 时, 对象的构造一定已经先行发生.
第八条
传递性
至此, 概念基本梳理完了. 后续增加的外延有, 分析 java 并发集合在实现时, 为了符合 happen-before 的一些处理.
来源: http://www.jianshu.com/p/3fb4d57b915f