Spring Controller中非volatile变量可见性与main方法差异原因
问题现象
以下为相关代码片段:
package com.example.demo; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; class VolatileTest{ private static boolean stop = false; public void go() throws Exception{ new Thread(()->{ System.out.println("thread started in " + Thread.currentThread().getName()); int counter = 0; while (!stop){ counter++; }; // #1 System.out.println("stopped at i=" + counter + " in " + Thread.currentThread().getName()); }).start(); Thread.sleep(1000); stop = true; } } @RestController public class MyVolatileController { @GetMapping("/volatile/test") public String test(){ new Thread(()->{ try{ new VolatileTest().go(); }catch(Exception e){} }).start(); return "volatile"; } public static void main(String[] args) throws Exception{ new Thread(()->{ try{ new VolatileTest().go(); }catch(Exception e){} }).start(); } }
预期行为:调用测试后程序永远不会执行到#1位置,因为stop变量未被volatile修饰,其他线程对该变量的修改无法被当前线程即时感知。
实际运行存在明显差异:
- 在类的
public static main方法中启动该测试时,运行表现符合预期,程序无法走到#1位置 - 在Spring Controller的接口方法中启动相同测试代码时,程序能够执行到#1位置
原因解释
首先要明确一个常见的认知偏差:非volatile修饰的变量,跨线程修改不可见不是必然发生的铁律,只是在缺少happens-before约束时,JIT编译器被允许做的一类优化——没有任何规则要求JVM必须让跨线程修改不可见。
两种场景表现不同,核心是JIT即时编译的优化触发条件不一致:
- 直接运行main方法的时候,
while (!stop) { counter++; }是非常纯粹的热循环,单线程下执行速度极快,JVM启动后很短时间内这段代码的执行次数就达到了C2编译的阈值。JIT分析字节码时发现循环内部根本没有修改stop变量的逻辑,就会做激进优化:直接把循环判断条件!stop替换成常量true,之后子线程根本不会再去读取主存里的stop值,哪怕主线程把stop改成true,子线程也完全感知不到,自然永远走不到#1的位置。 - 运行在Spring接口里的时候情况完全不同:Spring Boot启动本身要加载大量类、执行海量初始化逻辑,JVM预热的代码根本不是你写的这个小循环。等你发请求触发这段逻辑时,首先这段代码是解释执行的,不会上来就做死循环优化;就算后续执行次数够了触发JIT,Spring运行时的容器逻辑(比如Tomcat线程调度、Spring的上下文管理、AOP拦截逻辑里都自带大量安全点检查、内存屏障)会限制JIT的优化程度,不会允许它把
stop的判断直接优化成常量。子线程循环时会不定期从主存同步stop的值,等主线程把stop设为true之后,子线程迟早能读到这个修改,退出循环走到#1位置。
需要额外注意:Java内存模型从来没说过非volatile变量的跨线程修改一定不可见,它只保证有明确happens-before关系的时候修改一定可见,没有这个关系的时候,可见性是没有任何保障的——可能马上看到,可能很久之后看到,也可能永远看不到。main方法里跑出来的永远不退出,只是特定JDK版本、特定执行路径下刚好触发了优化的结果,不是什么固定的语言特性。
内容的提问来源于stack exchange,提问作者feiyuerenhai
相关产品推荐
相关产品推荐

