Java虚拟机高效并发之volatile变量的特殊规则

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/Soongp/article/details/80287589

粗糙的回忆一下上篇博文的知识点,JMM,工作内存与主内存,以及内存间的交互规则,内存交互的几种操作(lock、unlock、read、load、use、assign、store、write),其中lock、unlock、read、write作用于主内存,其余操作作用于工作内存,最后以内存间交互规则,引出并发安全,引出等效判断规则(先行发生原则,happens-before)。

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,提及volatile,许多程序员的第一理解就是“被volatile修饰的变量,在线程之间是可见的”,是的,之前我就是其中之一。

一、volatile变量特性

JMM中,当一个变量被volatile修饰后,它将具备2种特性

①保证变量对所有线程的可见性,“可见性”是指,当变量值被一个线程修改后,新值对于其他线程来说是立即可见的。在内存间交互操作层面上,对于普通变量而言,这个过程是这样的,线程的工作内存load从主内存中read的值,然后assign赋值,然后执行store操作,最终将更新的值write到主内存中。就是普通变量的值,在线程间的传递均需要通过主内存来完成,例如,线程A修改了一个变量的值,然后回写至主内存,另外一个线程B在A回写完成后,再从主内存读取,新变量值才能对线程B可见。so,为了“可见性”(立即可见),JMM对volatile变量定义了一些特殊规则,其中一条规则要求在工作内存中,每次修改变量后都必须立刻同步回主内存,用于保证其他线程可以看到自己对变量所做的修改。虽然“可见性”保证了volatile变量在线程之间的立即可见,对volatile的所有操作都能立刻反应到其他线程中,换句话说,是不是可以理解成“volatile变量在各个线程中是一致的”?如果非要较真的话,答案是NO,在各个线程的工作内存中,volatile变量也可以存在不一致的情况,,但由于每次使用变量之前都要先刷新,执行引擎看不到不一致的情况,所以认为volatile变量不存在一致性的问题。但是这不能保证“基于volatile变量的运算在并发中是安全的”,因为java中运算并非原子性的操作,导致volatile变量的运算在并发下一样是不安全的,例如:

public class Test002 {

	public static volatile int cnt = 0;
	
	public static void increase(){
		cnt++;
	}
	
	private static final int THREAD_COUNT = 20;
	
	public static void main(String[]  args){
		Thread[] threads = new Thread[THREAD_COUNT];
		
		for (int i = 0; i < THREAD_COUNT; i++) {
			threads[i] = new Thread(new Runnable() {
				@Override
				public void run() {
					for (int j = 0; j < 10000; j++) {
						increase();
					}
					
				}
			});
			threads[i].start();
		}
		
		while(Thread.activeCount() > 1){
			Thread.yield();
		}
		
		System.out.println(cnt);
	}
}

如果这段代码正确并发,20*10000 = 20000,最终的结果应为200000,但是运行会发现,每次结果都不一样,均小于200000,我们用Javap对这段代码反编译后,

public static void increase(){
	Code:
	stack=2, Locals=0, Args_size=0
	0:	getstatic
	3:	iconst_1
	4:	iadd
	5:	putstatic
	8:	return
	LineNumberTable:
	 line 14: 0
	 line 15: 8
}

发现只有一行代码的increase()方法在Class文件中是由4条字节码指令构成,从字节码层面就能分析出并发失败的原因:当getstatic指定把cnt的值取到操作栈顶时,volatile只保证了此时的值是正确的,但是在执行iconst_1、iadd这些指令时其他线程已经把cnt的值扩大了,而操作栈顶的数据就变成了过期数据,所以putstatic指令执行后就可能把较小的cnt值同步回主内存中(此处的getstatic和putstatic字节码指令会想到什么?在JVM的自动内存管理机制的内存划分中,线程私有的虚拟机栈,java方法的每次执行,在虚拟机栈中都伴随着一次入栈和出栈的操作,so,想起来了?)。由于volatile关键词只保证了变量的可见性,所以在不符合以下两条规则的场景中,我们仍需要通过加锁(synchronized或JUC包中的原子类)来保证原子性,

a.运算结果并不依赖当前值,或者能够确保只有单一的线程修改变量值。

b.变量不需要与其他的状态变量共同参与不变约束。

②使用volatile变量的第二个特性,或是说第二个语义是禁止指令重排序优化(上篇博文介绍JMM时,提及JVM的即时编译器有指令重排序优化)。普通变量仅仅保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这一点,也就是Java内存模型中描述的所谓的“线程内表现为串行语义”。

Map configOptions;
	char[] configText;
	//此变量必须定义为volatile
	volatile boolean initialized = false;
	//假设下列代码在线程A中执行
	//模拟读取配置信息,当读取完成后将initialized设置为true,通知其他线程配置可用
	configOptions = new HashMap();
	configText = readConfigFiles(fileName);
	
	processConfigOptions(configText, configOptions);
	initialized = true;
	
	//假设以下代码在线程B中执行
	//等待initialized为true,代表线程A已经把配置信息初始化完成。
	while(!initialized){
		sleep();
	}
	//使用线程A中初始化好的配置信息
	doSomethingWithConfig();

上述程序是一段伪代码,其中的描述场景很常见。如果定义的变量没有使用volatile修饰,就可能由于指令重排的优化,导致位于线程A中的最后一段代码“initialized = true”被提前执行,这样在线程B中使用的配置信息的代码可能就会出错,而volatile关键字的禁止指令重排语义可以避免此类情况的发生。

那么volatile是如何禁止指令重排序优化的?下面是一段标准的DCL单例代码,可以观察加入volatile和未加入volatile关键字所生成汇编代码的差别,

