当前位置: 首页 > news >正文

一个没加 volatile 的单例,让 1% 的请求拿到了半初始化的对象


title: 一个没加 volatile 的单例,让 1% 的请求拿到了半初始化的对象
tags: Java,JMM,volatile,happens-before,内存模型
category: Java


事故:1% 的 NPE,查了三天

前年我们接手一个老服务,偶发 NPE:单例配置对象AppConfig的字段在某些请求里是null,频率约 1%(高并发下)。诡异的是,对象明明在静态方法里new出来了,getInstance()返回的也不该是 null,却没人能稳定复现。

最后定位到经典的"双重检查锁定"少了一个volatile

public class AppConfig { private static AppConfig instance; // ← 这里没有 volatile private final Map<String, String> settings; private AppConfig() { this.settings = new HashMap<>(); settings.put("timeout", "3000"); settings.put("retry", "3"); // 构造函数里还有一堆重活 } public static AppConfig getInstance() { if (instance == null) { // 第一次检查 synchronized (AppConfig.class) { if (instance == null) { // 第二次检查 instance = new AppConfig(); // 问题在这行 } } } return instance; } }

高并发下,线程 A 执行instance = new AppConfig()。对象的创建在 JVM 眼里不是原子的,粗略拆成三步:

  1. 分配内存空间;
  2. 在内存上初始化AppConfig(调用构造器,填settings等字段);
  3. instance引用指向这块内存。

由于指令重排序(JIT 编译优化),步骤 2 和 3 可能被调换:先执行 3(引用指向未初始化的内存),再执行 2。此时线程 B 跑到第一次检查if (instance == null),看到instance不是 null(因为步骤 3 已经执行了),直接return instance,拿到一个构造还没跑完的对象——settings还是 null,于是后续settings.get(...)直接 NPE。

这个 bug 的教科书解法就是给instancevolatile。但"为什么加了就好"背后的happens-before,才是这次事故真正值得讲清楚的东西。

happens-before 到底在约束什么

JMM(Java Memory Model,JSR-133,JDK 5 起生效)要解决的问题只有两个:可见性有序性。它不保证"你写的代码按你写的顺序执行"(那是顺序一致性模型,太慢),而是给出一组happens-before 规则——只要两个操作之间存在 happens-before 关系,JMM 就保证前者对后者的可见性顺序

happens-before是偏序关系,核心规则有几条(JLS 17.4.5):

