Java面试--谈谈你对Volatile关键字的理解

Java面试–谈谈你对Volatile关键字的理解

今天面试的时候,傻逼了,这可是自己以前看过的,面试官问的时候,我整个一脸傻逼。好痛苦,看的好多东西记不住。感觉面试实习又要凉凉😭。

分析

  • 保证被volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
//线程1 
boolean stop = false; 
while(!stop){ 
  doSomething(); 
}
//线程2 
stop = true;

img

如果线程2改变了stop的值,线程1一定会停止吗?不一定。当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

  • 禁止指令重排序优化。
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 
  } 
}

write方法里的1和2做了重排序,线程1先对flag赋值为true,随后执行到线程2,ret直接计算出结果,再到线程1,这时候a才赋值为2,很明显迟了一步。但是用volatile修饰之后就变得不一样了

  • 使用volatile关键字会强制将修改的值立即写入主存;

  • 使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);

  • 由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

  • inc++; 其实是两个步骤,先加加,然后再赋值。不是原子性操作,所以volatile不能保证线程安全

正解

一旦被volatile修饰之后,保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的它会保证修改的值会立即被更新到主存中,当有其他线程读取到新值。同时它还禁止指令重排,在java内存模型当中,允许编译器和处理器对指令进行重新排序,但是重排序过程不会影响到单线程程序的执行结果,却会影响到多线程并发执行的正确性volatile无法保证对变量的操作都是原子性的

引发的思考

简单的回顾一下

首先需要理解线程安全的两个方面:执行控制内存可见

执行控制的目的是控制代码执行(顺序)及是否可以并发执行。

内存可见控制的是线程执行结果在内存中对其它线程的可见性。根据Java内存模型的实现,线程在具体执行时,会先拷贝主存数据到线程本地(CPU缓存),操作完成后再把结果从线程本地刷到主存。

synchronized关键字解决的是执行控制的问题,它会阻止其它线程获取当前对象的监控锁,这样就使得当前对象中被synchronized关键字保护的代码块无法被其它线程访问,也就无法并发执行。更重要的是,synchronized还会创建一个内存屏障,内存屏障指令保证了所有CPU操作结果都会直接刷到主存中,从而保证了操作的内存可见性,同时也使得先获得这个锁的线程的所有操作,都happens-before于随后获得这个锁的线程的操作。

volatile关键字解决的是内存可见性的问题,会使得所有对volatile变量的读写都会直接刷到主存,即保证了变量的可见性。这样就能满足一些对变量可见性有要求而对读取顺序没有要求的需求。

使用volatile关键字仅能实现对原始变量(如boolen、 short 、int 、long等)操作的原子性,但需要特别注意, volatile不能保证复合操作的原子性,即使只是i++,实际上也是由多个原子操作组成:read i; inc; write i,假如多个线程同时执行i++volatile只能保证他们操作的i是同一块内存,但依然可能出现写入脏数据的情况。

在Java 5提供了原子数据类型atomic wrapper classes,对它们的increase之类的操作都是原子操作,不需要使用sychronized关键字。

对于volatile关键字,当且仅当满足以下所有条件时可使用:

1. 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
2. 该变量没有包含在具有其他变量的不变式中。
  • Java 语言为了解决并发编程中存在的原子性、可见性和有序性问题,提供了一系列和并发处理相关的关键字,比如synchronizedvolatilefinalJava.until.concurren包等。

  • synchronized 通过加锁的方式,使得其在需要原子性、可见性和有序性这三种特性的时候都可以作为其中一种解决方案,看起来是“万能”的。的确,大部分并发控制操作都能使用synchronized来完成。

  • volatile 通过在volatile变量的操作前后插入内存屏障的方式,保证了变量在并发场景下的可见性和有序性

  • volatile 关键字是无法保证原子性的,而synchronized通过 monitorentermonitorexit 两个指令,可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。 每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。可以保证被synchronized修饰的代码在同一时间只能被一个线程访问,即可保证不会出现CPU时间片在多个线程间切换,即可保证原子性

那么,我们知道,synchronized和volatile两个关键字是Java并发编程中经常用到的两个关键字,而且,通过前面的回顾,我们知道synchronized可以保证并发编程中不会出现原子性、可见性和有序性问题,而volatile只能保证可见性和有序性那么,既生synchronized、何生volatile?

