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

Java 虚拟线程为何可能被 synchronized 固定

来源:17golang原创

时间:2026-11-14 22:39:01 351浏览 收藏

Java 虚拟线程并不是一遇到 synchronized 就一定会被“固定”在 carrier 线程上。这个说法主要来自 JDK 21–23 的实现限制:虚拟线程在 monitor 保护区内遇到某些阻塞操作时,可能无法从 carrier 卸载,长时间、频繁发生就会拖慢整体吞吐。JDK 24 已通过 JEP 491 改善 synchronized 的阻塞处理,但 native 方法和 Foreign Function & Memory API 仍可能造成 pinning。

官方资料:https://docs.oracle.com/en/java/javase/26/core/virtual-threads.html

实际排查时先看运行时版本和 JFR 事件,再决定是否改锁。JDK 24 及以后不需要因为“虚拟线程 + synchronized”这个组合本身就全面替换锁;真正应该优先修的是长时间持锁、锁内阻塞 I/O,以及仍会让虚拟线程无法卸载的 native/foreign 调用。

先把“被固定”理解成 carrier 没有被释放

虚拟线程由 Java 运行时调度,执行时会暂时挂载到一个平台线程上,这个平台线程就是 carrier。普通的阻塞 I/O 通常可以让虚拟线程挂起并释放 carrier,其他虚拟线程随后可以使用这个 carrier。pinning 的关键不是虚拟线程停止工作,而是它在阻塞时继续占着底层平台线程。

synchronized 使用对象 monitor 提供互斥和 happens-before 保证。旧版本中,如果虚拟线程在 synchronized 方法或代码块内执行阻塞操作,或者等待一个竞争激烈的 monitor,carrier 可能被一并占住。一个短小的内存临界区通常不会造成可感知问题;真正危险的是把网络、文件、数据库或外部 SDK 调用包在锁里。

虚拟线程、carrier、synchronized monitor 与 native 调用的固定边界说明图
图1:虚拟线程与 carrier、monitor、阻塞操作的静态关系;这是版本边界说明图,不是运行截图或执行证据。

先确认你面对的是哪个 JDK 行为

如果线上服务运行在 JDK 21、22 或 23,历史文章中关于 synchronized pinning 的提醒仍然有参考价值,尤其要关注频繁进入、并且会等待 I/O 的临界区。Oracle 的 JDK 23 指南明确建议避免频繁且长时间的 pinning,并在确有问题时考虑 ReentrantLock。

JDK 24 引入“Synchronize Virtual Threads without Pinning”。Oracle 的 JDK 26 迁移指南说明,JDK 24 会让在 synchronized 方法或语句中阻塞的虚拟线程释放底层平台线程,交给其他虚拟线程使用。因此,升级到 JDK 24+ 后,不能再简单地把所有 synchronized 都判定为 pinning 来源。

运行时范围需要重点关注的现象处理判断
JDK 21–23synchronized 内的长阻塞、竞争 monitor 可能拖住 carrier优先缩短临界区,并对高频长阻塞点评估 ReentrantLock
JDK 24+synchronized 的阻塞可更好地释放 carrier,但 native/foreign 阻塞仍可能 pin保留正确的短临界区,使用 JFR 找实际事件
所有版本锁内做外部调用会放大排队和尾延迟先重构资源边界,不要只换一个锁类名

最先改的是锁内的外部调用

下面的旧式写法把远程读取放进了 monitor。即使当前 JDK 能处理一部分 synchronized 阻塞,这种结构仍然让所有竞争者共享一个长临界区:远程服务变慢时,内存状态更新也会跟着排队。

final class ProfileCache {
    private final Object lock = new Object();
    private Profile profile;

    Profile load(String userId) throws IOException {
        synchronized (lock) {
            // 远程 I/O 的时长不可控,不要让它占着共享 monitor。
            Profile loaded = fetchFromService(userId);
            profile = loaded;
            return loaded;
        }
    }

    private Profile fetchFromService(String userId) throws IOException {
        // 示例只表达边界:真实项目里这里可能是 HTTP、JDBC 或文件读取。
        return remoteClient.fetch(userId);
    }
}

更稳妥的拆法是先在锁外取得数据,再在锁内完成短小的状态合并。若业务要求“只有一个请求负责刷新”,还要额外设计刷新标记或使用更高层的缓存协调机制,不能因为移出 synchronized 就默认消除了重复请求。

final class ProfileCache {
    private final Object lock = new Object();
    private Profile profile;

    Profile load(String userId) throws IOException {
        // 先在锁外执行可能长时间阻塞的调用。
        Profile loaded = fetchFromService(userId);
        synchronized (lock) {
            // 锁内只做内存状态更新,保持临界区短小。
            profile = loaded;
            return profile;
        }
    }

