你会 volatile 底层原理?不要耍这样的小聪明


1、常见面试题

  • volatile关键字的作用是什么?
  • volatile能保证原子性吗?
  • 之前32位机器上共享的long和double变量的为什么要用volatile?
  • i++为什么不能保证原子性?
  • volatile是如何实现可见性的?
  • volatile是如何实现有序性的?

2、volatile的作用

1、volatile保证可见性

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

那为什么会产生可见性问题呢?

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到高速缓存后再进行操作, 对共享变量操作之后不知道会何时写入内存。于是其他处理器不能及时得到最新的值,造成处理器中的数据不一致。

volatile如何保证可见性?

在解释原理之前,先介绍monitor对象,monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象都有一把看不见的锁,称为内部锁或者monitor锁。monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。monitor 是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。

底层实现:如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。(缓存行是高速缓存中可以分配的最小存储单位)

这个lock前缀指令的作用,Lock 前缀的指令在多核处理器下会引发两件事情:

  • 将当前处理器缓存行的数据写回到系统内存。

  • 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

1)Lock 前缀指令会引起处理器缓存写回到内存。Lock 前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存。因为该信号会锁住总线,导致其他 CPU 不能访问总线,不能访问总线就意味着不能访问系统内存。在最近的处理器里,LOCK#信号一般不锁总线,而是缓存,毕竟锁总线开销比较大。例如:Intel486和Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但是在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效。例如 Intel 64 处理器使用 MESI (修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,Intel 64 处理器能嗅探到其他处理器访问系统内存和它们内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。

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

那什么是缓存一致性协议呢?

我们常说的缓存一致性协议是指 MESI 协议,它是一个基于总线嗅探机制的协议。

MESI协议:Modified,已修改; Exclusive,独占;Shared,共享,Invalidated,已失效。(状态机模型)

image-20220327113000019

当一个处理器将数据从主内存读入缓存时,此时其他处理器的缓存中还没有这个数据,那么该处理器缓存中的这个数据就是独占状态,如果该缓存里面的数据还没有没修改,其他处理器又从主内存里面读了相同的数据,那么该数据就会变成共享状态,独占和共享状态都表示缓存中的数据不是脏数据。如果一个处理器修改了自己缓存里面的数据,但是还没写回主内存里面,那么这个修改了的缓存中的该数据就是已修改状态,就会导致其他处理器的缓存变为已失效状态。具体的转换流程就是上图中的这个状态机模型。

2、volatile保证有序性

volatile 变量的内存有序性是基于内存屏障(Memory Barrier)实现。

为什么会有有序性问题呢?

编译器和处理器对指令序列进行重排序:即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致。

指令重排的意义:JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

内存屏障是什么东西呢?

内存屏障,又称内存栅栏,是一个 CPU 指令。

在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止+ 特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。

那么volatile是怎么使用内存屏障来进行指令重排的呢?

JMM针对编译器制定的volatile重排序规则表 :” NO “ 表示禁止重排序。

image-20220327113006324

表的解释:I:当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后 。II:当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前 。III:当第一个操作是volatile写,第二个操作是volatile读时,不能重排序 。

上面语义的实现:编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略,可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义 。下面是基于保守策略的JMM内存屏障插入策略 :

1):在每个volatile写操作的前面插入一个StoreStore屏障 。

2):在每个volatile写操作的后面插入一个StoreLoad屏障 。

3):在每个volatile读操作的后面插入一个LoadLoad屏障 。

4):在每个volatile读操作的后面插入一个LoadStore屏障 。

图解:volatile写插入内存屏障后生成的指令序列示意图 :

为啥屏障会有重排序的作用呢:以StoreStore为例:StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存 。

image-20220327113021473

图解:volatile读插入内存屏障后生成的指令序列示意图 :

image-20220327113029154

注意:上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

3、volatile能保证原子性吗

volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性。

对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、加一、写三次操作,volatile并不能保证这三次操作的原子性。

共享的long和double变量的为什么要用volatile?

因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。在32位的机器上,对long和double变量的操作是分为两次操作,不是原子性的。但是在64位的机器上是原子性的操作。

参考链接:

Java 并发编程的艺术

https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247486479&idx=1&sn=433a551c37a445d068ffbf8ac85f0346&chksm=f98e48a5cef9c1b3fadb691fee5ebe99eb29d83fd448595239ac8a2f755fa75cacaf8e4e8576&scene=178&cur_album_id=1408057986861416450#rd


文章作者: Gtwff
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Gtwff !
  目录