接下来,本文就来论述一下,为什么Java中已经有了synchronized关键字,还要提供volatile关键字。

synchronized的问题

我们都知道synchronized其实是一种加锁机制,那么既然是锁,天然就具备以下几个缺点:

  • 有性能损耗

虽然在JDK 1.6中对synchronized做了很多优化,比如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁无锁状态 ——>偏向锁——> 轻量级锁——>重量级锁)等,但是他毕竟还是一种锁。

不难看出以上这几种优化,都是尽量想办法避免对Monitor进行加锁,但是,并不是所有情况都可以优化的,况且就算是经过优化,优化的过程也是有一定的耗时的。

所以,无论是使用同步方法还是同步代码块,在同步操作之前还是要进行加锁,同步操作之后需要进行解锁,这个加锁、解锁的过程是要有性能损耗的。

关于二者的性能对比,由于虚拟机对锁实行的许多消除和优化,使得我们很难量化这两者之间的性能差距,但是我们可以确定的一个基本原则是:volatile变量的读操作的性能小和普通变量几乎无差别,但是写操作由于需要插入内存屏障所以会慢一些,即便如此,volatile在大多数场景下也比锁的开销要低。

  • 产生阻塞

我们在深入理解多线程(一)——关于Synchronized的实现原理中介绍过关于synchronized的实现原理,无论是同步方法还是同步代码块,无论是ACC_SYNCHRONIZED还是monitorenter、monitorexit都是基于Monitor实现的。

基于Monitor对象,当多个线程同时访问一段同步代码时,首先会进入Entry Set,当有一个线程获取到对象的锁之后,才能进行The Owner区域,其他线程还会继续在Entry Set等待。并且当某个线程调用了wait方法后,会释放锁并进入Wait Set等待

image-20220217150346601

所以,synchronized实现的锁本质上是一种阻塞锁,也就是说多个线程要排队访问同一个共享对象。而volatile是Java虚拟机提供的一种轻量级同步机制,他是基于内存屏障实现的。说到底,他并不是锁,所以他不会有synchronized带来的阻塞和性能损耗的问题

volatile的附加功能

除了前面我们提到的volatile比synchronized性能好以外,volatile其实还有一个很好的附加功能,那就是禁止指令重排。我们先来举一个例子,看一下如果只使用synchronized而不使用volatile会发生什么问题,就拿我们比较熟悉的单例模式来看

我们通过双重校验锁的方式实现一个单例,这里不使用volatile关键字:

/**
 * @author wanglufei
 * @description: 双重检验锁
 * @date 2022/2/17/3:06 PM
 */
public class Singleton {
    public static Singleton singleton;

    public Singleton() {
    }

    public static Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                       singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

以上代码,我们通过使用synchronized对Singleton.class进行加锁,可以保证同一时间只有一个线程可以执行到同步代码块中的内容,也就是说singleton = new Singleton()这个操作只会执行一次,这就是实现了一个单例

但是,当我们在代码中使用上述单例对象的时候有可能发生空指针异常。这是一个比较诡异的情况。

现在我们假设Thread1 和 Thread2两个线程同时请求Singleton.getSingleton方法的时候

image-20220217151213721

  • Step1 ,Thread1 执行到第8行,开始进行对象的初始化。
  • Step2 ,Thread2 执行到第5行,判断singleton == null。
  • Step3 ,Thread2 经过判断发现singleton != null,所以执行第12行,返回singleton。
  • Step4 ,Thread2 拿到singleton对象之后,开始执行后续的操作,比如调用singleton.call()。

以上过程,看上去并没有什么问题,但是,其实,在Step4,Thread2 在调用singleton.call()的时候,是有可能抛出空指针异常的

之所有会有NPE抛出,是因为在Step3,Thread2 拿到的singleton对象并不是一个完整的对象

那么不什么叫不完整的对象的呢?

我们这里来先来看一下,singleton = new Singleton();这行代码到底做了什么事情,大致过程如下

