如果面试官再问你volatile 你这样跟他说

Posted Javachichi

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如果面试官再问你volatile 你这样跟他说相关的知识,希望对你有一定的参考价值。

前言

在多线程并发编程中synchronizedvolatile都扮演着重要的角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度
 往往我们在面试过程中都能说出volatile的特性,但是对于深入和扩展的相关问题就回答的不是特别好了,然后volatile又是我们面试必问的。所以本文就准备详细的讲一下这个关键字,希望能帮到小伙伴们。

学习volatile之前先了解JMM

在介绍Java内存模型(JMM)之前,先来简单聊一下计算机内存模型。

我们应该都知道,计算机在执行程序的时候,每条指令都是在CPU中执行的,而执行的时候,免不了会存在数据交互。而计算机上面的数据,是存放在主存当中的,也就是计算机的物理内存。

其实早期计算机中cpu和内存的速度是差不多的,但在现代计算机中,但是随着CPU技术的发展,cpu的指令速度远超内存的存取速度,这就导致CPU每次操作内存都要耗费很多等待时间。

于是乎就想出了解决办法,就是在CPU和内存之间加一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。

当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

虽然高速缓存解决了cpu与内存速度不相符的问题,但是也为计算机系统带来一个新的问题:缓存一致性(CacheCoherence)问题,也就是说,在多核cpu中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。

聊完计算机内存模型之后,下面我们开始聊一下Java内存模型

java内存模型(JMM)

  • JMM是什么?

JMM是Java内存模型,也就是Java Memory Model,简称JMM,本身是一种抽象的概念,实际上并不存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例域,静态域和构成数组对象的元素)的访问方式。

Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

内存模型的抽象示意图如下

  • JMM相关规定:

所有的共享变量都存储于主内存共享变量:实例变量和类变量,不包括局部变量,因为局部变量是线程私有的,所以不存在线程竞争问题。
 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本
 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存来完成
这里就会存在一个可见性的问题,下面讲volatile的时候进行统一讲解

  • JMM关于同步的规定:
  1. 线程解锁前,必须把共享变量的值刷新回主内存

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

  3. 加锁和解锁是同一把锁

volatile的定义

Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

volatile的三大特性

volatile的定义可以看出Volatile在日常的单线程环境是应用不到的

volatile是Java虚拟机提供的轻量级的同步机制(三大特性)

  1. 保证可见性

  2. 不保证原子性

  3. 禁止指令重排

可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
原子性:在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。
有序性:即程序执行的顺序按照代码的先后顺序执行。

下面详细说明这三个特性

volatile可见性

Java中的volatile关键字是通过调用C语言实现的,而在更底层的实现上,即汇编语言的层面上,用volatile关键字修饰后的变量在操作时,最终解析的汇编指令会在指令前加上lock前缀指令来保证工作内存中读取到的数据是主内存中最新的数据具体的实现原理是在硬件层面上通过:MESI缓存一致性协议:多个cpu从主内存读取数据到高速缓存中,如果其中一个cpu修改了数据,会通过总线立即回写到主内存中,其他cpu会通过总线嗅探机制感知到缓存中数据的变化并将工作内存中的数据失效,再去读取主内存中的数据。

IA32架构软件开发者手册对lock前缀指令的解释:  1、 会将当前处理器缓存行的数据立即回写到系统内存中,   2、这个写回内存的操作会引起其他cpu里缓存了该内存地址的数据失效(MESI协议)

即:JMM内存模型的可见性,指的是当主内存区域中的值被某个线程写入更改后,其它线程会马上知晓更改后的值,并重新得到更改后的值。

上面提到了两个概念:主内存工作内存

  • 主内存:就是计算机的内存,也就是经常提到的8G内存,16G内存

  • 工作内存:但我们实例化 new student,那么 age = 25 也是存储在主内存中

当同时有三个线程同时访问student中的age变量时,那么每个线程都会拷贝一份,到各自的工作内存,从而实现了变量的拷贝

缓存一致性

为什么这里主线程中某个值被更改后,其它线程能马上知晓呢?其实这里是用到了总线嗅探技术

在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。

为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有MSIMESI等等。

MESI

CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该内存变量的缓存行CPU高速缓存的中可以分配的最小存储单位,高速缓存中的变量都是存在缓存行中的。)设置为无效,因此当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。

总线嗅探

如何发现数据是否失效呢?

