阅读 257

面试官:请说下volatile的实现原理

volatile是面试中的高频问题,这篇文章能带你详细了解volatile的面试相关问题

推荐阅读:

文章目录

image.png

volatile的作用是什么?

volatile是一个轻量级的synchronized,一般作用与变量,在多处理器开发的过程中保证了内存的可见性。相比于synchronized关键字,volatile关键字的执行成本更低,效率更高。

volatile的特性有哪些?

并发编程的三大特性为可见性、有序性和原子性。通常来讲volatile可以保证可见性和有序性。

  • 可见性:volatile可以保证不同线程对共享变量进行操作时的可见性。即当一个线程修改了共享变量时,另一个线程可以读取到共享变量被修改后的值。
  • 有序性:volatile会通过禁止指令重排序进而保证有序性。
  • 原子性:对于单个的volatile修饰的变量的读写是可以保证原子性的,但对于i++这种复合操作并不能保证原子性。这句话的意思基本上就是说volatile不具备原子性了。

Java内存的可见性问题

Java的内存模型如下图所示。

在这里插入图片描述

这里的本地内存并不是真实存在的,只是Java内存模型的一个抽象概念,它包含了控制器、运算器、缓存等。同时Java内存模型规定,线程对共享变量的操作必须在自己的本地内存中进行,不能直接在主内存中操作共享变量。这种内存模型会出现什么问题呢?,

  1. 线程A获取到共享变量X的值,此时本地内存A中没有X的值,所以加载主内存中的X值并缓存到本地内存A中,线程A修改X的值为1,并将X的值刷新到主内存中,这时主内存及本地内存A中的X的值都为1。
  2. 线程B需要获取共享变量X的值,此时本地内存B中没有X的值,加载主内存中的X值并缓存到本地内存B中,此时X的值为1。线程B修改X的值为2,并刷新到主内存中,此时主内存及本地内存B中的X值为2,本地内存A中的X值为1。
  3. 线程A再次获取共享变量X的值,此时本地内存中存在X的值,所以直接从本地内存中A获取到了X为1的值,但此时主内存中X的值为2,到此出现了所谓内存不可见的问题。

该问题Java内存模型是通过synchronized关键字和volatile关键字就可以解决。

为什么代码会重排序?

计算机在执行程序的过程中,编译器和处理器通常会对指令进行重排序,这样做的目的是为了提高性能。具体可以看下面这个例子。

int a = 1;
int b = 2;
int a1 = a;
int b1 = b;
int a2 = a + a;
int b2 = b + b;
......
复制代码

像这段代码,不断地交替读取a和b,会导致寄存器频繁交替存储a和b,使得代码性能下降,可对其进入如下重排序。

int a = 1;
int b = 2;
int a1 = a;
int a2 = a + a;
int b1 = b;
int b2 = b + b;
......
复制代码

按照这样地顺序执行代码便可以避免交替读取a和b,这就是重排序地意义。

指令重排序一般分为编译器优化重排、指令并行重拍和内存系统重排三种。

  • 编译器优化重排:编译器在不改变单线程程序语义的情况下,可以对语句的执行顺序进行重新排序。
  • 指令并行重排:现代处理器多采用指令级并行技术来将多条指令重叠执行。对于不存在数据依赖的程序,处理器可以对机器指令的执行顺序进行重新排列。
  • 内存系统重排:因为处理器使用缓存和读/写缓冲区,使得加载(load)和存储(store)看上去像是在乱序执行。

注:简单解释下数据依赖性:如果两个操作访问了同一个变量,并且这两个操作有一个是写操作,这两个操作之间就会存在数据依赖性,例如:

a = 1;
b = a;
复制代码

如果对这两个操作的执行顺序进行重排序的话,那么结果就会出现问题。

其实,这三种指令重排说明了一个问题,就是指令重排在单线程下可以提高代码的性能,但在多线程下可以会出现一些问题。

重排序会引发什么问题?

前面已经说过了,在单线程程序中,重排序并不会影响程序的运行结果,而在多线程场景下就不一定了。可以看下面这个经典的例子,该示例出自《Java并发编程的艺术》。

class ReorderExample{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;              // 操作1
        flag = true;        // 操作2
    }
    public void reader(){
        if(flag){          // 操作3
            int i = a + a; // 操作4
        }
    }
}
复制代码

假设线程1先执行writer()方法,随后线程2执行reader()方法,最后程序一定会得到正确的结果吗?

答案是不一定的,如果代码按照下图的执行顺序执行代码则会出现问题。

在这里插入图片描述

操作1和操作2进行了重排序,线程1先执行flag=true,然后线程2执行操作3和操作4,线程2执行操作4时不能正确读取到a的值,导致最终程序运行结果出问题。这也说明了在多线程代码中,重排序会破坏多线程程序的语义。

as-if-serial规则和happens-before规则的区别?

区别:

  • as-if-serial定义:无论编译器和处理器如何进行重排序,单线程程序的执行结果不会改变。

  • happens-before定义:一个操作happens-before另一个操作,表示第一个的操作结果对第二个操作可见,并且第一个操作的执行顺序也在第二个操作之前。但这并不意味着Java虚拟机必须按照这个顺序来执行程序。如果重排序的后的执行结果与按happens-before关系执行的结果一致,Java虚拟机也会允许重排序的发生。

  • happens-before关系保证了同步的多线程程序的执行结果不被改变,as-if-serial保证了单线程内程序的执行结果不被改变。

相同点:happens-before和as-if-serial的作用都是在不改变程序执行结果的前提下,提高程序执行的并行度。

voliatile的实现原理?

前面已经讲述volatile具备可见性和有序性两大特性,所以volatile的实现原理也是围绕如何实现可见性和有序性展开的。

