Administrator
发布于 2018-04-11 / 3601 阅读
54

volatile 到底解决了什么问题?可见性与指令重排

同事问我:既然有 volatile,为什么还要加锁

上周 code review,同事看到我写的一个状态标记用了 volatile,问我:"这东西不是能保证线程安全吗,那 synchronized 还有什么用?"

我说不能这么理解,但当时没能讲清楚。下来自己看了几天 JMM 的资料,又写了几个例子验证,才算是理顺了。

先看一段跑不完的代码

这是最能说明问题的例子。一个线程改标记,另一个线程等标记变了就退出:

public class VolatileDemo {
    private static boolean stop = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            int i = 0;
            while (!stop) {
                i++;
            }
            System.out.println("子线程退出,i = " + i);
        }).start();

        Thread.sleep(1000);
        stop = true;
        System.out.println("主线程已把 stop 置为 true");
    }
}

按常识,1 秒后 stop 变 true,子线程应该退出。但实际运行结果是:主线程打印了"已把 stop 置为 true",然后程序永远不结束

我第一次跑出来的时候以为是偶发,又跑了十几次,在 JDK 8 + macOS 上有大概 7 成概率复现。加上 volatile 之后,20 次全部正常退出。

为什么会看不见:JMM 的可见性

根子在 Java 内存模型(JMM)上。JMM 规定,每个线程有自己的工作内存,变量的主副本放在主内存里。线程读写变量时,是先把主内存的值拷到工作内存,改完再写回去。

主内存   stop = false
            |
     +------+------+
     |             |
工作内存(线程A)  工作内存(线程B)
 stop=false      stop=false

线程 B 把 stop 改成 true 之后,这个新值可能还躺在 B 的工作内存里,没来得及刷回主内存;也可能刷回了主内存,但线程 A 的工作内存里还是旧副本,一直没去重新读。

更要命的是,JIT 编译器看到 while (!stop) 这个循环里没有任何写 stop 的操作,会认为"stop 在这个线程里不会变",然后把它优化成:

if (!stop) {
    while (true) {   // 循环展开,不再每次读变量
        i++;
    }
}

这下彻底没救了,连工作内存都不查了,直接死循环。

volatile 的作用就是打断这两个优化

  • 写 volatile 变量时,JMM 会立即把该线程工作内存中的新值刷新到主内存
  • 读 volatile 变量时,JMM 会把该线程工作内存中的副本置为无效,强制从主内存重新读;
  • volatile 修饰的变量不会被编译器做上述激进优化。

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

volatile 还有个作用我一开始完全没意识到——禁止指令重排序

编译器和 CPU 为了跑得快,会在不改变单线程语义的前提下打乱指令顺序。下面这段双重检查锁(DCL)单例就是经典受害者:

public class Singleton {
    private static Singleton instance;    // 没有 volatile!

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

instance = new Singleton() 这一句在字节码层面大致分三步:

  1. 分配一块内存空间;
  2. 在这块内存上初始化对象;
  3. 把 instance 引用指向这块内存。

第 2 步和第 3 步之间没有数据依赖,所以可能被重排成 1 → 3 → 2。如果线程 A 执行完 3(instance 已经非 null 了)但还没执行 2(对象还是个空壳),此时线程 B 进来判断 instance != null,直接 return 了一个还没初始化完的对象。后续调用它的方法就是各种诡异的空指针。

加上 volatile 之后,JVM 会在写操作前后插入内存屏障,禁止把 3 排到 2 前面:

private static volatile Singleton instance;

这就是 volatile 在 DCL 里不可或缺的原因,不是为了可见性,是为了有序性

happens-before:JMM 给开发者的承诺

上面说的"可见"和"有序",JMM 用一套更严格的方式定义,叫 happens-before 规则。如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 可见。JMM 定义了 8 条天然规则,我常用的是这几条:

  • 程序次序规则:同一个线程内,前面的操作 happens-before 后面的。(注意,这只保证单线程语义,不保证不被重排。)
  • volatile 变量规则:对一个 volatile 变量的写,happens-before 后续对这个变量的读。
  • 传递性:A happens-before B,B happens-before C,则 A happens-before C。
  • 监视器锁规则:unlock happens-before 后续的 lock。

volatile 规则 + 传递性组合起来有个很有用的推论:线程 A 先写了一个普通变量 x = 1,再写一个 volatile 变量 flag = true;线程 B 读到 flag == true,那么 B 一定能看到 x == 1。这个技巧在JDK 的 ConcurrentHashMap 等源码里很常见。

volatile 保证不了原子性

现在回到同事那个问题。volatile 能保证可见性和有序性,但它不保证原子性

写段代码验证:

public class VolatileAtomicDemo {
    private static volatile int count = 0;
    private static final int THREADS = 10;
    private static final int LOOPS = 10000;

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(THREADS);
        for (int i = 0; i < THREADS; i++) {
            new Thread(() -> {
                for (int j = 0; j < LOOPS; j++) {
                    count++;
                }
                latch.countDown();
            }).start();
        }
        latch.await();
        System.out.println("期望: " + (THREADS * LOOPS) + ",实际: " + count);
    }
}

跑 5 次的结果:

期望: 100000,实际: 43217
期望: 100000,实际: 38902
期望: 100000,实际: 51063
期望: 100000,实际: 44780
期望: 100000,实际: 40155

一次都没对过。因为 count++ 不是一步操作,它拆成"读 count → 加 1 → 写回"三步。volatile 只能保证读的时候拿到最新值、写完立刻刷回主内存,但管不了中间:线程 A 读到 count=10,还没来得及加 1,线程 B 也读到 10,俩人都算出 11 写回去,两次自增只增加了 1。

想要原子性有三条路:

  1. synchronized 或 Lock:最通用,有性能开销。
  2. AtomicInteger:CAS 自旋,无锁,这种简单计数场景首选:
private static AtomicInteger count = new AtomicInteger(0);
// ...
count.incrementAndGet();

实测同样的 10 万次自增,AtomicInteger 耗时约 8ms,synchronized 约 23ms。

  1. LongAdder:JDK 8 新增,高并发下比 AtomicInteger 更快,它内部维护多个 Cell 分散竞争。1000 个线程竞争的场景下我测过,LongAdder 比 AtomicInteger 快 4 倍多。但它求和时不保证强一致性,适合做监控计数这种允许略有偏差的场景。

所以到底什么时候用 volatile

我现在判断的标准是看这个操作本身是不是原子的:

场景能否用 volatile
一个线程写、多个线程读的状态标记(如 shutdown 标志)适合
变量的新值不依赖旧值(如 flag = true)适合
DCL 单例的实例引用必须加
count++ 这种读改写复合操作不行,用 AtomicInteger
需要多个变量一起保持一致的(如 i 和 j 的约束)不行,用锁

简单说,volatile 是轻量级的可见性保障,不是锁的替代品。它比 synchronized 便宜(没有上下文切换和阻塞),但能力也弱得多,只能在"写操作不依赖当前值"这个前提下安全使用。

那天 review 之后我把这段话发给了同事,他回了句"懂了,就是能读不能算"。虽然粗糙,倒是挺准确。

参考