ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

DCL与volatile:双重检查锁的线程安全与内存语义解析

DCL与volatile:双重检查锁的线程安全与内存语义解析 DCL 这三个字母凡是和 Java 并发打过交道的开发者都绕不开。双重检查锁Double-Checked Locking听起来复杂实际就是一个小套路先做一次不加锁判断为空再进锁进锁后再做一次判断。它出现在无数单例、缓存、连接池的初始化代码里也出现在无数面试题里。而 volatile 是这个套路里最容易被忽略、也最致命的一块拼图。这篇文章就想把 DCL 为什么需要双重检查、volatile 到底在保护什么讲透同时把它放到 C/C 和单片机场景里做对比因为很多人真的把不同语言里的 volatile 记混了。适合谁看写 Java 或 Android 时遇到过偶发空指针、想彻底搞懂 Java 内存模型的人以及从嵌入式转 Java、或者从 Java 转嵌入式需要厘清 volatile 语义边界的开发者。1. DCL 到底在解决什么问题1.1 先看加了锁为什么还要 DCL先写一个教科书版本的单例public class Singleton { private static Singleton instance; private Singleton() {} public static synchronized Singleton getInstance() { if (instance null) { instance new Singleton(); } return instance; } }这个写法线程安全没问题问题在于每次调用 getInstance 都要进入 synchronized。热点代码如果每秒几十万次调用锁竞争会把性能拖垮。HotSpot 的偏向锁、轻量级锁能缓解一部分但一旦竞争激烈锁膨胀到重量级之后就涉及线程阻塞、唤醒和潜在的上下文切换对低延迟路径非常不友好。DCL 的初衷就是让绝大多数调用都避开锁只有第一次真正需要创建对象的几个线程才去抢锁。DCL 的典型代码长这样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; } }前后两次判空加上一次 volatile 修饰。看似改动不大每一处都有不可省略的原因。1.2 两次判空的职责分工第一次判断放在锁外它不看锁只判断 instance 是不是 null。这段读是普通读执行很快大多数线程跑到这里发现不是 null直接 return。第二次判断放在锁内有了互斥保证看到的必然是内存中最新的状态。为什么锁内还要再判断一次因为存在一个典型的竞态窗口。考虑两个线程 A 和 B 同时调用 getInstance同时通过第一次判空都看到 null。A 先拿到锁创建实例释放锁。B 随后拿到锁如果锁内不做第二次判空B 会再创建出一个新实例并且覆盖 A 创建的实例。对单例来说这直接破坏了唯一性往小了说浪费资源往大了说可能导致状态不一致甚至把连接池、配置对象重复初始化。所以结论非常清晰外层判空是性能优化内层判空是正确性保障。两者缺一不可。另一个容易忽略的细节是第一次判空读到的旧值并不代表对象真的不存在。由于线程的工作内存可能滞后A 线程已经创建了实例B 线程仍然可能在第一次判空里看到 null这样 B 会进入锁。此时锁内的第二次判空会重新从主内存读最新值把 B 挡回去。这也是 DCL 看起来“多此一举”但实际是精心设计的原因。1.3 少写一次判空会踩什么样的坑很多初学者会写出这两种变形。第一种只保留锁内判空把外层判空去掉代码变成每次调用都走 synchronized正确性没问题性能回到最差版本。第二种更危险只保留外层判空锁内直接赋值public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { instance new Singleton(); } } return instance; }这会导致多个线程同时看到 nullA 进锁创建B 等在锁外A 释放后 B 也进去创建最终产生多个实例。我见过线上压测时构造函数被调了几十次的服务就是因为这种写法。排查的时候日志里只有调用入口没有显式 new 的地方最后通过构造器计数器才确认问题。正确姿势永远是外层代码保证快路径内层代码保证唯一性。2. volatile 为什么是 DCL 的“另一半”2.1 new 一个对象在 JVM 里分几步要理解 DCL 为什么需要 volatile必须先看new Singleton()在 JVM 里的真实执行过程。它绝不是一步完成的大致会分解成三步分配内存调用构造器完成字段初始化把引用赋值给 instance。单线程下这三个动作顺序无关紧要因为没人会看到中间状态。但在多线程下就有隐患。如果处理器或编译器对第二步和第三步做了重排先完成了引用赋值后执行构造器那么另一个线程在某个时间窗口里读到 instance 不为 null会直接返回这个还没构造完的对象。此时对象里的字段可能还是零值内部状态完全不对后面一调用就会出现莫名其妙的 NPE 或业务异常。这就是著名的“半初始化对象”问题。为什么 JVM 允许这种重排因为从单线程视角看构造器里的写操作和引用赋值之间没有数据依赖JIT 和处理器觉得换一下顺序不影响结果能提升性能。但多线程视角完全不同另一个线程可能在引用赋值后立即读到这个“热乎但不完整”的对象。DCL 里的 volatile 正是用来禁止这种不安全发布的关键。2.2 volatile 的内存屏障到底做了什么JMM 对 volatile 的语义可以概括为两条。第一是可见性写 volatile 变量时最新值会刷新到主内存其它线程后续读这个变量时会放弃自己工作内存里的缓存重新从主内存读。第二是禁止重排序围绕 volatile 读写编译器、JIT、处理器都不能把普通读写随意越过屏障。底层实现依赖内存屏障指令。在 x86 上volatile 写通常对应带 lock 前缀的写操作天然带有全屏障效果在 ARM 这种弱内存模型架构上会插入 dmb 等屏障指令成本比 x86 更高。这也是为什么同一个 DCL 代码在 x86 服务器上性能不错在 Android 手机上读路径开销会更明显。可以用一个生活化类比来理解可见性每个线程有自己的私人笔记本主内存是公共公告栏。普通写就好比在私人笔记本上写字别人看不到volatile 写等于强制你把这个内容贴到公告栏volatile 读等于先扔掉旧笔记再去看公告栏。更重要的是“往笔记本上写字”和“贴到公告栏”这两件事不能悄悄调换顺序否则别人会先看到一份空白公告。必须强调volatile 不保证原子性。volatile int count做count本质上还是读、加、写三步volatile 只能保证每次读最新、写最新不能把三步合为一体。想原子累加必须用 AtomicInteger 或者加锁。这个错误连很多资深工程师都会犯面试时也是高频考点。2.3 为什么老项目里 DCL 被称为反模式如果是 2004 年之前入行的人他们多半会告诉你 DCL 是反模式不要用。这话在当时是对的但现在有时代背景要说清楚。JDK 5 之前Java 内存模型不够强volatile 的语义没有充分保证禁止这一类重排序所以即便给 instance 加了 volatileDCL 也不安全。老书、老博客里那些“DCL 陷阱”的文章基本都是这个背景下的产物。后来 JSR-133 从 JDK 5 起重构了 Java 内存模型明确了 volatile 的可见性和有序性规则DCL volatile 才成为安全写法。你在 JDK 8、11、17 上写 DCL 都没有问题只要 instance 是 volatile。但面试时最好把这段历史讲出来证明你不是背了结论而是知道来龙去脉。否则别人只要追问一句“JDK 5 之前为什么不行”立刻就能判断你是真懂还是背题。3. 从 Java 到单片机volatile 不要包打天下3.1 C/C volatile 的真相在 C/C 的语境里volatile 不是线程同步工具它的核心目标是抑制编译器优化。C 标准说volatile 对象的值可能在当前线程控制流之外被修改因此编译器不能假定两次读取产生相同结果也不能把一个无副作用的 volatile 操作随意删除或合并。典型场景是信号处理、嵌入式外设和中断服务函数。举个最经典的例子volatile bool flag false; void ISR(void) { flag true; } int main(void) { while (!flag) { // do something } }如果去掉 volatile开了 O2 优化之后编译器可能发现 while 循环内部没有写 flag就把“读 flag”这个条件提升到循环外导致循环永远出不去。加上 volatile 后while 每次迭代都会强制从内存读 flag中断里更新的值才能被观察。但注意这里 volatile 只解决了“编译器别乱优化”的问题并没有保证内存同步和原子性。多线程开发中如果在线程之间共享状态C 应该使用 std::atomic、适当的 memory_order 或者 mutex而不是 volatile。很多人从 Java 转到 C把 volatile 当线程同步关键字用这是非常危险的错位。3.2 单片机里 volatile 变量的实际场景嵌入式开发中volatile 最常见的两个用途是外设寄存器和中断共享变量。STM32 系列里寄存器字段经常被宏定义为__IO本质就是 volatile。为什么需要因为外设寄存器所在地址不是普通 RAM每次读写都有硬件副作用。比如你往控制寄存器写两次 0x01如果编译器把第二次写优化掉了硬件行为就完全不对。对普通变量这种优化没问题对寄存器就是灾难。中断共享变量的场景更频繁主循环和中断服务函数共享的标志位、计数、DMA 完成标志都必须加 volatile否则高优化级别下容易出现死循环或漏判状态。但这里有个经典误区volatile 不保证原子性。一个 32 位变量在 8 位单片机上被主循环和中断同时访问读写可能被拆成多条汇编指令中断可能在读写中间插进来产生撕裂值。这个问题 volatile 解决不了一般需要进入临界区、关中断或者使用编译器内置的原子操作函数。嵌入式圈子里常说“volatile 只是给编译器看的不是给硬件和调度器看的”。这句话虽然糙但点出了边界。你在单片机里加 volatile本质是告诉编译器“这个地址的值会变的别瞎优化”。到了 Java 里加 volatile本质上是在跟 JMM 和 CPU 内存模型说“这个字段要安全发布别乱重排”。两者都叫 volatile干的事完全不是一个层面。3.3 三个世界 volatile 速查对照维度Java volatileC/C volatile单片机 volatile保证跨线程可见性是通过 JMM 和内存屏障否不保证缓存一致性否防止指令重排序是JMM 有明确规则部分编译期优化可抑制CPU 重排放不了主要防止编译器优化性重排保证原子性否否否典型用途线程间共享标志位、DCL 安全发布外设寄存器、信号处理共享变量中断共享变量、外设寄存器多线程同步怎么办配合 synchronized、atomic使用 std::atomic、mutex关中断、临界区、原子操作函数从语义层级看Java 的 volatile 其实更接近 C 里 std::atomic 的读写操作而不是 C 的 volatile。所以在 Java 里用 volatile 做发布安全到 C 里不能直接照搬在单片机里用它解决寄存器访问到 Java 里也不要指望它帮你解决原子性。把这张表记住能避开很多跨语言踩坑。4. DCL 常见错误与替代写法4.1 我见过最多的四个 DCL 翻车现场第一个也是最常见的错误忘记给 instance 加 volatile。代码看起来逻辑正确但运行环境一旦触发重排序另一个线程就能拿到半初始化对象。这种问题在低并发场景下很难复现一压测、一上多核就现形。我排查过的线上偶发 NPE 里有相当一部分就是单例字段漏写了 volatile。第二个是锁对象写错。比如在判空前写synchronized(instance)instance 还是 null直接抛空指针或者用synchronized(getClass())在多类加载器环境下并不能保证全局唯一。静态方法里最稳妥的就是锁Singleton.class。第三个是把 volatile 加在了无关字段上。有人以为单例内部某个成员变量加 volatile 就能修复 DCL 的安全发布但真正需要 volatile 的是 instance 引用本身。如果 instance 不是 volatile内部字段怎么修饰都没用因为问题出在“引用指向一个未完全构造对象”的发布路径上。第四个是构造器里泄漏 this。构造器启动线程、注册监听器、把 this 传给外部对象都会让别的线程在对象完整构造之前看到它。这种情况即使 volatile 也救不回来因为你有第二条绕过 DCL 的发布路径。规范做法是构造器只做纯初始化完成后统一由 volatile 引用发布。典型症状常见原因处理方向偶发 NPE、读到默认值instance 未加 volatileinstance 声明为 volatile构造函数被多次调用去掉锁内判空保留第二次判空初始化入口直接抛空指针synchronized(instance) 锁空对象使用 Singleton.class状态混乱且构造器中启动线程构造器泄漏 this构造器不做额外发布操作4.2 更省事的替代方案静态内部类与枚举如果只是经典的单例场景我通常建议团队优先使用静态内部类而不是手写 DCL。public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }这个写法几乎没有心智负担。原理是 JVM 的类初始化锁每个类的初始化过程由 JVM 保证只执行一次其它线程在类初始化完成前会被阻塞等待。Holder 类是私有静态类只有 getInstance 第一次被调用时才会被加载所以天然懒加载也不需要用 volatile。相比 DCL它少了很多需要解释的细节代码也更好维护。枚举单例更短public enum Singleton { INSTANCE }。Effective Java 作者专门推荐过它天然防反射实例化、防序列化破坏而且在 JVM 语义下枚举常量也是类加载时创建。不过如果你重度依赖 Spring 这类框架或者需要对枚举做复杂扩展会稍微有点绕。取舍标准很简单无参、无外部依赖的纯单例枚举最干净需要带参数初始化或与框架深度集成时静态内部类或 DCL 更灵活。4.3 DCL 还有哪些不可替代的适用场景有人听完替代方案会问DCL 是不是过时了不是。静态内部类和枚举的核心假设是单例不需要外部参数但现实中经常有实例化参数由运行时决定的情况比如配置中心地址、数据库用户名、环境切分标识。静态字段没法塞参数这时 DCL 仍然是最常用的安全发布模板。private static volatile ConnectionPool pool; public static ConnectionPool getPool(String url, String user) { ConnectionPool p pool; if (p null) { synchronized (ConnectionPool.class) { p pool; if (p null) { p new ConnectionPool(url, user); pool p; } } } return p; }注意局部变量 p 的用法先读一次 volatile 到局部变量判空、赋值都基于局部变量减少 volatile 读的次数。初始化完成后除非显式把 pool 置空后续所有读路径都会拿到同一个实例。如果初始化逻辑很昂贵或者可能抛异常还需要额外处理失败场景失败时把 pool 留空让下一次调用重试。不要把半初始化的中间结果发布出来。DCL 作为缓存模板在这些场景里依然有不可替代的位置。5. 定位 DCL 问题排查方法与实测经验5.1 用并发压测放大初始化窗口DCL 的 bug 很难用普通单测复现因为需要 CPU 重排或者 JIT 编译后才暴露。想验证正确性可以写一个并发压测准备 CountDownLatch让几十个线程同时等待放行构造函数里 sleep 几十毫秒把窗口拉大每个线程循环调用 getInstance校验实例字段是否全部初始化。没有 volatile 时这种测试有一定概率打印出非预期值但不能保证每次都能复现。因为重排不是每台机器、每个 JIT 版本都会发生普通压测的失败率不稳定。更专业的做法是用 OpenJDK 的 jcstress 框架它专门在 JMM 层面做压力测试通过注解声明期望的结果状态然后反复试探 JIT 和处理器行为。把官方样例改写成单例测试比普通压测可靠得多。如果你想知道 volatile 是否真的生成了内存屏障可以用-XX:UnlockDiagnosticVMOptions -XX:PrintAssembly看 JIT 生成的汇编。x86 上 volatile 写附近通常会出现带 lock 前缀的指令这就是内存屏障的落地点。代价是要装 hsdis 插件而且 PrintAssembly 参数只适合本机实验千万不要开在生产环境。绝大多数情况你不需要看汇编但当你怀疑某个改动是否能解决重排问题时看一眼比猜靠谱。5.2 线上偶发 NPE 的排查步骤线上已经出现偶发异常时我建议按这个顺序排查。先看单例或缓存字段是否声明为 volatile这是最高频原因。看第一次判空之前有没有被其它同步逻辑绕过。有些实现里 getInstance 前还有一层 ThreadLocal 或本地缓存判断可能引入额外状态。给构造函数加一个计数器或唯一 ID确认有没有被调用多次。查看构造器里有没有开启线程、注册回调、把 this 传给外部。如果有volatile 也拦不住。用线程转储抓现场看异常出现时其它线程是否正处于初始化入口或加锁区。实际工作中我遇到过构造函数计数器等于 2但 instance 明明加锁的场景。最后发现是有人为了写工具类测试直接 new Singleton() 绕过了单例入口。这种问题不属于 JMM 的坑属于代码纪律。如果怀疑是半初始化可以在异常抛出前把 instance 的 hashCode 和字段值打出来多个线程对比。如果看到同一个引用但内部字段不一致基本就是重排序发布导致的。5.3 几个关于性能的实测观点很多文章说 volatile 很慢这话有时代局限。在 x86 服务端volatile 读通常只相当于一次普通读加一些编译约束代价没有很多人想象中那么大。真正的开销集中在 volatile 写因为需要执行带 lock 前缀的指令触发缓存一致性协议。DCL 的快路径只有一次 volatile 读而 volatile 写只发生在初始化那一次所以整体成本通常可接受。在 ARM 或 Android 场景下volatile 读写会插入 dmb 屏障代价比 x86 高。如果 getInstance 被放在每秒百万级调用的路径上建议用 JFR 或 perf 实测不要凭感觉定论。还有一个实用优化把 volatile 读结果放进局部变量再判空避免一个方法里多次读同一个 volatile 字段。这样既减少内存访问次数也让代码逻辑更清晰。不要为了去掉 volatile 去搞 ThreadLocal 副本或 CAS 双重检查除非压测数据明确证明它是瓶颈。并发正确性永远优先性能排第二。我见过有人用 ThreadLocal 缓存单例引用结果线程池线程复用时状态串了最后回滚成标准 DCL。这类优化复杂度高、收益不确定不值得在生产环境冒险。5.4 最后的小技巧让单例对象尽量不可变最后分享一个我比较偏爱的小技巧让 DCL 管理的对象尽量不可变。所有业务字段在构造器里用 final 赋值不提供 setter初始化时不启动后台线程不把自己的 this 传递给任何监听注册中心。对外只暴露只读方法。如果运行期要更新配置不要给原对象加 setter而是构建一个新对象再用 volatile 引用整体替换。这种“不可变对象 volatile 引用轮换”模式天然避开了半初始化、脏读、状态撕裂一大串问题读路径完全无锁写路径只在发布那一刻做一次 volatile 写。我在一个规则引擎项目里就是这么做的路由规则对象使用不可变列表和不可变 Map更新时后台线程构建新对象然后一行this.rules newRules发布。读取线程从不加锁线上跑了大半年没有一次状态异常。后来把这个经验推广到本地缓存、限流配置和灰度开关都很稳。如果你想在生产里落地关键就是对象字段尽量 final别自己一个字段一个字段地 set数据一致性才能真正简单。
返回列表