  1. 程序次序规则:单线程内,代码顺序前面的操作 happens-before 后面的操作。
  2. 监视器锁规则unlockhappens-before 后续对同一个锁lock。这意味着synchronized块里写的东西,对下一个拿到同一把锁的线程可见。
  3. volatile 变量规则:对 volatile 变量的写 happens-before 后续对同一个volatile 变量的读。
  4. 传递性:A happens-before B,B happens-before C,则 A happens-before C。
  5. 线程启动/终止规则Thread.start()happens-before 线程里的任何操作;线程里的操作 happens-beforeThread.join()返回后的操作。

注意一个常见误解:happens-before 不是"时间上先发生"。它是一组"如果成立,则 JMM 保证可见性和顺序"的契约。两个操作即便时间上 A 早于 B,只要不在同一条 happens-before 链上,JMM 就不保证 B 能看到 A——重排序和 CPU 缓存就可能导致 B 看到旧值。

用三段代码把可见性钉死

第一段:没有 volatile,可见性不保证

public class VisibilityDemo { private boolean running = true; // 没有 volatile public void start() { new Thread(() -> { while (running) { // 可能永远看不到 false,死循环 // do work } }).start(); } public void stop() { running = false; // 主线程改了,工作线程可能一直从自己的 CPU 缓存读旧值 true } }

工作线程启动时把running读进自己的 CPU 缓存/寄存器,没有 volatile 的语义约束,JIT 可能直接把while(running)优化成while(true)(因为循环体内没修改running,编译器认为它不会变)。stop()在主线程改了主存的值,工作线程的缓存不失效,于是死循环。这是"可见性缺失"最直白的例子。

第二段:加 volatile,强制可见

private volatile boolean running = true; public void stop() { running = false; // 写 volatile:立即刷主存 + 让其他 CPU 的该变量缓存行失效 }

volatile的写会插入一个StoreStore 屏障(JDK 5 之后用lock前缀指令实现),强制把写操作的结果刷到主内存,并使其他处理器上该变量的缓存行失效。读volatile时会从主存重新加载。于是工作线程每次循环都看到最新值。volatile不保证原子性(比如i++还是竞态),只保证单次读写的可见性和禁止特定重排序。

第三段:volatile 禁止重排序,救了单例

回到开头的单例。instancevolatile后:

private static volatile AppConfig instance; // new AppConfig() 的"分配→初始化→赋值引用"三步,因为 volatile 的写屏障, // 不会被重排序到"赋值引用"之前对其它线程可见。

volatile的写之前有一个StoreStore 屏障,保证"对instance的写"这个动作之前,所有前面的写(包括对settings的初始化)都已经对其他处理器可见。线程 B 看到instance != null时(读 volatile,有 LoadLoad 屏障),根据 volatile 规则 + 传递性,它一定能看到 A 已经完成的settings初始化。半初始化对象的问题就此消失。

这里把三条规则串起来了:程序次序(A 线程里 settings 写在 instance 写之前)→ volatile 写规则(instance 写对 volatile 读可见)→ 传递性 → B 线程读 instance 后一定能看到 settings。这就是volatile在单例里不可替代的原因——它同时解决了可见性和禁止重排序。

不是所有共享变量都要 volatile

volatile解决不了原子性。下面这种计数在并发下仍然错:

private volatile int counter = 0; public void increment() { counter++; // 读-改-写三步,volatile 不保证这三步原子,竞态依旧 }

counter++编译成getfield → iadd → putfield,两个线程可能同时getfield拿到 5,都加一写成 6,丢了一次。这种情况该用AtomicIntegerCAS)或synchronized

private final AtomicInteger counter = new AtomicInteger(0); public void increment() { counter.incrementAndGet(); // 底层 Unsafe.compareAndSwapInt,单次原子完成 }

AtomicInteger内部也用volatile(它的value字段是volatile),但它把"读-改-写"封装成一个 CAS 原子指令,所以既能可见又能原子。换句话说,AtomicXxx=volatile+ CAS 循环,这是它比裸volatile多出来的能力。

JMM 的几个规则横向对比

手段可见性有序性(禁止重排)原子性性能开销典型用途
无修饰字段不保证不保证不保证(64位 long/double 在 32 位 JVM 可能撕裂)最低局部/单线程
volatile保证保证(变量级)仅单次读写低(内存屏障)状态标志、单例
synchronized保证保证(块级)保证(块级)中(锁竞争)复合操作、临界区
AtomicXxx保证保证保证(CAS)低-中计数器、无锁更新
final保证(构造结束即对其他线程可见)保证(构造内 final 写先发布)仅初始化最低不可变对象

final那行容易被忽略:正确发布(不 this 逃逸)的final字段,在构造器结束后对所有线程可见,不需要 volatile。我们AppConfigsettings字段其实可以声明为private final,配合volatile instance,语义最干净——final 保证字段本身构造完即发布,volatile 保证引用发布的可见与有序。

复盘数字

  • 事故频率:高并发下约 1% 请求触发半初始化单例,日均约 1.2 万次 NPE 告警,但分散在不同机器,单机难复现。
  • 定位耗时:3 天(前两天以为是下游配置服务偶发返回空,第三天才用jstack看到多个线程卡在settings.get上,结合代码审查发现缺volatile)。
  • 修复:在instancevolatile,并在settingsfinal,约 2 行改动。灰度后 NPE 归零。
  • 后续:把"双重检查锁定单例必须 volatile"写进团队 code review 清单,并推动单例统一改用enum或静态内部类(天生线程安全,无需 volatile)。

我的取舍判断

能用不可变对象解决的状态共享,优先用final+ 不可变,而不是volatile+ 可变。final的发布语义是 JMM 里最便宜、最稳的:构造完了就安全可见,没有内存屏障的运行期开销。我们后来把AppConfig这类"初始化一次、读多写零"的配置对象全部改成不可变(final字段 + 构造时一次性填好),从根上消除了"半初始化可见"的可能。

double-checked locking我基本不用了。它为了在"单例 + 懒加载 + 高性能"三个目标间妥协,引入了对volatile语义的精确依赖,而这点恰恰最容易被后人改坏(删volatile、或把构造逻辑挪到发布之后)。Java 5+ 实现懒加载单例,我更推荐静态内部类:

public class AppConfig { private AppConfig() { /* 初始化 */ } private static class Holder { static final AppConfig INSTANCE = new AppConfig(); // 类加载时线程安全地初始化 } public static AppConfig getInstance() { return Holder.INSTANCE; // 第一次调用才触发 Holder 类加载,天然懒加载且无需 volatile } }

JVM 保证类初始化(<clinit>)的线程安全,静态内部类又实现了懒加载——零volatile、零synchronized运行时开销,还不可能写错。

至于volatile该用在哪里:状态标志(如volatile boolean shutdown)、一次性安全发布(如本例单例)、独立观察(读多写少且每次读写独立)。凡涉及"读-改-写"复合语义的,别用volatile,老老实实用AtomicXxx或锁。把volatile当"轻量锁"用是新人最常见的误用。

留个思考题

volatile只禁止它自己附近的特定重排序,并不保证所有内存操作的全局顺序。那么:如果线程 A 先写普通变量x=1、再写volatile y=1,线程 B 读volatile y==1之后读x,B 一定看到x==1吗?如果把x也声明成volatile,结论会变吗?欢迎在评论区聊聊你对 happens-before 传递性的理解。

http://www.cnnetsun.cn/news/3947099.html

相关文章:

  • 广西住房和城乡建设厅网站_官方办事入口与最新政策查询全指南
  • OpenClaw Windows部署 保姆级操作教程,全程可视化无需命令行配置
  • Steam创意工坊免费下载终极指南:跨平台开源工具WorkshopDL深度解析
  • AI - Java之Spring AI Alibaba
  • Self-Correcting Large Language Models: Generation vs. Multiple Choice
  • 如何高效清理重复照片:AntiDupl智能图片去重工具完整指南
  • 从部署到工程化:MiniMax H3视频生成模型本地应用深度解析
  • 网站规划与建设进度如何把控:从0到1的实战复盘与避坑指南
  • 5个简单步骤:FanControl终极风扇控制配置指南
  • 中兴光猫工厂模式解锁工具:5分钟获取高级管理权限终极指南
  • Unity移动端城市场景构建:模块化设计与性能优化实践
  • 本地部署AI配音开源项目:从环境搭建到API批量调用的完整实践
  • NSGA-II算法在柔性作业车间多目标调度中的Matlab实现与应用
  • KVM主题:中断注入与APIC虚拟化原理
  • Nmap网络扫描从入门到实战:安装、核心参数与安全探测指南
  • WaveTools技术深度解析:鸣潮游戏性能优化与抽卡分析实战指南
  • 2024年企业破局关键:033340网站建设与管理从零基础到高转化实战指南
  • 5个实战技巧:彻底攻克FanControl风扇识别难题
  • Unlock-Music完全指南:如何在3分钟内解锁加密音乐实现跨平台播放
  • 免费解锁WeMod Pro会员功能:Wand-Enhancer完整使用指南
  • 浩辰CAD免费版安装指南:从下载到配置的完整教程
  • 《派出你的AI同事:WorkBuddy案例实战》048:监测数据自动成报与异常预警
  • 为什么现在的影城网站都长一个样?聊聊影城网站建设那些容易被忽略的真相
  • 工业软件底层架构设计与核心模块开发实践
  • 城通网盘解析神器:3分钟告别限速困扰,免费享受满速下载体验![特殊字符]
  • 终极风扇控制指南:用FanControl实现Windows散热管理的专业级定制
  • Unity体素世界构建:从Chunk分块到Greedy Meshing的Minecraft式实现
  • Python+Django构建光伏发电智能监控系统实战
  • 图像矢量化深度解析:用vectorizer实现PNG/JPG到SVG的专业转换实战指南
  • AI驱动API设计:基于OpenAPI与Mock服务的自动化工作流实践