多线程实战最佳实践 Java 篇——虚拟线程时代的惯用做法

· 更新日期: · · 多线程, Java, 业务应用, 故障排查, 设计

更新记录(仅首版,2026年08月02日 发布)
首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22175950)

以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。

Go Komura(2026)。《多线程实战最佳实践 Java 篇——虚拟线程时代的惯用做法》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/multithreading-best-practices-java/

DOI(已登记存档)
10.5281/zenodo.22175950
DOI(上次登记版本)
10.5281/zenodo.22175951

“想用 Java 把业务系统的批处理并行化”“Spring 写的 Web 应用里共享缓存偶尔会损坏”“接手了一个满是 new Thread 的老旧 Swing 应用”。同样是多线程方面的咨询,选择执行手段的问题、保护共享数据的问题、UI 与停止处理的问题,也需要分开来考虑。

Java 从 JDK 1.0 起就把多线程内置到语言里,并在 java.util.concurrent 中备齐了成熟的工具。JDK 21 还把虚拟线程正式化了。正因为工具丰富,按“让什么并发运行”“共享什么”“怎么停止”的顺序依次选择,才是设计的要点。1

本文是多线程实战最佳实践系列的 Java 篇。面向编写业务系统、批处理、服务器应用的开发者,以 LTS 的 JDK 21 及之后版本为主要对象,依据截至 2026 年 8 月的一手资料整理原则与注意事项。本文单独阅读也没有问题。把同样的原则用其他语言展开的还有“.NET 篇”“C++ 篇”“C 语言篇”。

1. 先说结论——执行、共享、停止要分开设计

即使选用了虚拟线程,共享数据的竞争和停止处理也不会自动得到解决。业务代码中不直接创建线程,把任务的执行交给 ExecutorService,然后按下面的顺序设计。2

要决定的事 基本方针 详细阅读的章节
用什么执行 I/O 等待用每个任务一条的虚拟线程,CPU 计算用核心数量级的平台线程 第 2 章
接受到什么程度 对外部服务的并发数用 Semaphore,对会积压的工作设置容量或提交上限 第 2、3 章
共享什么 拆分成部分结果,使用不可变数据、并发集合与队列 第 3 章
怎么保护状态 单个值的原子更新用 Atomic 系列,复合状态用专用锁。不要指望 volatile 提供原子性 第 4 章
怎么停止 把基于中断的协作式停止,与带期限的完成确认组合起来 第 5、6 章
怎么处理 UI 与排查 UI 交给专用线程。转储要按平台线程和虚拟线程分别选用 第 7、8 章

从头读的话,就按这张表的顺序推进。如果正在排查“停不下来”,可以从第 5、6 章读起;正在排查“卡住了”,可以从第 8 章读起。JDK 21~23 与 24 之后不同的钉住注意事项汇总在 2.4 节,预览功能与正式功能的区分汇总在第 9 章。

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 28 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

2. 选择执行手段——把 I/O 等待和 CPU 计算分开

2.1. 把工作交给 ExecutorService

在 Java 中同样,基本原则是不要把 new Thread 散落在业务代码里。把用 Runnable / Callable 表示的“工作”,与线程数量、队列这类“执行方式”分开,线程的创建、复用和销毁交给 ExecutorService2

执行手段首先按工作以什么为主来选。13

以 I/O 等待为主HTTP 调用、数据库、文件消耗 CPU 的计算有需要并发执行的任务任务以什么为主?虚拟线程Executors.newVirtualThreadPerTaskExecutor()一个任务一条。不做池化平台线程的固定线程池Executors.newFixedThreadPool(核心数量级)或 parallel stream对外部服务的并发数限制不用线程池,而用 Semaphore

图1:I/O 等待选虚拟线程,CPU 计算选传统线程池。并发访问数的限制要另行设计。

虚拟线程不是“更快的线程”。它提升的不是计算本身的执行速度,而是把大量等待时间长的工作并发处理,从而提高吞吐量。对于要把 CPU 用满的计算,仍然像以前一样使用核心数量级的平台线程固定线程池或 parallel stream。3

2.2. 虚拟线程不做池化,需要限流的资源用 Semaphore 保护

虚拟线程是与操作系统线程解耦的轻量线程。在 JDK 支持的 I/O、锁、sleep 等阻塞操作期间,它可以释放操作系统线程,因此一个 JVM 能承载数百万条这样的并发量。不过,并非所有阻塞都能释放。钉住的条件在 2.4 节单独说明。3

