Java内存模型(二)

2014/07/22

本文原创作者:Cloud Chou. 欢迎转载,请注明出处和本文链接

volatile型变量的特殊规则

volatile是Java虚拟机提供的最轻量级的同步机制,当一个变量被定义成volatile后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程改变了这个变量的值后,新值对于其他线程来说是可以立即得知的;第二个语义是禁止指令重排序,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

开发人员经常误解的一个描述:“volatile变量对于所有线程是立即可见的,对volatile变量的写操作都能立刻反应到其他线程之中,换句话说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是安全的”。这句话的论据部分并没有错,但是其论据不能得出“基于volatile变量的运算在并发下是安全的”这个结论。volatile型变量在各个线程的工作内存中不存在一致性问题,但是Java里面的运算并非原子运算,导致volatile变量的运算在并发下一样是不安全的。 象如下代码:

1
2
volatile int race =0 ;
race++

我们知道race++这个操作并不是原子运算,它会被编译成多条字节码指令,故此多线程计算时会出现结果不符合预期的情况。

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然需要通过加锁来保证原子性:

  • 1)运算结果并不依赖变量的当前值,或者能够确保只有单一线程修改变量的值
  • 2)变量不需要与其他的状态变量共同参与不变约束
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Map configOptions;
char[] configText;
volatile boolean initialized = false;
//假设以下代码在线程A中执行
//模拟读取配置信息,当读取完成后
//将initialized设置为true 来通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText,configOptions);
initialized = true;

//假设以下代码在线程B中执行
while(!initialized){
 sleep();
}
doSomethingWithConfig();

上述代码是一段伪代码,其中描述的场景十分常见,只是我们在处理配置文件时一般不会出现并发而已。如果定义initialized变量时没有使用volatile修饰,就可能由于指令重排序的优化,导致位于线程A中最后一句的代码”initialized=true”被提前执行,这样在线程B中使用配置信息的代码可能出现错误,而volatile关键字则可避免此类情况的发生。

再看看Java内存模型中对volatile变量定义的特殊规则,假定T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read,load,use,assign,store和write操作时需要满足以下规则:

  • 1)只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T对变量V执行load动作。线程T对变量V的use动作可以认为是与线程T对变量V的load和read动作相关联的,必须一起连续出现。(这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改后的值)
  • 2)只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动作;并且,只有当线程T对变量V执行的后一个动作是store的时候,线程T对变量V执行assign动作。线程T对变量V的assign动作可以认为是与线程T对变量V的store和write动作相关联的,必须一起连续出现。(这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改)
  • 3)假定动作A是线程T对变量V实施的use或assign动作,假定动作F是与A相关联的load或store动作,假定动作P是与动作F相应的对变量V的read或write动作;类似地,假定动作B是线程T对变量W实施的use或assign动作,假定动作G是与B相关联的load或store动作,假定动作Q是与动作G相应的对变量V的read或write动作。如果A先于B,那么P先于Q(这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同)

原子性、可见性和有序性

  • 1)原子性

    由Java内存模型直接保证的原子性变量操作包括read,load,use,assign,store,write这6个,我们大致可以认为基本数据类型的访问读写是具备原子性的(long和double除外)。

    如果需要更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,对应的字节码指令是monitorenter和monitorexit,对应到Java代码当中就是synchronized,但是至于moniorenter和monitorexit指令如何对应到lock和unlock操作的请参考《Java虚拟机规范》以及《Java语言规范》。

  • 2)可见性

    可见性就是当一个线程修改了变量共享变量的值,其他线程能够立即得知这个修改。

    volatile保证了多线程操作时变量的可见性。

    synchronized和final也能实现可见性,同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去,那么在其他线程中就能看见final字段的值。(this引用逃逸是一件很危险的事情,其它线程可能访问到初始化了“初始化了一半”的对象)

  • 3)有序性

    Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的,如果在一个线程观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

    Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。

先行发生原则(Happen-before)

先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A所产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值,发送了消息,调用了方法等。

