volatile关键字详解(从缓存一致性谈起)

在讲解volatile关键字之前,我们先来看看操作系统中缓存一致性的概念。

众所周知,cpu的运行速度是远高于主存的读写速度的,在运行过程中,为了交换数据,cpu必须频繁的进行数据的读写操作,主存读写速度慢造成了cpu运行的吞吐量减少。为了解决这一问题,现在的机器都会在添加一层高速缓存(其实不止一层,有多层).以后每次cpu进行读写操作时,都直接和高速缓存交互,之后在将高速缓存中的数据回刷到主存中。在单线程情况下这没有任何问题,但在多线程环境下这会带来一些隐患。因为这会造成每个线程更改了自己高速缓存中的数据时(即使将这个更改的数据从缓存中刷回主存),其他变量读取的仍是最开始的自己高速缓存中的数据,这就造成了数据的不可见性。为了预防数据不可见性,在硬件方面有一个缓存一致性协议协议。其中最出名的MESI协议。

缓存一致性协议(MESI):

    当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时, 发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读。

在jvm中,所有变量都存在主存当中,每个线程都有自己的工作内存(也就是前面说的高速cache)。也就是说,当访问一个共享变量时,多个java线程在自己的工作内存中都会有这个共享变量的一份副本。当某个线程更改了自己工作内存中更新了数据时,此时这个线程阻塞了或者其他原因,没有及时将这个更新的数据刷回主存,那么其他线程再从主存或自己的工作缓存中读取的数据还是原来旧值,也就是说,该数据的新值对其他线程来说是不可见的。为了防止共享变变量的不可见性,java提供了volatile关键字来保证。

当一个变量被volatile修饰时,其实就具备一下两层含义:
     1.当某个线程对该变量进行修改后,会立即将修改后的新值刷回主存,保证主存中永远都是最新的数据
     2.对该比变量施加了缓存行一致协议。也就是说,当前线程对该变量进行修改后,系统会通知其他线程它们工作缓存中数据已经无效,那么其他线程要再次读取该变量时,就
        会重新从主存中读取该变量,然后复制一份在它的工作缓存中。

首先我们来看一个没有被volatile修饰的变量的多线程例子:

package thread.volatile_learn;

public class no_volatile {
	private boolean flag = false;  //标志某个资源是否空闲
	public void doSomethind(){
		while(!flag){
			System.out.println("the resource is free ,let us do something");
		}
		if(flag){
			System.out.println("the resource is busy ,let us stop!");
		}
	}
	public static void main(String[] args) throws Exception {
		final no_volatile sharedObject = new no_volatile();
		
		new Thread(){
			public void run() {
				sharedObject.doSomethind();
			};
		}.start();
		
		Thread.sleep(3000);
		new Thread(){
			public void run() {
				sharedObject.flag=true;
			};
		}.start();
	}
}

这是一段用来讲解并发编程中的经典代码,第一个线程(A线程)在执行开始执行,由于falg=false,因此线程中的while循环会一直进行,A执行一段时间后,第二个线程(B线程)开始执行,并将flag设置为true,这时A线程会继续执行循环体,还是显示"thre resource is busy , let us stop"呢。大部分情况下线程A会立即结束,因为前面我们说过,现在的jvm其实已经实现了缓存一致性协议,也就是说当线程B修改了flag共享变量后,系统会尽可能将线程工作内存中的变量回刷到主存中。但这只是尽量,如果此时这个线程转去做其他事情,还没来得及讲工作内存中的变量回刷到主存,那么虽然此时线程A中的工作内存的flag缓存已经失效,重新从主存读取的,其实还是原来的那个值。这就造成了线程A会一直执行循环体。(其实这段代码进过我多次测试,想过在B线程设置flag之后,让他阻塞,但最终结果还是A线程会退出,并不会一直执行循环体。这说明在我测试的这些例子中,其实每次B线程都将修改的值从工作内存中及时的回刷到主存中了。从这点也可以看出,现在的JVM其实已经对数据可见性问题本身提供了很好的支持)。

如果我们使用volatile来修饰flag,那么每次进行修改后,线程都会立即将工作内存中修改了的变量回刷到主存中,这样,其他线程读取到的永远是最新的值。这就保证了数据的可见性。

但保证了数据的可见性,volatile可以保证对被修饰的数据的操作是原子性的吗?我们先来看下面这一段代码:


public class volatile_learn {
	private volatile int inc = 0;  //volatile保证修改一个共享变量时,立即将更改后的共享变量从工作缓存刷回主存。
	public void increase(){
		this.inc++;
	}
	public static void main(String[] args) {
		final volatile_learn sharedObject = new volatile_learn();
		for(int i=0;i<10;i++){
			new Thread(){
				public void run() {
					for(int j=0;j<100;j++){
						sharedObject.increase();
					}
				};
			}.start();
			
		}
		while(Thread.activeCount()>1){
			Thread.yield();
		}
		System.out.println(sharedObject.inc);
	}
}
按照之前我们说的,每次修改volatile修饰的变量后,其他线程都能‘看见’本次修改,那么所有的线程读取到的值就是最新值,那么执行这段代码,结果应该是1000. 然而答案并非如此,在我测试的10次中,只有两次是1000,其他都是800~1000之间。其实这是因为自增操作的非原子性有关。其实,自增操作看起来很简单一步,其实一共执行了三步:

1.读取变量的初始值(如果是第一次,还要将该变量copy一份然后放入工作内存作为高速cache使用)

2.cpu进行加1操作

3.cpu将修改后的值写入工作内存。

假如现在线程A读取了inc(假设此时值为10)的值,此时线程A阻塞,线程B开始抢占cpu资源,继续读取主存中Inc的值,并copy一份放入自己的工作内存中,然后进行加1操作,写入工作内存后立即(如果不用volatile,很难保证系统什么时候会回刷主存)回刷到主存中(此时volatile值为11)。此时A线程重新进入可运行状态并获得cpu资源开始运行,由于B线程已经修改了Inc的值,所以此时A线程的工作内存中的缓存已经失效,但由于A线程在阻塞前已经执行了inc的读取操作,所以线A继续执行inc+1操作,此时执行完自增操作,写入工作内存Inc的值是11,最后回刷到主存中。因此此时主存中的最终值是11,而不是12.  正是因为这样,虽然使用volatile修饰,最终运行结果仍然不是我们所期待的。究其原因,还是因为volatile只保证了数据的可见性,并不能保证对被修饰的变量的操作的原子性。

如何修改上面的代码使我们得到1000呢,很简单,在自增的方法前面加上synchronized修饰就行,这时该方法的执行就是一个原子操作。




猜你喜欢

转载自blog.csdn.net/summermangozz/article/details/75098773