java并发之volatile关键字

44次阅读

共计 2512 个字符,预计需要花费 7 分钟才能阅读完成。

Java 面试中经常会涉及关于 volatile 的问题。本文梳理下 volatile 关键知识点。

volatile 字意为“易失性”,在 Java 中用做修饰对象变量。它不是 Java 特有,在 C,C++,C# 等编程语言也存在,只是在其它编程语言中使用有所差异,但总体语义一致。比如使用 volatile 能阻止编译器对变量的读写优化。简单说,如果一个变量被修饰为 volatile,相当于告诉系统说我容易变化,编译器你不要随便优化(重排序,缓存)我。

Happens-before

规范上,Java 内存模型遵行happens-before

volatile 变量在多线程中,写线程和读线程具有 happens-before 关系。也就是写值的线程要在读取线程之前,并且读线程能完全看见写线程的相关变量。

happens-before:如果两个有两个动作 AB,A 发生在 B 之前,那么 A 的顺序应该在 B 前面并且 A 的操作对 B 完全可见。

happens-before 具有传递性,如果 A 发生在 B 之前,而 B 发生在 C 之前,那么 A 发生在 C 之前。

如何保证可见性

多线程环境下 counter 变量的更新过程。线程 1 先从主存拷贝副本到 CPU 缓存,然后 CPU 执行 counter=7,修改完后写入 CPU 缓存,等待时机同步到主存。在线程 1 同步主存前,线程 2 读到 counter 值依然为 0。此时已经发生内存一致性错误(对于相同的共享数据,多线程读到视图不一致)。因为线程 2 看不见线程 1 操作结果,也将这个问题称为 可见性问题

public class SharedObject {public int counter = 0;}

因为多了缓存优化导致,导致可见性问题。所以 volatile 通过消除缓存(描述可能不太准确)来避免。例如当使用 volatile 修饰变量后,操作该变量读写直接与主存交互,跳过缓存层,保证其它读线程每次获取的都是最新值。

    public volatile int counter = 0;

volatile 不单只消除修饰的变量的缓存。事实上与之相关的变量在读写时也会消除缓存,如同使用了 volatile 一样。

如下 years,months,days 三个变量中只有 days 是 volatile,但是对 years,months 读写操作也和 days 时也会跳过缓存,其它线程每次读到的都是最新值。

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

这是为什么?我们分析一下。

一个写线程调用 update,读线程调用 totalDays。单线程中,对于 update 方法,wa 与 wb 存在 happens-before 关系,wawb 之前执行并对 wb 可见。

多线程中 rc 与 wb 存在 happens-before 关系,wbrc 之前执行并对 rc 可见。根据 happens-before 传递性,wa需要在 rc 前先执行并对 rc 可见。

因为 wb 是 volatile 变量,所以 rc 获取的 years,months 也是最新值。

我们知道出于性能原因,JVM 和 CPU 会对程序中的指令进行重新排序。如果 update 方法里面 wawb顺序被重排,那它们的 happens-before 关系将不在成立。

为了避免这个问题,volatile 对重排序做了保证 对于发生在 volatile 变量操作前的其他变量的操作不能重新排序

由此我们得到 volatile 通过 消除缓存 防止重排 保证线程的可见性。

volatile 保证线程安全?

讨论线程安全,大家都会提及 原子性 顺序性 可见性。volatile 侧重于保证可见性,也就是当写的线程更新后,读线程总能获得最新值。在只有一个线程写,多个线程读的场景下,volatile 能满足线程安全。可如果多个线程同时写入 volatile 变量时,则需要引入同步语义才能保证线程安全。

模拟 10 个线程同时写入 volatile 变量,一个线程读 counter,执行完后正确结果应该是 counter=10。

    public static class WriterTask implements Runnable {
        private final ShareObject share;
        private final CountDownLatch countDownLatch;
        public WriterTask(ShareObject share, CountDownLatch countDownLatch) {
            this.share = share;
            this.countDownLatch = countDownLatch;
        }
        @Override
        public void run() {countDownLatch.countDown();
            share.increase();}
    }
    
    public class ShareObject {
        private volatile int counter;
        public void increase() {this.counter++;}
    }

执行结果出现 counter= 5 或 6 错误结果。

通过 synchronized,Lock 或 AtomicInteger 原子变量保证了结果的正确。

完整 demo https://gist.github.com/onlyt…

volatile 性能

volatile 变量带来可见性的保证,访问 volatile 变量还防止了指令重排序。不过这一切是以牺牲优化(消除缓存,直接操作主存开销增加)为代价,所以不应该滥用 volatile,仅在确实需要增强变量可见性的时候使用。

总结

本文记录了 volatile 变量通过消除缓存,防止指令重排序来保证线程可见性,并且在多线程写入的变量的场景下,不保证线程安全。

欢迎大家留言交流,一起学习分享!!!

正文完
 0