2667 字
约 8 分钟
18
S05-X26 · CAS、双重检查与动态线程池:并发正确性的三个落点

S05-X26 · CAS、双重检查与动态线程池:并发正确性的三个落点

并发 bug 的共同特征是压测看不见、生产偶发、日志无痕:原因多半不是逻辑写错,而是可见性与时序没写对。三个落点——CAS 的 ABA、DCL 的半初始化、线程池参数固化——都能靠同一套办法收口:把语义写进结构,把结构写进测试。


1. Compare-And-Swap:一条指令的语义与代价

CAS 的语义是"当前值等于期望值就换成新值,返回是否成功",x86 落到 lock cmpxchg,ARM 是一组 ldaxr/stlxr 循环;Java 里直接暴露它的是 AtomicInteger、AtomicReference 与 VarHandle。

AtomicLong seq = new AtomicLong(0);
seq.getAndIncrement();            // 递增并返回旧值,内部即 CAS 自旋

// 手动 CAS:只在条件满足时更新,失败只重读、不重跑业务
while (true) {
    long cur = seq.get();
    if (cur != expect) return false;
    if (seq.compareAndSet(cur, update)) return true;
}
维度 事实 工程含义
原子性粒度 单个变量 两字段一起变只能换锁或打包成对象
内存语义 成功时具备 volatile 读写语义 不必额外加 synchronized 也有可见性
失败重试 由调用方负责 高竞争下自旋会烧 CPU,重试次数要设上限
ABA 值 A→B→A,CAS 仍成功 带版本号 AtomicStampedReference
竞争成本 无竞争极快,高竞争退化 竞争激烈时 LongAdder 更划算

LongAdder 把累加拆到多个 Cell,读时求和:用读的实时性换写吞吐,计数类指标用它,精确快照的序号用 AtomicLong。


2. 什么时候 CAS 反而更慢

场景 表现 更合适的选择
临界区有多个写操作 CAS 只保单变量原子性 ReentrantLock 或 synchronized
竞争线程数远超 CPU 核数 自旋空转,上下文切换叠加 直接阻塞锁,避免活锁
操作本身耗时(含 IO、正则) 重试代价被放大数倍 锁 + 串行化,或改成分片
需要公平性 CAS 无排队语义,可能饿死 ReentrantLock(true)
读多写少且要一致视图 每读都要 CAS 校验 StampedLock 乐观读或 volatile

判据:临界区短且只有一个变量 → CAS;多个不变量 → 锁。 反过来用只会把偶发问题变成必然问题。


3. Double-Check:为什么要 volatile,以及为什么它还不够

双重检查锁(Double-Checked Locking)的经典写法:

public final class ConfigHolder {
    private static volatile ConfigHolder instance;   // volatile 不可省
    private final Map<String, String> conf;

    private ConfigHolder() {
        this.conf = loadFromDisk();                   // 构造过程可能很慢
    }

    public static ConfigHolder get() {
        ConfigHolder local = instance;                // 局部变量减少 volatile 读次数
        if (local == null) {
            synchronized (ConfigHolder.class) {
                local = instance;
                if (local == null) {
                    local = new ConfigHolder();        // 1 分配 2 构造 3 赋值
                    instance = local;
                }
            }
        }
        return local;
    }
}
环节 没有 volatile 会发生什么 加了 volatile 之后
new 的三步 可能把"赋值引用"提到"执行构造"前 volatile 写禁止该重排
另一线程的判断 看到非 null 但字段仍是默认值 读到的引用必然伴随完成构造
读开销 无 一次 volatile 读,x86 上接近普通读
局部变量 无 把两次 volatile 读减为一次,热路径上值得

volatile 解决的是"发布安全"。三个仍然要命的点:

  1. 构造器里不能泄漏 this。注册监听器或丢进线程池等于让别的线程看到半成品,volatile 救不了。
  2. Holder 模式(静态内部类)比 DCL 更简单,类初始化的锁由 JVM 保证,连 synchronized 都不用写。
  3. DCL 用在单例之外多半是错的。缓存需要的是"允许重复计算、不能读到坏值",ConcurrentHashMap.computeIfAbsent 更稳。
// 更稳的一步到位写法:类初始化即线程安全
public final class LazyConfig {
    private static final class Holder {
        static final Map<String, String> CONF = loadFromDisk();
    }
    public static Map<String, String> conf() {
        return Holder.CONF;
    }
}

4. Hippo4J:把线程池参数从启动参数里解放出来

线程池参数一旦写进代码或 application.yml,调整就得重启。Hippo4J 这类动态线程池组件的定位是运行期改值并留痕:把 ThreadPoolExecutor 注册成可观测对象,经配置中心推送变更,配合监控看队列水位与拒绝计数。

spring:
  dynamic:
    thread-pool:
      enable: true
      executors:
        - thread-pool-id: order-worker
          core-pool-size: 8
          maximum-pool-size: 32
          queue-capacity: 512
          keep-alive-time: 60
          rejected-handler: CallerRunsPolicy
          allow-core-thread-time-out: true

对应代码里拿到的是普通 ExecutorService,可以正常注入:

@Bean("orderWorker")
public ExecutorService orderWorker() {
    return new ThreadPoolExecutor(8, 32, 60L, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(512),
        new NamedThreadFactory("order-worker"),
        new ThreadPoolExecutor.CallerRunsPolicy());
}
参数 调大的收益 调大的风险 运行时能否改
corePoolSize 常驻线程变多,响应更快 空闲线程占内存,切换变多 可以(配 allowCoreThreadTimeOut)
maximumPoolSize 峰值吞吐上限提高 线程数超过下游连接池容量时,压力前移为排队 可以
queueCapacity 削峰更强,拒绝变少 排队延迟上升,超时集中爆发 只能换队列实例
keepAliveTime 收缩更快,空闲资源回收早 反复创建销毁,抖动 可以
rejectedHandler 换成 CallerRunsPolicy 可自然降速 反压到调用方线程,可能拖慢入口 可以

queueCapacity 值得单独看:LinkedBlockingQueue 容量构造时固定,"动态改长度"通常是换新队列并迁移任务,迁移期间不能丢。队列做小、最大线程数做大,更能暴露真实容量问题——大队列只是把超时从"拒绝"变成"更晚的拒绝"。

运行时核对实际生效值:

$ jcmd <pid> Thread.print | grep -c "order-worker"    # 实际线程数

5. Poka-yoke:让错误写不出来,而不是查得出来

Poka-yoke(防错设计)来自制造业:不是靠人小心,而是靠结构让错误动作做不成。并发代码里的防错手段其实很具体:

防错手段 拦住什么错误 代价
不可变对象 + final 字段 发布后被人改、半初始化泄漏 需要构造时一次算清
构造器私有 + 静态工厂 绕过初始化校验 多一层间接
参数在入口校验(Objects.requireNonNull) 空值延迟到深层才炸 少量分支
锁的获取顺序固定(按 ID 排序) 交叉死锁 需要统一约定并写进代码审查清单
队列容量必填、拒绝策略必填 默认无界队列导致的 OOM 每次都要显式写,烦但值
线程命名统一前缀 排查时认不出线程归属 需要一个 ThreadFactory

最后一条价值最高:jstack 里 500 个 pool-3-thread-17 难以归因,order-worker-3 一眼定位。命名工厂该是项目模板的一部分。


6. RED-GREEN-REFACTOR 用在并发上:先写会失败的测试

RED-GREEN-REFACTOR 三步在并发场景要改一处:并发测试的红不是断言失败,而是"跑一万次偶尔失败"。红阶段要写的是"能稳定复现问题"的测试。

@Test
void dclMustNotExposeHalfInitializedInstance() throws Exception {
    var latch = new CountDownLatch(1);
    var bad = new AtomicBoolean(false);
    Thread reader = new Thread(() -> {
        await(latch);
        if (ConfigHolder.get().conf() == null) bad.set(true);  // 读到半初始化
    });
    Thread writer = new Thread(ConfigHolder::get);
    reader.start(); writer.start(); latch.countDown();
    reader.join(); writer.join();
    assertFalse(bad.get(), "DCL 泄漏了半初始化对象");
}
阶段 并发场景的具体做法 通过标准
RED 用 CountDownLatch 对齐起跑线,重复 N 次 在去掉 volatile 的版本上必须失败
GREEN 加 volatile、改 Holder 模式 同一测试在 N 次内全绿
REFACTOR 换实现、抽工厂,仍然改测试 测试不改,行为不变

"在错误版本上必须失败"不能省:只写一个能过的并发测试等于没有保护,它可能永远覆盖不到那个时序窗口。


7. 排错:五类"看起来没问题"的现象

现象 真实原因 处理
单例偶发 NPE,重启即好 构造未完成就发布 改 Holder 模式,去掉构造器内的外部注册
计数器比实际少几条 高竞争下的重试被误当丢失 换 LongAdder,核对是否在 finally 累加
队列一直满但不扩容 队列无界,maximumPoolSize 用不到 改有界队列或 SynchronousQueue
改了核心数没生效 配置中心的值没推送到实例 核对 thread-pool-id 与实例标识
压测通过,上线超时 测试环境池参数与生产不同 池参数纳入配置审计,禁止写死

8. 边界:这三套办法管不到的地方

场景 为什么管不到 该换什么
跨进程并发 CAS/DCL 只在单 JVM 内有效 分布式锁、唯一约束、幂等键
需要严格顺序 CAS 重试会打乱顺序 按 key 分片串行化或队列分区
线程池外还有连接池 调池会把压力推到下游 先算下游容量再定上游线程数
虚拟线程(JDK 21+) 虚拟线程不该池化,DCL 的收益也变了 按载体线程数与 Semaphore 限流
依赖第三方客户端内部线程 你的池参数管不到它 看客户端文档的连接池与超时配置

一句话:能靠 CAS 的别上锁,能靠结构防的别靠自觉,能靠测试锁的别靠压测。


10. 自测

  1. 临界区里有两个字段必须同时更新,为什么 CAS 不适用?
  2. DCL 里 volatile 保住的是什么,保不住的是什么?
  3. Holder 模式为什么连 synchronized 都不用写?
  4. 动态线程池里 queueCapacity 为什么通常不能原地修改?
  5. "队列容量必填"这条 Poka-yoke 拦住的典型事故是什么?
  6. 并发测试的红阶段,为什么必须先在没有 volatile 的实现上跑出失败?
S05-X26 · CAS、双重检查与动态线程池:并发正确性的三个落点
https://www.clxhxhhr.top/posts/6016/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。