这里是用到了总线嗅探技术,就是每个处理器通过嗅探总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。

总线风暴

总线嗅探技术有哪些缺点?

由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探CAS循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用volatile关键字,至于什么时候使用volatile、什么时候用锁以及Syschonized都是需要根据实际场景的。

可见性代码验证

如何保证可见性呢?方法有哪些呢?

1、加锁

`class visibilityTest extends Thread {  
    private volatile boolean flag = false;  
    public boolean isFlag() {  
        return flag;  
    }  
    @Override  
    public void run(){  
        try {  
            Thread.sleep(3000);  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
        flag = true;  
    }  
}  
public class VolatileDemo {  
    public static void main(String[] args) {  
        visibilityTest visibilityTest = new visibilityTest();  
        visibilityTest.start();  
        for (; ; ) {  
            synchronized (visibilityTest) {  
                if (visibilityTest.isFlag()) {  
                    System.out.println(Thread.currentThread() + "我进来了,你呢?");  
                }  
            }  
        }  
    }  
}  
`

将共享变量加锁,无论是synchronized还是Lock都可以,加锁达到的目的是在同一时间内只能有一个线程能对共享变量进行操作,就是说,共享变量从读取到工作内存到更新值后,同步回主内存的过程中,其他线程是操作不了这个变量的。这样自然就解决了可见性的问题了,但是这样的效率比较低,操作不了共享变量的线程就只能阻塞。

2、volatile修饰修饰共享变量

成员变量没有被添加任何修饰时,是无法感知其它线程修改后的值

`package com.tinygray.volatileTest;  
  
import lombok.Data;  
  
/**  
 * @Author: tinygray  
 * @Description: 公众号:Madison龙少,关注我你会越来越优秀。  
 * @className: VolatileDemo  
 * @create: 2021-05-12 22:50  
 */  
@Data  
class ResourceData {  
    //没有加volatile 修饰  
    //private boolean flag = false;  
    //加volatile 修饰  
    private volatile boolean flag = false;  
      
    public boolean isFlag() {  
        return flag;  
    }  
}  
public class VolatileDemo {  
    public static void main(String[] args) {  
        visibility();  
    }  
  
    private static void visibility() {  
        /**  
         * 验证volatile可见性  
         *  
         */  
        ResourceData data = new ResourceData();  
        new Thread(() -> {  
            System.out.println(Thread.currentThread().getName() + "\\t come in");  
  
            // 线程睡眠3秒,假设在进行运算  
            try {  
                Thread.currentThread().join(3000);  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            data.setFlag(true);  
            System.out.println(Thread.currentThread().getName() + "\\t update flag value:" + data.isFlag());  
        }, "AAA").start();  
  
        while(!data.isFlag()) {  
            // main线程就一直在这里等待循环,直到number的值不等于零  
        }  
        System.out.println(Thread.currentThread().getName() + "over");  
    }  
}  
`
  • 成员变量没有添加volatile关键字修饰结果

如果你觉得自己学习效率低,缺乏正确的指导,可以加入资源丰富,学习氛围浓厚的技术圈一起学习交流吧!
[Java架构群]
群内有许多来自一线的技术大牛,也有在小厂或外包公司奋斗的码农,我们致力打造一个平等,高质量的JAVA交流圈子,不一定能短期就让每个人的技术突飞猛进,但从长远来说,眼光,格局,长远发展的方向才是最重要的。

`@Data  
class ResourceData {  
    private boolean flag = false;  
      
    public boolean isFlag() {  
        return flag;  
    }  
}  
`

最后线程没有停止,并行没有输出主线程结果,说明没有用volatile修饰的变量,是没有可见性

  • 成员变量添加volatile关键字修饰的结果
`@Data  
class ResourceData {  
    private volatile boolean flag = false;  
      
    public boolean isFlag() {  
        return flag;  
    }  
}  
`

主线程也执行完毕了,说明volatile修饰的变量,是具备JVM轻量级同步机制的,能够感知其它线程的修改后的值。

原子性验证

通过前面对JMM的介绍,我们知道,各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后在写回到主内存中的。

这就可能存在一个线程AAA修改了共享变量X的值,但是还未写入主内存时,另外一个线程BBB又对主内存中同一共享变量X进行操作,但此时A线程工作内存中共享变量X对线程B来说是不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。

  • 原子性概念

