本文暂不讲 JMM(Java Memory Model)中的主存, 工作内存以及数据如何在其中流转等等,
这些本身还牵扯到硬件内存架构, 直接上手容易绕晕, 先从以下几个点探索 JMM
原子性
有序性
可见性
指令重排
CPU 指令重排
编译器优化重排
Happen-Before 规则
原子性
原子性是指一个操作是不可中断的. 即使是在多个线程一起执行的时候,
一个操作一旦开始, 就不会被其它线程干扰. 例如 CPU 中的一些指令, 属于原子性的,
又或者变量直接赋值操作(i = 1),, 也是原子性的 即使有多个线程对 i 赋值, 相互也不会干扰.
而如 i++, 则不是原子性的, 因为他实际上 i = i + 1, 若存在多个线程操作 i, 结果将不可预期.
有序性
有序性是指在单线程环境中, 程序是按序依次执行的.
而在多线程环境中, 程序的执行可能因为指令重排而出现乱序, 下文会有详细讲述.
- class OrderExample {
- int a = 0;
- boolean flag = false;
- public void writer() {
- // 以下两句执行顺序可能会在指令重排等场景下发生变化
- a = 1;
- flag = true;
- }
- public void reader() {
- if (flag) {
- int i = a + 1;
- ......
- }
- }
- }
可见性
可见性是指当一个线程修改了某一个共享变量的值, 其他线程是否能够立即知道这个修改.
会有多种场景影响到可见性:
CPU 指令重排
多条汇编指令执行时, 考虑性能因素, 会导致执行乱序, 下文会有详细讲述.
硬件优化(如写吸收, 批操作)
cpu2 修改了变量 T, 而 cpu1 却从高速缓存 cache 中读取了之前 T 的副本, 导致数据不一致.
编译器优化
主要是 Java 虚拟机层面的可见性, 下文会有详细讲述.
指令重排
指令重排是指在程序执行过程中, 为了性能考虑, 编译器和 CPU 可能会对指令重新排序.
CPU 指令重排
一条汇编指令的执行是可以分为很多步骤的, 分为不同的硬件执行
取指 IF
译码和取寄存器操作数 ID
执行或者有效地址计算 EX (ALU 逻辑计算单元)
存储器访问 MEM
写回 WB (寄存器)
既然指令可以被分解为很多步骤, 那么多条指令就不一定依次序执行.
因为每次只执行一条指令, 依次执行效率太低了, 假设上述每一个步骤都要消耗一个时钟周期,
那么依次执行的话, 一条指令要 5 个时钟周期, 两条指令要占用 10 个时钟周期, 三条指令消耗 15 个时钟.
而如果硬件空闲即可执行下一步, 类似于工厂中的流水线, 一条指令要 5 个时钟周期,
两条指令只需要 6 个时钟周期, 因为是错位流水执行, 三条指令消耗 7 个时钟.
举个例子 A = B + C, 需要如下指令
指令 1 : 加载 B 到寄存器 R1 中
指令 2 : 加载 C 到寄存器 R2 中
指令 3 : 将 R1 与 R2 相加, 得到 R3
指令 4 : 将 R3 赋值给 A
注意下图红色框选部分, 指令 1, 2 独立执行, 互不干扰.
指令 3 依赖于指令 1, 2 加载结果, 因此红色框选部分表示在等待指令 1, 2 结束.
待指令 1, 2 都已经走完 MEM 部分, 数据加载到内存后, 指令 3 继续执行计算 EX.
同理指令 4 需要等指令 3 计算完, 才可以拿到 R3, 因此也需要错位等待.
再来看一个复杂的例子
- a = b + c
- d = e - f
具体指令执行步骤如图, 不再赘述, 与上图类似, 在执行过程中同样会出现等待.
这边框选的 X 统称一个气泡, 有没有什么方案可以削减这类气泡呢.
答案自然是可以的, 我们可以在出现气泡之前, 执行其他不相干指令来减少气泡.
例如可以将第五步的加载 e 到寄存器提前执行, 消除第一个气泡,
同理将第六步的加载 f 到寄存器提前执行, 消除第二个气泡.
经过指令重排后, 整个流水线会更加顺畅, 无气泡阻塞执行.
原先需要 14 个时钟周期的指令, 重排后, 只需要 12 个时钟周期即可执行完毕.
指令重排只可能发生在毫无关系的指令之间, 如果指令之间存在依赖关系, 则不会重排.
如 指令 1 : a = 1 指令 2: b = a - 1, 则指令 1, 2 不会发生重排.
编译器优化
主要指 jvm 层面的, 如下代码, 在 jvm client 模式很快就跳出了 while 循环, 而在 server 模式下运行, 永远不会停止.
- /**
- * Created by Administrator on 2018/5/3/0003.
- */
- public class VisibilityTest extends Thread {
- private boolean stop;
- public void run() {
- int i = 0;
- while (!stop) {
- i++;
- }
- System.out.println("finish loop,i=" + i);
- }
- public void stopIt() {
- stop = true;
- }
- public boolean getStop() {
- return stop;
- }
- public static void main(String[] args) throws Exception {
- VisibilityTest v = new VisibilityTest();
- v.start();
- Thread.sleep(1000);
- v.stopIt();
- Thread.sleep(2000);
- System.out.println("finish main");
- System.out.println(v.getStop());
- }
- }
我们可以通过修改 JAVA_HOME/jre/lib/i386/jvm.cfg, 将 jvm 调整为 server 模式验证下.
修改内容如下图所示, 将 - server 调整到 - client 的上面.
- -server KNOWN
- -client KNOWN
- -hotspot ALIASED_TO -client
- -classic WARN
- -native ERROR
- -green ERROR
修改成功后, java -version 会产生如图变化.
两者区别在于当 jvm 运行在 - client 模式的时候, 使用的是一个代号为 C1 的轻量级编译器,
而 - server 模式启动的虚拟机采用相对重量级, 代号为 C2 的编译器. C2 比 C1 编译器编译的相对彻底,
会导致程序启动慢, 但服务起来之后, 性能更高, 同时有可能带来可见性问题.
我们将上述代码运行的汇编代码打印出来, 如下图所示, 从红字注释的部分可以看出来,
只有第一次进入循环之前, 检查了下 stop 的值, 不满足条件, 进入循环后,
再也没有检查 stop, 一直在做循环 i++.
解决方案也很简单, 只要给 stop 加上 volatile 关键字, 再次打印汇编代码, 发现他每次都会检查 stop 的值.
就不会出现无限循环了.
再来看两个从 Java 语言规范中摘取的例子, 也是涉及到编译器优化重排, 这里不再做详细解释, 只说下结果.
例子 1 中有可能出现 r2 = 2 并且 r1 = 1;
例子 2 中是 r2, r5 值因为都是 = r1.x, 编译器会使用向前替换, 把 r5 指向到 r2, 最终可能导致 r2=r5=0, r4 = 3;
Happen-Before 先行发生规则
如果光靠 sychronized 和 volatile 来保证程序执行过程中的原子性, 有序性, 可见性, 那么代码将会变得异常繁琐.
JMM 提供了 Happen-Before 规则来约束数据之间是否存在竞争, 线程环境是否安全, 具体如下:
顺序原则
一个线程内保证语义的串行性; a = 1; b = a + 1;
volatile 规则
volatile 变量的写, 先发生于读, 这保证了 volatile 变量的可见性,
锁规则
解锁 (unlock) 必然发生在随后的加锁 (lock) 前.
传递性
A 先于 B,B 先于 C, 那么 A 必然先于 C.
线程启动, 中断, 终止
线程的 start()方法先于它的每一个动作.
线程的中断 (interrupt()) 先于被中断线程的代码.
线程的所有操作先于线程的终结(Thread.join()).
对象终结
对象的构造函数执行结束先于 finalize()方法.
来源: https://www.cnblogs.com/xdecode/p/8948277.html