用法是一个任务对应一条虚拟线程。把它当作廉价的一次性对象,不要设计成把虚拟线程放进 newFixedThreadPool 反复使用。应使用 Executors.newVirtualThreadPerTaskExecutor()13

另一方面,“对外部 API 最多 10 个并发连接”这类限制仍然必要。这个上限不用虚拟线程的池大小表达,而用 Semaphore 表达。不对虚拟线程做池化,和可以无限制访问外部服务,是两回事。3

在虚拟线程里运行的是普通的同步代码。设计思想是不必改写成 .NET 的 async/await 那种形式,而是让“一个请求一条线程”的直白代码原样大量运行。也可以写成 try (var executor = Executors.newVirtualThreadPerTaskExecutor()),但结束时 close() 会等到什么程度,请在 6.3 节确认。12

2.3. 平台线程池还要考虑等待队列的上限

“不做池化”说的是虚拟线程。Java 从 JDK 5(2004 年)起就有成熟的线程池,现在仍然按用途分别选用。

ThreadPoolExecutor 是可以细致配置线程数、队列、拒绝策略等的通用线程池。ForkJoinPool 是工作窃取型,它的 commonPool() 被用作 parallel stream 和 CompletableFuture 的默认异步执行位置。周期性执行则有 ScheduledThreadPoolExecutor。在 CPU 计算这类需要复用平台线程的场景中,它们依然是重要的工具。

但要注意,Executors.newFixedThreadPool 限制的是线程数,等待队列是无界的。在提交量持续超过处理量的常驻服务中,排队的任务及其数据会不断消耗内存。要么给 ThreadPoolExecutor 配置带容量的队列和拒绝策略,要么在提交一侧放置 Semaphore 之类的准入限制,让背压能够生效。3.6 节的队列也遵循同样的原则。

线程池是为了复用创建和保持成本都很高的操作系统线程而做的优化。虚拟线程足够轻量,开发者已经没有理由再去复用它本身。这并不意味着线程池变得低效了。

实际上,虚拟线程的底层同样如此:JDK 的调度器使用工作窃取的 ForkJoinPool,默认运行与可用处理器数量相当的载体线程(操作系统线程)。用少量操作系统线程承载大量并发处理的格局是一样的,只是这部分管理由 JVM 承担。可以这样理解:.NET 的异步 I/O 不会在等待期间一直占用线程,Java 走的是同一个方向,只是保留了同步代码的写法。1

2.4. 钉住问题要按 JDK 版本和阻塞位置分开考虑

虚拟线程无法离开载体线程、操作系统线程也一直等待的状态,称为钉住。频繁、长时间的钉住会损害虚拟线程在扩展性上的优势。3

阻塞位置 JDK 21~23 JDK 24 及之后
synchronized 代码块或方法内部 会发生钉住。对伴随频繁、长时间阻塞的位置,考虑替换为 ReentrantLock JEP 491 已解除这一限制。仅以应对钉住为由的机械替换没有必要
执行本地代码(JNI)或 foreign function 期间 有时无法释放载体线程 这与 synchronized 的改进无关,本地代码边界上的钉住依然存在

JDK 24 的 JEP 491 解除的,是源自监视器实现的 synchronized 钉住。如果大量加载经由 JNI 调用驱动程序或设备 API 而长时间阻塞的处理,载体线程被耗尽的问题依然存在。不要认为“既然是 JDK 24,在哪里等待都可以”。还要检查公司内部的规范是否仍停留在 JDK 21 时期的注意事项上。43

3. 减少共享可变状态——写同步之前先重新审视共享

3.1. 即使是 count++,处理一重叠加法也会丢失

竞态条件(race condition)是指结果随多条线程到达代码的先后顺序而变化的缺陷。count++ 看起来是一个表达式,实际上会拆成读取、加法、写回。若中途有别的线程插进来,其中一次加法就会丢失。

线程B共享变量 count线程A线程B共享变量 count线程Acount = 10加了两次却是 count = 11线程A 的加法丢失了读取(10)读取(10)在本地加一(11)在本地加一(11)写回(11)写回(11)

图2:两条线程读取同一个值后各自写回,本该加两次,却只留下了一次的结果。

另一个典型是互相等待对方的锁而无法推进的死锁。这一点在 4.3 节讨论。两者都依赖时序,因此在开发机上很少见,在核心数和负载都不同的生产环境中却可能频繁出现。加上调试器或日志后就不再复现,也是因为观测行为改变了时序。

