多线程实务最佳实践 Java 篇 ── 虚拟线程时代的准则

· · 多线程, Java, 业务应用, 故障排查, 设计

「想把业务系统的批处理用Java并行化」「Spring编写的Web应用里共享缓存偶尔会损坏」「接手了一个满是 new Thread 的老旧Swing应用」── Java从JDK 1.0起就把多线程内置在语言中,拥有 java.util.concurrent 这套成熟的工具箱,还在JDK 21用虚拟线程把并发处理的常识又往前推进了一步。正因为工具丰富,”选哪一个”的判断本身就直接决定了设计质量。

本文是多线程实务系列的Java 篇。面向用 Java 编写业务系统・批处理・服务器应用的开发者,把多线程设计的原则 ── 不直接创建线程、减少共享可变状态、加锁的纪律、事先设计好停止方式 ── 落实到 Java(以 LTS 版本 JDK 21 及以后为主要对象)的工具上,并结合虚拟线程时代的取舍与 Java 特有的陷阱,基于截至 2026 年 8 月的一手资料进行整理。本文写得可以单独阅读。同样的原则用其他语言展开的文章还有「.NET 篇」「C++ 篇」「C 篇」。

1. 先说结论

  • 业务代码中不写 new Thread,这一点在 Java 中同样适用。把任务交给 ExecutorService,线程的生命周期管理留给库来处理。1
  • 以 I/O 等待为主的任务交给虚拟线程。JDK 21 正式引入的虚拟线程要按照”一个任务对应一条虚拟线程”来使用,绝不能池化。并发数的限制不用池,而用 Semaphore 来实现。23
  • 虚拟线程是提升吞吐量的工具,不是让计算变快的工具。CPU 密集型的并行化,一如既往由与核心数相当的平台线程(固定线程池或 parallel stream)来承担。3
  • 加锁要用 private final 的锁对象,或专用的 ReentrantLocksynchronized(this) 或对公开对象加锁,都会与外部代码发生冲突。如果需要带超时的获取(tryLock),就用 ReentrantLock
  • JDK 21~23 中,synchronized 内部的阻塞会把虚拟线程钉住(pin)在 OS 线程上,但这个问题在 JDK 24(JEP 491)中已经解决。请区分旧的注意事项和新的实际情况。34
  • volatile 保证的是可见性与顺序,而不是原子性。计数器要用 AtomicInteger / LongAdder,复合状态要用锁。5
  • 停止线程唯一正确的方式,是通过中断(interrupt)实现的协作式停止。Thread.stop / suspend / resume 现在调用会抛出 UnsupportedOperationExceptionInterruptedException 不能吞掉,要么恢复中断状态,要么继续向上抛出。6
  • 停止 ExecutorService 要用”shutdown → awaitTermination → shutdownNow”这一两段式模式。shutdownNow 是尽力而为(标准实现是通过中断实现的),因此前提是任务一侧要能响应中断。1
  • Swing 的 UI 是 EDT(事件分发线程)独占的资源。从其他线程操作 UI,要通过 SwingUtilities.invokeLater 来委托。7

2. 为什么多线程很难 ── 竞态条件・死锁・内存模型

多线程带来的问题,无论语言是什么,归根结底可以分为两类。

竞态条件(race condition)是指结果会因为多个线程到达某段代码的先后顺序不同而改变的缺陷。最典型的例子是共享计数器:count++ 这一个表达式实际上会分成”读取 → 加一 → 写回”三个步骤。如果两个线程同时进入这三个步骤,一方的加一操作就会被另一方的写回覆盖并消失。每次执行结果都可能不同,而且无法预测会得到哪种结果。

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

图1: 共享计数器中加一操作丢失的典型竞态条件。在 count++ 的三个步骤之间如果被别的线程插入,后写回的一方就会覆盖前者

死锁是指两个线程互相等待对方持有的锁,导致谁都无法继续往下走的状态。线程A持有锁1并等待锁2,线程B持有锁2并等待锁1 ── 仅凭这一点,两者就会永远停在原地。

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

图2: 死锁中的循环等待。等待箭头一旦构成一个环,环内的所有线程就会永远停止

