一道经典Java面试题:volatile的底层实现原理

我欲只争朝夕
发布于 2022-11-4 11:18
浏览
0收藏

前言

大家好,我是捡田螺的小男孩。今天我们来探讨一道经典Java面试题:volatile的底层实现原理。

如何向面试官表现你的基础不错呢?可以从这几个方面,全方位回答这个问题:

  • volatile 是什么?有什么作用?
  • 现代计算机模型,MESI协议
  • 讲述JMM 模型
  • volatile 不能保证原子性
  • volatile 是如何保证可见性的?
  • volatile 是如何保证指令重排的?

1. volatile 是什么?有什么作用

volatile关键字是Java虚拟机提供的的最轻量级的同步机制。它作为一个修饰符出现,可以用来修饰变量。它有什么作用呢?它修饰的变量,可以保证变量对所有线程可见性,禁止指令重排,但是不能保证原子性。

一道经典Java面试题:volatile的底层实现原理-鸿蒙开发者社区

volatile特性

2. 现代计算机的内存模型,MESI协议,嗅探技术

计算机执行程序时,指令是由CPU处理器执行的,而打交道的数据是在主内存当中的。

由于计算机的存储设备与处理器的运算速度有几个数量级的差距,总不能每次CPU执行完指令,然后等主内存慢悠悠存取数据吧, 所以现代计算机系统加入一层读写速度接近处理器运算速度的高速缓存(Cache),以作为来作为内存与处理器之间的缓冲。

在多路处理器系统中,每个处理器都有自己的高速缓存,而它们共享同一主内存.现代计算机模型如下:

一道经典Java面试题:volatile的底层实现原理-鸿蒙开发者社区

  • 程序执行时,把需要用到的数据,从主内存拷贝一份到高速缓存。
  • CPU处理器计算时,从它的高速缓存中读取,把计算完的数据写入高速缓存。
  • 当程序运算结束,把高速缓存的数据刷新会主内存。

MESI协议是什么呢?MESI协议就是为了解决缓存一致性问题的缓存一致性协议

当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

CPU中每个缓存行标记的4种状态(M、E、S、I),也了解一下吧:

缓存状态

描述

M,被修改(Modified)

该缓存行只被该CPU缓存,与主存的值不同,会在它被其他CPU读取之前写入内存,并设置为Shared

E,独享的(Exclusive)

该缓存行只被该CPU缓存,与主存的值相同,被其他CPU读取时置为Shared,被其他CPU写时置为Modified

S,共享的(Shared)

该缓存行可能被多个CPU缓存,各个缓存中的数据与主存数据相同

I,无效的(Invalid)

该缓存行数据是无效,需要时需重新从主存载入

MESI协议是如何实现的?如何保证当前处理器的内部缓存、主内存和其他处理器的缓存数据在总线上保持一致的?多处理器总线嗅探

什么又是嗅探技术?

在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据库读到处理器缓存中。

3. 画出JMM 模型

JMM模型有点类似现代计算机的内存模型,而volatile 跟JMM息息相关,我们先回忆一下JMM模型哈

一道经典Java面试题:volatile的底层实现原理-鸿蒙开发者社区

Java内存模型

  • Java虚拟机规范试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。
  • 为了更好的执行性能,java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存打交道,也没有限制编译器进行调整代码顺序优化。所以Java内存模型会存在缓存一致性问题和指令重排序问题的。
  • Java内存模型规定所有的变量都是存在主内存当中,每个线程都有自己的工作内存。这里的变量包括实例变量和静态变量,但是不包括局部变量,因为局部变量是线程私有的。
  • 线程的工作内存保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接操作操作主内存。并且每个线程不能访问其他线程的工作内存。

3. volatile能保证原子性嘛?

不可以,可以直接举i++那个例子哈。要保证原子性,可以使用synchronzied或者lock。

/**
 * 程序员田螺
 */
class VolatileTest {

    public volatile int race = 0;

    public void increase() {
        race++;
    }

    public static void main(String[] args) {
        final Solution test = new Solution();
        for (int i = 0; i < 10; i++) {
            new Thread() {
                public void run() {
                    for (int j = 0; j < 10000; j++)
                        test.increase();
                }

            }.start();
        }

        //等待所有累加线程结束
        while (Thread.activeCount() > 2)
            Thread.yield();
        System.out.println(test.race);
    }
}

运行结果:

96994

4. volatile 是如何保证可见性的?

volatile变量,保证新值能立即同步回主内存,以及每次使用前立即从主内存刷新,所以我们说volatile保证了多线程操作变量的可见性

5. volatile 是如何保证指令重排的?

指令重排是指在程序执行过程中,为了提高性能, 编译器和CPU可能会对指令进行重新排序。volatile是如何禁止指令重排的?在Java语言中,有一个先行发生原则(happens-before)

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
  • 管程锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
  • 传递性:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

实际上volatile保证可见性和禁止指令重排都跟内存屏障有关。我们来看一段volatile使用的demo代码

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

编译后,对比有volatile关键字和没有volatile关键字时所生成的汇编代码,发现有volatile关键字修饰时,会多出一个lock addl $0x0,(%esp),即多出一个lock前缀指令,lock指令相当于一个「内存屏障」

lock指令相当于一个内存屏障,它保证以下这几点:

  • 1.重排序时不能把后面的指令重排序到内存屏障之前的位置
  • 2.将本处理器的缓存写入内存
  • 3.如果是写入动作,会导致其他处理器中对应的缓存无效。

第2点和第3点就是保证volatile保证可见性的体现嘛,第1点就是禁止指令重排列的体现。内存屏障又是什么呢?

内存屏障四大分类:(Load 代表读取指令,Store代表写入指令)

内存屏障类型

抽象场景

描述

LoadLoad屏障

Load1; LoadLoad; Load2

在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

StoreStore屏障

Store1; StoreStore; Store2

在Store2写入执行前,保证Store1的写入操作对其它处理器可见

LoadStore屏障

Load1; LoadStore; Store2

在Store2被写入前,保证Load1要读取的数据被读取完毕。

StoreLoad屏障

Store1; StoreLoad; Load2

在Load2读取操作执行前,保证Store1的写入对所有处理器可见。

为了实现volatile的内存语义,Java内存模型采取以下的保守策略

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

有些小伙伴,可能对这个还是有点疑惑,内存屏障这玩意太抽象了。我们照着代码看下吧:

一道经典Java面试题:volatile的底层实现原理-鸿蒙开发者社区

内存屏障保证前面的指令先执行,所以这就保证了禁止了指令重排啦,同时内存屏障保证缓存写入内存和其他处理器缓存失效,这也就保证了可见性,哈哈~有关于volatile的底层实现,我们就讨论到这哈~




文章转载自公众号:捡田螺的小男孩

标签
已于2022-11-4 11:18:32修改
收藏
回复
举报
回复
    相关推荐