    private Profile fetchFromService(String userId) throws IOException {
        // 统一保留底层异常,让调用方决定重试或降级。
        return remoteClient.fetch(userId);
    }
}

需要可中断或超时时,再考虑 ReentrantLock

ReentrantLock 不是虚拟线程的“自动解 pin 开关”,它的价值在于提供更细的控制:可以尝试加锁、响应中断、设置超时或选择公平策略。只有当这些能力确实解决了你的调度需求,或者在旧 JDK 上定位到长时间、频繁的 synchronized pinning 时,替换才有意义。

final class RefreshGate {
    private final ReentrantLock lock = new ReentrantLock();

    boolean tryRefresh(Duration timeout) throws InterruptedException {
        // 超时让调用方可以放弃等待,而不是无限占用请求资源。
        if (!lock.tryLock(timeout.toNanos(), TimeUnit.NANOSECONDS)) {
            return false;
        }
        try {
            // 这里应放短小的共享状态操作;不要把远程 I/O 放进锁内。
            refreshInMemoryState();
            return true;
        } finally {
            // 无论刷新成功还是抛出异常,都必须释放锁。
            lock.unlock();
        }
    }
}

如果只是保护一个很短的字段读写,synchronized 更直接,也更容易让维护者看出互斥范围。换锁时要同时复核可见性、异常路径和锁对象是否保持一致,不能只做机械替换。

synchronized、ReentrantLock、Semaphore 与外部 I/O 边界的方案关系图
图2:把共享状态、锁控制、并发上限和外部 I/O 分开考虑的静态方案关系图,不代表某次程序运行结果。

并发上限应该用 Semaphore 表达

很多团队用固定平台线程池限制外部服务并发,迁移到虚拟线程后仍然保留同一个池,结果把“线程资源管理”和“业务并发上限”混在了一起。Oracle 的虚拟线程采用指南建议用 Semaphore 表达受限资源的并发数;数据库连接池本身也已经承担了类似的排队职责。

final class LimitedClient {
    private final Semaphore permits = new Semaphore(10);

    Response call(Request request) throws InterruptedException {
        // 信号量表达的是外部服务最多允许的并发请求数。
        permits.acquire();
        try {
            // 虚拟线程可以在这里等待;不要为每个任务再建一个固定线程池。
            return client.send(request);
        } finally {
            // 无论远程调用成功、失败还是被取消,都归还许可。
            permits.release();
        }
    }
}

用 JFR 找仍然存在的真实 pinning

不要仅凭代码中出现了 synchronized 就判断服务一定被 pinning。JDK 文档提供了 jdk.VirtualThreadPinned 事件,它默认在阻塞超过阈值时记录;JDK 26 虚拟线程文档还说明,native 方法或 foreign function 是当前仍需重点关注的 pinning 来源。

# 启动一次录制,并在 JVM 退出时把 JFR 文件写到指定位置。
java -XX:StartFlightRecording:dumponexit=true,filename=virtual-thread.jfr \
     -cp app.jar com.example.Main

# 只打印虚拟线程被固定的事件,定位阻塞操作和调用栈。
jfr print --events jdk.VirtualThreadPinned virtual-thread.jfr

看到事件后,先看 blockingOperation、pinnedReason 和调用栈,再回到代码判断是 monitor、native/foreign 调用,还是其他运行时边界。没有事件时,不要为了迎合旧文章而全面替换 synchronized。

最后用这张表做选择

场景优先方案判断依据
短小的内存状态互斥synchronized语义清楚,临界区短,不涉及外部阻塞
需要超时、可中断或公平策略ReentrantLock需要显式控制加锁和释放路径
限制外部服务或资源并发Semaphore表达的是许可数量,不是平台线程数量
native/foreign 调用或锁内长 I/O重构边界并监控先缩短阻塞范围,再用 JFR 观察事件

所以,“Java 虚拟线程为何可能被 synchronized 固定”的准确答案是:旧 JDK 的 monitor 阻塞曾经让 carrier 无法释放,JDK 24 起这部分实现已经改善;现在更应该关注 native/foreign 阻塞和锁内长耗时操作。选择同步工具时,先看临界区、等待策略和资源上限,再看锁的名字。

相关问题

  • JDK 24 以后还需要把 synchronized 全部改成 ReentrantLock 吗?
  • 虚拟线程里用固定线程池限制并发有什么问题?
  • JFR 的 jdk.VirtualThreadPinned 事件应该重点看哪些字段?
  • 为什么把网络请求放到 synchronized 外面仍可能产生重复刷新?
声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>