这两者都依赖时序:在开发机上可能几万次才会命中一次的执行顺序组合,在核心数和负载都不同的生产服务器上却可能天天发生。”接上调试器就不复现”“加了日志就消失了”也是因为观测本身改变了时序,这是竞态缺陷的典型表现。正因如此,本文的所有原则都指向同一个方向 ── 在”正确地同步”之前,先”减少需要同步的地方”

2.1. Java 特有的前提 ── 内存模型与 happens-before

在此之上,Java 特有的一点是:共享数据的可见方式是由 Java 内存模型(JMM)的 happens-before 关系来定义的。

在没有同步的情况下读写共享变量,虽然不会像 C++ 那样导致”未定义行为”,但持续看到旧值・写入顺序被看成乱序这类情况是完全可能合法发生的。”明明在循环中检查 boolean 标志,却一直看不到其他线程改过的值”这种内存一致性错误,是 JMM 所允许的行为,而不是 JVM 的缺陷。5 防止这类问题的工具,就是能建立 happens-before 关系的机制 ── synchronizedvolatile、以及 java.util.concurrent 中的各个类。只要正确使用并发集合,”更新操作与之后的获取操作之间的 happens-before”就会由库来保证。8

也就是说,Java 的实务方针可以归纳为:不要在裸共享变量上耍花招。共享要用 java.util.concurrent 的工具,happens-before 交给库来建立。

3. 创建线程的方式 ── ExecutorService 与虚拟线程

3.1. 任务与执行的分离

在 Java 中承担”不自己创建线程”这一原则的是 ExecutorService。它把工作(Runnable / Callable)和”如何执行”(用几条线程、用哪种队列)分离开来,把线程的创建・复用・销毁都交给库来处理。1

JDK 21 以后,执行方式的选择变成了一个简单的二选一。23

以 I/O 等待为主HTTP 调用・数据库・文件占用 CPU 的计算有需要并发处理的任务任务主体是什么?虚拟线程Executors.newVirtualThreadPerTaskExecutor一个任务一条虚拟线程,不做池化平台线程固定线程池Executors.newFixedThreadPool - 与核心数相当或 parallel stream对外部服务的并发数限制不用池,而用 Semaphore

图3: JDK 21 以后执行方式的选择。先划出”I/O 就换等待方式,CPU 就并行化”这条界线,I/O 密集型交给虚拟线程,CPU 密集型交给传统线程池分担

在 CPU 密集型这一侧有一点需要注意。Executors.newFixedThreadPool 虽然限制了线程数,但等待队列是无限的。在提交速度持续超过处理速度的常驻服务中,被限制到核心数规模的只有线程本身,堆积在队列里的任务及其数据会持续消耗内存。在这种结构下,要么直接使用 ThreadPoolExecutor 来配置带容量上限的队列 + 拒绝策略,要么在提交端放置 Semaphore 之类的准入限制,做成能够施加背压的形式(与第 4 章队列部分的原则相同)。

3.2. 不要用错虚拟线程的用法

虚拟线程是与 OS 线程脱钩的轻量级线程,在JDK 的阻塞操作(标准库的 I/O・锁・sleep 等)期间会让出 OS 线程,因此可以在一个 JVM 里跑上数百万条。不过并不是”在任何阻塞下都能让出”。如果在执行原生代码(JNI)或 foreign function 期间发生阻塞,虚拟线程会一直被钉在承载线程(carrier thread)上。JDK 24(后文的 JEP 491)解决的是 synchronized 造成的钉住问题,原生边界的钉住依然存在,因此如果把通过 JNI 调用驱动程序或设备 API 而长时间阻塞的处理,大量放到虚拟线程上,承载线程就会被耗尽。不过正如官方指南强调的那样,它并不是”更快的线程”。代码的执行速度不会改变,它提供的是规模(吞吐量)。3

使用上的纪律有三条。3

  1. 不做池化。虚拟线程是廉价的一次性资源,”任务数 = 虚拟线程数”才是正确状态。把虚拟线程放进 newFixedThreadPool 是错误做法,应该以 try (var executor = Executors.newVirtualThreadPerTaskExecutor()) 的形式使用。
  2. 并发数的限制用 Semaphore像”对外部 API 最多同时 10 个连接”这样的限制,不用线程池大小来表达,而用信号量来表达。
  3. 不要用于 CPU 密集型工作。计算的并行化一如既往,由与核心数相当的平台线程来承担更合适。

