You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

基于以上结果提出两个疑问:

  1. 移除hasParam上的volatile修饰后程序是否仍能保持正确性?
  2. 编写的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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 14:57:29