承接的问题
14.3 把部署参数钉在了 K8s 的 requests/limits 上。参数对了,代码里仍然可以埋三种规范缺口:
- 锁被发布出去。 类把
public static ReentrantLock lock当成员暴露,两个模块各自synchronized(index.lock()),获取顺序相反。4.3 消除的反向边,从 API 层原样回来了。 - 线程没有名字。 转储里全是
pool-1-thread-1到pool-1-thread-8,不知道哪个在扣库存、哪个在发通知。 - 池没有归属。 服务收到 SIGTERM,上下文关了,进程却退不掉:
jcmd Thread.print里还有 8 个 worker 在等队列,代码里没有任何地方调用shutdown()。
三个问题的共同点不是「写错了」,而是没有一条可判定的规矩。「注意锁的可见性」是口号,「锁字段必须是 private final 且不提供 getter」才能被脚本检查。这一节把规范拆成命名与可见性、线程与池的生命周期、锁顺序三组,再用可运行脚本把能机械判定的部分接进 CI。
与卷二 15.3 的分工先说清:那一节给的是JUC 误用的 20 条清单和基础 grep 脚本,覆盖 volatile 复合操作、compute 副作用、无界队列、静态 SimpleDateFormat 等。本节不重复那些条目,只在它的脚本上补三类规范规则:X 类锁对象的可见性与暴露、Y 类线程/池/ThreadLocal 的生命周期、Z 类锁顺序与层级。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3,Temurin 25.0.5+8;写作时 load average 66–78,所有计时为单次运行、随调度波动、不作基准。
锁对象:private final,且只被自己持有
锁对象是并发代码里最容易被当成普通字段处理的类型。四条要求都能写成可检查的条目:
| 要求 | 具体写法 | 违反后的后果 |
|---|---|---|
| 字段私有 | private final | 外部代码可以用任意顺序获取,锁顺序契约失效 |
| 不可重新赋值 | final | 两个线程锁的不是同一个对象,互斥直接消失 |
| 不发布 | 不写返回锁的 getter,不放进参数或事件 | 调用方能在你的临界区之外持锁,形成跨类环 |
| 锁的对象可控 | 用私有 new Object() 或 ReentrantLock | 与无关代码共享同一把锁 |
第三条最常被辩解成「只是给测试用」。但 getter 一旦存在,生产代码也会用;4.3 的反向边检测只能看到转储里正在等待的锁,看不到「谁有权拿」。把 getter 删掉,比在文档里写「请不要跨模块使用」可靠得多。
第四条要展开,因为三种「看起来没问题」的锁对象都跨类共享:
- 字符串字面量。
synchronized ("catalog")锁的是常量池里的同一个String,任何加载了同一常量的类都在抢同一把锁。 - 包装类缓存。
Integer.valueOf(1)在 -128–127 内返回缓存实例,框架内部可能正用同一个对象做锁。 Class对象。synchronized (Book.class)全局唯一,等于给整个类加了一把公共锁。
症状是「单测都过,上线偶尔卡住」;证据是转储里两个毫不相干的线程在等同一把锁;根因就是锁对象被共享。修复不是加超时,而是换成私有锁对象;验证方法是让扫描脚本在 CI 里报 X-LOCK-EXPOSED / X-SYNC-ON-SHARED-CONSTANT。
线程与池:命名、归属、关闭
线程名必须在写代码时决定。Thread.ofPlatform().name("stock-worker-", 0) 看起来最省事,但每次调用都会新建一个 builder,序号从 0 重新开始;在 newThread 里反复调用,得到的是一串同名线程 stock-worker-0,比不命名更难排查。写 15.3 的验收探针时我先用了这个写法,转储里 16 个 worker 全叫 loan-worker-0,只能改成 ThreadFactory 加 AtomicInteger:
AtomicInteger seq = new AtomicInteger();
ThreadFactory factory = r -> Thread.ofPlatform()
.name("stock-worker-" + seq.incrementAndGet())
.unstarted(r);
池的归属只有一条:谁创建,谁负责关闭。JDK 19 起 ExecutorService 继承 AutoCloseable(本机 javap 输出:public interface ExecutorService extends Executor, AutoCloseable),所以能写成 try-with-resources。三种关闭要分清:
| 方法 | 是否接新任务 | 队列里的任务 | 正在跑的任务 |
|---|---|---|---|
shutdown() | 否 | 跑完 | 跑完 |
shutdownNow() | 否 | 返回未执行列表 | 尝试中断 |
close() | 否 | 跑完 | 跑完,然后等待终止 |
ThreadLocal 的规范和池绑在一起:池线程会被复用,一个请求设了值却不在 finally 里 remove(),下一个请求就会读到上一个请求的身份。15.2 的取证探针会给出可运行证据;这里只记结论:静态 ThreadLocal 加池线程,等于跨请求共享变量。
禁止项集中在「默认值不安全」的 API 上:
| 禁止 | 原因 | 替代 |
|---|---|---|
Thread.stop() | 已标记 removal,调用抛 UnsupportedOperationException,破坏对象一致性 | 中断 + 标志位 |
Thread.suspend() / resume() | JDK 25 已彻底移除,javac 直接报找不到符号 | LockSupport.park / Condition |
裸 new Thread(...) | 无池化、无命名、无拒绝策略 | 线程池或虚拟线程 |
无界 LinkedBlockingQueue | 把过载变成延迟,直到 OOM | 有界队列 + 拒绝策略 |
Executors.newFixedThreadPool | 底层是无界队列 | 显式 ThreadPoolExecutor |
Executors.newCachedThreadPool | 最大线程数是 Integer.MAX_VALUE | 显式上限 + 空闲回收 |
Executors.newSingleThreadExecutor | 单线程 + 无界队列,慢任务无限堆积 | 显式单线程池 + 有界队列 |
锁顺序:从局部约定到全局表
4.3 已经给出四条规则和转储检测脚本,这里补的是规范条目怎么落进代码审查。规范必须包含三样东西:
- 一张全局顺序表。 按资源类型分层:L0 配置/目录,L1 账户/会员,L2 分片/桶,L3 单条记录。只允许从低层往高层拿,持有高层时不得回取低层。
- 同类资源按业务键排序。 ISBN、会员 ID、账户 ID 这类稳定键,小的先拿;不能用
hashCode()或identityHashCode(),前者会碰撞、后者每次运行都可能不同。 - 动态集合先去重再排序。 批量扣减时请求里可能有重复 ISBN,重复加锁虽然可重入,但会打乱配对和超时计数。
规范里最容易缺的是「跨模块」这一半。一个模块内部按 ISBN 排序,另一个模块按会员 ID 排序,两个模块各持一把锁再调对方,环仍然成立。所以顺序表必须是仓库级的,每个锁都要写明层号,不能只写在某个 service 的注释里。
规范样例 OrderedBookIndex 展示了锁对象可见性、tryLock 超时、线程命名、有界队列和池关闭这五件事,可编译、可运行:
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public final class OrderedBookIndex {
private final ReentrantLock lock = new ReentrantLock();
private final ExecutorService pool;
public OrderedBookIndex() {
AtomicInteger seq = new AtomicInteger();
this.pool = new ThreadPoolExecutor(8, 8, 0, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(256),
r -> Thread.ofPlatform().name("stock-" + seq.incrementAndGet()).unstarted(r),
new ThreadPoolExecutor.AbortPolicy());
}
public boolean reserve(String isbn) throws InterruptedException {
if (!lock.tryLock(50, TimeUnit.MILLISECONDS)) {
return false;
}
try {
System.out.println("reserve " + isbn);
return true;
} finally {
lock.unlock();
}
}
public void close() {
pool.shutdown();
}
public static void main(String[] args) throws Exception {
OrderedBookIndex index = new OrderedBookIndex();
try {
System.out.println("reserved=" + index.reserve("978-7-111-7"));
} finally {
index.close();
}
}
}
这里只有一把锁,所以排序和层号没有出现在代码里;多把锁的排序获取与 tryLock 兜底在 4.3 的 OrderedTransfer,规范要求把 LockLevel 写成枚举而不是注释,测试环境断言 next.level().ordinal() >= held.level().ordinal(),把反向获取变成测试失败而不是线上死锁。
检查工具:javac 能做什么,脚本补什么
javac -Xlint:all 能发现的是语言层面的可见问题:弃用、removal、try-with-resources、fallthrough、this-escape。它不理解锁顺序、线程归属这类语义。拿埋了问题的示例树编译,真实输出是:
bad/com/plume/conc/core/LegacyStop.java:5: 警告: [removal] Thread 中的 stop() 已过时, 且标记为待删除
worker.stop();
^
bad/com/plume/conc/core/LegacyStop.java:6: 错误: 找不到符号
worker.suspend();
^
符号: 方法 suspend()
位置: 类型为Thread的变量 worker
1 个错误
1 个警告
这段输出本身就是规范依据:stop() 还在但已标记删除,suspend() 在 JDK 25 已经不存在。同一棵树上 public static ReentrantLock、Executors.newFixedThreadPool、无界队列,javac 一条都没报。
ErrorProne 和 SpotBugs 有并发检查器(双重检查锁、volatile 复合操作、锁对象等),但本书不引入这两个依赖,也不假设注解处理器已配置;这里只把它们作为对照,实际落地用下面的脚本。卷二 15.3 的基础脚本覆盖 JUC 误用,本节在它之上加三类规则:
| 家族 | 规则 | 检查什么 | 级别 |
|---|---|---|---|
| X | X-LOCK-EXPOSED | public / protected 锁字段 | BLOCK |
| X | X-LOCK-LEAKED-GETTER | getter 返回锁字段 | BLOCK |
| X | X-SYNC-ON-SHARED-CONSTANT | 锁字符串、包装类、Class | BLOCK |
| X | X-LOCK-NOT-FINAL | 锁字段缺 final | REQUIRE |
| Y | Y-EXECUTORS-FACTORY | 三个不安全工厂方法 | BLOCK |
| Y | Y-POOL-NO-SHUTDOWN | 有池字段但没有关闭调用 | BLOCK |
| Y | Y-TL-NO-REMOVE | 有 ThreadLocal 但没有 remove | REQUIRE |
| Y | Y-THREAD-NO-NAME | Thread.ofXxx() 三行内没有 .name( | SUGGEST |
| Y | Y-POOL-NO-NAME-FACTORY | new ThreadPoolExecutor 没有命名工厂 | SUGGEST |
| Z | Z-MULTI-LOCK-NO-ORDER | 方法内两次以上加锁且无 tryLock / 排序入口 | BLOCK |
| Z | Z-TRYLOCK-NO-TIMEOUT | 无参 tryLock() | REQUIRE |
| Z | Z-LOCK-LEVEL-UNDECLARED | 两把以上锁但没有 LockLevel | REQUIRE |
脚本全文如下,Python 3 直接跑,不依赖第三方库:
#!/usr/bin/env python3
"""scan_conc_norm.py — 并发规范粗筛:基线(卷二 15.3)+ X/Y/Z 三类新规则。"""
import re
import sys
from pathlib import Path
ROOT = Path(sys.argv[1] if len(sys.argv) > 1 else "src")
found = []
def add(level, rule, path, line, text):
found.append((level, rule, f"{path}:{line}", text.strip()[:64]))
LOCK = re.compile(r"^ {0,4}((?:(?:public|protected|private|static|final|volatile|transient)\s+)*)"
r"(ReentrantLock|ReentrantReadWriteLock|StampedLock|Object)\s+(\w+)\s*=")
SIG = re.compile(r"^\s*(?:(?:public|protected|private|static|final|synchronized|abstract)\s+)*"
r"[\w$.<>\[\], ?]+\s+(\w+)\s*\([^;{]*\)\s*(?:throws\s+[\w$., ]+)?\s*\{\s*$")
SKIP = {"if", "for", "while", "switch", "catch", "synchronized", "try", "do"}
def methods(lines):
out, i = [], 0
while i < len(lines):
m = SIG.match(lines[i])
if m and m.group(1) not in SKIP:
start, depth, j = i, 0, i
while j < len(lines):
depth += lines[j].count("{") - lines[j].count("}")
if depth <= 0 and j > start:
break
j += 1
out.append((start + 1, "\n".join(lines[start:j + 1])))
i = j + 1
else:
i += 1
return out
def scan(path, lines):
text = "\n".join(lines)
locks = {}
for n, line in enumerate(lines, 1):
m = LOCK.match(line)
if m and (m.group(2) != "Object" or "new Object" in line):
locks[m.group(3)] = (n, m.group(1), m.group(2))
for n, line in enumerate(lines, 1):
# 基线:卷二 15.3 已给,这里只保留与规范相关的四条
if re.search(r"\bnew\s+Thread\s*\(", line):
add("BLOCK", "BASE-RAW-THREAD", path, n, line)
if re.search(r"\.(stop|suspend|resume)\s*\(\s*\)", line):
add("BLOCK", "BASE-THREAD-STOP", path, n, line)
if re.search(r"new\s+LinkedBlockingQueue\s*<\s*>\s*\(\s*\)", line):
add("REQUIRE", "BASE-UNBOUNDED-QUEUE", path, n, line)
if "System.currentTimeMillis()" in line:
add("SUGGEST", "BASE-WALL-CLOCK", path, n, line)
# X 类:锁对象的可见性与暴露
fm = LOCK.match(line)
if fm and re.search(r"\b(public|protected)\b", fm.group(1)):
add("BLOCK", "X-LOCK-EXPOSED", path, n, line)
elif fm and "final" not in fm.group(1):
add("REQUIRE", "X-LOCK-NOT-FINAL", path, n, line)
if re.search(r'synchronized\s*\(\s*"', line) or \
re.search(r"synchronized\s*\(\s*(?:Integer|Long|Boolean)\.", line) or \
re.search(r"synchronized\s*\(\s*\w+\.class\s*\)", line):
add("BLOCK", "X-SYNC-ON-SHARED-CONSTANT", path, n, line)
m = re.search(r"\breturn\s+(?:this\.)?(\w+)\s*;", line)
if m and m.group(1) in locks:
add("BLOCK", "X-LOCK-LEAKED-GETTER", path, n, line)
# Y 类:线程、池与 ThreadLocal 的生命周期
if re.search(r"Thread\.of(?:Platform|Virtual)\s*\(", line):
if ".name(" not in "\n".join(lines[n - 1:n + 2]):
add("SUGGEST", "Y-THREAD-NO-NAME", path, n, line)
if re.search(r"Executors\.(newFixedThreadPool|newCachedThreadPool|newSingleThreadExecutor)", line):
add("BLOCK", "Y-EXECUTORS-FACTORY", path, n, line)
if "new ThreadPoolExecutor(" in line:
window = "\n".join(lines[n - 1:n + 11])
if "ThreadFactory" not in window and ".name(" not in window:
add("SUGGEST", "Y-POOL-NO-NAME-FACTORY", path, n, line)
pool = r"\b(?:ExecutorService|ThreadPoolExecutor|ScheduledExecutorService)\s+\w+\s*[;=]"
if re.search(pool, text) and not re.search(r"\.(shutdown|shutdownNow|close)\s*\(", text):
n = next(i for i, l in enumerate(lines, 1) if re.search(pool, l))
add("BLOCK", "Y-POOL-NO-SHUTDOWN", path, n, lines[n - 1])
if "ThreadLocal<" in text and ".remove(" not in text:
n = next(i for i, l in enumerate(lines, 1) if "ThreadLocal<" in l)
add("REQUIRE", "Y-TL-NO-REMOVE", path, n, lines[n - 1])
# Z 类:锁顺序与层级
for start, body in methods(lines):
acquires = body.count(".lock()") + len(re.findall(r"\bsynchronized\s*\(", body))
if acquires >= 2 and "tryLock(" not in body and "OrderedLocks" not in body:
add("BLOCK", "Z-MULTI-LOCK-NO-ORDER", path, start, body.splitlines()[0])
if re.search(r"\.tryLock\s*\(\s*\)", body):
add("REQUIRE", "Z-TRYLOCK-NO-TIMEOUT", path, start, body.splitlines()[0])
if len(locks) >= 2 and "LockLevel" not in text:
add("REQUIRE", "Z-LOCK-LEVEL-UNDECLARED", path, min(v[0] for v in locks.values()),
f"class declares {len(locks)} lock fields")
for f in sorted(ROOT.rglob("*.java")):
scan(str(f), f.read_text(encoding="utf-8").splitlines())
print("%-8s %-26s %-44s %s" % ("LEVEL", "RULE", "LOCATION", "EVIDENCE"))
for lv, rule, loc, txt in sorted(found, key=lambda x: ({"BLOCK": 0, "REQUIRE": 1, "SUGGEST": 2}[x[0]], x[1], x[2])):
print("%-8s %-26s %-44s %s" % (lv, rule, loc, txt))
c = {k: sum(1 for f in found if f[0] == k) for k in ("BLOCK", "REQUIRE", "SUGGEST")}
fam = {k: sum(1 for f in found if f[1].startswith(k)) for k in ("X", "Y", "Z")}
print(f"\nsummary: BLOCK={c['BLOCK']} REQUIRE={c['REQUIRE']} SUGGEST={c['SUGGEST']} total={len(found)}")
print(f"new-rules: X={fam['X']} Y={fam['Y']} Z={fam['Z']}")
sys.exit(1 if c["BLOCK"] else 0)
拿它扫一棵埋了问题的示例树(6 个类),本机输出节选(原表 20 行,按 BLOCK / REQUIRE / SUGGEST 排序):
BLOCK BASE-RAW-THREAD bad/.../LegacyStop.java:3 Thread worker = new Thread(() -> System.out.println("work"));
BLOCK BASE-THREAD-STOP bad/.../LegacyStop.java:5 worker.stop();
BLOCK X-LOCK-EXPOSED bad/.../LegacyCounter.java:4 public static ReentrantLock lock = new ReentrantLock();
BLOCK X-LOCK-LEAKED-GETTER bad/.../LegacyCounter.java:8 return lock;
BLOCK X-SYNC-ON-SHARED-CONSTANT bad/.../CatalogService.java:8 synchronized ("catalog") {
BLOCK Y-EXECUTORS-FACTORY bad/.../PoolFactory.java:8 private final ExecutorService pool = Executors.newFixedThreadPoo
BLOCK Y-POOL-NO-SHUTDOWN bad/.../PoolFactory.java:8 private final ExecutorService pool = Executors.newFixedThreadPoo
BLOCK Z-MULTI-LOCK-NO-ORDER bad/.../CatalogService.java:7 public void update(String isbn) {
REQUIRE BASE-UNBOUNDED-QUEUE bad/.../PoolFactory.java:14 4, 4, 0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
REQUIRE X-LOCK-NOT-FINAL bad/.../LegacyCounter.java:5 private ReentrantLock spare = new ReentrantLock();
REQUIRE Y-TL-NO-REMOVE bad/.../RequestContext.java:2 private static final ThreadLocal<String> CTX = new ThreadLocal<>
REQUIRE Z-LOCK-LEVEL-UNDECLARED bad/.../CatalogService.java:4 class declares 2 lock fields
REQUIRE Z-TRYLOCK-NO-TIMEOUT bad/.../CatalogService.java:20 public boolean tryUpdate() {
SUGGEST Y-THREAD-NO-NAME bad/.../PoolFactory.java:11 Thread.ofPlatform().start(() -> System.out.println("tick"));
summary: BLOCK=10 REQUIRE=6 SUGGEST=4 total=20
new-rules: X=4 Y=6 Z=4
同一脚本扫合规样例 OrderedBookIndex,输出只有表头和汇总:
summary: BLOCK=0 REQUIRE=0 SUGGEST=0 total=0
new-rules: X=0 Y=0 Z=0
合规样例同时是「可编译、可运行」的证明,-Xlint:all 零警告:
javac --release 25 -Xlint:all -d classes OrderedBookIndex.java
java -cp classes OrderedBookIndex
reserve 978-7-111-7
reserved=true
在 libraryd-conc 里落地
- 分级与闸门。
BLOCK直接失败 CI;REQUIRE允许存在但要求 reviewer 逐条确认,数量超过基线就失败;SUGGEST只进报告。级别按后果定,不按风格定。 - 基线冻结。 第一次接入把存量结果记成基线,新 PR 只要求不高于基线;
BLOCK永远要求 0。确有正当理由(测试代码故意暴露锁来复现死锁)就写// conc-waiver: 理由 + 到期时间,让评审看见,而不是在脚本里加路径白名单。 - 规则归属。 X、Y 两类进 CI;
Z-MULTI-LOCK-NO-ORDER误报率较高,先在conc-core的主源码上以REQUIRE跑一个迭代,确认模式后再升到BLOCK。 - 与运行期互补。 脚本看不到跨文件的锁顺序,4.3 的转储反向边检测补这一半;也看不到「锁内做了 I/O」,15.2 的评审问题补这一半。
常见错误与边界
- 把规范写成散文。 「注意线程安全」无法执行,也无法在评审里裁决;每条至少要有检查方式和违反后的处理。
- 锁 getter 改成包可见。 同包代码仍然能拿到;要发布就发布业务方法。
final加在错误的层级。private final Map只保证引用不变,不保证内容线程安全;规范要区分「引用不可变」和「状态可变」。remove()放在try里。 任务抛异常时不会执行;必须在finally。- 把
shutdown()当任务完成。 它只是不再接新任务,要等awaitTermination,或者用close()。 - 脚本当成判定器。
Z-MULTI-LOCK-NO-ORDER只在一个方法体里找第二把锁,跨方法、跨类完全看不见;Y-POOL-NO-SHUTDOWN只要文件里出现过.shutdown(就放过。它的定位是降低漏看率,不是证明正确。
小结
规范要能判定才有效。锁对象 private final、不发布、不用共享常量做锁;线程要有唯一名字,池要有归属并关闭,ThreadLocal 必须 remove;锁顺序要有一张仓库级层号表,同类按业务键排序、动态集合先去重再排序、只允许低层到高层。
工具边界要划清:本机 javac -Xlint:all 发现了 Thread.stop() 的 removal 警告、直接拒绝已移除的 Thread.suspend(),但对锁暴露、无界队列、池不关闭一条都报不出来。卷二 15.3 的基础脚本覆盖 JUC 误用,本节补的 X/Y/Z 三类规则在 6 个类的示例树上命中 20 处(X=4、Y=6、Z=4),在合规样例上 0 命中。下一节把这些条目变成评审流程里的问题、证据和阻断规则。
阅读导航:上一节 14.3 K8s 下的并发参数基线 | 下一节 15.2 Code Review 清单
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。