Java并发编程实战3-可见性与volatile关键字

1. 缓存一致性问题

在计算机中,每条指令都是在CPU执行的,而CPU又不具备存储数据的功能,因此数据都是存储在主存(即内存)和外存(硬盘)中。但是,主存中数据的存取速度高于外存中数据的存取速度(这也就是为什么内存条的价格会高),于是计算机就将需要的数据先读取到主存中,计算机运算完成后再将数据写入到外存中。

但是,CPU的计算能力太强了,CPU从主存中读取写入数据的速度还是太慢了,严重影响了计算机的性能。因此,CPU就有了高速缓存(cache)。也就是说,当程序再运行的时候,会将运算需要的数据从主存复制一份到CPU的高速缓存中,那么当CPU进行计算时就可以直接从它的高速缓存中读取和写入数据,当运算结束后,再将高速缓存中的数据刷新到主存中。

这样的逻辑在单线程中是没有问题,但是到了多线程中就有问题了。在多核CPU中,每个线程可能有运行在不同的CPU上,因此每个线程运行时都有自己的高速缓存;在单核CPU中,多线程是以线程调度的形式分别执行的,线程间的高速缓存也不同。在开始运算时,每个CPU都将主存中的数据复制到自己的高速缓存中,运算完成之后再将数据刷新到主存中,在这个过程中,问题就出现了。如果没有意识到问题的话,请看下面的例子。假设线程A和线程B都要执行下面的代码:

i = i + 1

假定i的初始值为0,线程A从主存中读取i的值到自己的高速缓存cache A中,此时cache A中i的值为0,CPU进行计算后,cache A中i的值变为了1。此时,线程B的执行进度是不确定:(1)如果线程B已经从主存中读取了i的初始值0到到了cache B中,CPU 计算完成之后,cache B中的i的值也变为了1,如果线程A和线程B分别将自己的缓存中的i值刷新到主存中,那么主存中的i的值最终为1;(2)如果线程B还没有从主存中读取i的值,线程A将cache A中的i刷新到主存中,那么主存中i的值为1,线程B再将主存中的i值读取到cache B中并计算并将i=2刷新到主存中,那么最终主存中i的值变为2。上述情况是假定线程A先执行的,如果线程B先执行时同样存在问题。

上面的问题上就是缓存一致性问题

于是,就出现了缓存一致性协议,最著名的就是Intel的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。由于没有对缓存一致性协议进行详细的了解,本文不再介绍,想要了解的读者请参考文章《缓存一致性(Cache Coherency)入门》。此时,多线程计算模型就如下图所示:

2. volatile关键字

对于缓存一致性问题,我们可以通过对代码块加锁的方法来解决:

synchronized(lock) {
	i += 1;
}

通过synchronized来解决缓存一致性问题的原因,《并发编程实战》中是这么写的:

当线程B执行到与线程A相同的锁监视的同步块时,A在同步块之中或之前所做的每件事,对B都是可见的。没有同步,就没有这样的保证。

本文的理解是:相同的锁监视的同步块,每一时刻只能保证有一个线程获得锁并进入,其它的线程就等待或阻塞直到获得获得锁。相当于,同步块中的代码是串行执行的,当然就不会出现缓存一致性的问题了。

注意,无论是书中写的还是本文理解的,都在强调相同的锁监视器,也就是说,不同线程中的synchronized使用的锁要是同一个。

至此,正如《并发编程实战》中写的:

锁不仅仅是关于同步与互斥的,也是关于内存可见的。为了保证所有线程都能够看到共享的、可变变量的最新值,读取和写入线程必须使用公共的锁进行同步。

至此,synchronized的作用:

  1. 复合操作的原子化和互斥;
  2. 内存可见性。

除了加锁之外,Java也提供另外一种保存内存可见性的方法:volatile变量。一旦一个共享变量被volatile修饰之后,那么久具备两层语义:

  1. 保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这个新值对于其它线程来说是立即可见的;
  2. 禁止进行指令重排。

下面通过一段代码来描述volatile关键字的作用:

public class NoVisibility {
	private static boolean ready;

	private static class ReaderThread extends Thread {
		public void run() {
			while(!ready){
				Thread.yield();
			}
		}
	}

	public static void main(String[] args){
		new ReaderThread().start();
		ready = true;
		//when ready=true, do something
	}
}

