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 解决的是"发布安全"。三个仍然要命的点:
- 构造器里不能泄漏
this。注册监听器或丢进线程池等于让别的线程看到半成品,volatile救不了。 - Holder 模式(静态内部类)比 DCL 更简单,类初始化的锁由 JVM 保证,连
synchronized都不用写。 - 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. 自测
- 临界区里有两个字段必须同时更新,为什么 CAS 不适用?
- DCL 里
volatile保住的是什么,保不住的是什么? - Holder 模式为什么连
synchronized都不用写? - 动态线程池里
queueCapacity为什么通常不能原地修改? - "队列容量必填"这条 Poka-yoke 拦住的典型事故是什么?
- 并发测试的红阶段,为什么必须先在没有
volatile的实现上跑出失败?