另外,在虚拟线程中运行的是普通的同步代码。虚拟线程的设计思想不是像 .NET 的 async/await 那样改写代码形态,而是让”一个请求一条线程”这种朴素写法原封不动地大量并行运行。2

3.3. 不要产生的误解 ── “不需要池”只是针对虚拟线程而言

请不要从”不做池化”这条纪律,读出”Java 没有线程池这种机制(或者说线程池效率低)”这样的结论。实际情况恰恰相反:Java 的线程池是从 JDK 5(2004 年)起就存在于标准库中的成熟工具。可以精细配置的通用池 ThreadPoolExecutor(由 Executors 的各个工厂方法创建)、工作窃取型的 ForkJoinPool(它的公共实例 commonPool 是 parallel stream 和 CompletableFuture 的默认执行位置)、用于周期性执行的 ScheduledThreadPoolExecutor ── 在 CPU 密集型工作中,如今依然是这些工具唱主角。

线程池本质上是一种”因为创建・持有 OS 线程的代价很高,所以要重复利用“的优化手段。虚拟线程把创建成本几乎降到了零,消除了这个前提,重复利用也就失去了意义 ── 准确的理解不是”池效率低”,而是”轻量到不再需要池这种优化”。而且在虚拟线程的底层,JDK 的调度器正是以工作窃取的 ForkJoinPool形式,运作着与核心数相当的承载线程(OS 线程)。2 也就是说,”用少数 OS 线程池来处理大量并发”这一结构本身依然存在,只是这个池的管理从开发者手中转移到了 JVM 那里。.NET 的 async/await 在 await 时把线程还给池,Java 用不改变代码形态的方式抵达了同样的终点,可以说这就是 Java 给出的答案。

4. 减少共享可变状态 ── 拆分・不可变・并发集合・队列

竞争只有在”多个线程”和”共享的可变数据”同时具备时才会发生。线程数由需求决定,设计上能削减的是共享这一侧。手段分为”拆分”“不可变化”“传递”三个系列,在 Java 里可以这样写。

拆分。在并行汇总中,不要让各个线程写入同一个共享的合计变量,而是让每个线程各自生成部分结果,最后再合并。parallel stream 的 reduce / collect 正是把这种形态当作框架提供出来,后面会提到的 LongAdder 同样是”内部把单元格拆分来分散竞争,读取时再合计”这种拆分策略的实现。减少对共享变量的写入次数,要排在”正确地写同步代码”之前。

不可变化。record 和不可变集合(List.copyOf / Map.copyOf)构造出”构建完成后就不再改写的数据”,就能不加同步地共享。对于配置和主数据,通用做法是”要替换时就创建新对象,再把 volatile 引用换过去”。不过”看起来是只读的”和”确实是不可变的”是两回事。record 的访问器返回的是构成元素的原始引用,List.copyOf 的复制也是浅拷贝(不会复制到元素对象内部),因此如果元素本身是可变的,持有别名的某个人依然可以改写内容,竞争依然存在。只有当对象图整体 ── 包括元素在内 ── 全部不可变时,才可以不加同步地共享。如果包含可变元素,就要传递深拷贝,或者让元素也改用 record / 不可变类型。

使用并发集合的复合操作。ConcurrentHashMap 的”没有就创建再放入”要用 computeIfAbsent。这个方法整个调用是原子执行的,如果键不存在,在这一次调用内部,映射函数恰好只会被调用一次8 这与 .NET 的 ConcurrentDictionary.GetOrAdd(竞争时工厂方法可能被执行多次)保证不同,是在两种语言之间来回切换的人容易混淆的一点。不过这并不等于”这个键一生只执行一次”。如果函数返回 null 或抛出异常,映射就不会被登记,后续调用会再次执行该函数(登记后又删除该条目时同理)。对于不允许副作用重复发生的初始化,设计时要连同”让函数以非 null 的方式成功”这一点也考虑进去。不过作为原子性的代价,计算期间会阻塞其他线程的部分更新操作,因此映射函数要保持简短,并且不能在函数内部更新这个 map 本身(递归式更新可能引发 IllegalStateException)。8

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

用队列传递。线程之间的数据流动交给 BlockingQueue指定了容量ArrayBlockingQueue,在队列满时 put 会阻塞,从而形成自然的背压,与 .NET 篇中 bounded 通道是同样的结构。即使在虚拟线程时代,这种明确划分生产者/消费者边界的设计依然有效。

