Java并发:移除单个volatile后程序及测试是否正确?
问题背景
现有一段包含两个volatile修饰字段的简化Java代码(约定两个字段均需保留):
volatile boolean hasParam; volatile String param; boolean hasParam() { if (param == null) { getParam(); } return hasParam; } String getParam() { String tmp = param; if (tmp == null) { tmp = "String"; hasParam = true; param = tmp; } return tmp; }
有推测认为可以移除hasParam字段上的volatile修饰,逻辑依据为:两个线程竞争调用hasParam()时,若第一个线程对volatile修饰的param执行*releasing store(释放存储)写入,第二个线程对同一字段执行acquiring read(获取读)*读到非null值,则其从hasParam读到的值必然为true;若acquiring read读到null,第二个线程会自行完成初始化写入并返回结果。
为验证猜想编写了如下JCStress测试用例:
@State @JCStressTest @Outcome(id = "true, String", expect = ACCEPTABLE, desc = "Boolean value was flushed") @Outcome(id = "false, String", expect = FORBIDDEN, desc = "Boolean value was not flushed") public class ConcurrencyTest { Value value = new Value(); @Actor public void actor1(ZL_Result r) { r.r1 = value.hasParameter(); r.r2 = value.param; } @Actor public void actor2(ZL_Result r) { r.r1 = value.hasParameter(); r.r2 = value.param; } static class Value { volatile boolean hasParam; volatile String param; boolean hasParameter() { if (param == null) { getParam(); } return hasParam; } String getParam() { String tmp = param; if (tmp == null) { tmp = "String"; hasParam = true; param = tmp; } return tmp; } } }
测试结果如下:
- 保留两个字段的
volatile修饰时,无禁止结果出现:
RESULT SAMPLES FREQ EXPECT DESCRIPTION false, String 0 0,00% Forbidden Boolean value was not flushed true, String 638 164 992 100,00% Acceptable Boolean value was flushed
- 仅移除
hasParam上的volatile修饰时,测试结果与双volatile场景完全一致:
RESULT SAMPLES FREQ EXPECT DESCRIPTION false, String 0 0,00% Forbidden Boolean value was not flushed true, String 1 074 450 432 100,00% Acceptable Boolean value was flushed
- 同时移除两个字段的
volatile修饰时,出现预期外的禁止结果:
RESULT SAMPLES FREQ EXPECT DESCRIPTION false, String 1 420 423 0,12% Forbidden Boolean value was not flushed true, String 1 164 432 249 99,88% Acceptable Boolean value was flushed
基于以上结果提出两个疑问:
- 移除
hasParam上的volatile修饰后程序是否仍能保持正确性? - 编写的JCStress测试用例是否存在设计问题?
问题解答
1. 关于移除hasParam字段volatile修饰的正确性判断
你的核心推导符合JMM(Java内存模型)规则,但这个结论只在非常严格的代码约束下成立,不是通用的线程安全写法:
- 从写入侧看:当前
getParam()里的写入顺序是先给普通变量hasParam赋值true,再给volatile修饰的param赋值。JMM对volatile写的内存屏障约束会严格保证:volatile写之前的所有普通写操作,都不会被重排序到volatile写之后,也就是说hasParam = true的写入一定先于param的写入对其他线程可见。 - 从当前方法的读取侧看:
hasParam()里的读取顺序是先读volatile的param判断是否为null,如果读到非null值,再读普通的hasParam返回。JMM对volatile读的内存屏障约束会严格保证:volatile读之后的所有普通读操作,都不会被重排序到volatile读之前,也就是说只要线程通过这个方法读到param非null,后续读到的hasParam一定是true,不可能出现param是"String"但返回false的情况。 - 必须明确这个写法的风险,它的鲁棒性非常差:
- 正确性完全绑定读写顺序:只要后续改代码时把
hasParam = true移到了param = tmp后面,或者在方法里先读hasParam再读param,会立刻打破HB(happens-before)关系,出现可见性问题。 - 你明确要求两个字段都保留,意味着字段本身可以被外部直接访问:如果有任何代码不经过
hasParam()方法、直接读hasParam字段,由于没有提前触发param的volatile读,不存在HB约束,完全可能读到过期的false值。 - 工程上除非有极致的性能压榨需求,否则不建议省这个
volatile,否则后续代码迭代非常容易引入隐蔽的并发bug,排查成本极高。
- 正确性完全绑定读写顺序:只要后续改代码时把
2. 关于JCStress测试用例的设计问题
你的测试用例设计有明显缺陷,通过测试不代表逻辑在所有场景下都正确:
- 覆盖的访问路径太单一:所有读取
hasParam的操作都在hasParameter()方法内部,严格走了「先读volatile的param、再读hasParam」的顺序,刚好满足HB规则的前提,完全没覆盖「直接读hasParam字段、不提前读param」的场景,而这种场景在保留两个字段的代码里是完全可能出现的。 - 校验维度不全:你只校验了方法返回值和
param的组合结果,没有校验直接读取hasParam字段的取值,也没有构造不同读写顺序的并发竞态场景。 - 测试环境有局限性:如果你是在x86这种强内存模型的CPU上跑测试,硬件本身就禁止了大部分读写重排序,哪怕代码有JMM层面的问题,也可能测不出来。如果要做完整验证,需要在ARM、RISC-V这类弱内存模型的架构上复测,同时加上
-XX:+StressLCM -XX:+StressGCM这类JCStress官方推荐的压测参数,强制触发JIT的指令重排序,避免漏测问题。
内容的提问来源于stack exchange,提问作者Sergey Tsypanov
相关产品推荐
相关产品推荐