public class Singleton {

	private volatile static Singleton instance;
	
	public static Singleton getInstance(){
		if (instance == null) {
			synchronized (Singleton.class) {
				if (instance == null) {
					instance = new Singleton();
				}
			}
		}
		return instance;
	}
	
	public static void main(String[] args){
		Singelton.getInstance();
	}
	
}

编译后,这段代码对instance变量赋值部分代码如下

0x01a3de0f:	mov		$0x3375cdb0,%esi	;...beb0cd75 33
							;{oop('Singleton')}
0x01a3de14:	mov		%eax,0x150(%esi)	;...89865001 0000	
0x01a3dela:	shr		$0x9,%esi		;...clee09
0x01a3deld:     movb	$0x0,0x1104800(%esi)            ;...c680048 100100
0x01a3de24:     lock add1 $0x0,(%esp)		        ;...f0830424 00
							;*putstatic instance
							;-
Singleton::getInstance@24

通过对比机会发现,关键变化在于有volatile修饰的变量,赋值后(mov %eax,0x150(%esi)这句便是赋值操作)多执行了一个“lock add1 $0x0, (%esp)”操作,这个操作相当于一个内存屏障(Memory Barrier或 Memory Fence,指令重排时不能把后面的操作重排到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障,但如果有两个或多个CPU同时访问同一块内存时,且其中有一个在观测另一个,就需要内存屏障来保障一致性了。这句指令中的“add1 $0x0, (%esp)”(把ESP寄存器的值加0)显然是一个空操作(采用这个空操作而不是空操作指令nop是因为IA32手册规定lock前缀不允许配合nop指令使用),关键在于lock前缀,查询IA32手册,它的作用是使得本CPU的Cache写入了内存,该写入动作也会引起别的CPU或者别的内核无效化其Cache,这种操作相当于对Cache中的变量进行了一次JMM中所说的“store和write”操作。所以,通过这样一个空操作,可以让前面volatile变量的修改对其他CPU是立即可见的。

那为什么说它(lock-->内存屏障-->volatile)禁止指令重排呢?从硬件架构上来讲,指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。但并不是说指令任意重排,CPU需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。比如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时,指令1与指令2是有依赖关系的,它们之间的顺序不能重排----(A+10)*2与A*2+10显然不相等,但是指令2可以重排到指令1,2之前或者中间,只要保证CPU执行后面依赖到A、B值的操作时能获取到正确的额A和B值即可。所以在本内CPU中,重排序看起来依然是有序的。因此“lock add1 $0x0 (%esp)”空操作指令把修改同步到内存时,意味着所有之前的操作都已经执行完成(即volatile变量操作之前的所有变量的操作,包括普通变量,对其他线程来说都是立即可见的,这也解释了之前一篇博文中提到的ReentrantLock可重入锁,在重入锁代码块中,对普通变量的操作,对其他线程也是立即可见的原因,同样也遵循了happens-before原则),这样便形成了“指令重排序无法越过内存屏障的效果”(这句话挺难理解的,换句话说就是内存屏障,保证了指令重排处理有依赖关系的指令时,能得出正确的执行结果)。

深入了解了volatile的语义后,再来看看在众多保障并发安全的工具中选用volatile的意义——它能让我们的代码比使用其他同步工具更快嘛?答案不是绝对的,在某些情况下,volatile的同步机制性能(JVM中最轻量级的同步机制)确实要优于锁(使用所synchronized关键字或JUC包中的锁),但虚拟机对锁实行了多种优化(自旋,适应性自旋,锁消除,锁粗化,轻量级锁CAS,偏向锁,后续我会陆续的介绍),使得我们很难量化地认为volatile就会比synchronized快多少。如果让volatile自己与自己比较,可以确定一个规则:volatile变量读操作的性能消耗与普通变量基本没什么差别,但是写操作可能会慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不会发生乱序执行。不过即便如此,大多数场景下,volatile的总开销仍然要比锁低,我们在volatile与锁中选择的唯一依据仅仅是volatile的语义能否满足使用场景。

二、JMM对volatile变量定义的特殊(操作)规则

假定T表示一个线程,V和W分别表示两个volatile变量,那么在进行read、load、use、assign、store、write操作时,需要满足以下规则:

①只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量执行use操作;并且只有当线程T对变量V执行的后一个动作是use时,线程T才能对变量V执行load动作。线程T对变量V的use动作可以认为是和线程T对变量V的read、load工作相关联,必须连续一起出现(这条规则要求在工作内存中,每次使用use变量V之前都必须从主内存刷新read、load最新的值,用于保证能看见其他线程对变量V所做的修改后的值)。

②只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动作;并且,只有当线程T对变量执行的后一个动作是store时,线程T才能对变量A进行assign操作。线程T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联,必须连续一起出现。(这条规则要求在工作内存中,每次修改V的值后,都必须立即同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改,此规则保证了volatile可见性语义)。

③假设动作A是线程T对变量V实施的use或assign动作,假设动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的对变量V的read或write操作;类似的,假定动作B是线程T对变量W实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的对变量W的read或write动作。如果A先于B,那么P先于Q(这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同)。之前提到的指令重排的示例中,是2个依赖动作在不同的线程中去执行,需要依赖volatile禁止重排语义(读取配置信息示例),此规则中是在同一个线程中执行A——F——P、B——G——Q,其实这里的规则类似于happens-before原则其中一条:程序次序原则,在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构

ok,volatile部分的学习暂停与此,若后面学习当中,涉及到volatile时,我们则“温故”,“知新”。

猜你喜欢

转载自blog.csdn.net/Soongp/article/details/80287589