Java内存模型中“天然的”先行发生关系:

  • 1)程序次序规则(Program Order Rule)

    在同一个线程内,程序代码里写在前面的操作先行发生于写在后面的代码。准确地说,因该是控制流顺序而不是程序代码顺序,因为要考虑分支,循环等结构。

  • 2)管程锁定规则(Monitor Lock Rule)

    对某个锁的unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,这里的“后面”是指时间上的先后顺序。也就是说,如果某个锁已经被lock了,那么只有它被unlock之后,其他线程才能lock该锁。表现在代码上,如果是某个同步方法,如果某个线程已经进入了该同步方法,只有当这个线程退出了该同步方法(unlock操作),别的线程才可以进入该同步方法。

  • 3)volatile变量规则(Volatile Variable Rule)

    对一个volatile变量的写操作先行发生于对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。也就是说,某个线程对volatile变量写入某个值后,能立即被其它线程读取到。

  • 4)线程启动规则(Thread Start Rule)

    Thread对象的start方法先行发生于此线程的每个动作。

  • 5)线程终止规则(Thread Termination Rule)

    线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等手段检测到线程是否已经终止运行。

  • 6)线程中断规则(Thread Interruption Rule)

    对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

  • 7)对象终结规则(Finalizer Rule)

    一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

  • 8)传递性(Transitivity)

    如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

其中程序次序规则,管程锁定规则,volatile变量规则,传递性规则经常用来推断先行发生关系。

需要注意的是,先行发生关系和时间上的先后顺序基本没有太大的关系。

时间上的先后顺序不能得出先行发生关系,示例代码如下所示:

1
2
3
4
5
6
7
private int value=0;
public void setValue(int value){
  this.value=value;
}
public int getValue(){
return value;
}

假设存在线程A和线程B,线程A先(时间上的先后)调用了”setValue(1)”,然后线程B调用了同一个对象的”getValue()”,那么线程B收到的返回值是不确定的,由于工作内存和主内存同步存在延迟,也由于可能存在重排序现象。 虽然时间上线程A的setValue()操作先于线程B的getValue()操作,但是并不能推断出线程A的setValue()操作先行发生于线程B的getValue()操作,如果有这种先行发生关系,那么可以推断出线程B的getValue()操作获得的值。

如果我们给getValue()方法和setValue()方法添加synchronized关键字,就能利用管程锁定规则推断出线程A的setValue操作先行发生于线程B的getValue操作,或者我们也可以将value定义为volatile变量,也能利用volatile变量规则推断出先行发生关系。

先行发生关系也不能推断出时间上的先后执行顺序,示例代码如下所示:

1
2
int i=1;
int j=2;

根据程序次序规则,我们可以推断出”int i=1”的操作先行发生于”int j=2”的操作,但是”int j=2”的代码完全有可能先被处理器执行(时间上的先后),这就是重排序,虚拟机规范是允许这种特性存在的,虚拟机可利用这种特性提高性能。

重排序

在同一个线程操作是顺序执行的(其实是按控制流执行),但是在某个线程看别的线程里的操作,则是乱序执行的。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class PossibleReording {
private static int x = 0, y = 0;
private static int a = 0, b = 0;

public static void main(String[] args) 
        throws InterruptedException {
Thread one = new Thread() {
@Override
public void run() {
a = 1;
x = b;
}
};
Thread other = new Thread() {
@Override
public void run() {
b = 1;
y = a;
}
};
one.start();
other.start();
one.join();
other.join();
System.out.println("(" + x + "," + y + ")");

}

}

这段代码的执行结果是什么呢?我们先不考虑工作内存和主内存的同步问题,假设一种执行顺序,one线程先执行完,然后other线程再执行,那么执行的操作序列会是a=1, x=b, b=1, y=a; 此时的输出结果将是(0,1) 假设另一种执行顺序,other线程先执行,one线程后执行,那么执行的操作序列是b=1,y=a,a=1,x=b,此时的输出结果是(1,0),如果one线程和other线程交替执行,a=1,b=1,y=a,x=b,此时的输错结果是(1,1)。

但是让意外的是竟然还会存在这样一种输出结果(0,0),这种结果很难想象。这便是重排序导致的现象。一种可能的重排序后的执行顺序如下图所示:

reording

线程A执行时本来应该先执行a=1,后执行x=b的,但是由于重排序的原因x=b先执行,a=1后执行。

这一节说的的先后是指时间上的先后,根据内存模型的程序次序规则,线程A里a=1还是先行发生于x=b的。

参考资料

《深入理解Java虚拟机》 第12章 Java内存模型与线程

《Java多线程设计模式》 附录B Java的内存模型。

¥打赏5毛

取消

感谢您的支持,我会继续努力的!

扫码支持
赏个5毛,支持我把

打开支付宝扫一扫,即可进行扫码打赏哦

本篇目录