正因为如此,要从在正确加同步之前先减少需要同步的位置开始。如果多线程运行本身就是需求,那么设计上要减少的是被共享的可变数据。

3.2. Java 内存模型规定“什么时候能被其他线程看到”

在 Java 中,共享数据的可见方式由 Java 内存模型(JMM)的 happens-before 关系定义。没有同步的共享变量访问不会像 C++ 那样成为未定义行为,但可能一直看到旧值,或者看到写入顺序发生了对调。5

例如,只是在循环中检查一个 boolean 标志,就可能始终看不到别的线程修改后的值。这不是 JVM 的缺陷,而是因为没有做必要的同步而产生的内存一致性错误。

建立 happens-before 的是 synchronizedvolatile 以及 java.util.concurrent 中的各个类。并发集合也在规范层面保证了更新某个值与之后取出该值之间的关系。不要在裸的共享变量上想办法,而要使用这些工具提供的保证。不过,值能被看到,和多个操作能作为一个整体执行,是两回事。与原子性的区别在 4.1 节梳理。56

3.3. 汇总先拆成部分结果,最后再合并

并行汇总时,比起让所有线程都写入同一个合计变量,应先考虑每条线程各自产生部分结果、最后再合并的形式。parallel stream 的 reduce / collect 就以框架的形式提供了这种结构。

用于高频累加的 LongAdder 同样采用把更新分散到内部单元、读取时再求和的策略。先减少对共享数据的写入,之后再选择必要的同步。

3.4. 要做成不可变,就得检查到元素内部

配置和主数据应作为构建完成后不再改写的不可变数据来共享。需要替换时,惯用做法是创建新对象,再把 volatile 的引用指过去。

但要注意,仅仅用了 recordList.copyOf,并不能让整份数据变成不可变的。record 的访问器会原样返回各组成部分的引用。即使用 List.copyOf / Map.copyOf 让集合无法修改,也不会把元素对象一并深度复制。

如果元素是可变的,持有同一元素引用的其他代码就能改写其内容,竞争依然存在。要作为不可变数据在无同步的情况下共享,就要确认包含元素在内的整个对象图都是不可变的。若含有可变元素,就用深拷贝切断共享,或者把元素也改成 record 或不可变类型。请区分“看起来只读”和“确实不可变”。

3.5. ConcurrentHashMap 要用它的复合操作及其保证范围

“键不存在就创建并放入”不要把检查和添加分开写,而要使用 ConcurrentHashMap.computeIfAbsent。整个方法调用是原子执行的,若键不存在,映射函数会在这一次调用中恰好被调用一次。这与竞争时工厂方法可能运行多次的 .NET ConcurrentDictionary.GetOrAdd 在保证上并不相同。6

频率计数器的惯用写法如下。

// 频率计数器的惯用写法:computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();

“这一次调用中一次”和“这个键的一生中一次”并不相同。如果函数返回 null 或抛出异常,就不会写入,后续调用会再次执行。删除了已写入的条目时也一样。对于不允许副作用重复的初始化,要连同“返回非 null 值以确保成功”以及条目的处理方式一起设计。6

另外,作为原子执行的代价,计算期间其他线程的部分更新会被阻塞。映射函数要保持简短单纯,并且不能在其中更新这个映射本身。可被检测到的递归更新可能会抛出 IllegalStateException6

3.6. 线程之间的传递使用带容量的队列

与其让多条线程直接操作数据,不如改成通过 BlockingQueue 传递。如果使用指定了容量的 ArrayBlockingQueue,队列满时 put 会阻塞,自然就对生产一侧形成了背压。

这与 .NET 篇中有界通道的结构相同。即使换成虚拟线程,明确生产者与消费者的边界以及积压量上限的设计依然有效。

4. 保护剩下的共享状态——volatile、Atomic 系列与锁分别选用

4.1. 可见性与原子性是不同的保证

volatile 是用于可见性与顺序,也就是用于建立 happens-before 的工具。它并不保证把“读取、计算、写回”合成一个整体,所以即使多条线程对 volatile int 执行 ++,也无法避免图2 中的加法丢失。5

想保护的东西 选用的工具 注意事项
简单状态的通知、指向不可变数据的引用替换 volatile 得不到复合操作的原子性
单个值的原子更新 AtomicInteger / AtomicLong / AtomicReference 高频累加的统计值也可以用 LongAdder
跨多个值的一致性 在所有接触同一数据的位置遵守同样的纪律

