《Java 并发编程·性能诊断与事故》15.1 编码规范与静态检查

把并发编码规范落成可执行条目:锁对象 private final 且不暴露、线程命名、池必须关闭、ThreadLocal 必须 remove、跨模块锁顺序与层号,并给出扩展卷二 15.3 的 X/Y/Z 三类规则的可运行检查脚本,附本机 javac -Xlint 与 20 处命中的真实输出。

承接的问题

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 已经给出四条规则和转储检测脚本,这里补的是规范条目怎么落进代码审查。规范必须包含三样东西:

  1. 一张全局顺序表。 按资源类型分层:L0 配置/目录,L1 账户/会员,L2 分片/桶,L3 单条记录。只允许从低层往高层拿,持有高层时不得回取低层。
  2. 同类资源按业务键排序。 ISBN、会员 ID、账户 ID 这类稳定键,小的先拿;不能用 hashCode() 或 identityHashCode(),前者会碰撞、后者每次运行都可能不同。
  3. 动态集合先去重再排序。 批量扣减时请求里可能有重复 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 误用,本节在它之上加三类规则:

家族规则检查什么级别
XX-LOCK-EXPOSEDpublic / protected 锁字段BLOCK
XX-LOCK-LEAKED-GETTERgetter 返回锁字段BLOCK
XX-SYNC-ON-SHARED-CONSTANT锁字符串、包装类、ClassBLOCK
XX-LOCK-NOT-FINAL锁字段缺 finalREQUIRE
YY-EXECUTORS-FACTORY三个不安全工厂方法BLOCK
YY-POOL-NO-SHUTDOWN有池字段但没有关闭调用BLOCK
YY-TL-NO-REMOVE有 ThreadLocal 但没有 removeREQUIRE
YY-THREAD-NO-NAMEThread.ofXxx() 三行内没有 .name(SUGGEST
YY-POOL-NO-NAME-FACTORYnew ThreadPoolExecutor 没有命名工厂SUGGEST
ZZ-MULTI-LOCK-NO-ORDER方法内两次以上加锁且无 tryLock / 排序入口BLOCK
ZZ-TRYLOCK-NO-TIMEOUT无参 tryLock()REQUIRE
ZZ-LOCK-LEVEL-UNDECLARED两把以上锁但没有 LockLevelREQUIRE

脚本全文如下,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 清单

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收