上面的这段代码是常见的采用标记中断线程的方法,然而这段代码却不一定能正常的执行。如同上文所讲,每个线程都有自己的cache,主线程main和子线程ReaderThread都有自己的缓存,开始执行之后,子线程ReaderThread会使用自己缓存中的ready值进行判断,而在主线程main中,在设置ready=ture之后,还要做其它的事情,只是将ready=true写到了自己的cache中,并没有将ready的值刷新到主存中,子线程ReaderThread也就不会停止。

但是,当用volatile修饰ready之后就变得不同了:

  1. 使用volatile关键字之会强制将修改的值立即刷新到主存中;
  2. 使用volatile关键字之后,当main线程进行修改时,会导致子线程ReaderThread的cache中的ready的值无效;
  3. 由于子线程ReaderThread缓存中的ready的值无效,所以再次读取ready值时回到主存中读取。

这样,就保证了main线程及其子线程的正常执行。

3. volatile与synchronized的区别:

加锁可以保证原子性和可见性; volatile变量只能保证可见性。

也就是说,volatile变量的操作不会加锁,不会使得操作对象为volatile变量的复合操作原子化,也不会引起执行线程的阻塞,相对于synchronized而言,只是轻量级的同步机制。

4. volatile的禁止指令重排

volatile关键字禁止指令重排有两层含义:

  1. 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定已经全部进行,且结果对后面的操作可见;在其后面的操作肯定还没有进行;
  2. 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

下面通过代码来理解:

//x、y is not volatile variable
//flag is volatile variable

x = 1; //line 1
y = 2; //line 2
flag = true; //line 3
x = 3; //line 4
y = 4; //line 5

由于flag是volatile变量,x、y不是volatile变量,在进行指令重排的时候,不会将第3行的语句放到第1行或第2行语句的前面,也不会放到第4行或第5行的后面,第1行和第2行的语句可以重排,但是一定始终在第3行的语句的前面,第4行和第5行的语句也可以重排,但是也一定在第3行语句的后面。

虽然,这个小的程序中,并没有大的作用,但是当第1、2、4、5行的语句是一些重要且复杂的语句时,效果就明显了:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
 
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

在这个程序中,如果inited变量不是volatile变量,那么语句1和语句2可以重排,就有可能导致语句2先执行,那么导致语句1中的真正执行初始化的操作并没有执行,线程2却已经认为已经初始化了,开始执行操作,就会导致程序错误。

5. volatile的原理和实现机制

volatile是如何保证可见性和禁止指令重排呢,《深入理解Java虚拟机》中有如下解释:

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,家兔volatile关键字时,会多出一个lock前缀指令

lock前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供3个功能:

  1. 它确保执行重排时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令重排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 它会强制将对缓存的修改操作立即写入主存;
  3. 如果是写操作,它会导致其它CPU中对应的缓存行无效。

6. volatile使用的场景

synchronized关键字可以保证原子性和可见性,但是影响执行效率;volatile可以保证变量的可见性,但是不能保证原子性,在某些情况下性能优于synchronized。因此,volatile不能替代synchronized,volatile使用时,应该满足下面2个条件:

  1. 写入变量时不依赖变量的当前值;
  2. 变量不需要与其它的状态变量共同参与不变约束。

本文的理解是,第一个条件是说,使用这个volatile变量仅仅是提供数值给其它的语句用,本身的修改依赖于自己的当前值;第二个条件是说,volatile变量不能与其它的变量一起参与运算作为某种约束,也就是说此时这个约束仍然不是原子的。

volatile适用的场景:

1. 状态标记位

volatile boolean inited = false;
//线程1:
context = loadContext();  
inited = true;            
 
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

2. double check

class Singleton {
	private volatile static Singleton instance = null;

	private Singleton() {

	}

	public static Singleton getInstance() {
		if(null == instance){
			synchronized (Singleton.class) {
				if(null == instance){
					instance = new Singleton();
				}
			}
		}
		return instance;
	}
}

参考资料

Java并发编程:volatile关键字解析

缓存一致性(Cache Coherency)入门

致谢

本文主要是在参考文献Java并发编程:volatile关键字解析,以及《并发编程实战》的相关章节的基础上,加上作者的理解写的,非常感谢前辈们的无私奉献!

posted @ 2017-05-26 14:41  Acode  阅读(340)  评论(0编辑  收藏  举报
您是本站第访问量位访问者!