不可分割完整性,也就是说某个线程正在做某个具体业务时,要么同时成功,要么同时失败。数据库也经常提到事务具备原子性。

代码验证volatile不保证原子性

完整代码

`package com.tinygray.volatileTest;  
  
import lombok.Data;  
  
import java.util.concurrent.atomic.AtomicInteger;  
  
/**  
 * @Author: tinygray  
 * @Description: 公众号:Madison龙少,关注我你会越来越优秀。  
 * @className: VolatileDemo  
 * @create: 2021-05-12 22:50  
 */  
@Data  
class ResourceData {  
    private volatile int number = 0;  
    private AtomicInteger atomicInteger = new AtomicInteger();  
  
    public void addPlusPlus() {  
        number++;  
        //atomicInteger.getAndIncrement();  
    }  
}  
public class VolatileDemo {  
    public static void main(String[] args) {  
        atomicByVolatile();  
    }  
  
    private static void atomicByVolatile() {  
        /**  
         * 验证volatile原子性  
         *      原子性:完整性,不可分割,某个线程在做某个具体业务的时候,中间不能被加塞或被分割,需整体完整,要莫同时成功,要同时失败  
         *  
         */  
        ResourceData data = new ResourceData();  
        for (int i = 0; i < 20; i++) {  
            new Thread(() -> {  
                for (int j = 0; j < 1000; j++) {  
                    data.addPlusPlus();  
                }  
            }, String.valueOf(i)).start();  
        }  
        // 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值  
        // 这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程  
        while (Thread.activeCount() > 2) {  
            // yield表示不执行  
            Thread.yield();  
        }  
        // 查看最终的值  
        // 假设volatile保证原子性,那么输出的值应该为:  20 * 1000 = 20000  
        System.out.println(Thread.currentThread().getName() + "\\t finally number value: " + data.getNumber());  
    }  
}  
`
  • 第一次结果:

  • 第二次结果:

上面代码如果保证原子性的情况下,应该输出20000的结果,但并不是,说明volatile不保证原子性

不保证原子性原因

volatile:从最终汇编语言从面来看,volatile使得每次将i进行了修改之后,增加了一个内存屏障lock addl $0x0,(%rsp)保证修改的值必须刷新到主内存才能进行内存屏障后续的指令操作。但是内存屏障之前的指令并不是原子的

代码例子

`public static volatile int race = 0;  
public static void increase() {  
    race++;  
}  
  
`

字节码

`public static void increase();  
Code:  
0: getstatic #2 // Field race:I  
3: iconst_1  
4: iadd  
5: putstatic #2 // Field race:I  
8: return  
`

指令"lock; addl $0,0(%%esp)"表示加锁,把0加到栈顶的内存单元,该指令操作本身无意义,但这些指令起到内存屏障的作用,让前面的指令执行完成。具有XMM2特征的CPU已有内存屏障指令,就直接使用该指令

volatile方式的i++,总共是四个步骤:i++实际为loadIncrementstoreMemory Barriers 四个操作。

内存屏障是线程安全的,但是内存屏障之前的指令并不是.在某一时刻线程1将i的值load取出来,放置到cpu缓存中,然后再将此值放置到寄存器A中,然后A中的值自增1(寄存器A中保存的是中间值,没有直接修改i,因此其他线程并不会获取到这个自增1的值)。如果在此时线程2也执行同样的操作,获取值i10,自增1变为11,然后马上刷入主内存。此时由于线程2修改了i的值,实时的线程1中的i10的值缓存失效,重新从主内存中读取,变为11。接下来线程1恢复。将自增过后的A寄存器值11赋值给cpu缓存i。这样就出现了线程安全问题。

如何保证原子性

1、在方法上加入 synchronized

 `public synchronized void addPlusPlus() {  
        number++;  
        //atomicInteger.getAndIncrement();  
    }`

我们能够发现引入synchronized关键字后,保证了该方法每次只能够一个线程进行访问和操作,最终输出的结果也就为20000

2、使用原子包装类AtomicInteger(Atomic的底层可以去看一下,后续也会讲)

上面的方法引入synchronized,虽然能够保证原子性,但是为了解决number++,而引入重量级的同步机制,有种杀鸡焉用牛刀。除了引用synchronized关键字外,还可以使用JUC下面的原子包装类,即刚刚的int类型的number,可以使用AtomicInteger来代替