volatile实现内存可见性原理

导致内存不可见的主要原因就是Java内存模型中的本地内存和主内存之间的值不一致所导致,例如上面所说线程A访问自己本地内存A的X值时,但此时主内存的X值已经被线程B所修改,所以线程A所访问到的值是一个脏数据。那如何解决这种问题呢?

volatile可以保证内存可见性的关键是volatile的读/写实现了缓存一致性,缓存一致性的主要内容为:

  • 每个处理器会通过嗅探总线上的数据来查看自己的数据是否过期,一旦处理器发现自己缓存对应的内存地址被修改,就会将当前处理器的缓存设为无效状态。此时,如果处理器需要获取这个数据需重新从主内存将其读取到本地内存。
  • 当处理器写数据时,如果发现操作的是共享变量,会通知其他处理器将该变量的缓存设为无效状态。

那缓存一致性是如何实现的呢?可以发现通过volatile修饰的变量,生成汇编指令时会比普通的变量多出一个Lock指令,这个Lock指令就是volatile关键字可以保证内存可见性的关键,它主要有两个作用:

  • 将当前处理器缓存的数据刷新到主内存。
  • 刷新到主内存时会使得其他处理器缓存的该内存地址的数据无效。

volatile实现有序性原理

前面提到重排序可以提高代码的执行效率,但在多线程程序中可以导致程序的运行结果不正确,那volatile是如何解决这一问题的呢?

为了实现volatile的内存语义,编译器在生成字节码时会通过插入内存屏障来禁止指令重排序。

内存屏障:内存屏障是一种CPU指令,它的作用是对该指令前和指令后的一些操作产生一定的约束,保证一些操作按顺序执行。

Java虚拟机插入内存屏障的策略

Java内存模型把内存屏障分为4类,如下表所示:

屏障类型指令示例说明
LoadLoad BarriersLoad1;LoadLoad;Load2保证Load1数据的读取先于Load2及后续所有读取指令的执行
StoreStore BarriersStore1;StoreStore;Store2保证Store1数据刷新到主内存先于Store2及后续所有存储指令
LoadStore BarriersLoad1;LoadStore;Store2保证Load1数据的读取先于Store2及后续的所有存储指令刷新到主内存
StoreLoad BarriersStore1;StoreLoad;Load2保证Store1数据刷新到主内存先于Load2及后续所有读取指令的执行

注:StoreLoad Barriers同时具备其他三个屏障的作用,它会使得该屏障之前的所有内存访问指令完成之后,才会执行该屏障之后的内存访问命令。

Java内存模型对编译器指定的volatile重排序规则为:

  • 当第一个操作是volatile读时,无论第二个操作是什么都不能进行重排序。
  • 当第二个操作是volatile写时,无论第一个操作是什么都不能进行重排序。
  • 当第一个操作是volatile写,第二个操作为volatile读时,不能进行重排序。

根据volatile重排序规则,Java内存模型采取的是保守的屏障插入策略,volatile写是在前面和后面分别插入内存屏障,volatile读是在后面插入两个内存屏障,具体如下:

  • volatile读:在每个volatile读后面分别插入LoadLoad屏障及LoadStore屏障(根据volatile重排序规则第一条),如下图所示

在这里插入图片描述

​ LoadLoad屏障的作用:禁止上面的所有普通读操作和上面的volatile读操作进行重排序。

​ LoadStore屏障的作用:禁止下面的普通写和上面的volatile读进行重排序。

  • volatile写:在每个volatile写前面插入一个StoreStore屏障(为满足volatile重排序规则第二条),在每个volatile写后面插入一个StoreLoad屏障(为满足volatile重排序规则第三条),如下图所示

    在这里插入图片描述

    StoreStore屏障的作用:禁止上面的普通写和下面的volatile写重排序

    StoreLoad屏障的作用:防止上面的volatile写与下面可能出现的volatile读/写重排序。

编译器对内存屏障插入策略的优化

因为Java内存模型所采用的屏障插入策略比较保守,所以在实际的执行过程中,只要不改变volatile读/写的内存语义,编译器通常会省略一些不必要的内存屏障。

代码如下:

public class VolatileBarrierDemo{
    int a;
    volatile int b = 1;
    volatile int c = 2;
    
    public void test(){
        int i = b;  //volatile读
        int j = c;  //volatile读
        a = i + j;  //普通写
        
    }
}
复制代码

指令序列示意图如下:

在这里插入图片描述

从上图可以看出,通过指令优化一共省略了两个内存屏障(虚线表示),省略第一个内存屏障LoadStore的原因是最后的普通写不可能越过第二个volatile读,省略第二个内存屏障LoadLoad的原因是下面没有涉及到普通读的操作。

volatile能使一个非原子操作变成一个原子操作吗?

volatile只能保证可见性和有序性,但可以保证64位的long型和double型变量的原子性。

对于32位的虚拟机来说,每次原子读写都是32位的,会将longdouble型变量拆分成两个32位的操作来执行,这样longdouble型变量的读写就不能保证原子性了,而通过volatile修饰的long和double型变量则可以保证其原子性。

volatile、synchronized的区别?

  • volatile主要是保证内存的可见性,即变量在寄存器中的内存是不确定的,需要从主存中读取。synchronized主要是解决多个线程访问资源的同步性。
  • volatile作用于变量,synchronized作用于代码块或者方法。
  • volatile仅可以保证数据的可见性,不能保证数据的原子性。synchronized可以保证数据的可见性和原子性。
  • volatile不会造成线程的阻塞,synchronized会造成线程的阻塞。

微信搜索公众号路人zhang,回复面试手册,领取更多高频面试题PDF版及更多面试资料。

文章分类
后端
文章标签