和 .NET 篇、C++ 篇一样,把原子更新交给 Atomic 系列,把复合状态交给锁。关键是不要只靠 volatile 去实现线程安全。

4.2. 锁不对应代码区间,而对应它保护的数据

为每一组需要保护的可变数据配一个专用的锁对象。然后,在所有接触这些数据的位置获取同一把锁。评审时要能确认“这份数据由哪把锁保护”。

要避免 synchronized(this)synchronized(SomeClass.class) 以及对公开对象加锁。因为外部代码也能锁住同一个对象,会产生意料之外的冲突。应使用不向外公开的 private final Object lock = new Object();,或专用的 ReentrantLock

4.3. 避免在持锁期间调用外部处理,并固定获取顺序

持着锁去执行 I/O、调用监听器或运行未知代码,会拉长持锁时间。如果被调用方又获取了另一把锁,还可能形成循环等待。

等待锁2 被释放等待锁1 被释放线程A正持有锁1线程B正持有锁2

图3:一方持有锁1 并等待锁2,另一方以相反的顺序等待,于是双方都无法继续前进。

在需要获取多把锁的位置,要让所有线程的获取顺序一致。对于无法保证顺序的位置,用 tryLock(timeout) 准备一条“取不到就释放已持有的锁并重来”的路径。“持锁期间不做耗时处理和外部处理”与“统一获取顺序”这两条要成套遵守。

4.4. 简短的互斥用 synchronized,需要额外功能就用 ReentrantLock

如果互斥简短而单纯,synchronized 就足够了。当需要通过 tryLock(timeout) 进行带期限的获取、需要公平性策略、需要多个 Condition,或者需要把获取和释放分到不同方法里时,就选择 ReentrantLock

通常使用 ReentrantLock 时,请不要破坏紧接 lock() 之后写 try,在 finallyunlock() 的形式。不要指望像 C++ 的 RAII 那样自动释放锁,而要构造成即使发生异常也会释放的结构。

与虚拟线程搭配时,还要确认 2.4 节的 JDK 版本差异。在 JDK 24 及之后,没有必要仅以应对钉住为由就一律替换 synchronized。不过,把锁保持简短的纪律没有变化。4

5. 定好任务的停止方式——不要吞掉中断

5.1. interrupt 不是强制终止,而是协作式停止的信号

Java 的停止与取消,要用基于中断(interruption)的协作式停止来设计。t.interrupt() 会置位目标线程的中断状态。如果线程正阻塞在 sleep / wait / join 等操作上,就会通过 InterruptedException 退出等待,此时中断状态会被清除。7

在本文参照的 JDK 21 API 中,过去作为强制手段的 Thread.stop / suspend / resume 会抛出 UnsupportedOperationException。不要退回到那些会在状态非法时释放锁、或者招致死锁的危险手段,而要做成由任务自己响应停止信号并结束的形式。7

能自行结束无法结束(例如在库内部)停止方调用 t.interrupt()中断状态被置位计算中的线程在循环中检查 Thread.interrupted()在 sleep / wait / join 上阻塞时抛出 InterruptedException 立刻唤醒(中断状态会被清除)做好清理并自行结束在 catch 中怎么做?用 Thread.currentThread().interrupt()恢复状态,留下信号

图4:收到中断的任务自己做好清理并结束。吞掉异常就会丢失停止的信号。

5.2. 明确接到 InterruptedException 之后的职责

不要写出 catch 了 InterruptedException 却什么都不做的代码。如果能在自己的职责范围内结束,就做好清理并结束。如果要把判断交给调用方,就把异常原样往上抛,或者用 Thread.currentThread().interrupt() 恢复状态、留下信号。7

计算中的循环也需要像图4 那样检查中断并走向结束。只实现发送停止请求的一方,若接收方无视它,仍然停不下来。这个性质同样适用于下一章的 shutdownNow()

6. 终止 ExecutorService——把请求与完成确认分开

6.1. 只调用 shutdownNow 并不等于停止完成

终止 ExecutorService 时,要把停止接收的操作、请求取消的操作、等待完成的操作分开。2

API 作用 仅靠它无法保证的事
shutdown() 停止接收新任务,让已提交的任务继续执行 调用它的线程并不会一直等到完成
awaitTermination(...) 在发出关闭请求后,等待完成、超时或被中断中的任一情况 超时情况下的停止完成
shutdownNow() 尝试停止正在执行的任务,并返回等待中尚未执行的任务 正在执行任务的强制终止,以及等待其结束
close() 停止接收,并等到 Executor 终止为止 等待时间的上限

