一、事故现场还原

业务场景:配置中心客户端单例

系统启动后,需要加载一份配置,并在全系统范围内复用。为了避免启动慢,同时又不想每次都加锁,团队采用了经典的“双重检查锁”单例写法。

问题代码

public class ConfigManager {

    private static ConfigManager instance; // 没有 volatile

    private Map<String, String> config;

    private ConfigManager() {
        this.config = loadFromRemote();
    }

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

    public String get(String key) {
        return config.get(key);
    }
}

线上偶发问题:

  • instance 不为 null
  • config 为 null
  • 调用 get() 直接 NPE
  • 重启后问题消失

代码没有任何显式并发写操作,但系统表现得像“内存被污染”。


二、时间线:问题是如何必然发生的

核心问题只有一句话:
对象创建不是原子操作。

new ConfigManager() 在 JVM 层面至少分三步:

  1. 分配内存
  2. 执行构造方法
  3. 将引用赋值给 instance

在没有 volatile 的情况下,这三步允许指令重排序

时间点 线程 A(创建实例) 线程 B(获取实例) instance 状态 config 状态
t1 分配对象内存 null 未初始化
t2 instance = 内存地址 非 null 仍为 null
t3 执行构造方法 非 null 初始化中
t4 读取 instance 非 null null
t5 构造完成 非 null 正常

关键点在这里:

  • 第二次 if (instance == null) 只能防止重复创建
  • 它防不住“看到一个没构造完的对象”
  • 只要发生一次指令重排,这个问题就是确定性存在的

这不是概率问题,只是是否被触发的问题。


三、薪资分水岭:不同段位的工程师如何应对

分析维度 月薪1-2万工程师 月薪3-5万工程师 年薪100万+工程师
问题定位 怀疑业务逻辑 怀疑并发可见性 直接锁定 DCL
根因分析 单例写错了 new 不是原子操作 指令重排 + 发布
解决方案 方法整体加锁 给 instance 加 volatile 替换单例实现
考虑范围 当前类 JVM 内存模型 系统级规范
后续动作 修完上线 补并发说明 禁止 DCL 写法

差距不在“会不会写 DCL”,而在是否理解 DCL 成立的前提条件


四、思维差异的底层逻辑

认知深度差异

  • 月薪1-2万

    • 认为 synchronized 已经解决并发
    • 停留在代码结构层
  • 月薪3-5万

    • 理解 new 的执行步骤
    • 知道 volatile 禁止重排
  • 年薪100万+

    • 关注对象发布模型
    • 优先选择更稳妥的设计

风险识别能力

  • 月薪1-2万

    • 只能在 NPE 出现后排查
  • 月薪3-5万

    • 能提前识别“半初始化对象”风险
  • 年薪100万+

    • 一眼识别:

      • DCL
      • 懒加载
      • 手写并发控制

抽象能力差异

  • 月薪1-2万

    • 修:这个单例有 bug
  • 月薪3-5万

    • 抽象:DCL 必须配合 volatile
  • 年薪100万+

    • 上升为原则:

      • 能不用 DCL 就不用
      • 单例优先交给 JVM

五、可操作的升级路径

给月薪1-2万工程师的刻意练习

  1. 练习:拆解 new 的执行过程

    • 写下 new Object() 的三步
    • 在纸上模拟两个线程的交错执行
    • 标出“对象引用先于构造完成”的时刻

给月薪3-5万工程师的突破挑战

  1. 挑战:替换 DCL 单例

    • 使用静态内部类
    • 使用枚举单例
    • 对比三种方式的初始化时机和并发语义
public class Singleton {

    private Singleton() {}

    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

六、总结

双重检查锁不是错,错的是在不了解前提的情况下使用它
volatile 不是为了可见性,而是为了禁止对象发布时的指令重排

薪资差异的本质,不是 API 熟练度,而是是否真正理解:
并发问题,90% 出在“看不见的那一步”。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