【Java并发】Volatile超详细全解!看完让你像了解自己一样了解它

转载请注明出处: https://blog.csdn.net/Fury97/article/details/81462888


其实写这篇文章之前犹豫了很久要不要写,感觉Volatile这个东西吧很熟悉,概念也基本都了解,但是有时候还是有点稀里糊涂。

面试时问的如果比较刁钻,我还是没办法很好的回答出来,说明还是没有深入理解它。

我决定在这里,好好的将Volatile这个小朋友总结一下。


目录

Volatile介绍

并发编程三个概念

原子性

可见性

volatile解决可见性

有序性

Volatile实现“可见性”的原理

Volatile实现禁止指令重排序原理

内存屏障

保守的内存屏障插入策略

详细分析


Volatile介绍

volatile在并发编程中很常见,相信大家都对Volatile有一定了解...这里我就不讲他的使用了,我们主要进一步分析volatile关键字的内存语义。Volatile关键字有如下两个作用主要作用:

  • 保证被volatile修饰的共享变量对所有线程总是可见的(可见性),也就是当一个线程修改了一个被volatile修饰的变量,新值总是可以被其他线程立即得知。

  • 禁止指令重排序优化。


 

这里先介绍下并发编程的三个概念:原子性、可见性、有序性。了解的朋友可以直接跳过此处直接看实现原理:

Volatile实现“可见性”的原理

 

并发编程三个概念

并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

  1. 原子性:是指一个操作是不可中断的,要么全部执行成功要么全部执行失败。
  2. 可见性:就是指当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。
  3. 有序性:Java内存模型中的程序天然有序性可以总结为一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存中主内存同步延迟”现象。

原子性

public class Test {
    public static volatile int i =0;
 
    public static void increase(){
        i++;
    }
}

上述代码中我们可以看到increase()方法中有一个i++的自增操作,这个操作不同于简单的赋值,i++操作应该分为三步

  • 获取i的值
  • i+1
  • 将新值写回内存

那么如果线程A刚获取了i的值,正在做+1操作时,线程B修改了i的值,那么就算线程B立即将i的值刷新至内存,线程A也无法得知,这样就会导致线程不安全。

所以volatile是无法保证“原子性”

 

可见性

先来了解一下可见性的定义:

由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题

了解了定义后,我们来看一个例子,了解volatile是如何解决可见性的。

public class Test {
	static int a = 0;
	int b = 0;
	
	public void write() {
	        a=1;
//此时另一个线程对共享变量a访问
                b=3;
	}

    public void read(){
            system.out.println(a);
    }

}

上述代码中我们可以看到,write()方法中有两个操作:a=1和b=3。  

如果在a=1执行完后,线程B访问共享变量a,这个时候a的值已经改变为1了,但是由于执行的线程并不会每次操作都更新回共享内存,导致这个时候共享内存中的a还是0,线程B访问的a的值就是错误的。

volatile解决可见性

public class Test {
	volatile static int a = 0;
	int b = 0;
	
	public void write() {
	        a=1;
//此时另一个线程对共享变量a访问
                b=3;
	}

    public void read(){
            system.out.println(a);
    }

}

volatile对于可见性有两个解决方案:

  • volatile变量被写后强制从缓存写回共享内存。
  • 这个写回内存的操作会使其他线程中缓存了该内存地址的数据无效,需要重新从内存读取。

这样,a=1操作后,会立即更新到共享内存,其他线程再读取的就是最新的值了,此时,程序具有“可见性”

有序性

public class Test {
	static int a = 0;
	int b = 0;
	
	public void write() {
	        a=1;
                b=3;
	}

    public void read(){
            system.out.println(a);
    }

}

这里我们可以看到write()方法运行时进行了两个赋值操作,单线程环境下,对于程序员而言,看到的结果就是a=1执行然后b=3执行。

但是其实并不一定会这么执行,有可能被编译器重排序或者cpu重排序,执行的顺序变成b=3 先执行,a=1后执行。

重排序虽然对单线程环境下没有任何影响,但是在多线程环境中就不一样了,我们看下面例子:

public class Test {
	static int a = 0;
	int b = 0;
	
	public void write() {
	        a=1;
//此时另一个线程访问a的值
                b=3;
	}
public void read(){
            system.out.println(a);
    }

}

假设线程A执行write()方法执行到a=1与b=3之间时,线程B执行read()方法访问a的值,那么按照程序本身的语义,应该访问到的值是a=1。

