接着 Java内存模型与线程(1),我们继续
关键字volatile
可以说是Java虚拟机提供的 最轻量级的同步机制
当一个变量被定义成volatile
之后,它将具备两种特性,第一是保证此变量 对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,变量值在线程间传递均需要通过主内存来完成,如:线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量的值才会对线程B可见。
但是不能说,基于volatile变量的运算在并发下是安全的,由于Java里面的运算并发原子操作,导致volatile变量的运算在并发下一样是不安全的。
/*
演示 volatile 变量在并发下也是不安全的
*/
public class Test {public static volatile int race =0;public static void increase(){race++;}private static final int THREADS_COUNT = 20;public static void main(String[] args) {Thread[] threads = new Thread[THREADS_COUNT];for (int i = 0; i < threads.length; i++) {threads[i] = new Thread(() -> {for (int i1 = 0; i1 < 10000; i1++) {increase();}});threads[i].start();}while (Thread.activeCount()>1)Thread.yield();System.out.println(race);}
}
/*
每次输出都不一样,本机上大致在40000~60000左右
*/
如果以上代码能够正确并发的话,最后输出的结果应该是200000,但是我们发现每次输出的结果都不一样,并且是一个小于200000的数字,这是为什么呢?我们反编译这段代码看看,发现只有一行代码的increase 方法在Class文件中是由4条字节码指令(return不是)。从字节码层面上很容易就分析出并发失败的原因了:当getstatic指令
把race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的,但是在执行iconst_1
、iadd
这些指令的时候,其他线程可能已经把race的值加大了,而在操作栈顶的值就变成了过期的数据,所以putstatic指令
执行后就可能把较小的race值同步回主内存之中。
public static void increase();descriptor: ()Vflags: (0x0009) ACC_PUBLIC, ACC_STATICCode:stack=2, locals=0, args_size=00: getstatic #2 // Field race:I3: iconst_14: iadd5: putstatic #2 // Field race:I8: returnLineNumberTable:line 24: 0line 25: 8
实事求是地说,我们在这里使用字节码来分析并发问题,仍然是不严谨的,因为 即使编译出来只有一条字节码指令,也并不意味着执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器将要运行许多行代码才能实现它的语义,如果是编译执行,一条字节码指令也可能转化成若干条本地机器码指令,此处使用-XX:+PrintAssembly
参数输出反汇编来分析会更加严谨一些,但是考虑到我们阅读的方便,并且字节码已经能说明问题,所以此处使用字节码来分析。
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证 原子性。
而在像如下的代码所示的这类场景就很适合使用volatile变量来控制并发,当shutdown()方法被调用时,能保证所有线程中执行的doWork方法都立即停下来。
volatile boolean shutdownRequested;
public void shutdown(){shutdownRequested=true;
}
public void doWork(){while(!shutdownRequested){// do stuff}
}
使用volatile变量的第二个语义是 禁止指令重排序优化(有序性),普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是Java内存模型中描述的所谓的“线程内表现为串行的语义”Within-Thread As-If-Serial Semantics)。
上面的解释还是比较拗口,我们还是用一段伪代码来解释下
Map configOptions;
char [] configText;
// 此变量必须定义为volati1e
volatile boolean initialized = false;
// 假设以下代码在线程A中执行模拟读取配置信息,当读取完成后将initialized设置为true来通知其他线程配置可用
configoptions = new HashMap ();
configText = readConfigFile(fileName);
processConfigOptions(configText,configoptions);
initialized = true;
// 假设以下代码在线程B中执行等待initialized为true,代表线程A已经把配置信息初始化完成
while (!initialized){sleep();
}
// 使用线程A中初始化好的配置信息
doSomethingWithConfig();
上述描述的场景十分常见,只是我们在处理配置文件时一般不会出现并发而已。如果定义initialized变量时没有使用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A中最后一句的代码“initialized=true”被提前执行,这样在线程B中使用配置信息的代码就可能出现错误,而volatile关键字则可以避免此类情况的发生。
volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插人许多 内存屏障(Memory Barrier或Memory Fence) 指令 来保证处理器不发生乱序执行。不过即便如此,大多数场景下volatile的总开销仍然要比锁来得低,我们在volatile与锁中选择的唯一判断依据仅仅是volatile的语义能否满足使用场景的需求。
本节的最后,我们再回头来看看Java内存模型中对volatile变量定义的特殊规则。假定T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下的规则:
Java内存模型要求lock、unlock、read、load、assign、use、store和write这八个操作都具有原子性,但是对于64位的数据类型**(long和double)**,在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这四个操作的原子性,这点就是所谓的long和double的非原子性协定Nonatomic Treatment of double and long Variables)。
如果有多个进程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。
不过这种读取到“半个变量”的情况非常罕见,因为Java内存模型虽然允许虚拟机不把long和double变量的读写实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这样实现。在实际开发中,目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要将用到的long和double变量专门声明为volatile。
介绍完Jva内存模型的相关操作和规则,我们再整体回顾一下这个 JMM模型 的特征。Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,我们逐个来看一下哪些操作实现了这三个特性。
原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为基本数据类型的访问读写是具备原子性的(long和double的非原子性协定例外,笔者的观点是知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。
可见性(Visibility):可见性就是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。上文在讲解volatile变量的时候我们已详细讨论过这一点。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
除了volatile之外,Java还有 两个关键字能实现可见性,它们是synchronized
和final
。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行sotre和write操作)”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值。如下所示,变量 i 与 j 都具备可见性,它们无须同步就能被其他线程正确地访问。
public static final int i;
public final int j;
static {i=0;//do something
}
{// 也可以选择在构造函数中初始化j =0;// do something
}
有序性(Ordering):Java内存模型的有序性在前面讲解volatile时也详细地i讨论过了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
Java语言提供了volatile
和synchronized
两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行1Ock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
如果Java内存模型中所有的有序性都只靠volatile和synchronized来完成,那么有一些操作将会变得很啰嗦,但是我们在编写Java并发代码的时候并没有感觉到这一点,这是因为Java语言中有一个 先行发生(happens-before) 的原则。这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的主要依据,依赖这个原则,我们可以通过几条规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题。
现在就来看看“先行发生”原则指的是什么。先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。这句话不难理解,但它意味着什么呢?我们可以举个例子来说明一下,如下所示的这三句伪代码:
//以下操作在线程A中执行
i=1;
//以下操作在线程B中执行
j=i;
//以下操作在线程C中执行
i=2;
假设线程A中的操作“i=1”先行发生于线程B的操作“j=i”,那我们就可以确定在线程B的操作执行后,变量j的值一定是等于1,得出这个结论的依据有两个,一是根据先行发生原则,“i=1”的结果可以被观察到:二是线程C登场之前,线程A操作结束之后没有其他线程会修改变量i的值。现在再来考虑线程C,我们依然保持线程A和B之间的先行发生关系,而C出现在线程A和B的操作之间,但是C与B没有先行发生关系,那j的值会是多少呢?答案是不确定!1和2都有可能,因为线程C对变量的影响可能会被线程B观察到,也可能不会,这时候线程B就存在读取到过期数据的风险,不具备多线程安全性。
下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。
start()
方法先行发生于此线程的每一个动作。Thread.join()
方法结束、Thread.isAlive()
的返回值等手段检测到线程已经终止执行。Thread.interrupted()
方法检测到是否有中断发生。finalize
方法的开始。