有关于volatile的思考

作为一个小菜鸟,我看了很多大佬写的文章,尝试站在大佬的肩膀上了解volatile的大致工作原理。这些文章解答了我心中的很多疑惑,其中是有一些问题是在找了很多资料后才逐渐明白的,对我而言,这是很有意义的。

说到Java中的volatile,我们可能很容易想到以下相关联的词:

  1. 指令重排
  2. 内存屏障
  3. CPU缓存一致性
  4. 可见性
  5. 原子性

指令重排

public class Singleton {
    public static Singleton singleton;

    private Singleton() {};

    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (singleton) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}
复制代码

比如上面代码中的new Singleton()其实可以分为3个步骤:

  1. 分配内存空间
  2. 初始化对象
  3. 将内存空间的地址赋值给对应的引用

假如发生指令重排序:

  1. 分配内存空间
  2. 将内存空间的地址赋值给对应的引用。
  3. 初始化对象

那是不是其他线程有可能获取到一个未初始化好的对象?volatile可以通过内存屏障解决这个问题

内存屏障

1、LoadLoad
Load1—>Loadload—>Load2:在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕

2、StoreStore
Store1—>StoreStore—>Store2:在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见

3、LoadStore
Load1—>LoadStore—>Store2:在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕

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

为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型处理器的重排序,在JMM中,编译器在生成字节码时会针对volatile插入相关内存屏障:

  1. 在每个volatile写操作之前插入一个StoreStore屏障:保证在volatile写之前,之前的写全部更新到主存
  2. 在每个volatile写操作之后插入一个StoreLoad屏障:在读后续变量之前,保证volatile写成功,防止指令重排
  3. 在每个volatile读操作之后插入一个LoadLoad屏障::读volatile之前,保证之前的变量读成功,防止之前的读和volatile读重排
  4. 在每个volatile读操作之后插入一个LoadStore屏障:保证在读volatile成功之后,才进行下面的写,防止之后的读和volatile读重排

CPU缓存一致性

10 张图打开 CPU 缓存一致性的大门

可见性

假如我们根据网上的一些资料,知道了volatile的一些特性:

  1. 我们把JVM中的工作内存和主存比如CPU中的L1、L2 Cache 和 CPU中的L1 Cache 、主存
  2. 线程1修改了volatile变量,会立即同步到主存,同时让线程2工作内存中的值变为失效,线程2去读取该值的时候,会重新去从主存中获取最新值
  3. 步骤2如何实现的呢?这里涉及到系统总线嗅探

引用网上看到最多的一句话:

当CPU0更新了主存之后,就向系统总线发一个通知,CPU1嗅探到了该通知,于是CPU1将自己的L1 L2 Caceh设置为失效,CPU1上的线程读取该值时,发现该值已失效,于是从主存中读取

网上基本上都是这样说的,但是仔细一想就感觉有问题,从CPU0发通知,到CPU1让缓存失效,整个流程是什么?很容易想到已下几个问题:
1、如果CPU0先更新主存再发通知,那在CPU1将自己的缓存设置失效这段时间内,CPU1不就读到老数据了吗?
image.png

2、如果CPU0向发通知再个更新主存,那在CPU1缓存失效到CPU0更新主存成功这段时间,CPU1不是又有可能读到主存中的老数据吗?
image.png

那这个通知究竟是如何来做的呢?

首先CPU0不会立即写主存,假设CPU0的缓存行为M(修改),CPU1的缓存行标记为I(失效),CPU1在读取失效的缓存行时,会先通知CPU0, 然后CPU0把修改后的结果告诉主内存和CPU1的缓存行,并标记为S

参考连接 www.zhihu.com/question/29…

原子性

我们知道volatile可以保证可见性,那为什么说volatile无法保证原子性呢?这里有几个问题:

1、其他CPU缓存失效的情况下,为什么其他线程对volatile的修改还是不能保证原子性?之前线程修改后的数据已经写回到主内存中了,线程切换后缓存失效其他线程不是会重新读主内存的数据到缓存中吗?
2、volatile前后不是会插入内存屏障吗?既然这样,为什么i++是不安全的?

这是我见过最清晰的解释:知乎大佬:volatile原子性分析

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

volicate修饰对象

网上都说:volicate修饰地址或对象时,操作的修饰的是对象或数组的引用,只有当这个引用的地址发生变化时,才会触发volicate特性,所以,当我们修改对象的属性或数组元素时,对其他线程不可见

我有好多疑问:

  1. volicate修饰一个对象时:对象都是放在里面的,假如我们修改这个对象里面的某个属性,此时这个对象的地址并没有发生变化啊,数据不都是要从堆里面读出来的吗?那是不是说在这种情况下,使用volicate修饰一个对象没有什么意义呢?除非我们new了一个新对象赋值给这个引用?
  2. volicate修饰一个数组时:

希望大佬可以解答一下

参考连接

  1. 10 张图打开 CPU 缓存一致性的大门
  2. 知乎大佬:volatile可见性分析
  3. 知乎大佬:volatile原子性分析
  4. 美团技术团队:Java内存访问重排序的研究
  5. 并发编程网:深入分析Volatile的实现原理
  6. 并发编程网:volatile是否能保证数组中元素的可见性
  7. 并发编程网:CopyOnWriteArrayList类set方法疑惑
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享