更多博客请见 我的语雀知识库


第一个作用:保证可见性

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主内存中,当其他线程读取此变量时,会去主内存中读取新值。这样就保证了多个线程之间的可见性

** 假如B线程时刻X去主存里读volatile修饰的变量的值,A线程在时刻Y修改了该变量的值,在时刻Z将修改后的值写回主存。现在时刻X在时刻Y和Z的中间,该变量被修改的时间点早于B读取它的时间,但B却没有读取到。那么可见性保证了吗?
这种情况不存在,总线机制保证B一定能读取到修改后的值。
具体见下文解释:**
上文提到了:“当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主内存中,当其他线程读取此变量时,会去主内存中读取新值”,实际上是这样的:
线程的CPU会一直在总线BUS上嗅探其内部缓存中的内存地址在其他处理器的操作情况,一旦嗅探到某处处理器打算修改其内存地址中的值,而该内存地址刚好也在自己的内部缓存中,那么处理器就会强制让自己对该缓存地址的无效。所以当该处理器要访问该数据的时候,由于发现自己缓存的数据无效了,就会去主存中访问。
这里涉及到了缓存一致性协议。
也就是说,只要修改的时间点发生在读取之前,即使还没来得及写回主存,其它线程的CPU也会让自己的cache中的该变量无效,让读操作一定去主存读。
事实上,完整的 MESI 协议更复杂,但我们没必要记得这么细。我们只需要记住最关键的 2 点:

  • 关键 1 - 阻止同时有多个核心修改的共享数据: 当一个 CPU 核心要求修改数据时,会先广播 RFO 请求获得 Cache 块的所有权,并将其它 CPU 核心中对应的 Cache 块置为已失效状态
  • 关键 2 - 延迟回写: 只有在需要的时候才将数据写回内存,当一个 CPU 核心要求访问已失效状态的 Cache 块时,会先要求其它核心先将数据写回内存,再从内存读取。

关键1中的”当一个 CPU 核心要求修改数据时,会先广播 RFO 请求获得 Cache 块的所有权,并将其它 CPU 核心中对应的 Cache 块置为已失效状态“就是解决上文”该变量被修改的时间点早于B读取它的时间,但B却没有读取到“这种情况的。保证B一定能读到。

如果有多个CPU同时对volatile变量做写操作。如何保证顺序?或者说,如何保证串行化? 由总线仲裁系统保证。 总线的独占性要求同一时刻最多只有一个主模块占用总线,天然地会将所有核心对内存的读写操作串行化。如果多个核心同时发起总线事务,此时总线仲裁单元会对竞争做出仲裁,未获胜的事务只能等待获胜的事务处理完成后才能执行。’

参考来源:https://cloud.tencent.com/developer/article/2197853

:::info 关于happens-before原则:
实现了该原则的编译器承诺: 在满足一些条件后:

  1. 如果一个操作在另一个操作之前发生(happens - before),那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。即使第一个操作的结果产生时间晚于第二个操作的开始时间。
  2. 两个操作之间存在 Happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 Happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 Happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM 允许这种重排序)

happens-before原则的条件有下面这些:

  1. 程序顺序规则:一个线程内的操作按代码顺序发生。
  2. 管程锁定规则:解锁操作发生在随后的同一个锁的加锁操作之前。
  3. volatile变量规则:对volatile字段的写操作发生在随后的读操作之前。
  4. 线程启动规则:线程的start()方法调用发生在该线程的任何操作之前。
  5. 线程终止规则:线程的所有操作都发生在线程的终止之前。
  6. 对象终结规则:对象的finalize()方法调用发生在该对象的垃圾回收之前。
  7. 线程中断规则:Thread.interrupted()方法检测到中断状态的发生,发生在实际的中断操作之前
  8. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

只要你的代码满足了这八条规则中的任何一条,或者通过规则间的传递性和组合可以满足,那么编译器就一定能实现happens-before。
在任何一条都不满足的情况下,happens-before原则不会生效。 image.png
为什么线程启动、终止、中断规则和对象终结规则也和这里完全没有关系?
因为这些规则与线程的生命周期有关。示例中的代码片段并没有涉及到线程的启动、终止或中断,因此这些规则也不适用。 :::

第二个作用:禁止指令重排

image.png
假如某个变量的读写涉及到了2条指令,那么由于指令重排序的原因,第一条指令和第二条指令中间可能穿插了多条其它线程的指令,导致读写速度变慢。加上volatile修饰去禁止指令重排序后,会提高该线程的运行效率。
指令重排序保证结果不受影响,但不保证这原本的多条指令集合是顺序执行的。

双重校验锁DCL:

关于 volatile 最出名的应用就是单例模式的 双重校验锁(Double Checked Locking,DCL) 写法了。如下:

public class SingleTon {
	// 私有化构造方法
	private SingleTon(){}; 

	private static volatile SingleTon instance = null;
	public static SingleTon getInstance() {
            // 第一次校验,减少锁的竞争  
            if (instance == null) { 
                synchronized (SingleTon.class) {
                       // 第二次校验
                        if (instance == null) {     
                            instance = new SingleTon();
                        }
                }
            }
    }
    
 	return instance;
}

先来解释下这两重校验分别作了什么:

第一重校验:

由于单例模式只需要创建一次实例,所以如果多次调用 getInstance 方法的话,应该直接返回第一次创建的实例。因此其实大部分时间都是不需要去执行同步方法里面的代码的,减少了锁的竞争。这样,第一重校验大大提高了性能。

第二重校验:

我们先假设没有第二重校验。
假设线程 t1 执行了第一重校验后,判断为 instance == null;
就在这个时候,发生上下文切换,另一个线程 t2 获得了 CPU 调度,并且也执行了第一重校验,也判断 instance == null,随后 t2 获得锁,创建实例;
然后,发生上下文切换,t1 又重新获得 CPU 调度,由于之前已经进行了第一重校验,结果为 true(不会再次判断),所以 t1 也会去获得锁并创建实例。这样就会导致创建多个实例。
所以需要在同步块里面进行第二重校验,如果实例为空,才进行创建。

再来解释下为什么 instance 一定要用 volatile 这个关键字来修饰。

这里就是 volatile 第二项特性 - 禁止指令重排的应用。在 Java 语言层面上,创建对象仅仅是一个 new 关键字而已,而在 JVM 中,对象的创建其实并不是一蹴而就的,忽略掉一些 JVM 底层的细节比如设置对象头啥的,对象的创建可以大致分三个步骤:

  1. 在堆中为对象分配内存空间
  2. 调用构造函数,初始化实例
  3. 将栈中的对象引用指向刚分配的内存空间

那么由于 JVM 指令重排优化的存在,有可能第二步和第三步发生交换:

  1. 在堆中为对象分配内存空间
  2. 将栈中的对象引用指向刚分配的内存空间(线程可能在这个时刻获取了未初始化的instance
  3. 调用构造函数,初始化实例

现在考虑重排序后,两个线程发生了以下调用:
image.png
在这种情况下,线程 T2 访问到的就是一个未完成初始化的对象,是个半成品,会报空指针异常的错误。
所以说,instance 一定要用 volatile 这个关键字来修饰,从而禁止指令重排。

作者:飞天小牛肉
链接:https://leetcode.cn/leetbook/read/concurrency/a8hxqg/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。