登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  科技周边 >  业界新闻

Java 25 虚拟线程文档更新后如何重新评估阻塞式服务

来源:17golang原创

时间:2026-09-14 21:54:42 493浏览 收藏

我最近重新看 Java 25 的虚拟线程文档时,最容易误判的一点不是 API,而是“线程池大小”这个旧指标。虚拟线程适合把大量等待网络、数据库或文件的任务写成直线代码,但它不会让一次 SQL 或一次 HTTP 调用本身变快。重新评估阻塞式服务时,应该把承载任务的线程数和保护下游资源的并发额度拆开。

如果服务的大部分时间都在等待,并且并发任务数较高,可以先把请求执行器改成每任务一个虚拟线程;数据库连接数、下游限额和 CPU 计算量仍要单独限制。迁移是否成功,要看吞吐、排队和资源利用率是否改善,而不是只看线程数量。
要点速览
  • 虚拟线程优化的是高并发等待型负载的吞吐,不是单请求延迟。
  • 不要用虚拟线程池限制数据库或下游服务,应该用信号量、连接池和明确的配额。
  • Java 25 的 JFR、线程转储和 VirtualThreadSchedulerMXBean 可以帮助确认迁移后的真实瓶颈。

先判断:阻塞是不是服务的主要工作

传统固定线程池把“平台线程很贵”和“下游最多允许多少并发”混在了一个数字里。例如线程池设为 200,可能只是因为机器无法长期承载更多平台线程,也可能是数据库连接池只有 200 个连接。这两个限制并不是一回事。

可以先按一次请求的时间拆分:CPU 执行占比、等待 HTTP 或 JDBC 的时间、排队等待连接的时间,以及序列化和日志时间。如果 CPU 已经接近核心数,虚拟线程不会凭空增加计算能力;如果请求大部分时间停在可阻塞的 I/O 上,增加可挂起的任务数才有意义。JEP 444 对这一点的表述很明确:虚拟线程追求的是 scale,而不是 speed。

观察到的现象优先处理方式不要误判为
大量请求等待网络,平台线程长期空闲或排队尝试每任务一个虚拟线程单次网络调用会变快
CPU 持续满载,任务主要做压缩或计算保持 CPU 并行度接近核心数虚拟线程能替代计算线程池
连接池耗尽、下游返回限流单独设置连接数和信号量把虚拟线程数量改小就解决了资源边界

把任务执行器改成每任务一个虚拟线程

Java 25 API 文档中的 Executors.newVirtualThreadPerTaskExecutor() 会为每个提交的任务创建一个虚拟线程,它不是把虚拟线程放进传统意义上的共享线程池。对请求、批量小任务或扇出调用来说,最小改法是保留 ExecutorService 的提交和等待语义,只替换执行器的来源。

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 每个外部调用对应一个独立任务,不复用昂贵的平台线程
    var user = executor.submit(() -> userClient.fetch(userId));
    var orders = executor.submit(() -> orderClient.fetch(userId));

    // 等待两个结果,异常沿着任务边界返回给请求处理代码
    return new Profile(user.get(), orders.get());
} catch (InterruptedException e) {
    // 保留中断标记,避免上层取消信号被吞掉
    Thread.currentThread().interrupt();
    throw new ServiceUnavailableException(e);
} catch (ExecutionException e) {
    // 生产代码应按 cause 分类记录下游错误
    throw new DownstreamException(e.getCause());
}

这里的关键不是把并发数调到很大,而是让一个请求在等待时释放 carrier。执行器用 try-with-resources 关闭时会等待已经提交的任务,因此特别适合一次请求内的有限扇出。任务本身仍要有超时、取消和异常分类,虚拟线程不会替你补上这些业务边界。

Java 25 虚拟线程每任务执行器与阻塞式 HTTP 和 JDBC 调用的操作输入示意图
图1:Java 25 虚拟线程每任务执行器的操作示意图;请求任务、等待型调用和执行边界均为原创解释性绘制,不是真实运行截图。

别再用线程池大小限制数据库和下游服务

迁移后最常见的回退是继续创建一个“虚拟线程池大小为 50”的包装器。这样既失去了大量挂起任务的伸缩空间,也没有表达真正的资源约束。更清楚的写法是:虚拟线程负责承载请求,信号量负责保护一个最多允许 50 个并发操作的下游。

private final Semaphore downstreamSlots = new Semaphore(50);

Result callDownstream(Request request) throws Exception {
    downstreamSlots.acquire(); // 只限制下游额度,不限制虚拟线程总数
    try {
        return httpClient.send(request); // 阻塞等待时,虚拟线程可以让出 carrier
    } finally {
        downstreamSlots.release(); // 无论成功或失败都归还额度
    }
}

数据库连接池、HTTP 客户端连接上限、消息系统配额和供应商限流都应有自己的指标。信号量也不是万能的:如果任务拿到许可后又做很长的 CPU 计算,应该缩小受保护区间;如果下游库已经内置连接池,则要确认外层信号量不会造成双重排队。

重新检查三个容易被忽略的边界

第一是 ThreadLocal。虚拟线程支持线程本地变量,但每个任务一个线程会改变“在线程池线程上缓存昂贵资源”的经济性。连接、会话或大对象不应因为换成虚拟线程而被每个请求各自复制,应该交给连接池、请求上下文或显式缓存。

第二是长时间阻塞的临界区和 native 调用。虚拟线程在某些场景下无法从 carrier 卸载;频繁且长时间的 pinning 会降低并发收益。JFR 的虚拟线程事件和 jdk.tracePinnedThreads 可以用来定位,而不是凭感觉把所有 synchronized 全部替换。

第三是观测方式。Java 25 提供的 VirtualThreadSchedulerMXBean 可以查看调度器目标并行度、carrier 数量和排队的虚拟线程数。迁移前后至少对比下表中的指标:

指标要回答的问题异常信号
请求吞吐与 p95/p99等待型并发是否真正转化为更多完成请求吞吐不变但尾延迟升高
数据库连接池等待瓶颈是否已经转移到数据库线程变多,连接等待更长
虚拟线程队列与 carrier 数调度器是否积压任务队列持续增长或 CPU 饱和
JFR pinned 事件是否有同步块或 native 调用卡住 carrier长时间、频繁出现 pinning
Java 25 虚拟线程调度器 JMX 与 JFR 观测指标的结果示意图
图2:用调度器队列、carrier 数量和 JFR pinned 事件复查迁移结果的示意图;面板数据为解释概念而绘制。

常见问题

虚拟线程能替代所有固定线程池吗?

不能。它适合承载大量等待型任务;CPU 密集型工作、定时调度、批量资源配额和有界队列仍需要明确的并行度或背压设计。

迁移后还需要连接池吗?

需要。虚拟线程不是数据库连接,也不会增加数据库能处理的并发量。连接池大小应按数据库和业务压测结果设置。

看到 synchronized 就必须改成 ReentrantLock 吗?

不必。只保护短小内存操作或只在启动阶段执行的同步块通常不值得改;优先调查会在同步区内进行长时间等待的热点。

怎样判断这次迁移值得保留?

用同一流量、同一依赖配额比较吞吐、尾延迟、CPU、连接池等待、队列长度和错误率。如果只是线程数下降而用户结果没有改善,就还没有证明迁移有效。

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