J:hi,T。
T:hi,J。
J:今天我们应该讲Java存储模式了,对吧?
T:是的,我将介绍Java存储模式和它的作用,然后介绍Happens-before,最后会通过一些实例来讲解怎么运用它。
J(迫不及待):那就开始吧。
Java存储模式简介和它的作用
T:好的,Java存储模式(Java Memory Model,JMM)根据特定的规则确定程序写入的值能否被其它程序正确的读取,例如一个线程中为变量value赋值:
value = 3;
存储模式将告诉你什么情况下,读取value值的线程能正确的看到3这个值。
用简单的话说,就是Java存储模式定义了一些基本规则,来保证在特定的情况下,程序会得到特定的结果。
J:哦,但这样有什么好处呢?
T:JMM规定了JVM的一种最小保证,这为JVM的实现提供了大量的自由度,JVM可以在不违背JMM的情况下对代码的执行做出优化。这样的设计,可以在对可预言性的需要和开发程序的简易性之间取得平衡。如果不了解这些,你就会对你的程序的某些行为感到困惑。
总的来说,了解了JMM,你可以更加明确在何时需要使用同步来协调线程的活动,并且可以利用JMM实现一些高性能且线程安全的容器;否则,你可能会因为不正确的使用同步而导致程序出现一些令人惊异的行为,例如我们在java并发编程1中讲到的可见性。下面我将介绍JVM的另外两种优化策略:重排序和地址复用。
重排序
我们从一个例子开始:
public class Test {
private static int r1 = 0;
private static int r2 = 0;
private static int a = 0;
private static int b = 0;
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
r2 = a;
b = 1;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
r1 = b;
a = 2;
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("(" + r1 + ", " + r2 + ")");
}
}
那么,思考一下,这段程序输出的结果可能为(1, 2)吗?
J:哦,让我好好想想,如果r2要等于2,那么必须等线程2执行了a=2之后线程1才能开始执行,而r1要等与1,又必须等线程1执行了b=1后线程2才能开始执行,这。。。不大可能吧。
T:实际上,这个是有可能的,原因就在于编译器允许重排序线程中执行的指令顺序,只要这不影响那个线程执行的结果,这也称之为重排序。因此,上面例子中两个线程中指令的顺序可能会被重排序为:
线程t1: b = 1; r2 = a;
线程t2:r1 = b; a = 2;
这样就可能导致最终的结果为:r2==2和r1==1。
J:哦,原来是这样。
地址复用
T:上面的程序存在问题,主要由于线程t1和t2存在数据竞争,而在数据竞争下就经常会出现一些意想不到的结果。我们再看下一个例子:
public class Test {
private static Point r1;
private static int r2;
private static Point r3;
private static int r4;
private static int r5;
private static Point r6;
private static Point p = new Point();
private static Point q = p;
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
r1 = p;
r2 = r1.x;
r3 = q;
r4 = r3.x;
r5 = r1.x;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
r6 = p;
r6.x = 3;
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("(" + r2 + ", " + r4 + ", " + r5 + ")");
}
}
class Point {
public int x = 0;
public int y = 0;
}
这段程序会导致一个编译器优化,即地址重用。由于r2和r5都是读取r1.x的值,并且在r2和r5的复制操作之间没有对r1.x的值的修改,因此,编译器会将r2和r5指向同一个地址,编译器优化后的结果如下:
线程t1:r1 = p; 线程t2:r6 = p;
r2 = r1.x; r6.x = 3;
r3 = q;
r4 = r3.x;
r5 = r2;
这样优化后的直接后果就是:如果线程t2中对p的修改在线程t1的r2赋值之后和r4赋值之前执行,则会导致执行的结果为:
r1 = p;
r2 = 0;
r3 = q;
r4 = 3;
r5 = 0;
这给人的感觉就是p.x的值开始为0,然后变为了3,然后再次变为0了,和实际情况不符。
J:哇,这确实得到了非常奇怪的结果。
T:是的,由于JMM并没有对这个做出要求,因此编译器是可以这么做的。
小结
总之,编译器在遵循JMM的前提下,只要保证在单线程的情况下,没有任何优化执行的结果和优化后执行的结果相同,就是合理的。而且在单线程的情况下,这些优化对我们来说是隐藏的,它除了提高程序执行的速度外,不会产生其它的影响。
J:也就是说,这些优化可以提高单线程环境下程序的执行效率,但却为多线程下的执行带来了问题。
T:是的,通过对JMM的学习,我们可以认识到哪些操作在多线程下是不安全的。
J:那我们快开始吧;
Java存储模型
Java存储模型通过动作的形式进行描述,即为所有程序内部的动作定义了一套偏虚关系,叫做happens-before,下面我们将对它做详细的讲解。
Happens-before
happens-before确保如果行为A happens-before行为B,则在行为B执行之前,行为A必定是可见的(无论A和B是否发生在同一个线程中)。而如果两个操作之间并未按照happens-before关系排序,JVM就可以对它们随意地重排序。
我们来看看JMM定义了的happens-before规则:
程序次序法则:线程中的每个动作A都Happens-before于该线程中的每一个动作B,其中,在程序中,