shutdownNow() 是尽力而为的。在 ThreadPoolExecutor 等标准实现中,典型做法是通过中断来取消,因此不响应中断的任务不会停止。如果使用自定义的 Executor,要确认该实现的取消方式,包括它是否发送中断。2

取消单个任务用 Future.cancel(true)。同样要注意,不要把“向执行中的任务发中断以请求停止”和“任务确实结束了处理”混为一谈。

6.2. 使用带期限的两阶段关闭

先停止接收新任务并等待已有任务跑完,超过期限后请求取消,再等待一次完成。如果没有结束,就把它与成功区分开并告知调用方。下面的示例以官方的两阶段模式为基础,包含了停止成败的判定和未执行 Future 的处理。2

/** 停止完成则返回 true。返回 false 时不得继续去释放共享资源。 */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
    pool.shutdown();                    // 第一阶段:停止接收新任务,等待已有任务跑完
    try {
        if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
            // 第二阶段:请求取消。未执行就被卸下的任务会被返回,
            // 因此把这些 Future 置为已取消,唤醒在 get() 上等待的调用方
            pool.shutdownNow().forEach(r -> {
                if (r instanceof Future<?> f) f.cancel(false);
            });
            if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
                System.err.println("Pool did not terminate");
                return false;           // 停止未完成。以能与成功区分的形式告知
            }
        }
        return true;
    } catch (InterruptedException ex) {
        pool.shutdownNow().forEach(r -> {
            if (r instanceof Future<?> f) f.cancel(false);
        });
        Thread.currentThread().interrupt();   // 同时恢复自身的中断状态
        return false;                   // 这条路径上停止也可能尚未完成
    }
}
在期限内完成超时已完成仍未结束shutdown()停止接收新任务用 awaitTermination等待跑完停止完成shutdownNow()向执行中的任务发送 interrupt(是否响应取决于任务)用 awaitTermination再等一次记录为异常(嫌疑是不响应中断的任务)

图5:把等待跑完的阶段,与请求取消后再次等待的阶段分开。若仍然没有结束,就按停止未完成处理。

在返回值仍为 false 的情况下,不得释放任务所使用的共享资源。不只是第二次等待超时的情况,在等待方自身被中断的路径上,任务也可能仍在运行。不要只留一条日志就当作成功,要让调用方能够判断停止尚未完成。

6.3. close 与 try-with-resources 只用在能跑完的作用域

从 JDK 19 起,ExecutorService 可以作为 AutoCloseable 使用。try (var executor = ...) 会在离开作用域时通过 close() 等待终止。它也可以与虚拟线程的 Executor 搭配使用。2

但要注意,close() 是不带超时地等待,因此不能替代带期限的两阶段模式。只要存在结束不了的任务或不响应中断的任务,尝试关闭的那条线程也会一直等下去。

对于就地提交任务、就地就能等到跑完的有限作用域,使用 try-with-resources。而在应用整体关闭这类不能一直等下去的路径上,要设计带期限的等待以及停止未完成时的处理方式。2

6.4. 队列中的任务未必就是使用方手上的 Future

shutdownNow() 返回的是留在执行队列里的对象。使用裸的 submit 时,它通常就是交给使用方的那个 FutureTask 本身,所以示例中的 cancel(false) 能唤醒在 get() 上等待的调用方。

另一方面,如果是通过 ExecutorCompletionService 之类的包装器提交的,返回的是队列中的包装器,与使用方的 Future 并不是同一个对象。存在仅靠上面的示例无法让使用方的 Future 完成的结构。

这种情况下,要在提交时保留一份使用方 Future 的列表,关闭时取消这些 Future;或者设计成把从队列中卸下的任务交还给它的持有者。请把“等待一件已确定不会执行的工作的一方”也纳入进来,确认整条停止路径。

7. 把 UI 交回专用线程——Swing 的 EDT

管理 Swing UI 的是事件分发线程(EDT)。Swing 组件的方法原则上不是线程安全的,从多条线程去操作会引发线程干扰和内存一致性错误。8

从其他线程更新界面时,用 SwingUtilities.invokeLater 委托给 EDT。反过来,在 EDT 上执行耗时处理会让界面卡住,因此繁重的工作要用 SwingWorker 之类交给工作线程。把工作移到外面,和把结果的显示交回 UI 线程,是成套的。8

