1.java内存模型的基础
1.1.java内存模型的抽象结构
在java中所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享 。局部变量(Local variables),方法定义参数 和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
Java线程之间的通信由Java内存模型控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化
JMM规定所有的变量都存在主内存中,每条线程还有自己的工作内存,线程中的工作内存了该线程使用到的变量的主内存副本,线程堆变量的操作都必须在工作内存中进行,而不能直接读写主内存变量,不同线程之间也不能直接访问对方工作内存中的变量,线程间变量值得传递通过主内存来完成。
通信的步骤:
1.线程A本地内存A中更新过的变量刷新到主内存中
2.线程B到主内存中读取线程A之前更新的共享变量
JMM通过控制主内存与每个线程的本地内存之间的交互来为java程序提供可见性保证
1.2内存间交互操作
一个变量如何从主内存拷贝到工作内存以及如何从工作内存同步回主内存的细节,JMM
定义了8种操作
lock(锁定):作用于主内存的变量,该操作把一个变量标识为一条线程独占的状态。
unlock(解锁):作用于主内存变量,它把一个处于锁定的变量释放出来,释放之后的变量才可以被其他线程锁定
read(读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作使用
load(载入):作用于工作内存变量,它把read操作从主内存中得到的变量值放入到工作内存的副本中。
use(使用):作用于工作内存变量,它把工作内存变量的值传递给执行引擎,每当虚拟机遇到一个需要使用使用变量值得字节码指令时就会执行use操作。
assign(赋值):作用于工作内存的变量,把一个从执行引擎收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时会执行这个操作。
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传递到主内存,以便随后的write使用
write(写入):作用于主内存变量,它把store操作从工作内存中得到的变量值写入到主内存变量中
交互操作的注意事项:
1.一个变量从主内存复制到工作内存,需要按顺序执行read和load
2.一个变量从工作内存复制到主内存,需要按顺序执行store和write
3.不允许read和load store和write操作之一单独出现,也就是不允许一个变量从主内存中读取了但是工作内存不接受,获取从工作内存发起了回写主内存不接受
4.不允许一个线程丢弃它最近的assign操作,也就是说变量在工作内存中改变之后必须同步回主内存
5.不允许一个线程无原因地(没发生过任何assign操作)把数据从工作内存同步回主内存
6.一个新的变量只能在主内存中诞生,也就是说在use和store之前,必须进行assign和load操作
7.一个变量在同一个时刻值允许一个线程对其进行lock操作
8.如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或者assign操作进行初始化
9.对一个变量unlock之前,必须把此变量同步回主内存(store和write操作)
1.3.从源码到指令序列的重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,重排序有一下几种类型
1.编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
2.指令级并行的重排序:多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
3.内存系统的重排序:由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
从java源代码到最终实际执行的指令序列
2.重排序
2.1数据依赖性
如果两个操作访问同一个变量而且其中一个是写操作,那么这两个操作具有依赖性,数据依赖分为三种类型,这三种类型的操作只要重排序两个操作的顺序,就会产生不同的执行结果
编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
2.2as-if-serial语义
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
这三个操作的依赖关系如下
编译器和处理器可以重排序A和B之间的执行顺序。下图是该程序的两种执行顺序
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
2.3重排序对多线程的影响
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
Public void reader() {
if (flag) { //3
int i = a * a; //4
……
}
}
}
flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入?
答案是:不一定能看到。
由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?
操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还根本没有被线程A写入,在这里多线程程序的语义被重排序破坏了!
当操作3和操作4重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作3和操作4重排序后,程序的执行时序图
在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。
从图中我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义!
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
3.顺序一致性
3.1 数据竞争
当程序没有正确同步时,就会存在数据竞争,JMM对数据竞争的定义如下
- 一个线程写变量
- 另一个线程读同一个变量
- 读和写没有通过同步来排序
当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果;如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序
如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)–即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同
3.2 顺序一致性内存模型
两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见
http://ifeve.com/java-memory-model-3/