5. 加锁的纪律 ── synchronized 与 ReentrantLock

5.1. 用什么加锁,加锁期间不做什么

加锁的单位不应该按”代码区间”来考虑,而应该按“数据”来考虑。给每一组想要保护的可变数据对应一个锁对象,在触碰这份数据的所有地方都获取同一把锁 ── 竞态缺陷的实质,往往就是这张对应表被破坏了。synchronized(this)synchronized(SomeClass.class) 会让外部代码也能锁住同一个对象,应当避免,改用不对外公开的 private final Object lock = new Object();,与想要保护的数据建立一对一的对应关系。

使用上再补充两条纪律。第一,加锁期间不做耗时的事・不做涉及外部的事。保持着锁执行 I/O・调用监听器・执行未知代码,不仅会延长持锁时间,还可能因为被调用方去获取另一把锁,而构成图2那样的循环等待。第二,固定多把锁的获取顺序。在需要获取两把以上锁的地方,规定所有线程都按相同顺序获取;顺序无法保证的地方,用后文的 tryLock(timeout) 准备一条”拿不到就放手重来”的路径。

synchronized 足以应付的是”简短而单纯的互斥”。当需要以下能力时,再进阶到 ReentrantLock

  • 通过 tryLock(timeout) 实现带超时的获取(把永远挂起变成可以记录并处理的失败)
  • 需要公平性策略・多个 Condition,或者想把加锁与解锁拆到不同方法中的场合

使用 ReentrantLock 时,请不要打破”lock() 之后紧跟 try,在 finallyunlock()“这一形式(因为 Java 没有相当于 C++ RAII 的语法,这个形式就是纪律的全部)。

5.2. 虚拟线程与钉住 ── JDK 24 带来的变化

虚拟线程刚引入时(JDK 21~23)存在一个限制:synchronized 代码块中发生阻塞,会把虚拟线程钉在 OS 线程上(无法让出 OS 线程,规模化的优势也随之失效),因此对于会频繁・长时间阻塞的地方,当时建议改用 ReentrantLock3 这个限制已经在 JDK 24 的 JEP 491 中通过重写监视器实现而得到解决,synchronized 不再钉住虚拟线程。4 如果使用的是 JDK 24 以后的版本,就不需要为了应对钉住问题而机械性地做替换了。值得确认一下公司内部的旧指南是否还停留在 JDK 21 时代的注意事项上。

5.3. Atomic 系与 volatile 的定位

单个变量的原子更新由 AtomicInteger / AtomicLong / AtomicReference 承担(如果只是高频率地做加法的统计值,则用抗竞争能力更强的 LongAdder)。volatile 保证的是可见性与顺序(happens-before),不提供复合操作的原子性。5 .NET 篇・C++ 篇的同一个结论在 Java 中同样成立:标志位与单一值用 Atomic 系,复合状态用锁,不要单靠 volatile 硬撑。

6. 停止方式的设计 ── 中断这一通用语言

6.1. interrupt 的用法

Java 的停止・取消统一由中断(interruption)机制承担。t.interrupt() 会置位目标线程的中断状态;如果目标正在 sleep / wait / join 等操作中阻塞,就会抛出 InterruptedException 将其立刻唤醒(此时中断状态会被清除)。6 过去作为强制手段使用的 Thread.stop / suspend / resume,由于本质上并不安全,如今调用会变成抛出 UnsupportedOperationException6

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

图4: 通过中断实现协作式停止。吞掉 InterruptedException 会让停止信号消失 ── 一旦 catch 到,只有”结束”或”恢复”这两个选择

实务中的纪律只需记住一条:不要写出 catch 了 InterruptedException 却什么都不做的代码。如果在自己的职责范围内能够结束,就在那里结束;如果不能,就用 Thread.currentThread().interrupt() 恢复状态,把信号传递给调用方(参见 FAQ)。

6.2. ExecutorService 的两段式关闭

