《Java 高级编程》附录 A 并发 API 速查

按 libraryd 的借阅计数、事件流水线与仪表盘聚合三条线,整理 volatile、synchronized、ReentrantLock、Condition、原子类、ConcurrentHashMap、六种阻塞队列、同步器、Executors、虚拟线程与 StructuredTaskScope 的关键方法、选型与常见误用,并标注 JDK 25 的预览状态。

这份速查怎么用

正文第 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 的取舍

维度AtomicLongLongAdder
写竞争所有线程 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 时忽略了失败子任务。
  • 把结构化并发当成「更快的线程池」;它的价值是作用域、取消和错误传播,不是吞吐。

选型清单

需求首选备选不要用
停止标志、配置引用volatileAtomicReference普通 boolean
短临界区、内存结构synchronizedReentrantLock无锁硬扛
需要超时/中断/公平ReentrantLockStampedLock(读多写少)synchronized 死等
高频计数LongAdderAtomicLongvolatile int++
精确序列号AtomicLongAtomicIntegerLongAdder
键值缓存/索引ConcurrentHashMapCaffeineHashMap + 外部锁
生产者消费者有界 BlockingQueueLinkedTransferQueue无界队列
限制并发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,价值在作用域取消与错误传播,不在吞吐数字。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发