Gravitino 如何进行一致性测试


概述

在 Gravitino 中,我们使用 JCStress 框架进行并发压力测试,以识别线程安全相关的微妙缺陷。这类问题往往难以通过传统的单元测试捕捉,例如:

  • 数据竞争(Race Condition)
  • 内存可见性问题(Visibility Violation)
  • 原子性缺失(Atomicity Violation)

JCStress 能有效帮助我们验证并发代码的正确性,确保缓存、元数据等核心组件具备良好的线程安全保障。

什么是 JCStress?

JCStress 是 Oracle 官方提供的 Java 并发测试工具,专为检测并发场景下的非确定性错误而设计。其主要特性包括:

  • 在多个线程间并发执行操作,模拟实际竞争环境;
  • 使用大量线程交错(Interleaving)组合反复运行;
  • 揭示极低概率但高危的并发缺陷;
  • 将测试结果分为三类:可接受(ACCEPTABLE)禁止(FORBIDDEN)值得关注(INTERESTING)

如何运行测试

在 Gravitino 中使用 jcstress-gradle-plugin 将 JCStress 集成到 Gradle build 系统中。

该插件的优势如下:

  • 自动生成测试运行器(test harness);
  • 提供内置 Gradle 任务(如 :core:jcstress);
  • 自动生成报告,在 build/reports/jcstress/ 下生成详细的 HTML 报告。

运行测试的命令

运行所有 JCStress 测试:

./gradlew :core:jcstress

仅运行指定测试类(正则匹配):

gradle jcstress --tests "MyFirstTest|MySecondTest"

执行完毕后,可以在如下位置查看报告: core/build/reports/jcstress/index.html

测试运行时间取决于测试复杂度与系统 CPU 资源,通常为数分钟至数十分钟不等。

解读测试结果

JCStress 的测试结果以表格形式展示,包括:

  • 每种观察结果的内容与出现频率;
  • 每个结果的期望分类(ACCEPTABLE/FORBIDDEN/INTERESTING);
  • 总迭代次数与执行时长。

如果测试出现 FAILED,说明检测到被定义为 FORBIDDEN 的结果,表示存在潜在并发问题。

配置方式

通过在 build.gradle 中添加如下配置,可调整测试策略:

jcstress {
    verbose = "true"
    timeMillis = "200"
    spinStyle = "THREAD_YIELD"
}

常用配置项说明:

配置项 说明
mode 执行模式:sanityquickdefaulttoughstress
cpuCount 使用的 CPU 核心数,默认使用全部。
forks 每个测试被 fork 的次数。
iterations 每个测试的迭代次数。
heapPerFork 每次 fork 的最大内存(MB)。
spinStyle 等待策略:如 THREAD_YIELDHARD 等。
affinityMode 线程与 CPU 的绑定模式:NONEGLOBALLOCAL
jvmArgs/jvmArgsPrepend 设置 JVM 启动参数。
reportDir 报告输出目录。

如何编写 JCStress 测试

JCStress 提供两种测试风格,适用于不同的并发验证场景。

风格一:基于 Arbiter 的测试

特点是通过独立的 @Arbiter 方法观察最终状态:

  1. 定义共享变量;
  2. 用 @Actor 注解要并发执行的方法;
  3. 用 @Arbiter 注解标注最终的观察方法;
  4. 使用 @JCStressTest + @Outcome 标注预期结果。
@JCStressTest
@Outcome(id = "1", expect = Expect.ACCEPTABLE, desc = "正常结果")
@Outcome(id = "0", expect = Expect.FORBIDDEN, desc = "出现竞态")
@State
public class ArbiterTest {
    int x;

    @Actor
    void actor1() {
        x = 1;
    }

    @Actor
    void actor2() {
        x = 1;
    }

    @Arbiter
    void arbiter(I_Result r) {
        r.r1 = x;
    }
}

风格二:直接结果记录(Direct Result Reporting)

特点是 actor 方法直接记录测试结果并写入结果对象

  1. 定义共享变量;
  2. @Actor 方法接收结果参数,如 II_Result
  3. 记录观察到的值;
  4. 使用 @Outcome 定义期望结果。
