前面已经用 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 的读写语义判断,不要把某个平台的汇编结果当成所有平台都相同的规则。