我所知道大厂高频面试题之 volatile 的一连串轰炸问题

前言需求


我们来看看不同大厂直接涉及到的一些有关volatile的面试题

蚂蚁花呗:请你谈谈volatile的工作原理

今日头条:Volatile的禁止指令重排序有什么意义?synchronied怎么用?

蚂蚁金服:volatile 的原子性问题?为什么i++ 这种不支持原子性?从计算机原理的设计来讲下不能保证原子性的原因

一、volatile 是什么?

Java语言规范第三版中对volatile的定义如下:java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应确保通过排他锁单独获得这个变量

Java语言提供了volatile,在某些情况下比锁更加方便。

如果一个字段被声明为volatile,java线程内存模型确保所有线程看到这个变量的值是一致的

从上面的官方定义我们可简单一句话说明:volatile是轻量级的同步机制主要有三大特性:保证可见性、不保证原子性、禁止指令重排

那么仅接问题就来了:什么是可见性?什么是不保证原子性?指令重排请你说说?

二、volatile 的可见性特征

在说可见性之前,我们需要从JMM开始说起,不然怎么讲?

我们知道JVM是Java虚拟机,JMM是什么?答:Java内存模型

那么JMM与volatile有什么关系?

别急,我们先来了解一下JMM是个什么玩意先

JMM(Java内存模型Java Memory Model,简称JMM)本身是一种抽象的概念并不真实存在,它描述的是一组规则或规范

通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式

简单来说就像中国的十二生肖,其中有一龙,但你能在动物园里牵一头出来吗?

这龙其实就是十二生肖之一,是一种规范,占位,约定,有一个位置属龙

JMM关于关于同步的规定

1.线程解锁前,必须把共享变量的值刷新回主内存

2.线程加锁前,必须读取主内存的最新值到自己的工作内存

3.加锁与解锁要同一把锁

什么玩意?又多两个知识点,什么是主内存、工作内存?

对于我们工作中的数据存储大概是这样:硬盘<内存

image.png

比如说当我们的小明同学存储在主内存中

image.png

这时有三个线程需要修改小明的年龄,那么会怎么操作呢?

image.png

假如线程t1,将小明的年龄修改为:37,这时会怎么样呢?

image.png

我们需要一种机制,能知道某线程操作完后写回主内存及时通知其他线程
image.png

简单的来说:比如下一节我们班的语文课修改为数学课,需要马上通知给我们班所有同学,下节课改为数学课了

image.png

结论:只要有变动,立即收到最新消息

JMM的主内存与工作内存描述

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域

Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域所有线程都可以访问

但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作

操作完成后再将变量写回主内存不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝

因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:

image.png

结论:当某线程修改完后并写回主内存后,其他线程第一时间就能看见,这种情况称:可见性

示例代码来认识可见性

class TestData{

     int number = 0;