  • 1、虚拟机遇到 new 指令,到常量池定位到这个类的符号引用
  • 2、检查符号引用代表的类是否被加载、解析、初始化过。如果没有,下一步操作
  • 3、虚拟机为 对象 分配内存。
  • 4、虚拟机将分配到的内存空间都初始化为零值。
  • 5、虚拟机对对象进行必要的设置。
  • 6、执行方法,成员变量进行初始化。
  • 7、将对象的引用指向这个内存区域。

我们把这个过程简化一下,简化成3个步骤

  • a、JVM为对象分配一块内存 M
  • b、在内存 M 上为对象进行初始化
  • c、将内存M的地址 赋值 给singleton变量

image-20220217151938522

因为将内存的地址赋值给singleton变量是最后一步,所以Thread1在这一步骤执行之前呢,Thread2在对singleton==null进行判断一直都是true的,那么他会一直阻塞,直到Thread1将这一步骤执行完。

但是,问题就出在以上过程并不是一个原子操作,并且编译器可能会进行重排序,如果以上步骤被重排成

  • a、JVM为对象分配一块内存Mc、将内存的地址复制给singleton变量
  • b、在内存M上为对象进行初始化

image-20220217152303127

这样的话,Thread1会先执行内存分配,在执行变量赋值,最后执行对象的初始化,也就是说,在Thread1还没有为对象进行初始化的时候,Thread2进来判断singleton==null就可能提前得到一个false,则会返回一个不完整的sigleton对象,因为他还未完成初始化操作。

这种情况一旦发生,我们拿到了一个不完整的singleton对象,当尝试使用这个对象的时候就极有可能发生NPE异常。

那么,怎么解决这个问题呢?因为指令重排导致了这个问题,那就避免指令重排就行了。所以,volatile就派上用场了,因为volatile可以避免指令重排。只要将代码改成以下代码,就可以解决这个问题:

/**
 * @author wanglufei
 * @description: 双重检验锁
 * @date 2022/2/17/3:06 PM
 */
public class Singleton {
  	// 添加 volatile 关键字 避免指令重排 
    public volatile static Singleton singleton;

    public Singleton() {
    }

    public static Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                       singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

对singleton使用volatile约束,保证他的初始化过程不会被指令重排。这样就可以保Thread2 要不然就是拿不到对象,要不然就是拿到一个完整的对象

synchronized是有序性的,但是这里为什么不行呢

想到这里,我还有一个问题。就是synchronized是有序性的,但是这里为什么不行呢(也就是指令重排)。🥺我难受呀!

首先,可以明确的一点是:synchronized是无法禁止指令重排和处理器优化的。那么他是如何保证的有序性呢?这就要再把有序性的概念扩展一下了。

**Java 程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的。**和没说一样!😓

以上这句话也是《深入理解Java虚拟机》中的原句,但是怎么理解呢?周志明并没有详细的解释。这里我简单扩展一下,这其实和as-if-serial语义有关。

**as-if-serial语义的意思指:不管怎么重排序,单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。**简单说就是,as-if-serial语义保证了单线程中,不管指令怎么重排,最终的执行结果是不能被改变的

那么,我们回到刚刚那个双重校验锁的例子,站在单线程的角度,也就是只看Thread1的话,因为编译器会遵守as-if-serial语义,所以这种优化不会有任何问题,对于这个线程的执行结果也不会有任何影响。

但是,Thread1 内部的指令重排却对 Thread2产生了影响

那么,就可以说,synchronized保证的有序性是多个线程之间的有序性,即被加锁的内容要按照顺序被多个线程执行。但是其内部的同步代码还是会发生重排序,只不过由于编译器和处理器都遵循as-if-serial语义,所以我们可以认为这些重排序在单线程内部可忽略

相关的面试题

synchronized 和 volatile的区别

  • Synchronized 是关键字,用来加锁。 Volatile 只是保持变量的线程可见性。通常适用于一个线程写,多个线程读的场景。
  • volatile 本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住
  • volatile 仅能使用在变量级别synchronized 则可以使用在变量、方法、和类级别的
  • volatile 仅能实现变量的修改可见性不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
  • volatile 不会造成线程的阻塞;**synchronized **可能会造成线程的阻塞。
  • volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

推荐阅读可以更好的理解。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值