可是由于有可能存在指令重排序,导致b=3先执行了,a=1还没执行,这样的话线程B访问到的值就是a=0了,破坏了原本的语义。

此时程序不具有“有序性”

但是如果加上volatile关键字:

public class Test {
	volatile static int a = 0;
	int b = 0;
	
	public void write() {
	        a=1;
//此时另一个线程访问a的值
                b=3;
	}
public void read(){
            system.out.println(a);
    }

}

这样,a就被volatile修饰了,volatile会禁止指令重排序,这样不管什么时候线程B访问a的值,一定都是当前最新的值。

此时,程序具有“有序性”。

 


我们花了不少篇幅讲解了原子性、可见性和有序性。现在,我们深入分析一下volatile的实现原理。


 

Volatile实现“可见性”的原理

Java代码在编译后会变成字节码,字节码被类加载器加载到Jvm里,Jvm执行字节码,最终需要转化为汇编指令在CPU上执行。

那么我们来看一下在对volatile变量进行写操作时,CPU会做什么事情。

Java代码:

instance = new Singleton();   //instance是volatile变量

转换为汇编代码:

0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24:lock addl $0x0,(%esp);

volatile变量在进行写操作时会多出第二行汇编代码,这个lock前缀的指令在多核处理器下引发的两件事情:

  • 将当前处理器的缓存行的数据写回到系统内存。
  • 这个写回内存的操作会使其他CPU中缓存了该内存地址的数据无效。

为了提高处理速度,处理器不直接和内存通信,而是先将系统内存的数据读入内部缓存后再进行操作,但是不一定会立即写回内存。如果对volatile变量进行写操作,Jvm就会向处理器发送一条lock前缀的指令,将这个变量所在的缓存行的数据写回系统内存。但是就算写回内存,其他的处理器缓存行中的数据还是旧数据,所以在多处理器下,处理器会采用嗅探技术来检查总线上传播的数据是不是和自己缓存中的数据一致,如果不一致则说明自己的数据过期了,那么处理器就会将此数据置为无效,当操作此数据时,需要重新从内存中读取最新数据。


Volatile实现禁止指令重排序原理

上文已经提到过,volatile通过“禁止指令重排序”的手段来实现有序性,那么我们现在分析一下volatile是如何禁止指令重排序的。

JMM通过插入内存屏障(Memory Barrier)来限制特定类型的“处理器重排序”。

 

内存屏障

内存屏障分为四种

  • StoreStore
  • StoreLoad
  • LoadStore
  • LoadLoad

这四个内存屏障分别可以理解为:

  • 写写屏障
  • 写读屏障
  • 读写屏障
  • 读读屏障

现在不理解没关系,请继续往下看。

 

保守的内存屏障插入策略

并不是所有地方都需要插入内存屏障来禁止重排序保证volatile语义,有些不影响语义的重排序是可以允许的,但是:

为了保证可以在任意处理器平台,任意程序中都能正确的得到volatile的语义,JMM采用保守策略来插入内存屏障。

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的前面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

为了解释每种内存屏障,我们来看一段代码:

public class Test {
	int a;
	volatile int v1 = 1;
	volatile int v2 = 2;

	void readAndWrite() {
		int i = v1; //第一个volatile读
		int j = v2; //第二个volatile读
		a = i + j;  //普通写
		v1 = i + i; //第一个volatile写
		v2 = j * 2; //第二个volatile写
	}

}

我们看一个详细介绍的图,图片来自《Java并发编程的艺术》。

上图充分的表现了所有的内存屏障的插入情况,可以看出“保守的插入策略”基本上能完全保证不会出现任何影响语义的重排序。

如果看不懂,没关系,我再做下详细的解释。

详细分析

每一个volatile操作,都会在此操作的上下各插入一条内存屏障来保证不被重排序(如果volatile是第一个操作则不用在前面插入屏障),但是volatile写如果是最后一个操作则必须插入一条屏障,因为编译器无法准确断定之后是否还有volatile读或者写,为了安全起见,会在后面插入一个StoreLoad屏障。

Store的意思是写,Load的意思是读,如果有一条volatile读指令,那么他上下的屏障一定是LoadStore/Load,volatile写指令上下的屏障一定是StoreLoad/Store

如果有两个连续的volatile操作,那么意味着他们之间会被插入两条屏障,此时,在保证正确语义的前提下,处理器会自动判断来删除一条屏障。


至此,volatile已全部分析完毕,不知道你是否已了解了它?

 

转载请注明出处: https://blog.csdn.net/Fury97/article/details/81462888

参考资料:《Java并发编程的艺术》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值