乐趣区

关于java:我所知道大厂高频面试题之-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. 加锁与解锁 要同一把锁

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

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

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

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

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

咱们须要一种机制,能晓得某线程操作完后写回主内存及时告诉其余线程

简略的来说:比方下一节咱们班的语文课批改为数学课,须要马上告诉给咱们班所有同学,下节课改为数学课了

论断:只有有变动,立刻收到最新消息

JMM 的主内存与工作内存形容

因为 JVM 运行程序的实体是线程,而每个线程创立时 JVM 都会为其创立 一个工作内存 (有些中央称为栈空间),工作内存是 每个线程的公有数据区域

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

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

操作实现后再 将变量写回主内存 不能间接操作主内存中的变量 ,各个线程中的 工作内存中存储着主内存中的变量正本拷贝

因而 不同的线程间 无奈 拜访对方的工作内存 ,线程间的通信(传值) 必须通过主内存来实现,其简要拜访过程如下图:

论断:当某线程批改完后并写回主内存后,其余线程第一工夫就能看见,这种状况称:可见性

示例代码来意识可见性

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 的原子性特色

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

指的是:不可分割,完整性,也即某个线程正在做某个具体业务时,两头不能够被加塞或者被宰割。须要整体残缺要么同时胜利,要么同时失败

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

那么咱们下面依据官网的定义总结一句话阐明,提到过不保障原子性

那么为什么会呈现不保障原子性呢?

咱们后面说到应用 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 规定 所有变量都存储在主内存 ,而线程对 变量的操作 (读取赋值等) 必须在工作内存中进行 吗?

首先要将 变量从主内存拷贝的本人的工作内存空间,而后对变量进行操作

所以咱们以后的 t1、t2、t3 初始值为 0

当咱们的线程调用办法进行 ++ 的时候,拷贝正本到本人的内存空间

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

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

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

咱们 volatile 保障了可见性,这时应该是第一工夫告诉其余线程

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

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

这里应用新的类 T1,抽取进去但等同代码是一样的

咱们依据 add 办法的事件,先看看它做了哪些事件

噢,看了剖析图,是否理解了理论 n ++ 分了三步骤

当咱们的线程 t1、t2、t3 执行第一步拷贝正本到本人空间

当咱们的线程 t1、t2、t3 执行第二步在本人空间操作变量

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

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 的指令重排

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

其实就是有序性,简略的来说程序员个别写的代码长这样

然而在咱们的电脑机器眼里,咱们的代码长这样

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

为了保障快、准、稳,会做一些指令重排进步性能

单线程环境外面确保程序最终执行后果和代码程序执行的后果统一。

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

多线程环境中载程交替执行, 因为编泽器优化重排的存在,两个线程中应用的变量是否保障一致性是无奈确定的后果无奈预测

示例一:班上同学答题

咱们有五道题

当只有一个同学的时候,咱们能够轻易抢,都是一题一题有执行程序

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

示例二:代码块执行程序

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

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

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

那么请问:执行程序能够是 4132、4123 呢?

答案是不能够的,因为必须要思考指令之间的 数据依赖性

示例三:代码执行程序

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

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

请问 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 上的线程都能读取到这些数据的最新版本

五、单例模式下的 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

退出移动版