`class ResourceData {  
    private AtomicInteger atomicInteger = new AtomicInteger();  
  
    public void addPlusPlus() {  
        atomicInteger.getAndIncrement();  
    }  
}  
`
 `private static void atomicByVolatile() {  
        /**  
         * 验证volatile原子性  
         *      原子性:完整性,不可分割,某个线程在做某个具体业务的时候,需整体完整,要莫同时成功,要同时失败  
         */  
        ResourceData data = new ResourceData();  
        for (int i = 0; i < 20; i++) {  
            new Thread(() -> {  
                for (int j = 0; j < 1000; j++) {  
                    data.addPlusPlus();  
                }  
            }, String.valueOf(i)).start();  
        }  
        // 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值  
        // 这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程  
        while (Thread.activeCount() > 2) {  
            // yield表示不执行  
            Thread.yield();  
        }  
        // 查看最终的值  
        // 假设volatile保证原子性,那么输出的值应该为:  20 * 1000 = 20000  
        System.out.println(Thread.currentThread().getName() + "\\t finally number value: " + data.getAtomicInteger());  
    }`

Volatile的有序性(禁止指令重排)

一般来说,我们写程序的时候,都是要把先代码从上往下写,默认的认为程序是自顶向下顺序执行的,但是CPU为了提高效率,在保证最终结果准确的情况下,是会对指令进行重新排序的。就是说写在前的代码不一定先执行,在后面的也不一定晚执行。

注意

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

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

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

为什么需要禁止指令重排,看下面案例**

指令重排 - 1

`public void mySort() {  
 int x = 11;  
 int y = 12;  
 x = x + 5;  
 y = x * x;  
}  
`

按照正常单线程环境,执行顺序是 1 2 3 4,但是在多线程环境下,可能出现以下的顺序:(2 1 3 4)、(1 3 2 4 )

上述的过程就可以当做是指令的重排,即内部执行顺序,和我们的代码顺序不一样,但是指令重排也是有限制的,即不会出现下面的顺序(4 3 2 1)

因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性

因为步骤 4:需要依赖于 y的申明,以及x的申明,故因为存在数据依赖,无法首先执行

例子

int a,b,x,y = 0

线程1线程2
x = a;y = b;
b = 1;a = 2;
x = 0; y = 0

因为上面的代码,不存在数据的依赖性,因此编译器可能对数据进行重排

线程1线程2
b = 1;a = 2;
x = a;y = b;
x = 2; y = 1

这样造成的结果,和最开始的就不一致了,这就是导致重排后,结果和最开始的不一样,因此为了防止这种结果出现,volatile就规定禁止指令重排,为了保证数据的一致性

指令重排 - 2

看下面这段代码

`/**  
 * ResortSeqDemo  
 * @Author: tinygray  
 * @Description: 公众号:Madison龙少,关注我你会越来越优秀。  
 */  
public class Xxxx {  
    int a= 0;  
    boolean flag = false;  
  
    public void method01() {  
        a = 1;  
        flag = true;  
    }  
  
    public void method02() {  
        if(flag) {  
            a = a + 5;  
            System.out.println("reValue:" + a);  
        }  
    }  
}  
`

我们按照正常的顺序,分别调用method01() 和 method02() 那么,最终输出就是 a = 6

但是如果在多线程环境下,因为方法1 和 方法2,他们之间不能存在数据依赖的问题,因此原先的顺序可能是

`a = 1;  
flag = true;  
  
a = a + 5;  
System.out.println("reValue:" + a);` 

但是在经过编译器,指令,或者内存的重排后,可能会出现这样的情况

`flag = true;  
  
a = a + 5;  
System.out.println("reValue:" + a);  
  
a = 1;  
`

也就是先执行 flag = true后,另外一个线程马上调用方法2,满足 flag的判断,最终让a + 5,结果为5,这样同样出现了数据不一致的问题

为什么会出现这个结果:多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

这样就需要通过volatile来修饰,来保证线程安全性

什么是重排序?重排序的作用是什么?

为了提高处理性能,并保证最后结果正确的情况下,编译器和处理器常常会对现有代码的执行顺序进行指令重排序。

重排序的类型有哪些呢?

重排序一般分为三种:

编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;

处理器重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;

内存访问重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

这里就要说一下as-if-serial语义

As-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义

源码到最终执行会经过哪些重排序呢?

源代码—>编译器优化重排序—>指令并行重排序—>内存访问重排序—>最终执行指令顺序

