面试常考volatile全方位讲解

3 篇文章 0 订阅
2 篇文章 0 订阅

volatile是一个与多线程访问时常用的类,相比于sychronized和lock,volatile的优势是它更加的轻量级,不会引起线程上下文的调度和切换,但volatile的同步性较差,在某些使用时也容易出错。本片文章我将主要volatile的面试常用知识点。

volatile的作用

1.保证了被修饰变量的内存可见性。
2.禁止指令重排序。

内存可见性和重排序的概念

说到这两个东西,就必须先来聊一聊java内存模型JMM。JMM是一种抽象模型,用来使java在各种操作系统和硬件的都能有一致的访问效果。我们首先要知道,在CPU中执行指令是很快的,而在CPU里又拥有着好几层高速缓存,这就使得CPU的处理速度比起内存访问来说快了许多,相差的不是一个数量级。
JMM模型就是对上诉优化的进行了一波抽象。JMM规定虽有的共享变量都存在主存中,相当于上面的内存,而每个线程都有着自己的工作内存,相当于上面的CPU和高速缓存。所有类存都只能在自己的工作内存中处理数据,然后再返回主存,具体流程图如下在这里插入图片描述
在线程执行的过程中,我们首先会从主存中读取变量的值到工作内存的副本中,然后再由CPU进行数据的处理,处理之后返回到主存中改变主存变量的值。
这样的操作是会存在一个问题的,比如一个简单的赋值语句

i = i + 1;

正常情况下,如果我们假设i = 0,如果只有一个线程执行, 那么毫无疑问返回值将会是1。但是当多线程情况下,将会出现一些问题。

线程1 load i from 主存 //此时主存中i = 0
执行i + 1 //此时在线程1的工作内存中i = 1,但还未返回到主存中
线程2 load i from 主存 //此时主存中i = 0
执行i + 1 //此时在线程2的工作内存中i = 1,但还未返回到主存中
线程1 save i to 主存
线程2 save i to 主存

可以看到我们执行了两此i + 1,但是最后的返回值却只加了一次,并且如果我们读取返回值快,写回过程慢的话,i的值还可能是0,这就是缓存不一致问题;

JMM则是围绕着解决如何在并发过程中解决原子性,可见性,有序性这三个特征而建立的,接下来我们详细了解下这三个过程。

有序性,原子性,可见性

1.原子性:即一个或者多个操作,要么执行后就一定要执行完毕,并且在执行的过程中不会被打断,要么就都不执行。

原子性是拒绝多线程操作的,即同一时刻只能是一个线程来操作。简单来说,就是在操作过程中不会受线程调度器中断的操作,这就是原子性。常见的具有原子性的操作有

  • 基本类型的读取和赋值操作,且赋值必须是数字赋值给变量,变量之间的相互赋值不是原子性操作。
  • 所有引用reference的赋值操作
  • java.concurrent.Atomic.* 包中所有类的一切操作

这里注意一下第一条,i++这条指令看起来像是具有原子性,但实际上这条指令等价于i = i + 1。执行过程中,首先会读取i的值,再加一,然后返回主存,那就是三步操作,并不满足原子性。

这么说来,只有简单的读取,赋值是原子操作,还只能是用数字赋值,用变量的话还多了一步读取变量值的操作。有个例外是,虚拟机规范中允许对64位数据类型(long和double),分为2次32为的操作来处理,但是最新JDK实现还是实现了原子操作的。

2.可见性指多线程访问一个变量时,一个线程改变了这个变量的值,其它线程都能立刻的看到新的值。

volatile就是为共享变量提供了可见性。当一个共享变量被volatile修饰后,如果一个线程改变了变量的值,那么修改后的值将会立即被更新到主存,并且当其它线程读取变量时,会直接从主存中读取数据,而使线程本地工作内存中的缓存值无效。这样就保证了变量的可见性。

3.有序性即程序的执行顺序按照代码的执行顺序进行。
JMM允许编译器和处理器对代码语句进行重排序,但是却规定了as-if-serail语义,即无论怎么重排序,都不能改变最后的结果。重排序不会影响单线程对的结果,但却会影响多线程的执行。比如

int a = 0;
bool flag = false;
 
public void write() {
a = 2; //1
flag = true; //2
}
 
public void multiply() {
if (flag) { //3
int ret = a * a;//4
}
 
}

我们可以先用线程1执行write(),再用线程2执行multiply(),由于重排序的影响,以及多线程的调度的影响,执行顺序就可能为2,3,4,1,这样的话最后的结果ret就是0,而不是我们期待的4。volatile可以保证变量的有序性,防止重排序,当然也可以利用synchronized和lock来保证有序性,相当于让线程顺序的执行代码,因而也保证了有序性。

