JUC从实战到源码:再识volatile
😄生命不息,写作不止
🔥 继续踏上学习之路,学之分享笔记
👊 总有一天我也能像各位大佬一样
🏆 博客首页 @怒放吧德德 To记录领地 @一个有梦有戏的人
🌝分享学习心得,欢迎指正,大家一起学习成长!
转发请携带作者信息 @怒放吧德德(掘金) @一个有梦有戏的人(CSDN)
前言
在多线程编程中,确保数据的一致性和线程间的通信是至关重要的。volatile关键字在Java中扮演着轻量级同步机制的角色,它确保了共享变量的内存可见性,即一个线程对共享变量的修改能够立即被其他线程看到。虽然volatile与synchronized都是同步机制,但volatile更轻量,适用于特定场景。本章将深入探讨volatile的特性,包括其如何实现内存可见性和禁止指令重排,以及内存屏障在其中的作用。为了更好地理解这些概念,建议读者先阅读我之前的文章【【多线程与高并发】- 浅谈volatile】,这将有助于加深对volatile在Java内存模型中作用的理解。
简介
volatile是Java语言中的一种轻量级的同步机制,它可以确保共享变量的内存可见性,也就是当一个线程修改了共享变量的值时,其他线程能够立即知道这个修改。跟synchronized一样都是同步机制,但是相比之下,synchronized属于重量级锁,volatile属于轻量级锁。可见:【【多线程与高并发】- 浅谈volatile】
volatile 特性
对于 volatile 特性,本文只是做了简单介绍,在浅谈 volatile 文中做了详细介绍。
可见性(Visibility)
使用volatile关键字修饰的变量会在每次读取时强制从主内存中获取最新的值,并在每次修改后立即刷新到主内存中。这就意味着,当一个线程修改了volatile变量的值,其他线程能够立即看到变化,而不必依赖CPU缓存的同步。
我们通过一段代码来了解
/**
* @Author: lyd
* @Date: 2024/11/15
*/
public class VolatileExample {
private volatile boolean stopFlag = false;
public void startThread() {
new Thread(() -> {
while (!stopFlag) {
// 执行任务
}
System.out.println("Thread stopped.");
}).start();
}
public void stopThread() {
stopFlag = true; // 修改volatile变量,使线程可见
}
public static void main(String[] args) throws InterruptedException {
VolatileExample example = new VolatileExample();
example.startThread();
Thread.sleep(1000); // 模拟执行一段时间
example.stopThread(); // 停止线程
}
}
对于这个开关变量(stopFlag),当我们设置为 volatile 和非 volatile 变量,是两种结果。
这个原理就是和【【多线程与高并发】- 浅谈volatile】这篇文章中介绍的那样,volatile 修饰的变量被修改后会提交到主内存,这个线程是感知得到这个变量的变化,因为在读取这个变量的时候,会从主内存中 copy 一份到工作内存中。
禁止指令重排序(Ordering)
Java编译器和CPU可能会为了优化性能对指令进行重排序,而volatile关键字能够禁止这种重排序。对于被标记为volatile的变量,Java内存模型保证了其写操作和后续的读操作不会被重排序,以确保代码执行的顺序符合预期。这在构建线程安全的懒加载(lazy initialization)或双重检查锁定(Double-Check Locking)模式时尤为重要。
内存语义
- 当写一个volatle变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
- 当读一个volatle受量时,JMM 会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量,所以volatile的与内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。
volatile 变量的读写过程
把读写分为了 8 个每个工作内存与主内存的原子操作,如图:
read:作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存。
load:作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载。
use:作用于工作内存,将工作内存变量测本的值传递给执行引擎,每当 JVM 遇到需要该变最的宇节码指令时会执行该规作。
assgn:作用于工作用于工作内存从执行引擎接收到的值低值给工作内存变成,每当 JVM 遇到一个给变量赋值字节码指令时会执行该操作。
store:作用于工作内存,将赋值完毕的工作变量的值写回给主内存。
write:作用于主内存,将store传输过来的变量值赋值给主内存中的变量
由于上达6条只能保证单条指令的原子件,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM 提供了另外两个原子指令。
*lock:作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程。
*unlock:作用于主内存。把一个处于锁定状态的变量释放,然后才能被其他线程占用。
*无法保证原子性
原子性指的是一项操作要么都执行,要么都不执行,中途不允许中断也不受其他线程干扰。
对于代码的案例可以见【【多线程与高并发】- 浅谈volatile】这篇文章,这里就不在赘述。
内存屏障
使用volatile可以实现禁止指令重排序,从而确保并发安全,那么volatile是如何实现禁止指令重排序呢?就是通过使用内存屏障(Memory Barrier)。
内存屏障(Memory Barrier),又叫内存栅栏或内存屏障指令,是计算机体系结构中用来控制CPU和编译器的指令执行顺序的机制。它确保在特定操作之间存在指定的顺序,避免由于优化、缓存或指令重排等造成的不一致性。内存屏障实际上是一种 JVM 指令,Java 内存模型得重排规则会要求 Java 编译器在生成 JVM 指令时插入特定得内存屏障指令,volatile 就实现了 JMM 中得可见性和有序性,但是无法保证原子性。内存屏障主要在多处理器环境和并发编程中起到了保证数据一致性和顺序性的作用。
内存屏障分类
- 内存屏障之前的所有写操作都要写回主内存。
- 内存屏障之后的所有该条件都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。
写屏障(Store Barrier):告诉处理器在写解障之前将所有存错在级仔(stores)中的数据问步到主内作,也就是说当看到store屏障指令,就必须把该指令之前所有写入指令执行完毕才能继续往下执行。
读屏障(Load Barrier):保证屏障后的读操作在屏障前的读操作之前完成。确保在某些情况下,后续的读操作能够正确地反映之前的数据。
*全屏障(Full Barrier):保证屏障前的所有内存写操作在屏障后的所有内存读写操作之前完成,常用于线程间同步,确保数据写入主内存之前不会被重新排序。
源码分析
首先打开 jdk 的源码,找到 Unsafe.java 类。我们可以看到这几个 native 方法,也就是读屏障、写屏障、全屏障定义方法。
接着找到对应的 c++代码,就是 unsafe.cpp ,在 466 行左右。
UNSAFE_ENTRY(void, Unsafe_LoadFence(JNIEnv *env, jobject unsafe))
// UnsafeWrapper是另一种宏,用于在调用Unsafe方法时包装调用过程,通常用于调试、日志记录或资源管理。
// 在这种情况下,UnsafeWrapper传入了一个字符串 "Unsafe_LoadFence",该字符串表示当前方法名,便于在调试时跟踪和识别Unsafe方法的调用。
UnsafeWrapper("Unsafe_LoadFence");
// 这一行是loadFence的核心,OrderAccess::acquire()是一种典型的读屏障或获取屏障(Load/Acquire Barrier)。
// 在Java的底层实现中,OrderAccess类用于封装各种内存屏障操作,确保内存访问的顺序性。
// OrderAccess::acquire()可以确保当前线程在屏障之前的所有读操作都在屏障之后的读操作之前完成,从而保证了对共享数据的可见性
OrderAccess::acquire();
UNSAFE_END
UNSAFE_ENTRY(void, Unsafe_StoreFence(JNIEnv *env, jobject unsafe))
UnsafeWrapper("Unsafe_StoreFence");
OrderAccess::release();
UNSAFE_END
UNSAFE_ENTRY(void, Unsafe_FullFence(JNIEnv *env, jobject unsafe))
UnsafeWrapper("Unsafe_FullFence");
OrderAccess::fence();
UNSAFE_END
可见进到了 UNSAFE_ENTRY 方法中,将会执行读写屏障。
UNSAFE_ENTRY是一个宏,用于封装JNI本地方法的注册流程,包含必要的JNI环境(JNIEnv)和对象引用(jobject unsafe),确保在本地代码中可以使用Java对象和环境。
UnsafeWrapper是另一种宏,用于在调用Unsafe方法时包装调用过程,通常用于调试、日志记录或资源管理。
- Acquire Barrier:用于保证屏障后的操作在此之前的所有操作完成,通常在获取锁时使用。
- Release Barrier:用于保证屏障之前的操作在此之后的所有操作完成,通常在释放锁时使用。
在Java中,Unsafe.loadFence()可以用于构建高效的非阻塞算法。例如,在某些CAS(Compare-And-Swap)操作中,可能只需要确保读取的数据是最新的,而不希望整个对象加锁。在这种场景下,可以插入loadFence,确保数据读取的最新性而不会被重排序。
接着再来看 orderAccess.hpp 类。
内存屏障大分类有两种:load 和 store,而细分且有四种:loadload、storestore、loadstore、storeload。
最终结合 linux 的底层源码 orderAccess_linux_x86.inline.hpp
来看。
像 loadload 方法,真正实现的是 acquire() 方法。
这个OrderAccess::acquire()函数实现了一个获取屏障(Acquire Barrier),在Java的底层代码中用于控制内存访问顺序。它使用了内联汇编来执行特定的处理器指令,从而实现读取屏障,确保多线程环境下的内存可见性。
四个屏障
- StoreStore屏障:确保在该屏障之后的第一个写操作之前,屏障前的写操作对其他处理器可见(刷新到内存)。
- StoreLoad屏障:确保写操作对其他处理器可见(刷新到内存)之后才能读取屏障后读操作的数据到缓存。
- LoadLoad屏障:确保在该屏障之后的第一个读操作之前,一定能先加载屏障前的读操作对应的数据。
- LoadStore屏障:确保屏障后的第一个写操作写出的数据对其他处理器可见之前,屏障前的读操作读取的数据一定先读入缓存。
屏障的插入策略
对于 happens-before 的 volatile 变量规则有如下表
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile 读 | 第二个操作:volatile 写 |
---|---|---|---|
普通读写 | 可以重排 | 可以重排 | 不可以重排 |
volatile 读 | 不可以重排 | 不可以重排 | 不可以重排 |
volatile 写 | 可以重排 | 不可以重排 | 不可以重排 |
上表的含义是,对于第一个操作是普通读写还是 volatile 读写,对应的第二个操作有 3 种,每种都会产生交叉效果,即对于指令是否课重排序的规则。
在volatile修饰的变量进行写操作时候,会使用StoreStore屏障和StoreLoad屏障,进行对volatile变量读操作会在之后使用LoadLoad屏障和LoadStore屏障。对于 volatile 的读写操作,对于第二操作会有读写屏障的插入,接下来将会进行介绍读屏障和写屏障的插入策略。
读屏障
在 volatile 读插入内存屏障如下指令序列示意图:
- 在每个 volatile 读操作后面插入一个 LoadLoad 屏障
- 禁止处理器把上面的 volatile 读与下面的普通读重排序。
- 在每个 volatile 写操作后面插入一个 LoadStore 屏障
- 禁止处理器把上面的 volatile 读与下面的普通写重排序。
写屏障
在 volatile 写插入内存屏障如下指令序列示意图:
- 在每个 volatile 写操作后面插入一个 StoreStore 屏障
- 可以保证在 volatile 写之前,其前面的所有普通写操作都已经刷新到主内存中。
- 在每个 volatile 写操作后面插入一个 StoreLoad 屏障
- 作用是来避免 volatile 写与后面可能有的 volatile 读/写操作重排序。
使用场景
- 状态标记:常用于布尔类型的状态标记,如停止标志
stopFlag
,使多个线程可以共享控制该标志的状态。 - 单例模式中的双重检查锁定:在单例模式中配合
volatile
和双重检查锁定来保证线程安全。 - 轻量级同步:
volatile
变量适合那些状态简单、非依赖其他数据、只需保证最新值的共享变量(例如计数器或状态标识符)。
总结
volatile关键字是Java中一种重要的轻量级同步机制,它通过确保内存可见性和禁止指令重排来保障多线程环境下的数据一致性。在本章中,我们详细探讨了volatile的特性,包括其如何通过内存屏障实现对指令重排的控制,以及它在实现状态标记、双重检查锁定和轻量级同步中的应用。尽管volatile提供了内存可见性和一定程度的有序性,但它并不能保证操作的原子性。因此,在使用volatile时,需要根据具体场景谨慎选择,以确保程序的正确性和性能。通过理解volatile的工作原理和使用场景,开发者可以更有效地利用这一机制来构建高效、可靠的并发程序。
转发请携带作者信息 @怒放吧德德 @一个有梦有戏的人
持续创作很不容易,作者将以尽可能的详细把所学知识分享各位开发者,一起进步一起学习。转载请携带链接,转载到微信公众号请勿选择原创,谢谢!
👍创作不易,如有错误请指正,感谢观看!记得点赞哦!👍
谢谢支持!