前面已经用 volatile 做过停止标记。它最容易被误用的地方,是把“看得见修改”理解成“所有操作都线程安全”。

看得见,不代表不会覆盖

假设两个线程同时执行 count++,即使 count 是 volatile,它们仍然可能都读到 10,各自算出 11,再分别写回。没有哪个写入消失,但一次递增的效果被覆盖了。

因此 volatile 适合状态标记、独立值的发布,不适合单独保护“读取后再更新”的操作。多个字段需要一起保持一致时,也不能只给每个字段都加上 volatile 就算完成同步。

发布一个完整配置

可以把相关字段放进不可变对象,再通过一个 volatile 引用整体替换:

public class VolatileConfigDemo {
    private static class Config {
        final String host;
        final int port;

        Config(String host, int port) {
            this.host = host;
            this.port = port;
        }
    }

    private static volatile Config config = new Config("localhost", 8080);

    public static void main(String[] args) throws InterruptedException {
        Thread writer = new Thread(() -> {
            config = new Config("localhost", 9090);
        });
        writer.start();
        Config current = config;
        System.out.println(current.host + ":" + current.port);
        writer.join();
    }
}

这里可能打印 8080,也可能打印 9090,但读取的是同一个配置对象。先保存到局部变量,是为了避免连续读取 volatile 引用时恰好跨过一次替换。

volatile 修饰的是 config 这个引用。假如对象里的字段仍然能被随意修改,引用的 volatile 属性不会自动把所有字段操作都变成安全的。

双重检查锁定

延迟创建单例时,经常见到两次判空:

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {
    }

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

    public static void main(String[] args) {
        System.out.println(getInstance() == getInstance());
    }
}

外层判断减少初始化完成后的加锁,内层判断处理多个线程先后进入同步块的情况。instance 需要 volatile,保证外层不加锁读取时,也能正确取得已经初始化的实例。

如果所有读取都在同一把锁内,就可以依赖锁的发布语义;这里正是因为存在锁外读取,才不能只看创建时用了 synchronized。

内存屏障

volatile 的顺序保证需要编译器和处理器配合实现。讨论屏障时常见 LoadLoad、LoadStore、StoreStore、StoreLoad,它们分别限制屏障两侧某类读写操作的重排。

这些名字描述的是顺序约束,不意味着每次 volatile 访问都会机械地生成四条独立的机器指令。具体指令和优化取决于处理器与 JVM。写 Java 代码时先依据 JMM 的读写语义判断,不要把某个平台的汇编结果当成所有平台都相同的规则。

最后修改:2018 年 02 月 02 日
如果觉得我的文章对你有用,请随意赞赏