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

前言需求


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

蚂蚁花呗:请你谈谈volatile的工做原理程序员

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

蚂蚁金服:volatile 的原子性问题?为何i++ 这种不支持原子性?从计算机原理的设计来说下不能保证原子性的缘由编程

1、volatile 是什么?

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

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

若是一个字段被声明为volatile,java线程内存模型确保全部线程看到这个变量的值是一致的安全

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

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

2、volatile 的可见性特征

在说可见性以前,咱们须要从JMM开始提及,否则怎么讲?ide

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

那么JMM与volatile有什么关系?

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

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

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

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

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

JMM关于关于同步的规定

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

2.线程加锁前,必须读取主内存的最新值到本身的工做内存

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

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

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

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输出最新的值

3、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

因此咱们当前的t一、t二、t3 初始值为0

image.png

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

image.png

好比说t一、t二、t3线程各自在本身的空间++完后,将变量写回主内存

image.png

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

image.png

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

image.png

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

image.png

这也就是为何不与咱们想的同样,是20000,反而是19853

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

image.png

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

image.png

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

image.png

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

当咱们的线程t一、t二、t3执行第一步拷贝副本到本身空间

image.png

当咱们的线程t一、t二、t3执行第二步在本身空间操做变量

image.png

当咱们的线程t一、t二、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能够解决这个问题呢?(小编水平不够,后面再补)

4、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

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

那么请问:执行顺序能够是413二、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

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

相关文章
相关标签/搜索