面试:volatile特性详解

目录

  • volatile 是什么?
  • volatile 的可见性
    • 那么JMM与volatile有什么关系?
    • JMM关于同步的规定
    • JMM的主内存与工作内存描述
    • 示例代码来认识可见性
  • volatile的原子性特征
    • 为什么说不保证原子性呢?
    • volatile怎么解决原子性问题
  • volatile的指令重排
    • volatile 禁止实现指令重排优化
  • 单例模式下的volatile

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.加锁与解锁要同一把锁

什么玩意?又多两个知识点,什么是主内存、工作内存?
对于我们工作中的数据存储大概是这样:硬盘<内存

面试:volatile特性详解_第1张图片
比如,把一个对象存储在主内存中,主内存是这样的:
面试:volatile特性详解_第2张图片
这时,假如有三个线程需要修改这个对象的一个属性,那么会怎么操作呢?
首先,要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作
如果线程1修改了某变量,会怎么样呢?
线程1先将自己的内存空间修改变量,再将变量写回主内存,不能直接操作主内存中的变量。

我们需要一种机制,能知道某线程操作完后写回主内存及时通知其他线程,即只要线程1有变动,其他线程2、线程3就立即收到最新消息,这种情况称:可见性

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

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

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

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

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

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

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

面试:volatile特性详解_第3张图片
结论:当某线程修改完后并写回主内存后,其他线程第一时间就能看见,这种情况称:可见性

示例代码来认识可见性

class TestNoVolatile{

     int number = 0;

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

        TestNoVolatile  data = new TestNoVolatile();

        new Thread(() -> {
            System.out.println(Thread.currentThread(). getName()+"\t come in");
            try {
                //暂停一会
                TimeUnit.SECONDS.sleep(5 );
            } 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: 10

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

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

class TestVolatile{

    volatile int number = 0;

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

        TestVolatile  data = new TestVolatile();

        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: 10
main    getMessage number value: 10

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

volatile的原子性特征

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

比如说:来上课同学在黑板上签到自己的名字,是不能被打断或者修改的
面试:volatile特性详解_第4张图片

为什么说不保证原子性呢?

我们前面说到使用volatile 可以让其他线程第一时间看到最新情况
但是这也是不好的地方,我们用案例来说说这种情况是怎么回事

class VolatileTest{

    volatile int number = 0;

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

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

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

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

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

运行结果如下:
main    finally number  value=50

我们使用volatile 来保证可见性,按理来说10个线程每个做10次
我们的到的结果应该是100才对,为什么是50呢??why?

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

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

比如说t1、t2、t3线程各自在自己的空间++完后,将变量写回主内存,这时因为线程之间交错,在某一时间段内出现了一些问题

面试:volatile特性详解_第5张图片
导致被t2 线程写入主内存,刷新数据写回主内存
面试:volatile特性详解_第6张图片
我们volatile保证了可见性,这时应该是第一时间通知其他线程
面试:volatile特性详解_第7张图片

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

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

class VolatileTest {

    volatile int number = 0;

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

面试:volatile特性详解_第8张图片
我们根据addNumPlus 方法的事情,先看看它做了哪些事情
number++;被拆分成了3个指令:
执行getfield拿到原始number
执行iadd进行加1操作
执行putfield把累加后的值写回主内存

volatile怎么解决原子性问题

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

    volatile int number = 0;

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

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

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

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

    //需要等待上面10个线程都全部计算完成后,再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:50
main     AtomicInteger finally number  value:100

为什么atomicInteger.getAndIncrement();可以保证原子性?
AtomicInteger原理对int进行封装,是基于CAS实现保证原子性的,在JAVA中,CAS通过调用C++库实现,由C++库再去调用CPU指令集。想了解AtomicInteger原理可以深入了解CAS工作原理

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呢?
答案是不可以的,因为必须要考虑指令之间的数据依赖性

再来个例子:

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特性详解_第9张图片

单例模式下的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.memory = allocate(); //1. 分配对象内存空间
2.instance = memory; //3. 设置instance指向刚分配的内存地址,此时instance! =null
3.instance(memory);//2.初始化对象

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

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

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

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

你可能感兴趣的:(Android,java,volatile)