JavaFX 也是同样的结构。UI 更新用 Platform.runLater 委托给应用程序线程。即使线程的种类变多,“UI 是管理它的那条线程的专属物”这一原则不会改变。

8. 备好验证与排查手段——设计评审、转储、压力测试

8.1. 先评审共享数据和停止路径

常规测试通过,也可能只是“碰巧没有发生竞争”。不要指望仅靠测试发现竞争缺陷,要把第一道防线放在设计上。

用一张对照表确认共享的可变数据、保护这些数据的锁,以及锁的获取顺序。再确认有没有吞掉 InterruptedException 的 catch,以及关闭和中断的路径能否到达所有任务。把第 3 章到第 6 章的设计原样作为评审条目。

8.2. 按线程种类采集相应的转储

平台线程的状态用 jstackjcmd <pid> Thread.print 查看。使用 -l 选项还能确认与锁有关的附加信息。9

但要注意,传统格式的转储不包含应用的虚拟线程。在使用虚拟线程的结构中追查卡住的请求时,用 jcmd <pid> Thread.dump_to_file -format=json <文件> 采集包含虚拟线程的格式。1

排查挂起时,每隔几秒采集 2~3 次转储,比对没有推进的线程。对于平台线程的等锁,要追查它在等什么、那把锁被谁持有。对于虚拟线程,则从包含虚拟线程的转储中确认处理停在了哪里。如果把 tryLock(timeout) 的超时记录到日志里,还能为采集转储提供触发时机。

8.3. 用接近生产的负载扰动执行顺序

在作为第二重准备的转储之外,再把压力测试作为第三重准备。用超过核心数的并行度长时间运行、把处理顺序随机化、插入人为延迟等方法,让引发竞争的执行顺序更容易被踩到。

请在发布前至少跑一次接近生产的数据量和线程数的测试。不过,不要把压力测试通过,当成不再需要设计共享状态的理由。

9. 新 API 的定位——把预览功能和正式功能分开

本章是截至 2026 年 8 月的梳理。为了不把已经可用的基本工具与仍在开发中的 API 混淆,这里单独分开说明。

结构化并发(StructuredTaskScope)是把多个相关子任务当作一个工作单元处理,并把失败传播与取消结构化的 API。它设想的用法以虚拟线程为前提,但在这个时点仍是预览功能。在 JDK 25 的第五次预览(JEP 505)中,它被改成使用 StructuredTaskScope.open() 的 API 形态,在 JDK 26 中又作为第六次预览(JEP 525)继续。1011

另一方面,处理不可变上下文共享的 Scoped Values 已在 JDK 25 转正。它是针对 ThreadLocal 的可变性、生命周期管理、继承开销等问题的解决方案。12

新 API 所指向的方向,也与本文的原则一致:明确任务的边界,共享尽量靠向不可变,停止按协作的方式处理。

10. 总结——实现前与评审时要确认的 10 项

# 要确认的事
1 业务代码中是否没有直接创建 new Thread,而是把任务交给了 ExecutorService
2 是否把 I/O 等待和 CPU 计算的执行手段分开,并为固定线程池的等待队列也考虑了上限或背压
3 是否没有对虚拟线程做池化,并用 Semaphore 限制了对外部服务的并发数
4 是否把共享数据转向拆分、不可变化和传递,并检查到了 record 与不可变集合的元素内部
5 是否让专用锁与数据一一对应,并避免了 synchronized(this) 和对公开对象加锁
6 是否使用了 computeIfAbsent 等复合操作,并遵守了映射函数的重新执行条件和处理保持简短
7 是否没有指望 volatile 提供原子性,而把计数器交给 Atomic 系列或 LongAdder、把复合状态交给锁
8 是否没有吞掉 InterruptedException,停止的信号能否传达到任务一侧
9 终止处理是否为带期限的两阶段模式。close() 是否只用在能保证跑完的作用域,并处理了停止未完成和未执行的 Future
10 是否把 Swing / JavaFX 的 UI 更新集中到了 EDT / 应用程序线程

Java 的虚拟线程开辟了让直白的同步代码原样扩展的道路。即便如此,提升吞吐量的工具、保护共享状态的工具、传达停止的工具依然是各自独立的。把执行、共享、停止的职责分开来选,正是 Java 多线程设计的基本功。

相关文章

相关咨询领域