    //当方法调用的时候,number值改为60
    public void  addNum(){
        this.number = 60;
    }
}
public static void main(String[] args) {

        TestData  data = new TestData();

        new Thread(() -> {
            System.out.println(Thread.currentThread(). getName()+"\t come in");
            try {
                //暂停一会
                TimeUnit.SECONDS.sleep(3 );
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //调用方法重新赋值
            data.addNum();
            System.out.println(Thread.currentThread(). getName()+"\t updated number value: " +data.number);
        },"AAA"). start();

        while(data.number == 0){

        }
        System.out.println(Thread.currentThread(). getName()+"\t getMessage number value: "+data.number);
    }
    
    
运行结果如下:
AAA     come in
AAA     updated number value: 60

但是有没有发现,当将number 修改为60的时候,main主线程并不知道

所以我们的main线程需要被通知,需要被第一时间看见修改的情况

class TestData{

    volatile int number = 0;

    //当方法调用的时候,number值改为60
    public void  addNum(){
        this.number = 60;
    }
}
public static void main(String[] args) {

        TestData  data = new TestData();

        new Thread(() -> {
            System.out.println(Thread.currentThread(). getName()+"\t come in");
            try {
                //暂停一会
                TimeUnit.SECONDS.sleep(3 );
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //调用方法重新赋值
            data.addNum();
            System.out.println(Thread.currentThread(). getName()+"\t updated number value: " +data.number);
        },"AAA"). start();

        while(data.number == 0){

        }
        System.out.println(Thread.currentThread(). getName()+"\t getMessage number value: "+data.number);
    }
    
    
运行结果如下:
AAA     come in
AAA     updated number value: 60
main    getMessage number value: 60

这时我们的main 接收到最新情况,并没有进入while循环,没有像刚刚那样一直傻傻的等待,所以直接getMessage输出最新的值

三、volatile的原子性特征

首先我们来看看什么是原子性?

指的是:不可分割,完整性,也即某个线程正在做某个具体业务时,中间不可以被加塞或者被分割。 需要整体完整要么同时成功,要么同时失败

比如说:来上课同学在黑板上签到自己的名字,是不能被打断或者修改的

image.png

那么我们上面根据官方的定义总结一句话说明,提到过不保证原子性

那么为什么会出现不保证原子性呢?

我们前面说到使用volatile 可以让其他线程第一时间看到最新情况

但是这也是不好的地方,我们用案例来说说这种情况是怎么回事

class TestData{

    volatile int number = 0;

    //当方法调用的时候,number值++
    public void addNumPlus(){
        number ++;
    }
}

我们采用for循环来模拟二十个线程,每个线程做1000次的调用方式

public static void main(String[] args) {
    TestData data = new TestData();
    for (int i = 1; i<= 20; i++){
        new Thread(() -> {
            for (int j= 1; j<= 1000; j++){
                //调用方法重新赋值
                data.addNumPlus();
            }
        },String.valueOf(i)). start();
    }

    //需要等待上面20个线程都全部计算完成后,再main线程取得最终的结果值看是多少?
    while(Thread . activeCount() > 2){
        Thread.yield();
    }

    System.out.println(Thread.currentThread().getName()+"\t finally number  value"+ data.number);

运行结果如下:
main    finally number  value19853

我们使用volatile 来保证可见性,按理来说20个线程每个做1000次

我们的到的结果应该是20000才对,为什么是19853呢??why!

图解为什么不保证原子性

还记得我们的JMM规定所有变量都存储在主内存,而线程对变量的操作(读取赋值等)必须在工作内存中进行吗?

首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作

image.png

所以我们当前的t1、t2、t3 初始值为0

image.png

当我们的线程调用方法进行++的时候,拷贝副本到自己的内存空间

image.png

比如说t1、t2、t3线程各自在自己的空间++完后,将变量写回主内存

image.png

这时因为线程之间交错,在某一时间段内出现了一些问题

image.png

导致被t2 线程写入主内存,刷新数据写回主内存

image.png

我们volatile保证了可见性,这时应该是第一时间通知其他线程

image.png

这也就是为什么不与我们想的一样,是20000,反而是19853

图解解读字节码++操作做了哪些事情

image.png

这里使用新的类T1,抽取出来但同等代码是一样的

image.png

我们根据add 方法的事情,先看看它做了哪些事情

image.png

噢,看了分析图,是否了解了实际n++ 分了三步骤

当我们的线程t1、t2、t3执行第一步拷贝副本到自己空间

image.png

当我们的线程t1、t2、t3执行第二步在自己空间操作变量

image.png

当我们的线程t1、t2、t3执行第三步将值写回给主内存时

image.png

volatile怎么解决原子性问题

1.添加synchronized的方式
2.使用AtomicInteger

class TestData{

    volatile int number = 0;

    //当方法调用的时候,number值改为60
    public void  addNum(){
        this.number = 60;
    }
    public void addNumPlus(){
        number ++;
    }

    AtomicInteger atomicInteger = new AtomicInteger();
    public void addAtomic(){
        atomicInteger.getAndIncrement();
    }

}
 public static void main(String[] args) {

    TestData data = new TestData();
    for (int i = 1; i<= 20; i++){
        new Thread(() -> {
            for (int j= 1; j<= 1000; j++){
                //调用方法重新赋值
                data.addNumPlus();
                data.addAtomic();
            }
        },String.valueOf(i)). start();
    }

    //需要等待上面20个线程都全部计算完成后,再main线程取得最终的结果值看是多少?
    while(Thread . activeCount() > 2){
        Thread.yield();
    }

    System.out.println(Thread.currentThread().getName()+"\t int finally number  value:"+ data.number);

    System.out.println(Thread.currentThread().getName()+"\t AtomicInteger finally number  value:"+ data.atomicInteger);
}


运行结果如下:
main     int finally number  value:19966
main     AtomicInteger finally number  value:20000

为什么使用AtomicInteger可以解决这个问题呢?(小编水平不够,后面再补)

四、volatile的指令重排

那么我们来聊聊什么是指令重排,什么是指令重排?

其实就是有序性,简单的来说程序员一般写的代码长这样

image.png

但是在我们的电脑机器眼里,我们的代码长这样

image.png

换句话说,什么是指令重排的呢?

image.png

为了保证快、准、稳,会做一些指令重排提高性能

image.png

单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。

处理器在进行重排序时必须要考虑指令之间的数据依赖性

多线程环境中载程交替执行,由于编泽器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的结果无法预测

示例一:班上同学答题

image.png

我们有五道题

当只有一个同学的时候,我们可以随便抢,都是一题一题有执行顺序

当有多个同学的时候,我们无法控制顺序,抢到哪一题就是哪一题

示例二:代码块执行顺序

public void mySort()
{
    int x=11;   //语句1
    int y=12;   //语句2
    x= x + 5;   //语句3
    y= x * x;   //语句4
}

当我们单线程的时候,他的顺序是1234

当我们多线程的时候,他有可能顺序就是:2134、1234了

那么请问:执行顺序可以是4132、4123呢?

答案是不可以的,因为必须要考虑指令之间的数据依赖性

示例三:代码执行顺序

image.png

请问x y 是多少?答:x = 0 y = 0

如果编译器对这段代码进行重新优化后,可能会出现以下情况

image.png

请问x y 是多少?答:x = 2 y = 1

示例四:代码块执行顺序

public class ReSortSeqDemo{

    int    a=0;
    boolean flag = false;

    public void method01(){
        a =1;        //语句1
        flag = true;//语句2
    }
    public void method02(){

        if(flag){
            a=a+5;            //语句3
        }
        System.out.println("*****retValue: "+a);
    }
}

假如示例代码出现指令重排的情况,语句1,语句2 的顺序便从1-2,变成2-1,这个时候flag = true

当两个线程有一个线程抢到flag = true 就会执行下面的if判断

这时就会有两个结果:a = 6 、a =5

volatile 禁止实现指令重排优化

volatile禁止实现指令重排优化,从而避免多线程下程序出现乱序执行的现象

先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,他的作用有两个作用:

1.保证特定操作的执行顺序

2.保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条MemoryBarrier则会告诉编译器和CPU,不管什么指令都不能让这条Memory Barrier指令重排序

也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化

内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本

image.png

五、单例模式下的volatile

我们都知道单例模式下懒汉有非线程安全的情况发生,常见的方式下采用DCL(Double Check Lock)

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

那么多线程情况下会指令重排导致读取的对象是半初始化状态情况

其实DCL机制也不一定是线程安全的情况,原因是有指令重排

原因在于某一个线程执行第一次检测的时候有以下情况
1.读取到instance !=null
2.instance 的引用对象没有完成初始化

简单来说:分座位,有一个叫张三的人一个小时候才来坐这个位置

理论上座位分配出去了,但实际上人并没有到,有名无实

我们来分析一下instance = new Singleton(); 这一步代码
1.memory = allocate(); //1. 分配对象内存空间
2.instance(memory);//2.初始化对象
3.instance = memory; //3. 设置instance指向刚分配的内存地址,此时instance! =null

简单来说对应的步骤是
1.有个张三的需要分配座位,我为给他留一个位置
2.给张三的位置分配好网线,电脑,擦干净桌子
3.一个小时后张三到了把位置给他坐下,进行上课

虽然说这是理论上来说是这样的,但是很抱歉步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。

指令重后变成了以下的执行顺序
1.memory = allocate(); //1. 分配对象内存空间
2.instance = memory; //3. 设置instance指向刚分配的内存地址,此时instance! =null
3.instance(memory);//2.初始化对象

但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。

所以当一条线程访问instance不为nul时,由于instance实例未必已初始化完成,也就造成了线程安全问题。

即示例未初始化完成,保留的是默认值,这样也是出问题

所以使用volatile禁止指令重排,老老实实按顺序来

参考资料


尚硅谷:Java大厂面试题全集(周阳主讲):volatile

你可能感兴趣的:(java,面试,volatile,程序员)