@JCStressTest
@Outcome(id = "0, 1", expect = Expect.ACCEPTABLE, desc = "线程 2 看到了写入")
@Outcome(id = "0, 0", expect = Expect.ACCEPTABLE, desc = "线程 2 没看到写入")
@State
public class DirectReportingTest {
    int x;

    @Actor
    void actor1() {
        x = 1;
    }

    @Actor
    void actor2(II_Result r) {
        r.r1 = 0;
        r.r2 = x;
    }
}

可用 @Description 添加简要说明,需要避免过长字符串,否则可能会触发 StringIndexOutOfBoundsException

风格选择

场景 推荐风格
需要统一观察最终状态 Arbiter 风格
Actor 可即时记录观察结果 直接记录风格
关注可见性或重排序问题 直接记录风格

线程安全测试示例

示例测试说明:

  • 两个 @Actor 并发往缓存中放入两个不同 key(schema 和 table);
  • @Arbiter 验证缓存中是否存在这两个实体;
  • 定义了三种结果:
    • 2:两个都存在
    • 1:只存在一个
    • 0:都不存在
public class xxx {
  // some entity to test.
  
  @JCStressTest
  @Outcome.Outcomes({
      @Outcome(id = "2", expect = Expect.ACCEPTABLE, desc = "Both put() calls succeeded; both entries are visible in the cache."),
      @Outcome(id = "1", expect = Expect.FORBIDDEN, desc = "Only one entry is visible; potential visibility or atomicity issue."),
      @Outcome(id = "0", expect = Expect.FORBIDDEN, desc = "Neither entry is visible; indicates a serious failure in write propagation or cache logic.")})
  @Description("Concurrent put() on different keys. Both schema and table should be visible (result = 2). " + "Lower results may indicate visibility or concurrency issues.")
  @State

  public static class ConcurrentPutDifferentKeysTest {
    private final EntityCache cache;

    public ConcurrentPutDifferentKeysTest() {
      this.cache = new CaffeineEntityCache(new Config() {
      });
    }

    @Actor
    public void actor1() {
      cache.put(schemaEntity);
    }

    @Actor
    public void actor2() {
      cache.put(tableEntity);
    }

    @Arbiter
    public void arbiter(I_Result r) {
      int count = 0;
      if (cache.contains(schemaEntity.nameIdentifier(), schemaEntity.type())) count++;
      if (cache.contains(tableEntity.nameIdentifier(), tableEntity.type())) count++;

      r.r1 = count;
    }
  }
  
  // ... other test classes
}

JCStress 测试生命周期

JCStress 遵循以下测试执行流程:

  1. 状态初始化:每次测试迭代前重新构造 @State 对象,确保隔离性;
  2. 并发执行:多个 @Actor 方法并发运行,模拟真实竞争;
  3. 内存屏障插入:确保执行顺序后再执行 @Arbiter
  4. 结果判定:将每次迭代的结果与定义的 @Outcome 比较归类;
  5. 高频迭代:每个测试重复运行百万次以上,以暴露极低概率的错误。

可用执行模式

模式 描述
sanity 快速检查测试是否配置正确(几秒)
quick 快速反馈,适合开发中使用(几十秒)
default 平衡模式,推荐常规使用(几分钟)
tough 强力模式,适合 CI 测试或发布前验证(较长时间)

问题类型

JCStress 主要帮助检测如下并发问题:

  • 竞态条件(Race Condition):多个线程无同步访问共享变量;
  • 可见性问题:线程修改对其他线程不可见;
  • 原子性破坏:非原子操作被部分执行;
  • 执行顺序错误:由于 CPU/JVM 重排序导致的行为异常。

Jcstress 编写建议

  • 每个测试聚焦一个并发问题;
  • 使用清晰的类名与注释解释每个结果;
  • 避免使用非确定性行为(如 Thread.sleep());
  • 多次运行测试以验证稳定性;
  • 对 FORBIDDEN 或 INTERESTING 的结果重点关注和修复。

参考


文章作者: pancx
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 pancx !
评论
  目录