【Java】关于关键字volatile的深入理解

Volatile关键字

  volatile:adj易变的;不稳定的;用来修饰变量的。翻译里的易变是针对多线程的,多线程修改同一个变量有可能会造成某些问题,就要使用volatile关键字。其最主要的两个作用是:线程可见性与禁止指令重排。

线程可见性(禁止寄存器优化)

我们首先来看一个程序

public class Visiable implements Runnable {
	
	private static boolean flag = true;
//	private static volatile boolean flag = true;

	public static void main(String[] args) throws InterruptedException {

		new Thread(new Visiable(), "线程1").start();
		Thread.sleep(2000);
		flag = false;
	}

	@Override
	public void run() {
		while (flag) {
			
		}
		System.out.println("结束了");
	}

}

没有加volatile的情况下:该程序会一直跑while那个死循环,停不下来。

加了volatile才会出现最后的结束了的输出。

解释这种原因就要说到JMM(Java Memory Model)Java内存模型了。

主存(Memory)中有A资源,一个线程(线程1)要访问这个数据就将它移入线程本地内存,其组成就是CPU相关的缓存Cache和寄存器。另一个线程(线程2)也读了这个数据,对于线程1修改的内容根本看不见。因为对于值的修改只是相关本地寄存器的值修改,而不是真正的主存。

在这里插入图片描述

对于上面的程序。线程1将从主存中的flag的值(true)保存在自己的线程本地内存中,在这个线程中没有感到主线程对于flag值的修改,因此一直在跑死循环。

所以volatile线程可见性就是,对于volatile修饰的变量,每个线程都去主存读取相应的值,而不用自己的线程本地内存进行寄存器优化。

禁止指令重排序

  首先要讲一个概念,CPU的乱序执行。乱序执行不是说随便执行,而是最后你看起来一致性是一样的,不过执行的过程在单线程里前后的顺序调换了。

  举个例子,假如要执行两条语句,一个是读取外存一个数,一个是仅仅把一个数进行自增。我们就简单认为两件事是一条语句就能执行的(实际并不是,汇编级别中赋值都要三条语句)。读取外存肯定慢一点,一个数自增肯定较快。我们知道CPU是个急性子,运行速度十分之快,为了更快完成任务,他可能会首先把第二件快的事做完,第一件还没完成呢,但两件事并无直接联系,在单线程最后结果不论顺序是怎样都是一致的。

  乱序执行的优点:在单线程前后两条语句无直接联系情况下,提高了效率。
  乱序执行的缺点:对于多线程,顺序是很重要的,可能因为某些提前运行造成大的错误,差之毫厘,谬之千里。

引入一个专业术语as if serial(看上去像序列化),其是本质是在单线程里面,完成一项任务前后语句执行顺序可以发生变化,但不影响最终结果,看上去语句好像还是顺序执行,只不过内在是较快的先执行完了。

一个程序证明CPU乱序执行

程序如下

public class CPUOutofOrderExecute {

	private static int x = 0, y = 0;
	private static int a = 0, b = 0;
	
	public static void main(String[] args) throws InterruptedException {

		int i = 0;
		for(; ;) {
			i++;
			x = 0;
			y = 0;
			a = 0;
			b = 0;
			
			Thread thredOne = new Thread(new Runnable() {
				
				@Override
				public void run() {
					a = 1;				//1
					x = b;				//2
				}
			});
			
			Thread thredTwo = new Thread(new Runnable() {			
				@Override
				public void run() {
					b = 1;				//3
					y = a;				//4
				}
			});
			
			thredOne.start();
			thredTwo.start();
			thredOne.join();
			thredTwo.join();
			String result = "第" + i + "次 (" + x + "," + y + ")";
			if (x == 0 && y == 0) {
				System.out.println(result);
				break;
			} else {
//				System.out.println(result);
			}
		}
	}

}

注释标记的语句1:a = 1; 语句2:x = b; 语句3:b = 1; 语句四:y = a;

如果不存在乱序执行,则一定是顺序执行,也就意味着1一定在2之前执行,3一定在4之前执行。其输出结果就是多种排列组合,只要保证我们的顺序执行规则1在2前,3在4前。其可以是1234,13324,1342,3124等,那么它的x和y的值就绝对不可能是0,0。如果出现了0,0,那么就证明一定出现了乱序执行

输出结果

413130(0,0)

终于在41万次等待后,我们终于遇到了这种情况,通过坚持不懈的等待,终于让我们抓到了。这就很强有力的证明了,CPU具有乱序执行的特点。

虽然说CPU运行了这么久才进行了一次乱序执行,但只要有一次,就证明它对于多线程是存在这种问题和漏洞了,就算你CPU很厉害41万次都没错,但只要我抓住一次错,就是这万分之一的概率也不能忽视!

