volatile关键字深入浅出

volatile关键字


volatile关键字提供最轻量级的同步机制,但是想完全正确的理解该关键字你需要知道的东西有很多。比如先行并发原则、指令重排序以及最重要的JMM。下面一起来看看吧。

前言

一、看一下这段代码?

public class Test extends Thread {
    //定义一个变量来控制循环结束
	static boolean falg =true;
	
    @Override
	public void run() {
		System.out.println("该线程启动了");	
		while(falg) {
			
		   }
	  //如果上边是一个死循环,下边这句话将不会打印
		System.out.println("该线程停止了");
	}

	public static void main(String[] args) throws InterruptedException {
		Test test = new Test();
		test.start();
		//自定义的线程先启动,后修改变量的值
		Thread.sleep(100);
        falg = false;
    }
}

我们知道上述的代码是不会停止的,因为Java内存模型的规则导致这种现象的发生。

如果你一眼看不出或者对JMM不是很清楚,那么请务必看一下我写的另一篇文章JMM(Java内存模型)这对你理解volatile关键字很有帮助。

内存模型图:
在这里插入图片描述内存模型规则

1.我们所有定义的变量(除局部变量)都存放在主内存(Main Memory)
2.每个线程都有自己的工作内存,工作内存中存放需要使用的变量的主内存副本!
3.线程对所有变量的操作都必须在工作内存中进行。不能直接在主内存中操作。
4.不同线程之间的无法直接访问对方工作内存中的变量。线程间变量值的传递需要通过主内存进行传递。

现在我们应该知道为什么会出现上述情况了吧。我们定义的变量falg是存放在主内存中的,而线程使用的变量是自己工作内存中的变量(该变量是主内存中变量的一个副本)。根据上述规则当一个线程更改变量的值后,需要通过主内存来进行值的传递,但是另一个线程并没有及时获取该变量当前最新值

volatile关键字保证线程中变量的可见性:即变量在修改后将新值同步回主内存,在变量读取之前从主内存刷新变量值

在这里插入图片描述

问:volatile关键字怎么保证变量对线程的可见性?

得益于:先行发生原则

先行发生原则可以作为判断数据是否存在竞争,线程是否安全的一种手段

先行发生原则是Java内存模型中定义的两项操作之间的偏序关系。比如说操作1
先行发生于操作2,操作1的影响能被操作2所观察到(影响包括对变量的修改,发送
消息,调用方法)等等

举个例子:

操作1:
    i = 1;
操作2:
    j = i;
分析:
    假设操作1发生在操作2之前,那么我们可以根据先行发生原则来断定j的值就是1
此时在上述情况满足的情况下我们又添加一个操作3
操作3:
   i = 2;
   这时我们的j值就可能是1或者2.原因是可能操作2在操作3之前就执行了,或者
   说是先执行了操作3后执行了操作2.即操作3对变量i的影响有可能被操作2观察到,
   但是有可能没有观察到。此时操作2有可能读取到过期数据,因此是线程不安全的。

Java内存模型中已定义的8种先行发生规则

仅展示部分常见规则
1.管程锁定规则:一个unlock操作先行发生与对同一个锁的lock操作
2.volatile变量原则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
3.线程启动规则:线程的start()方法会先执行在线程的每一个动作之前
4.线程终止规则:线程中所有的操作都先行发生在此线程的终止检测。


我们在衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。

那么我们在什么时候使用该关键字呢,有两点:

问题来了:volatile关键字可以保证线程安全吗?

看一段代码:

public class Test { 
	static volatile int i = 0;
	//定义一个方法做累加
	public static void add() {
		i++;
	}	
	public static void main(String[] args) throws InterruptedException {
       //做50个循环,创建50个线程,每个线程做50次累加
		for(int i = 0 ; i < 50; i++) {
			new Thread(new Runnable() {
				@Override
				public void run() {
					for(int j = 0; j < 50;j++) {
						add();
					}
				}
			}).start();
		}
		Thread.sleep(3000); //当前线程睡眠3秒
        System.out.println(i);
    }
}
//打印结果:2498

从上述的结果中我们可以看出结果并不是我们预知的2500而是偏小了。所以我们知道volatile关键字是不能保证线程安全的。不能!不能!不能!

上述结果简单的进行分析:为什么会出现这种情况呢
首先我们要知道i++不是一个原子性操作,此时i++在我们的字节码指令中是四条指令。
拿到我们的i值将他放入操作数栈的栈顶中,此时栈顶的i值在读取的时候一定是最新的值,但是在做iadd操作的
时候,也许别的线程已经将i的值+1了,这样会导致此时的操作数栈顶的元素要小于此时i的实际大小。最后导致
整体的结果偏小。

那么我们什么时候使用该关键字呢,一般我们有两个前提:
①:运算结果并不依赖当前变量的值,或者说只有一个线程可以修改变量的值
②变量不需要与其它状态的变量共同参与不变约束

在这里插入图片描述

volatile关键字可以禁止指令重排序

什么是指令重排序?

在不改变程序运行结果的前提下(单线程)对指令的顺序进行重新排序,从而优化运行
效率的一种手段。

举个例子:

 x + 3;
 y + 4;
 x + 5;
 y + 6;
 优化后:
  x + 3;
  x + 5;
  y + 4;
  y + 6;
  这样做的目的可以减少读取变量x,y的次数

我们要知道指令重排序对与单线程来说是没有什么线程安全问题,但是在多线程前提下就会造成线程安全问题,那么volatile关键字是怎么做到禁止指令重排序呢? —> 内存屏障

内存屏障是一种屏障指令,它使cpu或者编译器在屏障之前和之后的内存指令进行
排序约束,这就表明在屏障之前的指令一定会在屏障之后的指令先执行!!!

内存屏障的四种类型

LoadLoad屏障:
抽象场景:Load1; LoadLoad; Load2
Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

StoreStore屏障:
抽象场景:Store1; StoreStore; Store2
Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见

LoadStore屏障:
抽象场景:Load1; LoadStore; Store2
在Store2被写入前,保证Load1要读取的数据被读取完毕。

StoreLoad屏障:
抽象场景:Store1; StoreLoad; Load2
在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的

那么volatile关键字是怎么做的?

1.在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
2.在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。

总结

提到volatile关键字我们要理解Java内存模型和可见性,简单了解指令重排序和内存屏障。

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值