深入浅出Java并发包—指令重排序
前面大致提到了JDK中的一些个原子类,也提到原子类是并发的基础,更提到所谓的线程安全,其实这些类或者并发包中的这么一些类,都是为了保证系统在运行时是线程安全的,那到底怎么样才算是线程安全呢?
Java并发与实践一书中提出,当多个线程同时访问一个类的时候,如果不用考虑这些线程在运行时环境下的调度和交替运行,并且不需要做额外的同步以及在调用代码时不需要做其他的协调,这个类的运行仍然是正确的,那么这个类是线程安全的。
很显然只有资源竞争时才会出现线程不安全,而无状态的类将永远是线程安全的。因此我们再做分层结果的时候,Service层可以轻松的使用单例去显示,而展示层和数据层却需要每个单独的线程单独一个对象去处理。
之前说了这么一些原子类,他们都是线程安全的类,原子操作的描述是多个线程执行同一个操作时,其中一个线程要么完全执行完成这个操作,要么根本没有执行任何步骤。
在JDK中,JAVA语言为了维持顺序内部的顺序化语义,也就是为了保证程序的最终运行结果需要和在单线程严格意义的顺序化环境下执行的结果一致,程序指令的执行顺序有可能和代码的顺序不一致,这个过程就称之为指令的重排序。指令重排序的意义在于:JVM能根据处理器的特性,充分利用多级缓存,多核等进行适当的指令重排序,使程序在保证业务运行的同时,充分利用CPU的执行特点,最大的发挥机器的性能!
我们来看一组代码示例:
package com.yhj.concurrent; /** * @Described:Happen-Before测试 * @author YHJ create at 2013-4-13 下午05:12:36 * @ClassNmae com.yhj.concurrent.HapenBefore */ public class HappenBefore { static int x,y,m,n;//测试用的信号变量 public static void main(String[] args) throws InterruptedException { int count = 10000; for(int i=0;i<count;++i){ x=y=m=n=0; //线程一 Thread one = new Thread(){ public void run() { m=1; x=n; }; }; //线程二 Thread two = new Thread(){ public void run() { n=1; y=m; }; }; //启动并等待线程执行结束 one.start(); two.start(); one.join(); two.join(); //输出结果 System.out.println("index:"+i+" {x:"+x+",y:"+y+"}"); } } }
这段代码循环1w次, 每次启动两个线程去修改x、y、m、n四个变量,能得到什么结果的呢?运行一下,很容易得到x=1,y=0;x=0,y=1两种结果,事实上根据JVM规范以及CPU的特性,我们很可能还能得到x=0,y=0或者x=1,y=1的情况。当然上端代码大家不一定能得到x=0,y=0或者x=1,y=1的结果,这是因为这段代码太简单了,以现在CPU 的运算速度,根本无需做线程切换就能将这些很快的执行完毕。x=1,y=1这种情况大家也许还能理解,当发生线程切换时,第一个线程第一行代码执行完毕,再次执行第二线程的第一行代码,就会发生x=1,y=1的结果。但x=0,y=0是否可能发生?按照现在的JVM和CPU特性,这种情况的确是存在的。由于线程的run方法里面的动作对结果是无关的,因此里面的指令可能发生指令重排序,即使是按照程序的顺序方法执行,数据从线程缓冲区刷新到主存也是需要时间的(之前有提到,原理可参考http://yhjhappy234.blog.163.com/blog/static/316328322011101723933875/,实践可通过下面方框中的测试代码验证)。假定是按照m=1,x=n,n=1,y=m执行的,显然x=0是很正常的,m=1虽然在y=m之前执行,但是线程one有可能还没来得及将m=1的数据从高速缓存(work memory)写入主存,线程two就从主存中取m的数据,所以还有可能是0,这样就发生了数据错误!尤其是在大并发和多核CPU的执行下,数据的结果就更无法确定了!
package com.yhj.jvm.memory.concurrent; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * @Described:并发常量测试 * @author YHJ create at 2013-04-17 下午08:54:24 * @FileNmae com.yhj.jvm.memory.concurrent.ConcurrentStaticTest.java */ public class ConcurrentStaticTest { public static int counter = 0;//volatile public final static int THRED_COUNT = 20; public static void plus() { counter++; } /** * @param args * @Author YHJ create at 2011-11-17 下午08:54:19 */ public static void main(String[] args) { ExecutorService executorService = Executors.newCachedThreadPool(); for(int i=0;i<THRED_COUNT;++i){ executorService.execute(new Runnable() { @Override public void run() { for(int j = 0;j<10000;++j){ plus(); } } }); } //等待所有进程结束 while(Thread.activeCount()>1){ Thread.yield(); } System.out.println(counter); } }
为了解决此类额外难题,Java存储模型引入了happens-Before发则,确保并发情况下的数据正确性!通俗的说就是如果动作B要看到动作A的执行结果(无论A/B是否在同一个线程中),那么A/B必须满足happens-before发则!
在说happens-before发则之前我们还得先看另外一个概念:在Java中还有一个概念叫JMMA(Java Memory Medel Action):Java模型动作。一个Action包含:编写读、变量写、监视器加锁、释放锁、线程启动(start)、线程等待(join)。关于锁我们后续会详细介绍。
说了这么多,那究竟什么是happens-before发则呢?完整的发则如下
(1)同一个线程中的每个Action都happens-before于出现在其后的任何一个Action。
(2)对一个监视器的解锁happens-before于每一个后续对同一个监视器的加锁。
(3)对volatile字段的写入操作happens-before于每一个后续的同一个字段的读操作。
(4)Thread.start()的调用会happens-before于启动线程里面的动作。
(5)Thread中的所有动作都happens-before于其他线程检查到此线程结束或者Thread.join()中返回或者Thread.isAlive()==false。
(6)一个线程A调用另一个另一个线程B的interrupt()都happens-before于线程A发现B被A中断(B抛出异常或者A检测到B的isInterrupted()或者interrupted())。
(7)一个对象构造函数的结束happens-before与该对象的finalizer的开始
(8)如果A动作happens-before于B动作,而B动作happens-before与C动作,那么A动作happens-before于C动作。
法则中提到了一个关键字volatile,其实我们前面讲JVM的时候也多次提到这个关键字,今天我们略微扩展一点,因为他对我们后续的CAS理解有很大帮助。
Volatile相当于synchronized的一个弱实现,他实现了synchronized的语义却没有锁机制,它确保对volatile字段的更新以可预见的形式告知其他线程。
Java存储模型不对对olatile指令的操作做重排序,保证volatile的变量都能按照指令的顺序执行。
Volatile类型的变量不会被缓存在寄存器中(寄存器中的数据只有当前线程可以访问),或者其他对CPU不可见的地方,每次都需要充主存中读取对应的数据,这保证每次对变量的修改,其他线程也是可见的,而不是仅仅修改自己线程的局部变量,在happens-before发则中,对一个volatile变量进行写操作后了,此后的任何读操作都可见该次写操作的结果。
Volatile关键字主要用于以下场景
volatile boolean condition = false; public void method() { while(!condition){ doSth(); } }
应用volatile关键字的三个发则
(1)写入变量不依赖此变量的值,或者只有一个线程修改此变量
(2)变量的状态不需要与其它变量共同参与不变约束
(3)访问变量不需要加锁
Happens-before和volatile是后面锁和原子操作的基础,那锁操作和原子操作是怎么实现的呢?请参考后续连载的章节!