Java并发编程(七):原子性、可见性、有序性与happens-before

一、三大特性

1.1 原子性

原子是化学反应中不可再分的基本微粒,其在化学反应中不可分割。在计算机中,它表示的是一个操作,可能包含一个或多个步骤,这些步骤要么全部执行成功要么全部执行失败,并且执行的过程中不能被其它操作打断,这类似于数据库中事务的原子性概念。

前文提到的:i = i + 1,就是一个非原子操作,它涉及到获取i,获取1,相加,赋值等4个操作,所以在多线程情况下可能会出现并发问题。

我们在前文提到的JMM八种操作中,read、load、assign、use、store、write都直接保证了原子性(当然,这里我们不考虑long和double的非原子协议),所以基本数据类型的读写是具备原子性特征的。但是,很多时候我们的操作并不是简单的基本数据的读写,比如i=i+1,我们要保证它的原子性该怎么做呢?可以通过八种操作中的lock和unlock来达到目的。但是JVM并没有把lock和unlock操作直接开放给用户使用,它提供了更高层次的字节码指令monitorenter和monitorexit来对应lock和unlock,而这两个字节码指令反映到我们的java代码中,就是我们所熟知的synchronized关键字,后面我们会详细探讨。

1.2 可见性

可见性表示的是,如果有线程更新了某一个共享变量的值,则其它线程要能够立即感知到最新的内容。如果不能保证可见性,则可能出现类似于数据库中的脏读情况。

前文介绍JMM的时候也提到了,如果要保证可见性,那么变量被一个线程修改后,需要将其修改后的最新值同步回主存,然后其它线程要读取该变量时,需要从主存刷新最新的值到本地内存,就这样通过主存实现可见性。但是将最新值同步回主存的时机是没有强制要求的,也不知道其它线程什么时候可能会去从主存刷新最新值,所以普通变量在多线程操作时是保证不了可见性的。

这时有一个比较好使的关键字:volatile。JMM对它定义了一些特殊的访问规则,它能保证修改后的最新值能立即同步到主存,同时,每次使用都从主存刷新。所以volatile能够保证多线程场景下的可见性。但是Java里的运算并不是原子操作(比如前面提到的i++会有4条字节码指令,具体到机器指令可能会有更多操作),volatile只能保证获取的值是最新的,但是后续的操作过程中,变量在主存中可能已经被修改了,操作的实际上还是为“旧”值,所以它并不能保证原子性。原理和适用场景在后面我们会详细探讨。

1.3 有序性

从字面上的意思理解,有序就是要保证代码按照既定的顺序依次执行。但是CPU(或编译器)出于性能优化的目的,在保证不会对程序运行结果产生影响的前提下,代码的执行顺序可能会和我们既定的顺序不一致。

所以对于Java程序有一句话(主要内容摘自<<深入理解Java虚拟机>>):如果在本线程观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),也就是普通的变量只能保证在方法执行过程中,所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与代码的执行顺序一致;后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”的现象。

指令重排序具体到计算机工程领域也就是乱序执行(out-of-order execution)。它是一种范式,处理器根据一个由输入数据可用性所决定的顺序执行指令,而不是按照原始数据所决定的顺序运行。CPU可以根据各电路单元的空闲状态和各指令能否提前执行的情况进行分析,然后可能将多条指令不按程序既定的顺序分开发送到对应的电路单元处理。通过这种方式,可以避免因获取下一条程序指令所引起的处理器等待。

比如:

int i=1;

int j=1;

这两行代码互相没有任何依赖关系,谁先执行还是后执行,对程序运行结果都不会有什么影响。经过指令重排后,可能 int j=1;就比int i=1;先执行了。不同的CPU架构可能支持不同的重排规则,像Load-Load、Load-Store、Store-Store、Store-Load等等。

指令重排的后果在并发的情况下有时会是严重的,比如以下代码:

boolean initialized = false;
Util util;

//初始化线程
initUtil();
initialized = true;

//其它线程
while(!initialized){
    //wait
}
useUtil();

以上代码中,由一个初始化线程执行Util的初始化操作,初始化成功之后将initialized字段设置为true,其它线程通过判断initialized参数判断Util是否初始化,来确定是否能够使用。初始化操作和设置initialized的操作如果发生了重排,就会出现Util还没有初始化,就将Initialized设置成了true,其它线程“误认为”Util已经初始化完成,进而去使用它,则会出现问题。而volatile就可以解决指令重排引发的这个问题,后面我们再详细探讨。

二、happens-before

happens-before也就是先行发生原则。它不用依赖任何关键字或工具,是Java设定的一些基本且重要的规则,通过这些规则,我们可以解决并发环境下两个操作之间是否可能存在冲突的所有问题。下面我们列出这些规则:

2.1 程序次序原则(Program Order Rule)

指在一个线程内,按照程序代码的控制流顺序(考虑到循环等结构),编写在前面的操作先行发生于后面的操作。需要注意这里注重的是单线程内。比如:

user.setName("bob");

user.getName();

在一个线程内,上述代码中的set方法要先行于get方法执行。

2.2 管程锁定原则(Monitor Lock Rule)

对于同一个锁,一个unlock操作要先行于下一次的lock操作。

2.3 volatile 原则(Volatile Variable Rule)

对一个volatile修饰变量的写操作要先行发生于下一次的读操作。

2.4 线程启动原则(Thread Start Rule)

Thread对象的start()方法先行发生于此线程的每一个操作。

2.5 线程终止原则(Thread Termination Rule)

线程中的所有操作都先行发生于对该线程的终止检测(比如Thread.join()方法结束。

2.6 线程中断原则(Thread Interruption Rule)

对线程interrupt方法的调用先行发生于被中断线程的代码检测到中断事件的发生。

注:Java的中断是一种协作机制,中断操作并不能直接中断一个线程,仅仅只是标识该线程接收到一个中断请求,至于实际什么时候中断,要中断线程自己检测处理。

2.7 对象终结原则(Finalize Rule)

一个对象的初始化完成先行发生于其finalize()方法的开始。

注:如果对象覆盖了finalize()方法,那么在GC的第一次标记后,会放入一个叫做F-Queue的队列中,由一个Finalizer线程触发该方法的调用。

2.8 传递性(Transitivity)

如果A先行发生于B,B先行发生于C,那么A先行发生于C。

在Java中,无需任何同步手段就能保证这些规则。如果要判断对一个共享变量的操作是否为线程安全,可以直接套用上述系列规则,如果不适用于任何规则,那么就需要一些同步手段才能保证线程安全性。另外,时间上的先后顺序和先行发生原则之间没有直接的联系,我们在判断并发安全问题的时候不能受到时间顺序的干扰(这里的时间先后顺序指的就是行为发生的顺序)。后面具体总结同步手段的时候会详细介绍。

三、总结

这篇博文只是简要描述了一些简单的、概念性的东西,但这些又是非常重要的,它们贯穿整个Java并发编程。在真正开始研究Java并发编程之前,应该要对它们有一个感性的认识。

参考:<<深入理解Java虚拟机>>

注:本文是博主的个人理解,如果有错误的地方,希望大家不吝指出,谢谢

你可能感兴趣的:(JAVA,并发编程,Java并发编程)