CPU乱序执行会产生严重后果吗

  答案是肯定会的,经典例子就是我们的单例模式DCL(Doucle check lock)。

这里我们首先给个分析字节码的例子。

class T {
	int m = 8;
}

T t = new T();

所翻译的汇编代码

0 new #2 <T>
3 dup
4 invokespecial #3 <T.<init>>
7 astore_1
8 return

当我们执行T t = new T();,会有这五条语句。
new #2 <T>这句就和C语言里的malloc函数一样,去堆内存申请了一段空间,t对象多大他就多大 。在java中空对象占八个字节,对象的引用(String成员)占四个字节,还需要是8的倍数。注意这条语句执行代表刚刚new出来,m是默认值也就是0。
invokespecial #3 <T.<init>>这句话说明调用了T的构造方法,只有调用了构造方法之后,m的值才会变成8。
astore_1这句话是把栈空间的t和堆空间的内存建立起来连接。

整体来说new一个对象分为以下步骤,先申请内存,赋值默认值,构造方法赋值初始值,最后建立连接!

DCL单例模式代码

public class DCLSingleton {

	private static volatile DCLSingleton me;
	
	private DCLSingleton() {
	}
	
	public static DCLSingleton getInstance() {
		
		if (me == null) {				//这个if为了效率更高,加volatile就是为了这里
			synchronized (DCLSingleton.class) {
				if (me == null) {
					me = new DCLSingleton();
				}
			}
		}
		
		return me;
	}
	
}


//1.new					//半初始化都是默认值
//2.invoke...<init>		//调用构造方法
//3.astore				//连接堆和栈

假设第一个线程1刚上来用DLC单例模式,只执行到汇编代码第一句new,算半初始化(相关数值还没填好),这个时候发生了指令重排序,2和3交换了,首先进行连接,是半初始化对象进行连接,假设正好执行到这里这线程1停下来了。线程2来了,第一个if (me == null),此时me已经经过连接了,它是实际指向堆空间的值不为null,直接就返回了,返回了个什么?返回了个半初始化的对象,这当然不可以,显然不是线程2想要的(相关数值没填好)。

饿汉模式不会出现这样的问题,因为饿汉是ClassLoad的时候,JVM级别保证只来一次。

volatile禁止指令重排的方法:内存屏障

  JVM保障被volatile修饰的变量(内存)不可以进行指令重排序,如何保障两条语句不可以乱挪动位置呢?答:**内存屏障。**不可重排序的语句中间加堵墙。

JSR(Java规范提案)内存屏障:

  1. LoadLoad:对于这样的语句Load1;LoadLoad;Load2,在Load2及后续的读操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕;
  2. StoreStore:对于这样的语句Store1;StoreStore;Store2,在Store2及后续的写操作执行前,保证Store1的写入操作对其他处理器可见;
  3. LoadStore:对于这样的语句Load1;LoadStore;Store2,在Store2及后续的写入操作被刷出前,保证Load1要读取的数据被读取完毕;
  4. StoreLoad:对于这样的语句Store1;StoreLoad;Load2,在Load2及后续的读操作要读取的数据被访问前,保证Store1的写入操作对其他处理器可见;

上面的规范不用死记硬背,理解一个就很容易了。例如LoadLoad,对于前一个有load操作,后一个还有load操作,加个屏障。

volatile实现细节(JVM层面)

对于volatile修饰的内存空间,如果有写操作前面加StoreStore屏障(上下Store不许换位置),下面加 StoreLoad(上Store下Load不许换位置)屏障。

对于volatile修饰的内存空间,如果有读操作前面加 LoadLoad屏障(上下Load不许换位置),下面加 LoadStore(上Load下Store不许换位置)屏障。

在这里插入图片描述

volatile的底层实现(汇编层面)

volatile底层实现汇编是一条指令lock addl,给某个寄存器加个0,就相当于空语句,但是指令nop不可以加lock
一条空语句怎么能实现volatile的两大功能呢?答案在于lock。

Lock用于在多处理器中执行指令时对共享内存的独占使用。
它的作用是能够将当前处理器对应的缓存的内容刷新到内存,并且使其他处理器对应的缓存失效。
另外还提供了有序的指令无法越过这个内存屏障的作用。

使得其他处理器缓存失效:我们知道处理器和主存之间存在多级缓存的,各级缓存速度不尽相同,当有lock指令时使得某一处理器的缓存失效,那它只能去内存去重新读取数据了,这就实现了线程可见性(禁止寄存器优化)。

有序的指令无法越过这个内存屏障:这条指令的前面指令和后面指令不可以换位置。实现了禁止指令重排序。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值