JMM自带一些有序性,即不需要通过任何手段就可以保证的有序性,通常称为happens-before原则。
1、程序顺序规则: 一个线程中的每个操作,happens-before于该线程中的任意后续操作

2、监视器锁规则:对一个线程的解锁,happens-before于随后对这个线程的加锁

3、volatile变量规则: 对一个volatile域的写,happens-before于后续对这个volatile域的读

4、传递性:如果A happens-before B ,且 B happens-before C, 那么 A happens-before C

5、start()规则: 如果线程A执行操作ThreadB_start()(启动线程B) , 那么A线程的ThreadB_start()happens-before 于B中的任意操作

6、join()原则: 如果A执行ThreadB.join()并且成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

7、interrupt()原则: 对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生

8、finalize()原则:一个对象的初始化完成先行发生于它的finalize()方法的开始
第一条主要是强调的单线程执行时的正确性,由于重排序的存在,并不能保证线程中代码的执行顺序,但是但线程中,最后的执行结果确实一定的。
第二条即只有当锁被释放了,才能加锁成功。
第三条则是多线程方位共享变量时,写入操作一定是在读操作之前的。

我们现在可以回到前面那个例子中,只是这一次我们给共享变量加上了volatile关键字

int a = 0;
volatile bool flag = false;
 
public void write() {
a = 2; //1
flag = true; //2
}
 
public void multiply() {
if (flag) { //3
int ret = a * a;//4
}
 
}

现在我们根据有序性来分析一下。程序顺序规则:1 happens-before 2, 3 happens 4,volatile规则:2 happens-before 3,传递性规则1 happens-before 4。
可以看出volatile修饰的数据具有以下特点

  1. 读入数据时,JMM会直接从主存中读取,而会使该线程对应的本地内存置为无效。
  2. 写入数据时,JMM会立即更新到主存。

volatile能保证原子性吗

并不能,为了方便理解我们举个例子说明:


public class Test {
public volatile int inc = 0;
 
public void increase() {
inc++;
}
 
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
 
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}

我们来分析一下,按道理说结果应该时10000。现在假设一个线程A首先读取了inc的值为1,在没有修改变量前,这时线程A被阻塞了,线程B开始执行,由于A并没有写入数据,所以也就触发不了volatile,这是B读入线程工作内存的值仍然是1,之后自增变为2,再立即返回主存,值为11,随后A自增,返回主存,仍然为11。我们可以看到我们执行了两次自增,然而却只加了一次。

虽然volatile读取时会无效化线程工作内存中的缓存,读取主内存的值,但是由于A线程并没有修改共享变量前就调换到B线程了,使得B读取时仍然为10。

只有当读取数据时,JMM发现了自己缓存行无效,才会读取主存中的值。这也就导致了volatile并不具有原子性。

volatile的原理

可以通过加了volatile和没有加volatile的代码生成的汇编语言看出,加了volatile关键字的lock会多出一个lock前缀,lock前缀将会保证:
1 . 重排序时不能把后面的指令重排序到内存屏障之前的位置
2 . 使得本CPU的Cache写入内存
3 . 写入动作也会引起别的CPU或者别的内核无效化其Cache,相当于让新写入的值对别的线程可见。

为什么双重锁单例模式会使用volatile.

public class DoubleCheckedLocking { // 1
	private static Instance instance; // 2
	public static Instance getInstance() { // 3
		if (instance == null) { // 4:第一次检查
			synchronized (DoubleCheckedLocking.class) { // 5:加锁
				if (instance == null) // 6:第二次检查
				instance = new Instance(); // 7:问题的根源出在这里
			} // 8
		} // 9
		return instance; // 10
	} // 11
}

上面是一个没有加volatile的双重锁单例模式,当多线程访问时,在第7行处就会有问题。
第七行实例化可以看成是以下三步:

memory = allocate();  // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory;  // 3:设置instance指向刚分配的内存地址

我们知到JMM允许在保证结果不改变的情况下,对代码进行重排序,即可能会出现以下情况

memory = allocate();  // 1:分配对象的内存空间
instance = memory;  // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象

如果还没初始化对象时,另外一个线程进来到方法中,就会在双重锁的第四行判断instance!=null,从而就会导致返回了一个未初始化的对象。加入volatile就能防止其重排序,因而就保证了线程安全性。

码字不易,感谢拜读。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值