合同会社小村软件承接 Java 编写的业务系统与批处理的多线程设计评审,共享状态损坏、“偶尔停不下来、卡住”这类由并发处理引起的故障排查(线程转储分析),以及引入虚拟线程的技术咨询。

参考链接

  1. OpenJDK,JEP 444: Virtual Threads。关于虚拟线程在 JDK 21 成为正式功能,它是能大幅降低高吞吐并发应用的编写、维护与观测成本的轻量线程,其设计思想是让“一个请求一条线程”的直白同步代码原样扩展,JDK 的虚拟线程调度器是以 FIFO 模式运行的工作窃取 ForkJoinPool 且默认并行度为可用处理器数量,以及新增了包含虚拟线程的线程转储格式 jcmd Thread.dump_to_file(纯文本与 JSON 格式)、传统线程转储不包含虚拟线程等内容。  2 3 4 5 6

  2. Oracle,ExecutorService (Java SE 21 & JDK 21 API)。关于 shutdown() 让已提交的任务跑完同时停止接收新任务,shutdownNow() 尝试停止正在执行的任务并返回等待中任务的列表但典型实现是经由 Thread.interrupt() 的取消、其保证不超过尽力而为、不响应中断的任务不会结束,awaitTermination 可以等待完成,close()(Java 19 起,AutoCloseable)会 shutdown 并等到完成、可用于 try-with-resources,以及文档以 shutdown → awaitTermination → shutdownNow 的两阶段关闭作为使用示例等内容。  2 3 4 5 6 7 8

  3. Oracle Java SE Core Libraries,Virtual Threads。关于虚拟线程由 Java 运行时实现、在阻塞 I/O 期间会释放操作系统线程的轻量线程,它面向的是扩展性(吞吐量)而不是速度(延迟),并不适合 CPU 密集型处理,虚拟线程绝不能池化而要每个任务用一条(newVirtualThreadPerTaskExecutor),限制并发数应使用 Semaphore 而不是线程池,JDK 21 时点在 synchronized 内部阻塞会导致钉在操作系统线程上因而频繁、长时间的情况被建议替换为 ReentrantLock,以及可以用 -Djdk.tracePinnedThreads 检测钉住等内容。  2 3 4 5 6 7

  4. OpenJDK,JEP 491: Synchronize Virtual Threads without Pinning。关于 JDK 24 重写了 JVM 的监视器实现以适配虚拟线程,在 synchronized 代码块或方法内部阻塞时虚拟线程不再被钉在载体线程上,由此 JDK 21~23 时代“把 synchronized 替换为 ReentrantLock”的应对措施原则上不再必要等内容。  2

  5. Oracle,The Java Tutorials,Memory Consistency Errors。关于内存一致性错误产生于多条线程对同一份数据有不一致的可见方式,避免它的关键是 happens-before 关系(保证某条语句的内存写入能被另一条语句看到),以及 synchronized、volatile、Thread.start / join 等会建立 happens-before 等内容。  2 3

  6. Oracle,ConcurrentHashMap (Java SE 21 & JDK 21 API)。关于 computeIfAbsent 的整个方法调用是原子执行的、键不存在时映射函数恰好只被调用一次,计算期间其他线程的部分更新操作会被阻塞因而计算应保持简短单纯,不得在映射函数内修改这个映射、可被检测到的递归更新会抛出 IllegalStateException,以及取值操作(get)不会阻塞、对每个键的更新与之后的取值之间成立 happens-before 关系等内容。  2 3 4

  7. Oracle,Thread (Java SE 21 & JDK 21 API)。关于 Thread.stop / suspend / resume 本质上并不安全(会在状态非法时释放锁从而让损坏的对象被看到,suspend 会招致死锁)因而属于计划删除的弃用项,现在调用会抛出 UnsupportedOperationException,interrupt() 会置位中断状态并对阻塞在 sleep / wait / join 的线程抛出 InterruptedException 将其唤醒(此时中断状态会被清除),以及 interrupted() 与 isInterrupted() 在状态处理上的差异等内容。  2 3

  8. Oracle,The Java Tutorials,The Event Dispatch Thread。关于 Swing 的事件处理代码运行在事件分发线程(EDT)上,大多数 Swing 对象的方法都不是线程安全的、从多条线程调用会引发线程干扰和内存一致性错误因而对 Swing 组件的访问原则上应在 EDT 上进行,从其他线程应通过 SwingUtilities.invokeLater / invokeAndWait 向 EDT 委托任务,以及 EDT 上的任务应尽快结束等内容。  2

  9. Oracle,The jstack Command (Java SE 21 Tools Reference)。关于 jstack 会输出指定 Java 进程中所有线程的栈跟踪(类名、方法名、行号),-l 选项可以显示包含锁相关附加信息的详细内容,以及它会与 jcmd 等其他诊断工具配合使用等内容。 

  10. OpenJDK,JEP 505: Structured Concurrency (Fifth Preview)。关于结构化并发 API 把相关的子任务群当作一个工作单元处理并把错误传播与取消结构化,StructuredTaskScope 被改为通过静态工厂方法(open)打开,以及在 JDK 25 时点它是第五次预览、还不是正式功能等内容。 

  11. OpenJDK,JEP 525: Structured Concurrency (Sixth Preview)。关于结构化并发在 JDK 26 中作为第六次预览继续,也就是说在截至 2026 年 8 月的现行 JDK 中它仍是预览功能,使用时需要启用预览功能等内容。 

  12. OpenJDK,JEP 506: Scoped Values。关于 Scoped Values 在 JDK 25 转正,它是在线程内以及线程之间安全且高效地共享不可变上下文数据的机制,是针对 ThreadLocal 的问题(可变性、生命周期管理、继承开销)的解决方案等内容。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

