为什么DCL单例要加volatile?——CPU乱序执行与内存屏障

📅 2026/6/16 19:44:56
为什么DCL单例要加volatile?——CPU乱序执行与内存屏障
为什么DCL单例要加volatile——CPU乱序执行与内存屏障面试的时候面试官问DCL单例为什么要加volatile我脱口而出防止指令重排序。面试官继续问那volatile是怎么实现的底层的内存屏障是什么我…我卡住了。相信很多Java程序员都有类似的经历。今天我们就来彻底搞懂这个问题。一、从一个经典的面试题说起1.1 DCL单例的代码先回顾一下DCLDouble Check Lock单例的代码publicclassSingleton{privatestaticvolatileSingletoninstance;publicstaticSingletongetInstance(){if(instancenull){// 第一次检查避免每次都加锁synchronized(Singleton.class){if(instancenull){// 第二次检查防止重复创建instancenewSingleton();}}}returninstance;}}注意那个volatile关键字很多人知道要加但不知道为什么。1.2 问题的根源对象创建过程new Singleton()这行代码在CPU层面并不是原子操作。它大致分为以下几个步骤1. 分配内存空间 2. 初始化对象执行构造方法 3. 把引用指向分配的内存在正常情况下这个顺序是没问题的。但CPU有个坏习惯——乱序执行。二、CPU的乱序执行2.1 什么是乱序执行CPU为了提高效率会对指令进行重排序。比如指令1: 去内存读数据要等80ns 指令2: 计算一个值不依赖指令1的结果1ns就能完成 CPU不会傻等指令1而是先执行指令2这就像你去餐厅点菜你点了红烧肉要等30分钟你又点了凉拌黄瓜2分钟就好厨师不会等红烧肉做好再做黄瓜而是先做黄瓜2.2 乱序执行的证明看这段代码publicclassDisorder{privatestaticintx0,y0;privatestaticinta0,b0;publicstaticvoidmain(String[]args)throwsInterruptedException{inti0;for(;;){i;x0;y0;a0;b0;ThreadonenewThread(()-{a1;// 步骤1xb;// 步骤2});ThreadothernewThread(()-{b1;// 步骤3ya;// 步骤4});one.start();other.start();one.join();other.join();if(x0y0){// 这种情况理论上不应该发生但实际上会发生System.out.println(第i次x,y);break;}}}}如果按照代码顺序执行x和y不可能同时为0。但实际上由于乱序执行x0,y0是可能出现的。2.3 乱序执行在DCL中的问题回到DCL单例new Singleton()的三个步骤可能被重排正常顺序1.分配内存 → 2.初始化对象 → 3.引用指向内存 乱序后 1.分配内存 → 3.引用指向内存 → 2.初始化对象这会导致什么问题// 线程1instancenewSingleton();// 执行了步骤1和3还没执行步骤2// 线程2if(instancenull){// 返回false因为instance已经不为null了// 不会进入if块}returninstance;// 返回了一个半初始化的对象线程2拿到的是一个半初始化的对象——内存分配了引用指向了但构造方法还没执行完。这时候使用这个对象可能会出现各种奇怪的错误。三、内存屏障CPU的交通规则3.1 什么是内存屏障内存屏障Memory Barrier是一条CPU指令它告诉CPU“屏障前后的指令不能重排序。”就像马路上的隔离带指令1写操作 ---- 内存屏障 ---- 指令2读操作 有了屏障指令1一定在指令2之前完成3.2 x86的内存屏障指令在x86架构下有三种内存屏障; sfence: 写屏障 ; 在sfence指令前的写操作必须在sfence指令后的写操作前完成 sfence ; lfence: 读屏障 ; 在lfence指令前的读操作必须在lfence指令后的读操作前完成 lfence ; mfence: 全屏障 ; 在mfence指令前的读写操作必须在mfence指令后的读写操作前完成 mfence3.3 Lock指令除了内存屏障还有一种更暴力的方式——lock指令lock add [counter], 1 ; 原子操作同时是全屏障lock指令会锁住内存子系统确保操作的原子性和顺序性。四、volatile的实现细节4.1 JVM层面的内存屏障当我们在Java代码中使用volatile时JVM会在适当的位置插入内存屏障volatile写StoreStoreBarrier ← 确保之前的写操作对当前写可见 volatile 写操作 StoreLoadBarrier ← 确保当前写对之后的读可见volatile读LoadLoadBarrier ← 确保之前的读操作在当前读之前完成 volatile 读操作 LoadStoreBarrier ← 确保当前读在之后的写操作之前完成4.2 回到DCL单例有了volatileDCL单例的执行过程变成了// 线程1instancenewSingleton();// 实际执行// 1. 分配内存// 2. 初始化对象// ---- StoreStoreBarrier ----// 3. 引用指向内存volatile写// ---- StoreLoadBarrier ----// 线程2if(instancenull){// volatile读// 由于内存屏障线程1的步骤2一定在步骤3之前完成// 所以线程2看到的instance一定是完全初始化好的}五、JSR-133与happens-before原则5.1 什么是happens-beforehappens-before是JMMJava Memory Model的核心概念。如果操作A happens-before 操作B那么A的结果对B可见。注意happens-before不是指时间上的先后而是指可见性。5.2 happens-before的规则规则说明程序次序规则同一个线程内按代码顺序执行管程锁定规则unlock happens-before 同一个锁的lockvolatile变量规则volatile写 happens-before volatile读线程启动规则start() happens-before 线程的每个操作线程终止规则线程的所有操作 happens-before join()线程中断规则interrupt() happens-before 检测到中断对象终结规则构造方法 happens-before finalize()传递性A happens-before BB happens-before C → A happens-before C5.3 volatile变量规则的应用// 线程1volatileinta1;intb2;// 线程2intca;// volatile读intdb;根据规则a 1happens-beforec avolatile规则b 2happens-beforea 1程序次序规则c ahappens-befored b程序次序规则根据传递性b 2happens-befored b所以线程2读到的b一定是2。六、as-if-serial语义6.1 单线程的保证as-if-serial语义是指不管怎么重排序单线程程序的执行结果不能改变。// 单线程下inta1;intb2;intcab;CPU可能会重排a和b的赋值顺序但c的结果一定是3。6.2 多线程的挑战但在多线程下as-if-serial就不够了// 线程1a1;flagtrue;// 线程2if(flag){System.out.println(a);// 可能输出0}线程1可能重排序先执行flag true再执行a 1。线程2看到flag为true时a可能还没被赋值。这就是为什么我们需要volatile和内存屏障。七、Write Combining技术7.1 什么是Write CombiningCPU在写数据时不是每次都直接写入L1缓存而是先写到一个合并写缓冲区Write Combining Buffer等缓冲区满了再一起写入L2。CPU写入 → WC Buffer → L2缓存 ↓ 同时写入L17.2 为什么要这样做因为写L1缓存需要时间如果每次都直接写CPU就要等待。有了WC BufferCPU可以继续执行不用等。7.3 对编程的影响// 高性能场景下数据的写入顺序可能和代码顺序不一致// 如果对顺序有严格要求需要使用内存屏障八、总结这篇文章我们深入探讨了DCL单例为什么要加volatile防止对象创建过程中的指令重排序CPU乱序执行为了提高效率但可能带来问题内存屏障CPU提供的禁止重排序的机制volatile的实现JVM通过插入内存屏障来实现volatilehappens-before原则JMM的核心规则as-if-serial语义单线程的保证Write CombiningCPU的写优化技术理解这些底层知识不是为了炫技而是为了在遇到并发问题时能够快速定位问题的根源。毕竟知道是什么容易知道为什么才是真本事。参考资料《深入理解Java虚拟机》Intel CPU手册JSR-133规范