大家好,我是徒手敲代码,今天来分享一下 volatile 这个关键字。
本文将多线程场景,比喻成在饭店里,服务员上菜,以及修改菜单的场景,将抽象的东西实例化。
在计算机中,假设 CPU 是一个精明的老板,它有多个服务员(线程),而内存则是一本巨大的菜单,记载着各种菜肴(数据)。为了提高效率,CPU还为每个服务员配备了私人备忘录(高速缓存),让他们快速查阅 和更新菜单信息。然而,这种看似牛逼的机制,却悄悄埋下了线程不安全的隐患。
多核CPU中的线程不安全
在多核CPU的舞台上,每个服务员都拥有自己的专属厨房(核心),可以独立烹饪菜肴。当一个服务员在自己的厨房里更新了某道菜的价格,如果没有及时通知其他服务员,他们可能还在用旧的价格为客人下单。这就是线程不安全问题的源头:每个线程之间的通信延迟,导致了数据的不一致。
Java中的线程不安全
当 Java 线程修改某个变量时,它首先在自己的工作内存中进行操作,就像服务员在私人备忘录上涂改菜价。然而,如果不采取措施,这些改动可能不会立刻同步到全局菜单(主内存,即Java堆内存),导致其他线程看到的仍是过期信息。这跟多核CPU中,高速缓存可能导致的数据不一致问题,是同一个道理。
可见性
"可见性"好比服务员间关于菜单更新的默契约定。当一个服务员修改了菜价,其他服务员应当立即知晓并使用新价格。在 Java 中,volatile 关键字就是实现这种"可见性"的秘密武器。它确保了一个线程对volatile变量的修改,对其他线程来说是立即可见的,仿佛服务员之间通过无线电广播实时通报菜价变动。"牛肉饭快买完了,改成五十蚊一份!Over Over !!"
volatile保证变量可见性的原理
在处理器的世界里,内存被划分为不同的区域,如寄存器、高速缓存、主内存等。volatile变量牛逼的地方在于,它能迫使 CPU 遵循以下这些特定的规则:
- 写入规则:当一个线程修改 volatile 变量时,不仅更新自己的高速缓存,还要立即将新值写回主内存,就像服务员不仅在私人备忘录上改价,还要在公共菜单上正式公布。
- 读取规则:当另一个线程访问 volatile 变量时,它必须从主内存中获取最新值,而非依赖自己高速缓存的副本。这就如同服务员在接单前,先确认公共菜单上的最新价格。
缓存一致性协议
处理器内部的缓存一致性协议,是 volatile 走上人生巅峰的幕后功臣。这些协议确保了当一个缓存中的数据被修改并写回主内存时,其他缓存中对应的旧数据会被标记为无效 ,迫使其他线程下次访问时从主内存重新加载。这就如同饭店的无线电系统自动通知所有服务员,他们的私人备忘录关于某道菜的价格已失效,需查阅公共菜单获取最新信息。
加了volatile 就万事大吉了吗?
未免想得太简单了吧?如果真是这样,那还要程序猿干嘛呢? 尽管 volatile 保证了可见性,但线程安全问题仍未彻底解决。这是因为 CPU 为了优化性能,有时候会给你来一个指令重排序。就好比服务员在处理订单时,先给客人上饮料(操作A),再记账(操作B),虽然不影响最终结果,但如果另一个服务员恰好此时查看账目,可能会看到未完成的记录。以下 Java 代码演示了这种情况:
arduino
volatile boolean ready = false;
int result = 0;
// 线程1
public void writer() {
// 操作A
result = 1;
// 操作B
ready = true;
}
// 线程2
public void reader() {
// 循环等待 ready 变为 true
while (!ready);
// 假设此时输出为 0,则存在线程安全问题
System.out.println(result);
}
尽管ready
被声明为volatile,但由于 CPU 可能对操作A和B进行了重排序,线程2可能在ready
变真之前看到未初始化的result
,导致输出错误结果。虽然重排序并未改变单个变量的值,但破坏了操作的逻辑顺序,引发了线程安全问题。
原子性问题
volatile 虽神通广大,却无法保证一个变量的线程安全。原因在于,Java中的运算并非原子操作。例如以下这行代码,其实包含读取b、加1、写入a三个步骤。
ini
int a = b + 1;
在多线程环境下,如果多个线程同时执行这段代码,可能会导致最终结果计数少了。这就像服务员同时给同一道菜计数,一人加1后还没写入菜单,另一人又开始加1,结果只增加了1次销量。
下面这段小程序,就可以模拟上述这个场景。
ini
class VolatileCounter {
volatile int count = 0;
public void increment() {
count++;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
VolatileCounter counter = new VolatileCounter();
Thread[] threads = new Thread[100];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
counter.increment();
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("Expected count: " + (threads.length * 10000));
System.out.println("Actual count: " + counter.count);
}
}
运行这段代码,你会发现实际计数值往往小于预期,证明 volatile 无法保证increment()
方法的线程安全性。
volatile什么时候能保证线程安全?
那么,何时volatile能确保线程安全呢?答案是:当仅需要保证变量的可见性,且该变量的读写操作天然具备原子性时。
用单例模式举个例子。
csharp
public class Singleton {
private volatile static Singleton instance; // 使用volatile修饰实例
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 创建单例实例
}
}
}
return instance;
}
}
在这个例子当中:第一,我们需要所有线程都能看到instance
变量被正确赋值;第二,对引用类型(如Singleton实例的引用)的读写操作本身是原子性的。因此可以保证线程安全。
今天的分享到这里结束了,如果你喜欢这种分享知识的方式,可以在下方留言喔。
关注公众号"徒手敲代码",让知识变得简单。
回复"电子书",获取大佬推荐的Java书籍💪