volatile如何实现的禁止指令重排呢

通过内存屏障实现的。

内存屏障是什么?

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。

内存屏障可以被分为以下几种类型

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。

  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java编译器会在这种情况下不放置内存屏障

Intel 64/IA-32架构下的内存访问重排序

Intel 64和IA-32是我们较常用的硬件环境,相对于其它处理器而言,它们拥有一种较严格的重排序规则。Pentium 4以后的Intel 64或IA-32处理的重排序规则如下。

在单CPU系统中:

  • 读操作不与其它读操作重排序。

  • 写操作不与其之前的写操作重排序。

  • 写内存操作不与其它写操作重排序,但有以下几种例外

  • CLFLUSH的写操作

  • 带有non-temporal move指令(MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, and MOVNTPD)的streaming写入。

  • 字符串操作

  • 读操作可能会与其之前的写不同位置的写操作重排序,但不与其之前的写相同位置的写操作重排序。

  • 读和写操作不与I/O指令,带锁的指令或序列化指令重排序。

  • 读操作不能重排序到LFENCE和MFENCE之前。

  • 写操作不能重排序到LFENCE、SFENCE和MFENCE之前。

  • LFENCE不能重排序到读操作之前。

  • SFENCE不能重排序到写之前。

  • MFENCE不能重排序到读或写操作之前。

在多处理器系统中:

  • 各自处理器内部遵循单处理器的重排序规则。

  • 单处理器的写操作对所有处理器可见是同时的。

  • 各自处理器的写操作不会重排序。

  • 内存重排序遵守因果性(causality)(内存重排序遵守传递可见性)。

  • 任何写操作对于执行这些写操作的处理器之外的处理器来看都是一致的。

  • 带锁指令是顺序执行的。

  • 值得注意的是,对于Java编译器而言,Intel 64/IA-32架构下处理器不需要LoadLoad、LoadStore、StoreStore屏障,因为不会发生需要这三种屏障的重排序。

volatile会在变量写操作的前后加入两个内存屏障,来保证前面的写指令和后面的读指令是有序的。

volatile在变量的读操作后面插入两个指令,禁止后面的读指令和写指令重排序。

不光volatile能保证有序性,也有其他的实现方式也能保证,所以再JDK5出现了happen-before原则,也叫先行发生原则。

根据Java内存模型中的规定,可以总结出以下几条happens-before规则。Happens-before的前后两个操作不会被重排序且后者对前者的内存可见。

  • 程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。

  • 监视器锁法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。

  • volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。

  • 线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。

  • 线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。

  • 中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。

  • 终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。

  • 传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C

Java内存模型关于重排序的规定,总结后如下表所示:

  • 表中“第二项操作”的含义是指,第一项操作之后的所有指定操作。如,普通读不能与其之后的所有volatile写重排序。另外,JMM也规定了上述volatile和同步块的规则尽适用于存在多线程访问的情景。例如,若编译器(这里的编译器也包括JIT,下同)证明了一个volatile变量只能被单线程访问,那么就可能会把它做为普通变量来处理。

  • 留白的单元格代表允许在不违反Java基本语义的情况下重排序。例如,编译器不会对对同一内存地址的读和写操作重排序,但是允许对不同地址的读和写操作重排序。

应用

单例模式-双重检查单例

最终版本代码

`/**  
 * @Author: tinygray  
 * @Description: 公众号:Madison龙少,关注我你会越来越优秀。  
 * @className: SingletonDemo   
 * @create: 2021-05-12 22:50  
 */  
public class SingletonDemo {  
  
    private static volatile SingletonDemo instance = null;  
  
    private SingletonDemo () {  
        System.out.println(Thread.currentThread().getName() + "\\t 我是构造方法SingletonDemo");  
    }  
  
    public static SingletonDemo getInstance() {  
        if(instance == null) {  
            // a 双重检查加锁多线程情况下会出现某个线程虽然这里已经为空,但是另外一个线程已经执行到d处  
            synchronized (SingletonDemo.class) //b {  
                //c不加volitale关键字的话有可能会出现尚未完全初始化就获取到的情况。原因是内存模型允许无序写入  
                if(instance == null) {  
                    // d 此时才开始初始化  
                    instance = new SingletonDemo();  
                }  
            }  
        }  
        return instance;  
    }  
  
    public static void main(String[] args) {  
//        // 这里的 == 是比较内存地址  
//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());  
//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());  
//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());  
//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());  
  
        for (int i = 0; i < 10; i++) {  
            new Thread(() -> {  
                SingletonDemo.getInstance();  
            }, String.valueOf(i)).start();  
        }  
    }  
}  
`

