登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  java教程

Java virtual thread 使用 synchronized 后为什么仍会阻塞平台线程

来源:17golang原创

时间:2026-09-09 04:22:40 457浏览 收藏

很多人第一次把 Java virtual thread 接到旧的同步代码上,会看到“平台线程也被占住了”的现象,于是把原因简单归结为 synchronized。这个判断要先加上 JDK 版本:JDK 21–23 中,虚拟线程在同步块里执行阻塞操作,确实可能固定 carrier;从 JDK 24 开始,JEP 491 已让虚拟线程在常规监视器等待和持锁阻塞时释放底层平台线程。若 JDK 24+ 仍出现平台线程长时间阻塞,优先检查调用者是不是平台线程、是否进入 native/Foreign Function,或临界区里根本不是“等待”而是持续计算。

结论先说:synchronized 仍然会造成互斥和锁等待,但在 JDK 24+ 不应再把普通监视器竞争自动等同于 virtual thread pinning。先看 JFR 的阻塞原因和 JDK 版本,再决定是否缩小临界区或换成 ReentrantLock
要点速览
  • 锁等待是应用层语义,线程固定是运行时调度问题,两者不是同一个指标。
  • JDK 21–23 要警惕 synchronized 包住 I/O;JDK 24+ 普通 monitor 已支持释放 carrier。
  • JFR 的 jdk.VirtualThreadPinnedjcmd Thread.dump_to_file 比线程名更可靠。

先分清“锁等待”和“线程固定”

synchronized 做的第一件事是获取对象监视器。已有线程持锁时,后来者要等待;这说明共享资源有竞争,并不自动说明载体平台线程被永久占住。虚拟线程通常可以在阻塞 I/O 时从 carrier 卸载,让同一个 carrier 去运行别的虚拟线程,但某些运行时边界会阻止卸载。

可以把现象拆成四个问题:谁在等 monitor,谁持有 monitor;等待发生在虚拟线程还是平台线程;等待期间是否进入阻塞 I/O;JFR 是否记录了 pinned 事件。下面的关系图只表达静态边界,不代表执行时序。

虚拟线程任务、synchronized 监视器、载体平台线程、阻塞 I O 与 JFR 诊断的静态关系图
图1:把 synchronized 的锁等待与 carrier 固定放在两个边界中观察,避免把所有阻塞都归因于同一个问题。

JDK 版本会改变 synchronized 的判断

旧版本的典型风险是把慢 I/O 放进同步块:

public synchronized String loadProfile() throws IOException {
    // 旧版 JDK 中,这里的阻塞 I/O 可能固定虚拟线程的 carrier
    return httpClient.send(request, BodyHandlers.ofString()).body();
}

在 JDK 21–23,频繁且长时间的这种写法会减少可用 carrier,吞吐量随并发上升而变差。短暂的内存操作或只在启动阶段使用的 synchronized 通常不值得为了虚拟线程强行改写。

JDK 24 的变化是“同步而不固定”:虚拟线程可以在等待 monitor 时卸载,也可以在持有 monitor 的代码遇到可卸载的阻塞时释放平台线程。它没有取消 synchronized 的互斥、可见性或 happens-before 语义,也不代表锁内代码会变快。平台线程本身仍然不能像虚拟线程一样卸载;如果调用者就是平台线程,它等待 monitor 时当然会处于阻塞状态。

看到的现象更可能的含义先查什么
虚拟线程在 monitor 上等待锁竞争,JDK 24+ 不必然是 pinning持锁线程与临界区长度
carrier 长时间不释放旧 JDK、native/foreign function 或不可卸载边界JFR VirtualThreadPinned
平台线程处于 BLOCKED平台线程自己在等 monitor,或被锁内工作占用线程转储与调用栈

用 JFR 和 jcmd 定位真实阻塞点

不要只根据线程名里的 ForkJoinPoolcarrier 下结论。Oracle 文档给出的 JFR 事件 jdk.VirtualThreadPinned 默认在超过 20ms 时记录,并且会带出阻塞操作与原因。可以在复现窗口采集一段 recording,再筛选相关事件:

# 采集结束后,只打印虚拟线程相关事件
jfr print --events jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed recording.jfr

# 导出线程转储,保留平台线程与虚拟线程的调用栈
jcmd  Thread.dump_to_file -format=json threads.json

若事件原因指向 native 或 foreign function,换锁通常不是根治;应先把这段调用移出高频临界区,或评估它是否必须在虚拟线程中执行。若只有大量 monitor 竞争而没有 pinned 事件,问题更接近锁粒度、持锁时间或平台线程调用者。

按 JDK 版本和阻塞类型选择修复

修复顺序应当是“先证据,后替换”。对保护内存状态的短临界区,保留 synchronized 往往更清晰;对可能做慢 I/O 的高频路径,先缩小锁区:

public String loadProfile() throws IOException {
    String requestId;
    synchronized (stateLock) {
        // 只在锁内读取共享状态,不把网络调用带进临界区
        requestId = currentRequestId;
    }
    // 网络等待发生在锁外,减少其他任务的排队时间
    return httpClient.send(buildRequest(requestId), BodyHandlers.ofString()).body();
}

如果业务确实需要可中断、可超时或更灵活的锁策略,再考虑 ReentrantLock,并用 try/finally 保证释放:

lock.lock();
try {
    // 临界区只保留必须互斥的内存操作
    updateCacheEntry();
} finally {
    // 即使业务抛出异常,也必须归还锁
    lock.unlock();
}

不要为了追求“虚拟线程化”而给所有 synchronized 加一层锁,也不要用固定平台线程池假装限制外部服务并发;并发上限应由明确的信号量或资源池承担。

JDK 版本、synchronized、ReentrantLock、native 调用与 JFR 诊断的决策边界图
图2:版本差异只决定 synchronized 的 carrier pinning 行为,最终修复仍要回到阻塞类型和证据。

相关问题

JDK 24+ 还需要把 synchronized 全部换成 ReentrantLock 吗?

不需要。先用 JFR 证明存在频繁、长时间的 pinning,再针对慢 I/O 或不可控调用缩小临界区;短内存操作保留 synchronized 通常更简单。

为什么虚拟线程很多,但平台线程数量没有一起增加?

虚拟线程由 Java runtime 调度并复用少量 carrier。它的价值是提高等待型任务的并发吞吐,不是让每个任务都拥有一个新的 OS 线程。

看到 BLOCKED 就说明发生了 pinning 吗?

不是。BLOCKED 可能只是某个线程等待 monitor;pinning 要看虚拟线程是否在不可卸载边界中占住 carrier,JFR 的 jdk.VirtualThreadPinned 更适合做判断。

参考:Oracle Java SE 25 Virtual ThreadsJDK 24 Significant ChangesOpenJDK JEP 444

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>