Java volatile作用以及为何不能保证原子性

1.前言

volatile在多线程编程中是一个十分重要的关键字,volatile被称为轻量级的synchronized,它保证了数据的可见性,同时其执行成本较synchronized更低。

2.保证可见性

多线程环境中,每个线程都有自己的工作空间,某个线程对数据修改后,该数据不一定能立刻在其他线程中更新(不可见)。

例如如下代码:

public class VolatitleTest {

	public static void main(String[] args) throws InterruptedException {
		Test test = new Test();
		new Thread(test).start();
		Thread.sleep(1000);
		Test.a = 1;
	}
}

class Test implements Runnable{	
	static int a = 0;
	@Override
	public void run() {
		while(a == 0) {}
	}
}

在 1s 后,main 线程中修改 a 的值为 1,test 中的循环理应结束,然而事实上程序在 1s 后仍然在运行。这便是由于 a 的不可见性造成的:cpu 在执行while 循环时十分繁忙,并不能及时从主内存中获取到最新的 a 的值,才导致while 循环继续进行。

若在 Test 中的变量 a 前使用 volatile 修饰, 程序会在 1s 后正常结束。

	static volatile int a = 0;

原因:

在多核处理的环境下,在对volatile修饰的变量进行写操作时,会发生两件事情:

  • 将当前处理器缓存中的变量结果回写到主内存中。
  • 使其他处理器缓存中的该变量的数据失效。

如此一来,在 main 线程中修改 a 的值后,主内存中的 a 会立即更新。test 中的 a 将会失效,在下一次访问时会从主内存中获取新值。这样的机制保证了某个线程修改数据后对于其他线程是可见的。

3.防止指令重排

指令重排: 你写的代码执行的顺序不一定是你写的顺序,为了提高代码执行的效率,JMM 可能会修改你程序执行顺序。

单线程中,为了保证程序执行结果不变,具有数据依赖性的语句,并不会被重排,数据依赖分为三种:

  • 写后读
	a = 1;
	b = a;
  • 写后写
	a = 1;
	a = 2;
  • 读后写
	a = b;
	b = 1;

以上三种情况,重排后会改变程序执行的结果,因此不会被重排。

指令重排的具体规则可参考 happens-before 规则,本文不做阐述。

volatile 禁止指令重排的一个经典应用场景是 dcl 单例模式。

class Test{	
	private static Test instance;
	
	private Test() {}
		
	public Test getInstance() {
		//instance被创建后,没必要再加锁独占资源,直接返回即可
		if(instance != null){
			return instane;
		}
		synchronized(Test.class){
			if(instance == null) {
				instance = new Test();
			}
			return instance;
		}
	}
}

new Test() 的过程并不是一步完成的,其分为三步:

  1. 开辟内存
  2. 初始化
  3. 将地址返回

其中开辟内存与初始化进行较慢,没有必要等待其执行完成后再执行下一条语句,cpu资源未被占满时,是可以同时执行之后的语句的,但由于1、2步的执行效率较慢,第3步可能先执行完毕。

在单线程中,getInstance() 函数会等待1、2步执行完毕后再返回 instance。但是在多线程环境下,可能会出现一些意想不到的错误。

例如,A 线程调用 getInstance(),抢占到锁,发现 instance 为 null,开始执行 new Test(),但由于上述原因,第三步可能先执行完,instance 指向开辟的内存,此时,B 线程也调用 getInstance,由于双重校验锁的缘故,并不会等待 A 线程释放锁,而是直接拿到 instance,然而,这时 instance 仍是未初始化完成的,此时若在 B 线程中操作 instance,就会造成意想不到的错误。

解决方法: 只需给 instace 加上 volaitile修饰即可。

	private volatile static Test instance;

volatile 可以防止 new 时发生指令重排,使步骤 3 等待1、2执行完成后再执行,保证安全性。

4.不保证原子性

对于某些复合的语句 volatile 并不能保证其原子性。

听着有些晦涩,直接看代码:

public class VolatitleTest {

	public static void main(String[] args) {
		Test test = new Test();
		for(int i = 0; i < 1000; ++i) {
			new Thread(test).start();
		}
	}
}

class Test implements Runnable{
	
	public volatile static int a = 0;
	@Override
	public void run() {
		System.out.println(Thread.currentThread() + " " + (a++));
	}
}

如果上述程序是线程安全的,打印的最大 a 的值应为 999。

本地执行程序某次打印结果为:
在这里插入图片描述
其中有两组重复的数字,按理说,volatile 保证了数据修改后其他线程可见,为何会造成此线程不安全的问题呢。别急,我们来看看 a++ 的具体操作。

  1. 拿到 a
  2. 计算 a + 1
  3. 将 a + 1 的结果赋值给 a

假设现在 a = 1,有线程 A线程 B 同时执行 a++,线程 A 计算完 a + 1 后,线程 B 三步都执行完毕,此时线程 B 的缓存中 a 的值改变,立刻将主内存中 a 的值更新为 2,并使线程 A 中的 a (1) 失效,但是,线程 A此时 a + 1 已经执行完毕,线程 A 执行第 3 步时,直接将原本的计算结果 2 赋值给 a。于是,执行两次 a++ 后,a 的值实际只增加了 1。只有在线程 A 执行第 2 步之前更新 a 的值才不会出错。所以,该程序并非线程安全的,加上类锁使线程独占资源才能规避该问题。

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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值