既然有了虚拟线程,是不是就不再需要线程池(ExecutorService)了?
要看用途。虚拟线程是用来大批量运行以 I/O 等待为主的任务的机制,它提升的不是代码的速度,而是吞吐量。I/O 密集型的工作应该给每个任务用一条虚拟线程(Executors.newVirtualThreadPerTaskExecutor),不能对虚拟线程做池化。另一方面,对于要把 CPU 用满的计算并行化,一如既往,更适合用限制在核心数量级的平台线程池(或 parallel stream)。此外,如果想收紧对外部服务的并发访问数,虚拟线程时代推荐用 Semaphore 来限制,而不是靠线程池的大小来限制。
synchronized 和 ReentrantLock 应该用哪一个?
如果互斥简短而单纯,synchronized 就足够了,代码也更简洁。当需要 tryLock 带来的带超时获取、公平性策略、多个 Condition 这类功能时,再选择 ReentrantLock。另外,与虚拟线程搭配时有一个历史遗留的注意事项:在 JDK 21~23 中,如果在 synchronized 代码块内阻塞,虚拟线程会被钉在操作系统线程上,因此当时建议把伴随频繁、长时间阻塞的位置替换为 ReentrantLock。到了 JDK 24(JEP 491),监视器的实现被重写,这个限制已经解除。如果用的是 JDK 24 及之后的版本,就不需要仅以钉住为理由去做替换了。
加上 volatile 就能做到线程安全吗?
不能。Java 的 volatile 会在对该变量的写入与读取之间建立 happens-before 关系,保证可见性(其他线程能看到最新的写入)与顺序,但不保证“读取、计算、写回”这类复合操作的原子性。如果多条线程对 volatile int 的计数器执行 ++,加法就会丢失。计数器要用 AtomicInteger / AtomicLong(高频汇总则用 LongAdder),要一并保护多个变量则用锁。volatile 真正适用的场景,几乎只限于简单状态标志这种“一条线程写、其他线程只读”的情形。
可以 catch 住 InterruptedException 然后忽略它吗?
不可以。中断是 Java 标准的停止与取消信号,把它吞掉就会造出“停不下来的线程”。抛出 InterruptedException 的那一刻,中断状态就已经被清除了,因此如果自己无法把处理结束掉,就要用 Thread.currentThread().interrupt() 恢复状态、给调用方留下信号,或者把异常原样往上抛。catch 住却什么都不做的空代码块,是关闭不起作用、shutdownNow 被无视这类问题的典型原因。
不能用 Thread.stop 停止线程吗?
不能。Thread.stop 本质上并不安全(会在状态非法时释放锁,让损坏的对象被其他线程看到),因此长期以来一直被标为弃用,在现在的 Java 中调用它会抛出 UnsupportedOperationException。Thread.suspend / resume 也一样。停止线程的正当手段只有基于中断(interrupt)的协作式停止。如果使用 ExecutorService,shutdown 只是停止接收新任务并等待已有任务跑完,不会向正在执行的任务发送中断。尝试停止正在执行任务的是 shutdownNow,而它是尽力而为的(标准实现中通过中断进行)。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表