Java volatile 到底解决什么问题:可见性、禁止重排与双重检查锁单例

📅 发布时间:2026/7/23 0:18:32
Java volatile 到底解决什么问题:可见性、禁止重排与双重检查锁单例 Java volatile 到底解决什么问题:可见性、禁止重排与双重检查锁单例面试问 volatile,十有八九会答「保证可见性、不保证原子性」。这话没错,但真到写代码时就懵了:什么时候该加 volatile?为什么单例的双重检查锁一定要加它?加了 volatile 的count为什么还是会错?这篇把这三件事一次讲透,全是能跑的代码。先看一个「死循环」bug下面这段代码,主线程想通过改running来让子线程停下:publicclassStopThread{// 没加 volatileprivatestaticbooleanrunningtrue;publicstaticvoidmain(String[]args)throwsInterruptedException{newThread(()-{longi0;while(running){// 子线程一直读 runningi;}System.out.println(stopped, ii);}).start();Thread.sleep(1000);runningfalse;// 主线程改成 falseSystem.out.println(main set runningfalse);}}直觉上子线程一秒后就该停。但在 server 模式 JVM(-server,生产默认)下,这个程序大概率永远不停。原因不是「主线程没改成功」,而是子线程根本没看见改动。JIT 编译器看到running在循环里从没被当前线程修改,就把它优化成了:if(running){while(true){i;}// running 被提到循环外,只读一次}子线程读的是自己工作内存里的副本,主线程的写停留在它自己那边,两边没同步。这就是可见性问题。volatile 修复可见性给running加上 volatile:privatestaticvolatilebooleanrunningtrue;再跑,子线程立刻就停了。volatile 干了两件事保证可见性:写一个 volatile 变量时,JVM 会把当前线程工作内存里的值立刻刷回主内存。读一个 volatile 变量时,JVM 会让当前线程工作内存里的副本失效,强制从主内存重新读。所以主线程runningfalse一写,子线程下一次while(running)就能读到最新值。JIT 也不敢再把它缓存到寄存器里。一句话:volatile 保证一个线程的写,对其他线程立即可见。这是它最主要、也最该用的场景——「状态标志位」。但 volatile 不保证原子性,别拿它做计数器很多人以为 volatile 变量的就线程安全了,这是最常见的误用:publicclassCounter{privatestaticvolatileintcount0;publicstaticvoidmain(String[]args)throwsInterruptedException{Runnabletask()-{for(inti0;i10000;i){count;// 期望最终是 100000}};Thread[]tsnewThread[10];for(inti0;i10;i){ts[i]newThread(task);ts[i].start();}for(Threadt:ts)t.join();System.out.println(count);// 实际几乎每次都 100000}}跑出来是 8 万多、9 万多,反正不是 10 万。因为count不是一条指令,它是三步:读 count 到寄存器寄存器 1写回 countvolatile 只保证每一步读到/写出的是最新值,但保证不了这三步中间不被别的线程插队。两个线程同时读到 100,各自 1,各自写回 101,一次自增就丢了。计数器要用AtomicInteger(底层 CAS)或synchronized:privatestaticfinalAtomicIntegercountnewAtomicInteger(0);// ...count.incrementAndGet();// 这才是原子自增记住判断标准:volatile 只适合「一个线程写、多个线程读」的标志位;只要有「读-改-写」这种复合操作,就得上原子类或锁。volatile 的第二个作用:禁止指令重排这是双重检查锁单例(DCL)必须加 volatile 的原因,也是最容易被跳过的知识点。先看没加 volatile 的经典 DCL:publicclassSingleton{privatestaticSingletoninstance;// 没加 volatile,有坑publicstaticSingletongetInstance(){if(instancenull){// 第一次检查(不加锁,快)synchronized(Singleton.class){if(instancenull){// 第二次检查(加锁,防重复创建)instancenewSingleton();// 问题就出在这一行}}}returninstance;}}instance new Singleton()看着是一行,实际是三步:分配内存调用构造函数初始化对象把instance指向这块内存问题在于:JVM 允许在不影响单线程结果的前提下重排指令,把顺序变成 1 → 3 → 2。即「先让 instance 指向内存,再初始化」。于是可能出现这个时序:线程 A 执行到「1 → 3」,instance已经非 null,但对象还没初始化完。线程 B 走第一次检查instance null,发现不为 null,直接return instance。线程 B 拿到一个半成品对象,访问它的字段可能是默认值甚至崩溃。给instance加上 volatile 就能禁止这种重排:publicclassSingleton{privatestaticvolatileSingletoninstance;// 加上 volatilepublicstaticSingletongetInstance(){if(instancenull){synchronized(Singleton.class){if(instancenull){instancenewSingleton();// volatile 保证 2 一定在 3 之前}}}returninstance;}}volatile 会插入内存屏障,禁止步骤 2、3 之间重排,保证「对象完全初始化后,instance 才指向它」。这就是 DCL 里 volatile 不可省的原因。顺带一提:如果你不需要懒加载的极致控制,静态内部类方式写单例更简单,天然线程安全且懒加载,连 volatile 都不用操心:publicclassSingleton{privateSingleton(){}privatestaticclassHolder{// 类加载机制保证 INSTANCE 只初始化一次,且线程安全staticfinalSingletonINSTANCEnewSingleton();}publicstaticSingletongetInstance(){returnHolder.INSTANCE;// 第一次调用才触发 Holder 类加载}}happens-before:volatile 还能「捎带」普通变量volatile 有个常被忽视的能力:volatile 写之前的所有普通变量写,对 volatile 读之后的操作都可见。这叫 happens-before 规则。publicclassConfig{privateintport;// 普通变量privateStringhost;// 普通变量privatevolatilebooleanreadyfalse;// volatile 标志位// 线程 A:先填数据,最后置 readypublicvoidinit(){port8080;host10.0.0.1;readytrue;// volatile 写,前面两个普通写都会刷到主内存}// 线程 B:看到 ready 就能安全读 port/hostpublicvoiduse(){if(ready){// volatile 读connect(host,port);// 保证读到 8080 和 10.0.0.1,不会是默认值}}}只要ready用 volatile,线程 B 一旦读到ready true,就一定能看到线程 A 在此之前写的port、host,哪怕它们本身不是 volatile。这是用一个 volatile 标志位「守护」一组数据的常见手法。小结volatile 解决可见性:一个线程的写立即对其他线程可见,最适合「状态标志位」(如 running、shutdown、ready)。volatile 不解决原子性:count这种读-改-写复合操作照样出错,计数器用AtomicInteger或synchronized。volatile 禁止指令重排:双重检查锁单例里的instance必须加 volatile,否则可能拿到半初始化对象;不追求懒加载精细控制就用静态内部类单例。happens-before:volatile 写能「捎带」它之前的普通变量写,让读到标志位的线程安全看到那组数据。记忆点:volatile 是「可见性 有序性」,不是「原子性」——它守的是标志位,不是计数器。