双重检查为啥要使用volatile呢?

这就要说到对象的创建步骤

1、分配内存空间。2、调用构造器,实例化。3、返回内存地址给引用。

  • memory = allocate(); // 1、分配对象内存空间

  • instance(memory); // 2、初始化对象

  • instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null

但是我们通过上面的三个步骤,能够发现,步骤2 和 步骤3之间不存在 数据依赖关系,而且无论重排前 还是重排后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的

如果是下面的顺序会造成什么问题呢?

  • memory = allocate(); // 1、分配对象内存空间

  • instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null,但是对象还没有初始化完成

  • instance(memory); // 2、初始化对象

这个过程中是有可能发生指令重排的,有可能构造函数在对象初始化完成前就赋值完成了,在内存里面开辟了一片存储区域后直接返回内存的引用,这个时候还没真正的初始化完对象。这个时候如果其他线程就会发现实例对象不等于null,然而对象还没有真正创建好,这个时候就会出现空指针异常

也就是当我们执行到重排后的步骤2,试图获取instance的时候,会得到null,因为对象的初始化还没有完成,而是在重排后的步骤3才完成,因此执行单例模式的代码时候,就会重新在创建一个instance实例.

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

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

所以需要引入volatile,来保证出现指令重排的问题,从而保证单例模式的线程安全性

如何保证单例的

第一个线程走到第一次检查时发现对象为空,然后进入锁,第二次就检查时也为空,那么就去创建对象,但是这个时候又来了一个线程来到了第一次检查,发现为空,但是这个时候因为锁被占用,所以就只能阻塞等待,然后第一个线程创建对象成功了,由于对象是被volatile修饰的能够立即反馈到其他线程上,所以在第一个线程释放锁之后,第二个线程进入了锁,然后进行第二次检查时,发现对象已经被创建了,那么就不在创建对象了。从而保证的单例。

小节

synchronized和volatile的区别

volatile关键字的本质是告诉jvm,该变量在寄存器中的值是不确定的,需要在主存中读取,而synchronized关键字是锁住当前变量,只有当前线程可以访问,其他线程等待。

1、 volatile只能作用于变量,而synchronized可以作用于变量、方法和代码块

2、多线程访问volatile不会发生阻塞,而synchronized关键字可能发生阻塞。

3、 volatile能够保证数据的可见性,就是在多个线程之间是可见的,不能保证原子性,而synchronized关键字都可以保证。

4、volatile关键字主要解决的是多个线程之间的可见性,而synchronized关键字保证的是多个线程访问资源的同步性。

volatile适用于场景

某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,实现轻量级同步。

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。

通常来说,使用volatile必须具备以下2个条件:

1、对变量的写操作不依赖于当前值。2、该变量没有包含在具有其他变量的不变式中。

下面列举两个使用场景

1、状态标记量。2、双重检查(单例模式)

最后

以下是Java面试1—到5年以上开发必问到的面试问点,也都是一线互联网公司Java面试必备技能,下面是参照阿里年薪50W所需具备的技能图,大家可以参考下!
在这里插入图片描述

同时针对这12个技能,我在这整理了一份Java架构进阶面试专题PDF文档(含450题解析,包括Dubbo、Redis、Netty、zookeeper、Spring cloud、分布式、高并发,设计模式,MySQL等知识点解析,内容丰富,图文结合!)

蚂蚁金服Java研发岗三面:MySQL+秒杀+Redis+JVM等(终获offer)
这份专题文档是免费分享的,有需要的朋友可以看向下面来获取!!

需要完整版文档的小伙伴,可以一键三连,下方获取免费领取方式!
在这里插入图片描述

以上是关于如果面试官再问你volatile 你这样跟他说的主要内容,如果未能解决你的问题,请参考以下文章

面试官再问你 HashMap 底层原理,就把这篇文章甩给他看

要是面试官再问我volatile,我就这么答

面试官再问你 HashMap 底层原理,就把这篇文章甩给他看!

以后面试官再问你三次握手和四次挥手,直接把这一篇文章丢给他

要是面试官再问我synchronized,我就这么答

面试官再问单点登录,把这篇发给他!