J:hi,T。
T:hi,J。
J:今天我们应该讲Java存储模式了,对吧?
T:是的,我将介绍Java存储模式和它的作用,然后介绍Happens-before,最后会通过一些实例来讲解怎么运用它。
J(迫不及待):那就开始吧。
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存储模型通过动作的形式进行描述,即为所有程序内部的动作定义了一套偏虚关系,叫做happens-before,下面我们将对它做详细的讲解。
1)Happens-before
happens-before确保如果行为A happens-before行为B,则在行为B执行之前,行为A必定是可见的(无论A和B是否发生在同一个线程中)。而如果两个操作之间并未按照happens-before关系排序,JVM就可以对它们随意地重排序。
我们来看看JMM定义了的happens-before规则:
程序次序法则:线程中的每个动作A都Happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都出现在动作A之后; 监视器锁法则:对一个监视器锁的解锁Happens-before于每一个后续对同一监视器锁的加锁; volatile变量法则:对volatile域的写入操作Happens-before于每一个后续对同一域的读操作; 线程启动法则:在一个线程中,对Thread.start的调用会Happens-before于每一个启动线程中的动作; 线程终结法则:线程中的任何动作都Happens-before于其他线程检测到这个线程已经终结(或者从Thread.join调用中成功返回,或者Thread.isAlive返回false); 中断法则:一个线程调用另一个线程的interrupt Happens-before于被中断的线程发现中断(通过抛出InterruptedException,或者调用isInterrupted和interrupted); 终结法则:一个对象的构造函数的结束Happens-before于这个对象finalizer的开始; 传递性法则:如果A Happens-before于B,且B Happens-before于C,则A Happens-before于C。
J:怎么感觉程序次序法则和重排序有点矛盾呢?
T:程序次序法则并不是暗示两个动作必定按照程序的顺序发生,如果重排序的结果和合法执行的结果是一致的,那么重排序后也是合法的。这样说太过于抽象了,举个例子吧:
public class Test { private static int x = 0; private static int y = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(new Runnable() { @Override public void run() { x = 5; y = 6; } }); Thread t2 = new Thread(new Runnable() { @Override public void run() { while (true) { if (y == 6) { System.out.println(x); return; } } } }); t1.start(); t2.start(); t1.join(); t2.join(); } }
根据重排序,t2线程打印的结果可能为5,也可能为0。根据程序次序法则,x=5 happens-before y=6,但是t2的执行和t1的执行之间不存在happens-before关系,因此t2中在y==6的情况下就可能看到x==5,也可能看到x==0。
但是,如果y是volatile变量,我们可以看到:
1)根据程序次序法则:x=5 happens-before y=6;
2)根据volatile变量法则:y=6 happens-before 读取y;
3)根据程序次序法则:读取y happens-before 读取x。
最后,根据传递性法则,我们就可以保证t2的打印结果始终为5。
J:通过这个例子来看就好多了,我想我已经差不多明白happens-before了。
T:我下面将分别用一个正面的例子和一个反面的例子来说明Happens-before的运用,我们从正面的例子开始:
FutureTask位于java.util.concurrent包下面,它实现了Runnable和Future接口。打算通过线程执行其实例的类都需要实现Runnable接口,而Future接口则用于表示异步计算的接口,主要包含方法:
cancel:试图取消对此任务的执行。 get:如有必要,等待计算完成,然后获取其结果,可以指定时间。 isCancelled:如果在任务正常完成前将其取消,则返回 true。 isDone:如果任务已完成,则返回 true。
一个通常的使用FutureTask的例子如下:
public class Test { public static void main(String[] args) throws InterruptedException, ExecutionException { ExecutorService executor = Executors.newFixedThreadPool(10); try { Future<String> future = executor.submit(new Callable<String>() { @Override public String call() throws Exception { Thread.sleep(2000); return "It is done!"; } }); System.out.println(future.get()); } finally { executor.shutdown(); } } }
我们这里的讲解聚焦于FutureTask对happens-before法则的使用,为了帮组我们对FutureTask的源码的分析,下面我实现了一个简化版的FutureTask(去掉了cancel功能和对异常的处理部分,代码存在缺陷,完整的代码请看JDK源码:java.util.concurrent.FutureTask.java):
public class FutureTask<V> implements Runnable { private final Sync sync; public FutureTask(Callable<V> callable) { sync = new Sync(callable); } @Override public void run() { sync.innerRun(); } public V get() throws InterruptedException, ExecutionException { return sync.innerGet(); } protected void set(V v) { sync.innerSet(v); } private final class Sync extends AbstractQueuedSynchronizer { private static final long serialVersionUID = -7828117401763700385L; /** State value representing that task is ready to run */ private static final int READY = 0; /** State value representing that task is running */ private static final int RUNNING = 1; /** State value representing that task ran */ private static final int RAN = 2; private final Callable<V> callable; private V result; private volatile Thread runner; Sync(Callable<V> callable) { this.callable = callable; } protected int tryAcquireShared(int ignore) { return innerIsDone() ? 1 : -1; } boolean innerIsDone() { return isRan(getState()) && runner == null; } private boolean isRan(int state) { return (state & RAN) != 0; } protected boolean tryReleaseShared(int ignore) { runner = null; return true; } V innerGet() throws InterruptedException, ExecutionException { acquireSharedInterruptibly(0); return result; } void innerSet(V v) { for (;;) { int s = getState(); if (s == RAN) return; if (compareAndSetState(s, RAN)) { result = v; releaseShared(0); return; } } } void innerRun() { if (!compareAndSetState(READY, RUNNING)) return; runner = Thread.currentThread(); if (getState() == RUNNING) { // recheck after setting thread V result; try { result = callable.call(); } catch (Throwable ex) { return; } set(result); } else { releaseShared(0); // cancel } } } }
下面是AQS,这里只列举相关的代码部分,我会在后面的文章中详细介绍AQS,这里只对我们关心的流程做简单介绍。
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { ....... //判断tryAcquireShared是否满足,不满足进入doAcquireSharedInterruptibly public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (tryAcquireShared(arg) < 0) doAcquireSharedInterruptibly(arg); } //线程将在这里等待,直到条件满足后返回 private void doAcquireSharedInterruptibly(int arg) throws InterruptedException { final Node node = addWaiter(Node.SHARED); boolean failed = true; try { for (;;) { final Node p = node.predecessor(); if (p == head) { //每次唤醒后调用tryAcquireShared,看条件是否满足 int r = tryAcquireShared(arg); if (r >= 0) { setHeadAndPropagate(node, r); p.next = null; // help GC failed = false; return; } } //parkAndCheckInterrupt中线程进入等待 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } } //tryReleaseShared中将runner设置为null,然后在doReleaseShared唤醒等待的线程 public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; } private void doReleaseShared() { for (;;) { Node h = head; if (h != null && h != tail) { int ws = h.waitStatus; if (ws == Node.SIGNAL) { if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) continue; // loop to recheck cases //唤醒等待线程 unparkSuccessor(h); } else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) continue; // loop on failed CAS } if (h == head) // loop if head changed break; } } ...... }
认真看一下上面的代码,然后回答下面两个问题:
1)runner变量为什么要声明为volatile变量;
2)result为什么不需要声明为volatile变量。
J:这个有点难,得花点时间。
...一个小时之后...
J:我大概明白为什么runner需要声明为volatile变量,但还是不明白为什么result不需要声明为volatile变量。
T:好吧,那我们先从runner开始吧:
runner会在tryReleaseShared方法中置空,tryReleaseShared在执行run的线程中调用;runner会在innerIsDone方法中判断是否为空,innerIsDone是在调用get方法的线程中调用。因此,runner会在一个线程中修改,又在另一个线程中读取,为了保证可见性,runner需要被声明为volatile变量。
那result为什么不需要声明为volatile变量呢?它是靠什么来保证它的可见性的呢?
下面我将使用happens-before规则来证明result具有可见性,通过对程序的分析,我们可以得出:
1)innerSet中对result赋值的操作happens-before对releaseShared的调用(程序次序法则);
2)releaseShared中调用tryReleaseShared;
3)tryReleaseShared的调用成功happens-before tryAcquireShared的成功调用(volatile变量法则);
4)acquireSharedInterruptibly中调用tryAcquireShared;
5)innerGet中acquireSharedInterruptibly的调用成功happens-before result结果的返回(程序次序法则)。
从上面的分析,然后根据传递性法则:innerSet对result赋值的操作happens-before innerGet中result结果的返回。
由此,证明完成,结果成立,也就是说get的线程始终能够获取到正确的result值。
J(开心):我现在已经彻底的理解happens-before了。
T:恭喜恭喜。FutureTask的例子为我们展示了怎样利用happens-before原则“驾奴”在同步之上,这样做的好处在于性能的提升,坏处是非常容易出错,因此使用时一定要非常小心。
J:好的,那我们继续开始反例吧。
T:好的。
这是一个经典的例子,在早期版本的JVM中,同步,甚至是无竞争的同步,都存在惊人的性能开销,许多聪明的小技巧被发明出来降低同步的影响,DCL就是其中一种,但却是丑陋的那种,看下面的代码(这段代码来自这里http://www.iteye.com/topic/260515):
public class LazySingleton { private int someField; private static LazySingleton instance; private LazySingleton() { this.someField = new Random().nextInt(200) + 1; // (1) } public static LazySingleton getInstance() { if (instance == null) { // (2) synchronized (LazySingleton.class) { // (3) if (instance == null) { // (4) instance = new LazySingleton(); // (5) } } } return instance; // (6) } public int getSomeField() { return this.someField; // (7) } }
DCL认为程序最坏的情况就是看到instance的过期值(即为null),这时,DCL将在加锁的情况下再做一次判断,这样能够规避风险,确保获取到instance的最新值。但实际情况却比这更加糟糕,因为线程可以在获取到instance的当前值时,instance内部变量的状态还是过期的,为什么呢?变量someField,它会在构造函数中初始化,当语句2的判断认为instance不为null后返回instance后,因为someField的写入和读取并没有happens-before关系,因此getSomeField并不能保证获取到someField的最新值(根据可见性原则)。
J:很简单,我们为getSomeField方法添加同步就解决这个问题了嘛:
public synchronized int getSomeField() {
return this.someField; // (7)
}
T:这样任然是不正确的。
J:为什么?
T:你仔细观察的话,语句5和7并没有使用同一个锁,如果要加锁,就需要这样:
public int getSomeField() {
synchronized(LazySingleton.class){
return this.someField; // (7)
}
}
但如果这样做的话,就又会存在同步带来的效率问题了,其实在JDK 5之后,将instance声明为volatile就可以了,如下:
private volatile static LazySingleton instance;
J:volatile还能使对象内部的变量也成为可见的?
T:不能。这里之所以正确,是因为在添加了volatile之后,代码就存在以下的happens-before关系了:
1)语句1写入someField值happens-before语句5对instance的写入(程序次序法则);
2)语句5对instance的写入happens-before语句2中获取instance(volatile变量法则);
3)语句2获取instance happens-bufore语句7返回someField(程序次序法则)。
根据传递性法则,语句1写入someField值happens-before语句7返回someField,由此保证了DCL的正确性,也不会导致性能有太多的下降。
J:好复杂。
T:无论怎样,现在DCL已经不再使用了,这里只是将它作为一个学习的例子,如果你需要使用延迟初始化,现在有更好的方法:
public class LazySingleton { private int someField; private LazySingleton() { this.someField = new Random().nextInt(200) + 1; } private static class LazySingletonHolder{ public static LazySingleton instance = new LazySingleton(); } public static LazySingleton getInstance() { return LazySingletonHolder.instance; } public int getSomeField() { return this.someField; } }
JVM将会把LazySingletonHolder的初始化延迟到真正使用它的时刻[JLS 12.4.1]。由于instance在静态初始阶段进行初始化的,所以不需要额外的同步。线程第一次调用getInstance时,引起LazySingletonHolder的加载和初始化,这时,instance将完成初始化。
到这里,DCL的例子也就结束了。希望你对JMM已经有了一个全面的认识,这对于我们后面对并发容器的分析非常重要。非常感谢你一直坚持到现在,下次再见了。
J:好的,下次再见。