Java 里的数据不一致问题通常不像空指针那样直接抛出异常。比如一个计数器类,单线程下每次调用 increment 后值都会加一,放到多线程环境里连续调用 1000 次后结果经常小于 1000。这类问题不是代码逻辑写错,而是多个线程对同一个共享变量执行读改写操作时,中间状态被其他线程覆盖。传统排查手段往往失效:加 System.out.println 会改变线程调度,Thread.sleep 只能猜测窗口,最终表现为偶发且难以复现。要稳定复现这些竞态条件,需要一个能系统制造线程交织并统计结果的工具,jcstress 就是为此设计的。

先看一个最典型的计数器实现:
public class Counter {
private int value;
public void increment() {
value++;
}
public int getValue() {
return value;
}
}
这里的 value++ 实际包含三步:读取当前值、计算加一、写回新值。两个线程同时执行时,可能都读到了同一个旧值,然后各自写回一个仅加一的结果,最终丢失一次更新。jcstress 可以通过大量随机调度把这种极小概率的丢失稳定暴露出来。
为什么竞态条件难以用普通测试复现
单测框架通常会按顺序执行线程,或者只创建少量线程做简单 join,真正触发竞态条件的概率非常低。例如上面 Counter 的丢更新问题,在本地循环 10 万次可能只出现几次,而且每次出现时线程栈和日志都无法反映当时的指令交错顺序。JIT 编译器还可能把 value++ 优化成原子指令,或者做循环外提等变换,导致单机上观察到的现象和线上完全不一致。
另一个难点是可见性。即使一个线程完成了写操作,另一个线程也不一定能立刻看到,因为 CPU 缓存和指令重排序会打乱实际执行顺序。普通测试只能验证最终结果是否正确,而无法判断某一次观测是否满足 Java 内存模型的合法结果。更麻烦的是,一旦在代码里加入 println 或日志,线程暂停和 I/O 操作会改变调度,竞态条件可能消失,造成误判。
因此,排查并发数据不一致需要两件事:一是能在同一份代码上反复制造不同的线程调度和内存屏障组合,二是能区分哪些结果是合法允许出现的、哪些是内存模型禁止出现的。jcstress 同时提供了这两项能力。它是 OpenJDK 的 code tools 项目之一,通过注解定义并发测试场景,由运行时在大量 fork 的 JVM 中收集结果分布。
jcstress 的核心模型与第一个测试
jcstress 测试类通常使用 @JCStressTest 标记,并用 @State 表示共享状态,@Actor 表示并发执行的动作,@Outcome 描述期待的结果。一个测试可以由多个 Actor 方法同时运行,运行时会在不同 JVM 进程、不同 CPU 核数和不同 JIT 阶段下重复执行这些 Actor,并统计最终达到的状态。
以自增计数器为例,我们想验证两个线程同时调用 increment 后,value 是否可能小于 2。编写测试类如下:
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
@JCStressTest
@Outcome(id = "1, 1", expect = Expect.ACCEPTABLE, desc = "两个线程都读到旧值并丢失更新")
@Outcome(id = "2, 1", expect = Expect.FORBIDDEN, desc = "不允许出现其中一个结果大于实际调用次数")
@Outcome(id = "1, 2", expect = Expect.FORBIDDEN, desc = "不允许出现其中一个结果大于实际调用次数")
@Outcome(id = "2, 2", expect = Expect.ACCEPTABLE, desc = "两个线程串行执行,结果正确")
@State
public class CounterIncrementTest {
private final Counter counter = new Counter();
@Actor
public void actor1(II_Result r) {
counter.increment();
r.r1 = counter.getValue();
}
@Actor
public void actor2(II_Result r) {
counter.increment();
r.r2 = counter.getValue();
}
}
这里的结果类型 II_Result 表示两个 int 返回值 r1 和 r2。两个 Actor 各自调用 increment 后读取 getValue,如果两个线程完全串行,结果就是 (2,2);如果发生竞态导致丢更新,读取结果可能是 (1,1)。另一种情况是其中一个线程先完成自增但还没来得及读值,另一个线程后完成,可能得到 (1,2) 或 (2,1),但 value 本身只增加了一次,这类结果也应被判定为禁止。实际运行时,jcstress 会在几千到几十万次试验中统计这些组合的分布。
输出报告里会按 result 列出 observed 次数和 expected 判断。如果出现 FORBIDDEN 结果,说明代码违反了预期,开发者可以直接定位到具体的非法状态。对于 Counter 这种非原子自增,报告通常会显示 (1,1) 的出现次数明显大于零,这就是数据不一致的直接证据。
用 jcstress 验证可见性和指令重排序
除了丢失更新,可见性问题同样会导致数据不一致。例如一个线程写入普通字段后,另一个线程可能长时间看不到新值,甚至永远看到旧值。下面这个测试验证 volatile 关键字对可见性的影响:
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
@JCStressTest
@Outcome(id = "0, 0", expect = Expect.ACCEPTABLE, desc = "两个线程都未观察到写入")
@Outcome(id = "0, 1", expect = Expect.ACCEPTABLE, desc = "第二个线程观察到第一个线程的写入")
@Outcome(id = "1, 0", expect = Expect.FORBIDDEN, desc = "第二个写入却第一个读到,不符合程序顺序")
@Outcome(id = "1, 1", expect = Expect.ACCEPTABLE, desc = "两个线程都观察到写入")
@State
public class VolatileVisibilityTest {
int x;
volatile int y;
@Actor
public void actor1(II_Result r) {
x = 1;
y = 1;
r.r1 = y;
r.r2 = x;
}
@Actor
public void actor2(II_Result r) {
r.r1 = y;
r.r2 = x;
}
}
这段代码稍微复杂一点:actor1 先写普通字段 x 再写 volatile 字段 y,然后读回;actor2 先读 y 再读 x。如果 volatile 的写读没有建立正确的 happens-before 关系,actor2 可能在读到 y 等于 1 后仍读到旧的 x,从而得到 (1,0) 这种违反直觉的结果。jcstress 会通过大量重复执行和内存屏障变化,检验所有符合 Java 内存模型的允许结果。
如果去掉 volatile,JIT 可能会重排序普通字段的写读,或者 CPU 缓存不会及时刷新,导致 actor2 看到 y 的更新却看不到 x 的更新。jcstress 报告中 forbidden 的 (1,0) 可能以极低概率出现,这就是一个典型的可见性缺陷。
双重检查锁定的案例与结果判读
双重检查锁定是单例模式中常见的一个性能优化写法,但如果没有正确使用 volatile,可能返回未完全构造的对象。用 jcstress 可以验证这一点。测试类如下:
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;
@JCStressTest
@Outcome(id = "1", expect = Expect.ACCEPTABLE, desc = "单例初始化完成")
@Outcome(id = "0", expect = Expect.FORBIDDEN, desc = "返回了未初始化的单例")
@State
public class DoubleCheckedLockingTest {
private static class Singleton {
private int value = 42;
}
private Singleton instance;
@Actor
public void actor1() {
if (instance == null) {
synchronized (this) {
if (instance == null) {
instance = new Singleton();
}
}
}
}
@Actor
public void actor2(I_Result r) {
Singleton s = instance;
if (s != null) {
r.r1 = s.value;
} else {
r.r1 = -1;
}
}
}
这里 Singleton 的实例化包含三步:分配内存、调用构造函数给 value 赋值、将引用赋值给 instance。如果没有 volatile 语义,JIT 或 CPU 可能把第三步提前到第二步之前,导致另一个线程看到 instance 非空,但 value 还未写入默认值以外的内容。jcstress 的 FORBIDDEN 结果 0 表示 actor2 读取到了 value 为 0 的未完全构造对象。
运行时可以通过 JVM 参数让 jcstress 更激进地进行指令调度。如果测试类中的 instance 没有 volatile 修饰,报告会频繁出现 0 结果;加上 volatile 后,0 结果应当消失。这类实验可以帮助开发者理解 Java 内存模型中对 final 字段和 volatile 写入的保证。
本地与 CI 中运行 jcstress 的配置
jcstress 的依赖引入非常直接。使用 Maven 时,在 pom.xml 中加入以下内容:
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-core</artifactId>
<version>0.16</version>
<scope>test</scope>
</dependency>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<includes>
<include>**/*Test.java</include>
</includes>
</configuration>
</plugin>
也可以直接通过命令行运行 jcstress 的 jar 包。下载 jcstress.jar 后执行:
java -jar jcstress.jar -t CounterIncrementTest
-t 参数可以指定要运行的测试类,省略则运行全部测试。jcstress 会 fork 多个 JVM 来避免 JIT 状态互相影响,因此单次完整运行可能需要几分钟。对于持续集成环境,建议单独划分一个 stage 运行 jcstress,而不是和普通单测混在一起。结果报告通常输出在 jcstress-results 目录,格式包含 HTML 和文本,可以直接查看 forbidden 结果的出现情况。
需要特别注意的是,jcstress 只能证明在给定输入下是否存在非法结果,不能证明代码绝对正确。如果测试中没有出现 forbidden 结果,也不代表并发逻辑完全安全,可能只是调度组合没有覆盖到。因此 jcstress 更适合作为复现和回归工具,配合代码 review 和内存模型分析一起使用。
小结
并发数据不一致的排查核心在于稳定复现。jcstress 通过随机化线程调度和 JVM 参数组合,把原本几天才出现一次的竞态条件压缩到几分钟内概率性出现,同时用 expected/forbidden 结果给出明确的判断依据。在实际排查中,可以先怀疑共享可变状态的访问顺序,然后用 jcstress 编写针对性的 Actor 测试。如果报告出现 forbidden,再到源码中查找对应的读写操作,往往能快速定位到缺少 synchronized、volatile 或原子类的问题。
掌握 jcstress 并不意味着可以忽略并发设计原则。它更像一个显微镜,帮助开发者在测试阶段发现那些隐藏在大量正常结果中的非法状态。对于关键业务中的计数器、缓存、单例和状态机,建议在持续集成中加入 jcstress 用例,让数据不一致问题在进入生产之前就被拦截。