这份速查怎么用
正文第 4–6 章按「可见性 → 原子类与锁 → 并发容器与同步器 → 虚拟线程」展开,每一节都有实验和取舍。附录不重复推导,只把 libraryd 里真正会用到的 API 压成一张可查的表:什么时候选它、关键方法是什么、常见误用长什么样。
三条 libraryd 主线对应三类问题:
LoanCounters:高频计数,回答「借了多少、还了多少」。LoanEventPipeline:借阅事件从 HTTP 线程交给后台消费者,回答「队列满了怎么办」。DashboardService:并发查询借阅列表、逾期数量、罚金合计,回答「多个远程调用怎么合并」。
表中的版本状态以本机 Temurin 25.0.5 为准。除 StructuredTaskScope 外,本文示例都不需要 --enable-preview。
可见性与互斥
volatile:只保证可见性与有序性
public final class LoanEventWorker {
private volatile boolean running = true;
public void stop() {
running = false;
}
public boolean isRunning() {
return running;
}
}
事件循环每次迭代读取 isRunning(),stop() 写入后循环能及时退出。volatile 保证一个线程写入后,另一个线程能读到最新值,并禁止该变量相关的重排序。它不保证复合操作原子性,所以 volatile int count; count++ 仍然是错的。
libraryd 场景:事件流水线的停止标志、配置热更新时的引用替换。常见误用有两种:把 volatile 当成锁去保护「先检查再修改」,以及用 volatile 修饰一个可变对象的引用,却继续修改对象内部字段。
synchronized:互斥与 happens-before
public final class InMemoryBookRepository {
private final Map<String, Book> books = new HashMap<>();
public synchronized void save(Book book) {
books.put(book.isbn(), book);
}
public synchronized Optional<Book> findById(String isbn) {
return Optional.ofNullable(books.get(isbn));
}
}
synchronized 同时提供互斥和跨线程可见性:退出监视器前的写入,对随后进入同一监视器的线程可见。
JDK 25 的结论要更新:synchronized 不再 pin 虚拟线程。JDK 24 的 JEP 491 已经解决监视器导致的载体线程占用,JDK 25 延续这一行为。仍然会 pin 的主要是本地方法或 FFM 调用期间等场景。因此「虚拟线程里不能写 synchronized,必须换 ReentrantLock」是过时建议;是否换锁应该看公平性、可中断、超时和条件队列需求。
libraryd 场景:单进程内的内存仓储、短小的计数器更新。常见误用:锁住 this 之外的公开对象、锁住会被复用的装箱对象、在锁内做 JDBC 或 HTTP 调用、把锁粒度放大到整个请求处理。
显式锁与条件队列
ReentrantLock:需要超时、中断或公平性时
| 方法 | 作用 | 注意 |
|---|---|---|
lock() | 获取锁,不可中断 | 在 try 之前调用,unlock() 放 finally |
lockInterruptibly() | 可被中断地获取 | 调用方要处理 InterruptedException |
tryLock() | 立即尝试,不阻塞 | 返回 false 时不要假装成功 |
tryLock(timeout, unit) | 限时尝试 | 适合 HTTP 请求路径的快速失败 |
newCondition() | 创建条件队列 | await() 前必须持有锁 |
libraryd 场景:同一 ISBN 的借阅与归还需要串行化,但不想让请求线程无限等待,可以用 tryLock(200, MILLISECONDS),失败返回 INTERNAL 或让客户端重试。
常见误用:
unlock()没放在finally,异常路径永久占锁。- 用
tryLock()后不检查返回值,直接进入临界区。 - 公平锁用在高吞吐路径,吞吐下降却不自知;公平性要按需求选,不要默认开。
Condition:把等待条件写清楚
public final class BoundedBuffer<T> {
private final Deque<T> items = new ArrayDeque<>();
private final int capacity;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();
public BoundedBuffer(int capacity) {
this.capacity = capacity;
}
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (items.size() == capacity) {
notFull.await();
}
items.addLast(item);
notEmpty.signalAll();
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (items.isEmpty()) {
notEmpty.await();
}
T item = items.removeFirst();
notFull.signalAll();
return item;
} finally {
lock.unlock();
}
}
}
await() 会释放锁并进入等待,被唤醒后重新竞争锁,所以条件必须放在 while 里重新检查,不能用 if。signal() 只唤醒一个等待者;每个条件只有一类等待者时它就够,一旦多个条件共用等待队列或等待者可能等待不同条件,就要用 signalAll()。本机示例保守地用 signalAll()。
libraryd 场景:第 5 章的事件流水线在队列满时让生产者等待,而不是直接丢弃事件。常见误用:没有持锁就调用 await()(抛 IllegalMonitorStateException)、用 if 包住 await()、只 signal() 却有多类等待者。
原子类与计数
AtomicLong 与 LongAdder 的取舍
| 维度 | AtomicLong | LongAdder |
|---|---|---|
| 写竞争 | 所有线程 CAS 同一个变量 | 分散到多个 Cell,最后汇总 |
| 读取 | get() 是精确当前值 | sum() 是弱一致快照 |
| 适合 | 序列号、状态机、需要精确读 | 高频计数、监控指标 |
| 不适合 | 极高写竞争下的纯计数 | 需要「读到的就是此刻精确值」 |
public final class LoanCounters {
private final LongAdder borrowed = new LongAdder();
private final LongAdder returned = new LongAdder();
public void recordBorrowed() {
borrowed.increment();
}
public void recordReturned() {
returned.increment();
}
public long borrowed() {
return borrowed.sum();
}
public long returned() {
return returned.sum();
}
}
libraryd 的借阅/归还计数用 LongAdder,因为写多读少,仪表盘晚几百毫秒看到汇总值没有影响。借阅流水号用 AtomicLong.incrementAndGet(),因为需要唯一且单调的精确值。
常见误用:
- 把
LongAdder.sum()当成强一致快照,用它做「先检查再扣减」。 - 在 CAS 循环里做有副作用的操作,失败重试导致副作用执行多次。
- 用
getAndIncrement()和incrementAndGet()时没想清楚返回的是旧值还是新值。
并发容器
ConcurrentHashMap
| 方法 | 语义 | libraryd 用法 |
|---|---|---|
computeIfAbsent | 键不存在时计算并放入 | isbn -> Book 只读缓存 |
merge | 合并旧值与新值 | 按 ISBN 累计借阅次数 |
compute | 原子地基于旧值计算 | 状态迁移、计数增减 |
putIfAbsent | 不存在才放入 | 幂等初始化 |
forEach / search / reduce | 弱一致批量遍历 | 导出统计,不要求强一致 |
ConcurrentHashMap<String, Integer> borrowCount = new ConcurrentHashMap<>();
for (String isbn : List.of("B1", "B1", "B2", "B1", "B3")) {
borrowCount.merge(isbn, 1, Integer::sum);
}
System.out.println(borrowCount);
本机输出:
{B2=1, B3=1, B1=3}
libraryd 场景:BookCatalogCache 用 computeIfAbsent(isbn, this::loadFromDb) 做只读缓存;LoanEventPipeline 用 ConcurrentHashMap<String, AtomicInteger> 统计每个会员的活跃借阅数。CopyOnWriteArrayList 适合监听器列表这种读多写极少的场景,不适合借阅列表本身。
常见误用:
containsKey再put的两步操作,中间可能被其他线程插入。- 用
null作为键或值,ConcurrentHashMap直接抛NullPointerException。 - 在
computeIfAbsent的映射函数里修改同一个 map,可能死锁或抛IllegalStateException。 - 把
size()当作精确值做业务判断;它是弱一致的,应该用mappingCount()并接受近似。
阻塞队列
六种实现的边界与语义
| 实现 | 边界 | 语义 | libraryd 场景 | 典型误用 |
|---|---|---|---|---|
ArrayBlockingQueue | 有界数组 | FIFO,可选公平 | 事件流水线,容量固定 | 容量拍脑袋,满了就丢 |
LinkedBlockingQueue | 默认无界 | FIFO,链表 | 任务队列,允许短时堆积 | 默认构造导致内存无上限 |
LinkedTransferQueue | 无界 | 生产者可等待消费者接手 | 低延迟交接 | 当成普通队列,忽略 transfer 语义 |
SynchronousQueue | 零容量 | 直接交接 | 线程池 handoff | 生产者比消费者快时大量阻塞 |
PriorityBlockingQueue | 无界 | 按优先级出队 | 逾期事件优先处理 | 元素不可比较,运行时才报错 |
DelayQueue | 无界 | 到期才能取出 | 借阅到期提醒 | 单线程 take() 卡住其他到期任务 |
public final class LoanEventPipeline implements AutoCloseable {
private final BlockingQueue<LoanEvent> queue;
private final ExecutorService consumer;
public LoanEventPipeline(int capacity) {
this.queue = new ArrayBlockingQueue<>(capacity);
this.consumer = Executors.newSingleThreadExecutor(
runnable -> new Thread(runnable, "loan-event-consumer"));
this.consumer.submit(this::consumeLoop);
}
public void publish(LoanEvent event) throws InterruptedException {
queue.put(event);
}
private void consumeLoop() {
try {
while (!Thread.currentThread().isInterrupted()) {
LoanEvent event = queue.take();
handle(event);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void handle(LoanEvent event) {
// 写审计日志、更新统计、推送到下游
}
@Override
public void close() {
consumer.shutdownNow();
}
}
libraryd 的流水线用有界 ArrayBlockingQueue,把「队列满」变成明确的背压信号:HTTP 线程在 put 上等待,或者用 offer(timeout) 超时后返回 DB_UNAVAILABLE。不要用无界队列掩盖消费能力不足。
常见误用:
- 捕获
InterruptedException后不恢复中断标志,线程无法退出。 - 消费者处理异常没有兜底,一次异常就结束消费循环。
shutdownNow()后没有等待在途事件,进程退出时丢事件。- 把
poll()返回null当成「队列空」以外的含义;null不能入队,所以它只表示超时。
同步器
| 同步器 | 一次性 | 典型用途 | libraryd 场景 |
|---|---|---|---|
CountDownLatch | 是 | 等待 N 个事件完成 | 启动时等待迁移与缓存预热完成 |
Semaphore | 否 | 限制并发数 | 限制同时访问数据库的虚拟线程数 |
CyclicBarrier | 否 | N 方互相等待后一起继续 | 压测轮次对齐、集成测试阶段同步 |
Phaser | 否 | 多阶段、可动态注册 | 复杂流水线的阶段门禁 |
CountDownLatch ready = new CountDownLatch(3);
CountDownLatch start = new CountDownLatch(1);
for (int i = 0; i < 3; i++) {
Thread.ofPlatform().start(() -> {
ready.countDown();
try {
start.await();
// 同时开始
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
ready.await();
start.countDown();
Semaphore 限制并发要成对出现:
Semaphore dbPermits = new Semaphore(8);
dbPermits.acquire();
try {
// 访问数据库
} finally {
dbPermits.release();
}
libraryd 场景:DashboardService 同时发起三个查询,用 Semaphore(8) 把虚拟线程的数据库并发压到连接池上限以内;启动阶段用 CountDownLatch 等待 Flyway 迁移和缓存预热。
常见误用:
CountDownLatch想重复使用,第二轮永远不归零。Semaphore.release()没有对应的acquire(),许可数越用越多。CyclicBarrier的 parties 数与实际线程数不一致,所有线程永久等待。- 用
CountDownLatch的await()不带超时,启动失败时进程挂住。
Executors 与虚拟线程
| 工厂 | 线程模型 | 队列 | 适用 |
|---|---|---|---|
newFixedThreadPool(n) | 固定 n 个平台线程 | 无界 | CPU 密集型、稳定负载 |
newCachedThreadPool() | 按需创建,空闲回收 | SynchronousQueue | 短任务突发 |
newSingleThreadExecutor() | 单线程 | 无界 | 串行消费、事件顺序 |
newScheduledThreadPool(n) | 定时/周期 | 延迟队列 | 到期提醒、清理任务 |
newVirtualThreadPerTaskExecutor() | 每个任务一个虚拟线程 | 无 | I/O 密集型、请求级并发 |
newThreadPerTaskExecutor(factory) | 每个任务一个新线程 | 无 | 需要自定义线程属性 |
ExecutorService 自 JDK 19 起实现 AutoCloseable,可以用 try-with-resources:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Dashboard> future = executor.submit(() -> dashboardService.load(memberId));
return future.get();
}
虚拟线程的关键事实:
- 虚拟线程适合阻塞 I/O,不适合 CPU 密集计算;CPU 密集仍然用固定大小的平台线程池。
- 不要池化虚拟线程。每个任务新建一个,创建成本很低。
- 虚拟线程数量不等于数据库并发。连接池、限流器和下游配额仍然是硬约束,要用
Semaphore或连接池本身限流。 - JDK 25 中
synchronized不再 pin 虚拟线程;本地方法/FFM 调用仍可能 pin。 ThreadLocal在虚拟线程上仍然可用,但大量虚拟线程各自持有一份拷贝会放大内存;请求上下文优先用 JDK 25 正式的ScopedValue。
libraryd 场景:Javalin 7 的请求处理用虚拟线程执行器;DashboardService 的三个子查询也各自 fork 虚拟线程,但总并发由信号量约束。
常见误用:
- 用
newFixedThreadPool(200)跑阻塞 I/O,线程数上去了,数据库连接池先崩。 - 用
Executors.newCachedThreadPool()处理无上限请求,线程创建速度超过下游承受能力。 - 提交任务后不保存
Future,异常被吞掉。 shutdown()后不awaitTermination(),以为任务已经跑完。
StructuredTaskScope(JDK 25 预览)
StructuredTaskScope 在 JDK 25 仍是预览 API(JEP 505,第五次预览)。编译和运行都要加 --enable-preview,并且 --release 25 与 --enable-preview 必须同时出现。JDK 21 预览期的 new StructuredTaskScope.ShutdownOnFailure() 写法已经不存在,当前形态是 open(Joiner)。
public Dashboard load(String memberId) throws Exception {
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow())) {
var loans = scope.fork(() -> loanQuery.activeLoans(memberId));
var overdue = scope.fork(() -> loanQuery.overdueCount(memberId));
var fines = scope.fork(() -> loanQuery.totalFineCents(memberId));
scope.join();
return new Dashboard(memberId,
(List<Loan>) loans.get(), overdue.get(), fines.get());
}
}
三个 fork 并发执行,join() 等待全部完成;任一子任务失败,join() 抛 StructuredTaskScope.FailedException,其余子任务被取消,close() 负责收尾。本机把三个查询换成 sleep 与固定返回值,编译运行命令如下:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
javac --release 25 --enable-preview -d out-preview StructuredDemo.java
java --enable-preview -cp out-preview StructuredDemo
本机输出:
dashboard=Dashboard[memberId=M1, activeLoans=[B1, B2], overdueCount=1, totalFineCents=350]
failure=java.util.concurrent.StructuredTaskScope$FailedException
failure message=java.lang.IllegalStateException: fines query failed
timeout=java.util.concurrent.StructuredTaskScope$TimeoutException elapsedMs=58
cancel path=FailedException cancelled=1
超时用 open(joiner, config -> config.withTimeout(Duration.ofMillis(50))) 设置;本机 50ms 超时在 58ms 返回 TimeoutException。
libraryd 场景:DashboardService 的三个查询必须全部成功才返回仪表盘,用 allSuccessfulOrThrow;如果产品允许「部分数据可用」,才考虑 anySuccessfulResultOrThrow 或自定义 Joiner。
常见误用:
- 忘记
--enable-preview,编译期直接报「StructuredTaskScope 是预览 API」。 - 照抄 JDK 21 的
ShutdownOnFailure/ShutdownOnSuccess构造器,JDK 25 不存在。 - fork 出去的子任务修改共享的
ArrayList,却以为结构化并发会自动保证线程安全。 join()抛出的FailedException没有处理,或用anySuccessfulResultOrThrow时忽略了失败子任务。- 把结构化并发当成「更快的线程池」;它的价值是作用域、取消和错误传播,不是吞吐。
选型清单
| 需求 | 首选 | 备选 | 不要用 |
|---|---|---|---|
| 停止标志、配置引用 | volatile | AtomicReference | 普通 boolean |
| 短临界区、内存结构 | synchronized | ReentrantLock | 无锁硬扛 |
| 需要超时/中断/公平 | ReentrantLock | StampedLock(读多写少) | synchronized 死等 |
| 高频计数 | LongAdder | AtomicLong | volatile int++ |
| 精确序列号 | AtomicLong | AtomicInteger | LongAdder |
| 键值缓存/索引 | ConcurrentHashMap | Caffeine | HashMap + 外部锁 |
| 生产者消费者 | 有界 BlockingQueue | LinkedTransferQueue | 无界队列 |
| 限制并发 | Semaphore | 连接池上限 | 无限制创建线程 |
| 请求级 I/O 并发 | 虚拟线程 | 平台线程池 | 池化虚拟线程 |
| 多路查询合并 | StructuredTaskScope(预览) | CompletableFuture.allOf | 裸 Future.get() 串行等待 |
小结
volatile管可见性,synchronized和ReentrantLock管互斥;JDK 25 中synchronized不再 pin 虚拟线程。- 需要超时、中断、公平性或条件队列时用
ReentrantLock+Condition,条件判断必须放在while里。 - 高频计数用
LongAdder,需要精确当前值或序列号用AtomicLong;sum()不是强一致快照。 - 并发容器优先用
computeIfAbsent、merge、compute的原子复合操作,不要写检查再更新。 - 阻塞队列优先有界,把满队列变成背压;消费者要处理中断、异常和关闭。
- 虚拟线程按任务创建,不池化;并发上限交给信号量或连接池。
StructuredTaskScope在 JDK 25 仍是预览 API,价值在作用域取消与错误传播,不在吞吐数字。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。