悲剧,绝对的悲剧,悲剧中的悲剧。
分类: 云计算
2014-12-20 21:58:33
最近在看《深入理解Java虚拟机:JVM高级特性与最佳实践》讲到了线程相关的细节知识,里面讲述了关于java内存模型,也就是jsr 133定义的规范。
系统的看了jsr 133规范的前面几个章节的内容,觉得受益匪浅。废话不说,简要的介绍一下java内存规范。
在jsr-133中是这么定义的
A memory model describes, given a program and an execution trace of that program, whether
the execution trace is a legal execution of the program. For the Java programming language, the
memory model works by examining each read in an execution trace and checking that the write
observed by that read is valid according to certain rules.
也就是说一个内存模型描述了一个给定的程序和和它的执行路径是否一个合法的执行路径。对于java序言来说,内存模型通过考察在程序执行路径中每一个读操作,根据特定的规则,检查写操作对应的读操作是否能是有效的。
java内存模型只是定义了一个规范,具体的实现可以是根据实际情况自由实现的。但是实现要满足java内存模型定义的规范。
这个要感谢硅工业的发展,导致目前处理器的性能越来越强大。目前市场上基本上都是多核处理器。如何利用多核处理器执行程序的优势,使得程序性能得到极大的提升,是目前来说最重要的。
目前所有的运算都是处理器来执行的,我们在大学的时候就学习过一个基本概念 程序 = 数据 + 算法 ,那么处理器负责计算,数据从哪里获取了?
数据可以存放在处理器寄存器里面(目前x86处理都是基于寄存器架构的),处理器缓存里面,内存,磁盘,光驱等。处理器访问这些数据的速度从快到慢 依次为:寄存器,处理器缓存,内存,磁盘,光驱。为了加快程序运行速度,数据离处理器越近越好。但是寄存器,处理器缓存都是处理器私有数据,只有内存,磁 盘,光驱才是才是所有处理器都可以访问的全局数据(磁盘和光驱我们这里不讨论,只讨论内存)如果程序是多线程的,那么不同的线程可能分配到不同的处理器来 执行,这些处理器需要把数据从主内存加载到处理器缓存和寄存器里面才可以执行(这个大学操作系统概念里面有介绍),数据执行完成之后,在把执行结果同步到 主内存。如果这些数据是所有线程共享的,那么就会发生同步问题。处理器需要解决何时同步主内存数据,以及处理执行结果何时同步到主内存,因为同一个处理器 可能会先把数据放在处理器缓存里面,以便程序后续继续对数据进行操作。所以对于内存数据,由于多处理器的情况,会变的很复杂。下面是一个例子:
初始值 a = b = 0
process1 process2
1:load a 5:load b
2:write a:2 6:add b:1
3:load b 7: load a
4:write b:1 8:write a:1
假设处理器1先加载内存变量a,写入a的值为2,然后加载b,写入b的值为1,同时 处理2先加载b,执行b+1,那么b在处理器2的结果可能是1 可能是3。因为在load b之前,不知道处理器1是否已经吧b写会到主内存。对于a来说,假设处理器1后于处理器2把a写会到主内存,那么a的值则为2。
而内存模型就是规定了一个规则,处理器如何同主内存同步数据的一个规则。
在介绍java内存模型之前,我们先看看两个内存模型
Sequential Consistency Memory Model:连续一致性模型。这个模型定义了程序执行的顺序和代码执行的顺序是一致的。也就是说 如果两个线程,一个线程T1对共享变量A进行写操作,另外一个线程T2对A进行读操作。如果线程T1在时间上先于T2执行,那么T2就可以看见T1修改之后的值。
这个内存模型比较简单,也比较直观,比较符合现实世界的逻辑。但是这个模型定义比较严格,在多处理器并发执行程序的时候,会严重的影响程序的性能。 因为每次对共享变量的修改都要立刻同步会主内存,不能把变量保存到处理器寄存器里面或者处理器缓存里面。导致频繁的读写内存影响性能。
Happens-Before Memory Model : 先行发生模型。这个模型理解起来就比较困难。先介绍一个现行发生关系 (Happens-Before Relationship)
如果有两个操作A和B存在A Happens-Before B,那么操作A对变量的修改对操作B来说是可见的。这个现行并不是代码执行时间上的先后关系,而是保证执行结果是顺序的。看下面例子来说明现行发生
A,B为共享变量,r1,r2为局部变量
初始 A=B=0
Thread1 | Thread2
1: r2=A | 3: r1=B
2: B=2 | 4: A=2
|
凭借直观感觉,线程1先执行 r2=A,则r2=0 ,然后赋值B=1,线程2执行r1=B,由于线程1修改了B的值为1,所以r1=1。但是在现行发生内存模型里面,有可能最终结果为r1 = r2 = 2。为什么会这样,因为编译器或者多处理器可能对指令进行乱序执行,线程1 从代码流上面看是先执行r2 = A,B = 1,但是处理器执行的时候会先执行 B = 2 ,在执行 r2 = A,线程2 可能先执行 A = 2 ,在执行r1 = B,这样可能 会导致 r1 = r2 = 2。
那我们先看看先行发生关系的规则
解释一下以上几个先行发生规则的含义
规则1应该比较好理解,因为比较适合人正常的思维。比如在同一个线程t里面,代码的顺序如下:
thread 1
共享变量A、B
局部变量r1、r2
代码顺序
1: A =1
2: r1 = A
3: B = 2
4: r2 = B
执行结果 就是 A=1 ,B=2 ,r1=1 ,r2=2
|
因为以上是在同一个线程里面,按照规则1 也就是按照代码顺序,A = 1 先行发生 r1 =A ,那么r1 = 1
再看规则2,下面是jsr133的例子
按照规则2,由于unlock操作先于发生于lock操作,所以X=1对线程2里面就是可见的,所以r2 = 1
在分析以下,看这个例子,由于unlock操作先于lock操作,所以线程x=1对于线程2不一定是可见(不一定是现行发生的),所以r2的值不一定是1,有可能是x赋值为1之前的那个状态值(假设x初始值为0,那么此时r2的值可能为0)
对于规则3,我们可以稍微修改一下我们说明的第一个例子
A,B为共享变量,并且B是valotile类型的
r1,r2为局部变量
初始 A=B=0
Thread1 | Thread2
1: r2=A | 3: r1=B
2: B=2 | 4: A=2
那么r1 = 2, r2可能为0或者2
|
因为对于volatile类型的变量B,线程1对B的更新马上线程2就是可见的,所以r1的值就是确定的。由于A是非valotile类型的,所以值不确定。
规则4,5,6这里就不解释了,知道规则就可以了。
可以从以上的看出,先行发生的规则有很大的灵活性,编译器可以对指令进行重新排序,以便满足处理器性能的需要。只要重新排序之后的结果,在单一线程里面执行结果是可见的(也就是在同一个线程里面满足先行发生原则1就可以了)。
java内存模型是建立在先行发生的内存模型之上的,并且再此基础上,增强了一些。因为现行发生是一个弱约束的内存模型,在多线程竞争访问共享数据 的时候,会导致不可预期的结果。有一些是java内存模型可以接受的,有一些是java内存模型不可以接受的。具体细节这里面就不详细说明了。这里只说明 关于java新的内存模型重要点。
在java里面,如果一个类定义了一个final属性,那么这个属性在初始化之后就不可以在改变。一般认为final字段是不变的。在java内存 模型里面,对final有一个特殊的处理。如果一个类C定义了一个非static的final属性A,以及非static final属性B,在C的构造器里面对A,B进行初始化,如果一个线程T1创建了类C的一个对象co,同一时刻线程T2访问co对象的A和B属性,如果 t2获取到已经构造完成的co对象,那么属性A的值是可以确定的,属性B的值可能还未初始化,
下面一段代码演示了这个情况
public class FinalVarClass {
public final int a ;
public int b = 0;
static FinalVarClass co;
public FinalVarClass(){
a = 1;
b = 1;
}
//线程1创建FinalVarClass对象 co
public static void create(){
if(co == null){
co = new FinalVarClass();
}
}
//线程2访问co对象的a,b属性
public static void vistor(){
if(co != null){
System.out.println(co.a);//这里返回的一定是1,a一定初始化完成
System.out.println(co.b);//这里返回的可能是0,因为b还未初始化完成
}
}
}
|
为什么会发生这种情况,原因可能是处理器对创建对象的指令进行重新排序。正常情况下,对象创建语句co = new FinalVarClass()并不是原子的,简单来说,可以分为几个步骤,1 分配内存空间 2 创建空的对象 3 初始化空的对象 4 把初始化完成的对象引用指向 co ,由于这几个步骤处理器可能并发执行,比如3,4 并发执行,所以在create操作完成之后,co不一定马上初始化完成,所以在vistor方法的时候,b的值可能还未初始化。但是如果是final字 段,必须保证在对应返回引用之前初始化完成。
对于volatile字段,在现行发生规则里面已经介绍过,对volatile变量的写操作先于对变量的读操作。也就是说任何对volatile变量的修改,都可以在其他线程里面反应出来。对于volatile变量的介绍可以参考 本人写的一篇文章 《java中volatile关键字的含义》 里面有详细的介绍。
volatile在java新的内存规范里面还加强了新的语义。在老的内存规范里面,volatile变量与非volatile变量的顺序是可以重新排序的。举个例子
public class VolatileClass {
int x = 0;
volatile boolean v = false;
//线程1write
public void writer() {
x = 42;
v = true;
}
//线程2 read
public void reader() {
if (v == true) {
System.out.println(x);//结果可能为0,可能为2
}
}
}
|
线程1先调用writer方法,对x和v进行写操作,线程reader判断,如果v=true,则打印x。在老的内存规范里面,可能对v和x赋值 顺序发生改变,导致v的写操作先行于x的写操作执行,同时另外一个线程判断v的结果,由于v的写操作先行于v的读操作,所以if(v==true)返回 真,于是程序执行打印x,此时x不一定先行与System.out.println指令之前。所以显示的结果可能为0,不一定为2
但是java新的内存模型jsr133修正了这个问题,对于volatile语义的变量,自动进行lock 和 unlock操作包围对变量volatile的读写操作。那么以上语句的顺序可以表示为
thread1 thread2
1 :write x=1 5:lock(m)
2 :lock(m) 6:read v
3 :write v=true 7:unlock(m)
4 :unlock 8 :if(v==true)
9: System.out.print(x)
|
由于unlock操作先于lock操作,所以x写操作5先于发生x的读操作9
以上只是jsr规范中一些小结行的内容,由于jsr133规范定义了很多术语以及很多推论,上述只是简单的介绍了一些比较重要的内容,具体细节可以参考jsr规范的public view :