ExecutorService 的停止 API 建立在中断模型之上。shutdown() 会停止接受新任务,让已提交的任务完走;shutdownNow() 会尝试停止正在执行的任务。作为接口规范,这是尽力而为的,标准实现(如 ThreadPoolExecutor)明确写明典型情况下是通过 Thread.interrupt() 来取消的 ── 也就是说不响应中断的任务,即使用 shutdownNow 也停不下来,如果使用自行实现的 Executor,还需要在实现文档中确认它的取消方式(是否发送中断)。1 官方文档给出的停止惯用模式,就是下面的两段式模式。1

/** 停止完成时返回 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: ExecutorService 的两段式关闭。”礼貌等待 → 用中断请求 → 仍不结束就记录为异常”这样的分阶段设计

另外,shutdownNow() 返回的 Runnable 的取消操作有一个局限。返回的是曾经排在执行队列中的对象:如果是普通的 submit,那就是交给调用者的 FutureTask 本身;但如果任务是通过 ExecutorCompletionService 之类的包装器提交的,返回的就是队列内部的包装对象,与调用者手上的 Future 是不同的对象。在这种结构下,上面的取消操作不会让调用者一侧的 Future 完成,因此请设计成:提交时自行保留一份 Future 列表,在关闭时对那份列表执行取消(或者把被撤下的任务返还给持有者)。

JDK 19 以后新增的 close()(AutoCloseable)把”shutdown 后等到完成”这套动作变成了可以用 try-with-resources 书写的形式,与虚拟线程的 newVirtualThreadPerTaskExecutor 组合而成的 try (var executor = ...) 是现代的基本写法。1 不过 close() 并不能替代上面的两段式模式。因为它不带超时地等待完成,只要有一个任务不响应中断或者迟迟不结束,试图关闭的线程就会永远阻塞。这是一种适合”作用域内任务数量有限且能保证完走”的场景(当场提交、当场等待的用法)的工具;像应用关闭路径这种”必须在有限时间内结束”的地方,请使用带期限的两段式模式。取消单个任务则同样通过中断,由 Future.cancel(true) 来完成。

7. UI 线程 ── Swing 的 EDT

桌面应用有一条不分语言与框架的铁律:”UI 是管理它的那个线程独占的资源”。在 Swing 中,这个专属线程就是事件分发线程(EDT),Swing 组件的方法原则上不是线程安全的,从多个线程去操作会引发线程干扰和内存一致性错误。来自其他线程的画面更新要通过 SwingUtilities.invokeLater 委托给 EDT;反过来,如果在 EDT 上执行耗时处理,UI 就会卡死,因此繁重的工作要用 SwingWorker 之类的机制交给工作线程去做。7 JavaFX 的结构也是一样,UI 更新通过 Platform.runLater 委托给应用程序线程。

8. 验证与调试 ── 线程转储这件武器

不能指望靠测试就能发现竞态缺陷。因为常规测试会把”碰巧没有发生竞争”的那次执行也算作成功。防御要按三层来考虑。

第一道防线是设计。在评审时用表格确认”哪些是共享的可变数据”“各自由哪把锁保护(5.1 的对应表)”“锁的获取顺序是否唯一”“有没有吞掉 InterruptedException 的 catch”“停止路径(shutdown/中断)是否能传达到所有任务”。

第二,要熟练使用线程转储。Java 有获取”卡住的此刻线程状态”的标准工具:用 jstack(或 jcmd <pid> Thread.print)可以输出堆栈跟踪,加上 -l 选项还能输出锁的附加信息。9 需要注意的是,这种传统格式的转储是面向平台线程的,不包含应用中的虚拟线程。在使用虚拟线程的结构中(第 3 章)追踪被阻塞的请求时,请使用能连虚拟线程一并转储的 jcmd <pid> Thread.dump_to_file -format=json <文件>2 排查挂起问题的基本步骤,是每隔几秒取 2~3 次转储,核对没有在运行的线程在等待哪把锁、那把锁又被谁握着。如果把 tryLock(timeout) 的超时记录到日志里(见 5.2),连取转储的时机都可以自动化。

第三,用负载去摇晃它。用超过核心数的并行度长时间运行・把处理顺序随机化・插入人工延迟,这类压力测试是在开发机上更容易”抽中”竞争的现实手段。请在发布前至少用一次与生产环境相当的数据量和线程数跑一遍测试。

9. 未来的 Java 并发处理 ── 结构化并发

最后说一点更靠前沿的话题。以虚拟线程为前提、”把多个子任务当作一个工作单元来处理,并把失败传播与取消结构化”的 Structured Concurrency(StructuredTaskScope)正在开发中,截至 2026 年 8 月仍是预览特性。在 JDK 25 的第五次预览(JEP 505)中,它被改成了通过 StructuredTaskScope.open() 的 API 形式,并在当前的 JDK 26 中作为第六次预览(JEP 525)继续存在。1011 另一方面,解决 ThreadLocal 问题、提供不可变上下文共享的 Scoped Values 已在 JDK 25 中正式化。12 本文的原则(明确任务边界、共享保持不可变、停止采用协作方式)与这些新 API 所追求的方向是一致的。

10. 总结 ── Java 版核对清单

  1. 业务代码中是否还残留 new Thread(是否已经改用 ExecutorService / 虚拟线程)
  2. I/O 密集型和 CPU 密集型是否分开了执行方式(图3 的分支)
  3. 是否没有把虚拟线程池化,并发数限制是否用 Semaphore 来表达
  4. 共享数据是否不可变(record / List.copyOf),或者是否用了 java.util.concurrent 的工具
  5. 有没有对 synchronized(this) / 公开对象加锁
  6. 是否使用了 ConcurrentHashMap 的复合操作(computeIfAbsent 等),并把映射函数保持简短
  7. 有没有对 volatile 抱有原子性的期待(计数器是否用了 Atomic 系 / LongAdder)
  8. 是否完全没有吞掉 InterruptedException 的 catch
  9. ExecutorService 的停止是否采用了带期限的两段式模式(使用 close() 的地方,是否仅限于能保证任务完走的作用域内)
  10. Swing/JavaFX 的 UI 更新是否都汇聚到了 EDT/应用程序线程

Java 是并发处理工具体系最完备的语言之一,虚拟线程的出现,又打开了”让朴素的同步代码原样扩展规模”这条路。正因如此,正确掌握各个工具的分工 ── 哪个是提升吞吐量的工具、哪个是互斥的工具、停止的信号是什么 ── 才是 Java 多线程设计的实质所在。

相关文章

相关咨询领域

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

参考链接

  1. 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

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

  3. Oracle Java SE Core Libraries, Virtual Threads。关于虚拟线程是由 Java 运行时实现、在阻塞 I/O 时会让出 OS 线程的轻量级线程、这是为了扩展规模(吞吐量)而非速度(延迟)的特性、不适合 CPU 密集型处理、虚拟线程绝不应池化而是每个任务用一条(newVirtualThreadPerTaskExecutor)、并发数限制应使用 Semaphore 而非线程池、在 JDK 21 时点 synchronized 内部的阻塞会导致钉在 OS 线程上因此对频繁・长时间的情况建议改用 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, Thread (Java SE 21 & JDK 21 API)。关于 Thread.stop / suspend / resume 本质上并不安全(会在锁处于非法状态时被释放,导致其他线程看到损坏的对象;suspend 会招致死锁)因而属于计划移除的已弃用方法,现在调用会抛出 UnsupportedOperationException、interrupt() 会置位中断状态,对正在 sleep / wait / join 中阻塞的线程会抛出 InterruptedException 将其唤醒(此时中断状态会被清除)、interrupted() 与 isInterrupted() 在状态处理上的差异等内容。  2 3

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

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

  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 代码块内发生阻塞,会把虚拟线程钉在 OS 线程上,因此当时建议把会频繁・长时间阻塞的地方替换成 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 住却什么都不做的空代码块,是“shutdown 不起作用”“shutdownNow 被无视”这类问题的典型原因。
不能用 Thread.stop 来停止线程吗?
不能。Thread.stop 本质上并不安全(会在锁处于非法状态时被释放,导致其他线程看到损坏的对象),因此长期以来一直被标为已弃用,在现在的 Java 中调用它会抛出 UnsupportedOperationException。Thread.suspend / resume 也是同样情况。停止线程的正当手段只有通过中断(interrupt)实现的协作式停止。如果使用的是 ExecutorService,shutdown 只是停止接受新任务并等待完走,并不会向正在执行的任务发送中断;尝试停止正在执行任务的是 shutdownNow,而它是尽力而为的(标准实现是通过中断实现的)。

作者简介

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

Go Komura

小村软件有限公司 代表

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

返回博客列表