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

Java虚拟线程承载阻塞I/O服务的线程模型设计

来源:17golang原创

时间:2026-09-20 08:31:38 451浏览 收藏

Java 虚拟线程适合承载“请求很多、每个请求大部分时间都在等待”的服务。把一个请求放进 Executors.newVirtualThreadPerTaskExecutor() 后,数据库查询、HTTP 调用或 socket 读取发生阻塞时,运行时可以挂起虚拟线程并释放承载它的操作系统线程;但 CPU 密集计算不会因此变快,连接池、下游限流和内存也不会自动扩大。

要点速览
  • 使用 JDK 21 及以上,把虚拟线程按任务创建,不要把它们再放进固定大小线程池。
  • 虚拟线程数量与数据库连接数、HTTP 并发额度是两件事,有限资源要用信号量或资源池单独限流。
  • 排序、压缩、加密等 CPU 密集工作应交给有界的平台线程执行器;迁移后重点观察 pinning、队列长度和下游耗时。
实际落地时可以把请求处理保留为同步写法,把“每个请求一个线程”换成“每个任务一个虚拟线程”;只在等待型 I/O 周围放宽并发,在 CPU 计算和外部有限资源处设置独立边界。

先判断请求是在等待,还是在计算

虚拟线程的价值是提高并发承载量,不是让某一段 Java 代码跑得更快。一个请求如果大部分时间在等待数据库、HTTP、文件或队列,传统平台线程会长期占住操作系统线程;虚拟线程则可以在 JDK 支持的阻塞操作处暂停,把承载线程交给其他任务。相反,视频转码、复杂排序、密码计算等 CPU 密集工作会持续占用处理器,创建更多线程只会增加调度和上下文压力。

Java虚拟线程、平台线程承载和阻塞I/O释放关系的静态结构图
图1:虚拟线程在计算区运行于承载平台线程,进入阻塞 I/O 后可以暂停并释放承载线程;图中表达资源关系,不表示固定执行顺序。

用按任务创建的虚拟线程承载阻塞 I/O

JDK 21 已将虚拟线程作为正式能力提供。下面的最小服务骨架把每个请求交给一个虚拟线程,并通过 try 作用域在关闭执行器时等待已提交任务结束。代码中的 callRemoteService 可以替换成 JDBC、HTTP Client 或文件 API;示例重点是线程模型,而不是某个框架的路由写法。

import java.time.Duration;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;

static String handleRequest(String request) throws Exception {
    // 每个任务创建一个虚拟线程,避免把虚拟线程当作需要复用的昂贵资源。
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var remote = executor.submit(() -> callRemoteService(request));
        // get() 只会让当前虚拟线程等待,不应把等待误判为 CPU 计算。
        return remote.get();
    } catch (InterruptedException e) {
        // 保留中断标记,让上层能够停止取消后的请求处理。
        Thread.currentThread().interrupt();
        throw e;
    } catch (ExecutionException e) {
        // 对外抛出业务异常时保留原始 cause,便于记录真正的 I/O 错误。
        throw new IllegalStateException("下游调用失败", e.getCause());
    }
}

static String callRemoteService(String request) throws InterruptedException {
    // 这里代表一个可能阻塞的网络或数据库调用;生产代码应设置超时。
    Thread.sleep(Duration.ofMillis(20));
    return "processed:" + request;
}

不要在每个请求里创建一个长期存活的共享执行器。更常见的做法是把执行器作为应用组件创建并复用,任务提交仍然是“一任务一虚拟线程”。如果业务希望并发地调用多个下游服务,可在同一个作用域提交多个任务,再集中等待和处理异常。

给数据库和下游接口加独立并发闸门

虚拟线程便宜,不代表数据库连接、第三方配额或文件句柄无限。把固定线程池改成虚拟线程后,最容易出现的回归是请求进来了很多,但所有请求同时争抢一个只有几十个连接的资源池。此时不应重新把虚拟线程池改小,而应在资源边界使用 Semaphore 或让连接池本身承担排队。

import java.util.concurrent.Semaphore;

final class DownstreamGate {
    // permits 表示允许同时占用的下游名额,而不是虚拟线程总数。
    private final Semaphore permits = new Semaphore(32);

    String query(String key) throws Exception {
        permits.acquire();
        try {
            // 只有真正占用下游连接的区间才持有许可,减少无谓排队。
            return blockingQuery(key);
        } finally {
            // 无论查询成功、失败还是取消,都必须归还名额。
            permits.release();
        }
    }
}
Java虚拟线程请求层、Semaphore闸门、连接池和CPU执行器之间边界的静态关系图
图2:请求可以由大量虚拟线程承载,但数据库连接、下游配额和 CPU 执行器各有自己的边界,需要分别配置。

CPU 密集阶段使用有界平台线程执行器

如果请求先读取数据,再做大规模排序或压缩,可以让虚拟线程负责前后的 I/O,把计算段提交到大小接近处理器数量的有界执行器。这样做的目的不是追求一个固定数字,而是避免无界请求把大量 CPU 任务同时排入内存;具体大小应根据压测、任务时长和业务延迟目标调整。

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

final class Workloads implements AutoCloseable {
    private final ExecutorService cpuExecutor =
        Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    String process(String key) throws Exception {
        // 外层可运行在虚拟线程,阻塞读取完成后才进入 CPU 阶段。
        var input = blockingRead(key);
        var future = cpuExecutor.submit(() -> expensiveTransform(input));
        // 等待计算结果时保留中断语义,不吞掉取消信号。
        return future.get();
    }

    @Override
    public void close() {
        // 应用退出时关闭平台线程执行器,避免遗留非守护线程。
        cpuExecutor.shutdown();
    }
}

迁移后检查锁、资源和可观测性边界

迁移并不是把线程工厂名替换掉就结束。长时间阻塞的 synchronized 区域可能使虚拟线程 pin 住承载平台线程;应缩小锁保护范围,并用 JFR 的虚拟线程事件观察实际影响。线程本地变量也要重新评估:虚拟线程不复用,不能再依靠“同一个线程反复拿到同一个昂贵连接”来做资源缓存。

工作负载建议模型边界检查
大量 HTTP、JDBC 或文件等待按任务创建虚拟线程超时、取消、连接池和下游配额
排序、压缩、加密等计算有界平台线程执行器处理器利用率、队列和尾延迟
同一共享状态的复合更新缩小锁范围或改为消息串行化避免在锁内执行阻塞 I/O
迁移后的异常排查JFR、日志关联和压测pinning、内存、取消和资源泄漏

常见问题

虚拟线程是否应该放进固定大小线程池?

通常不应该。虚拟线程应按任务创建;如果要限制数据库或第三方服务并发,应限制那个具体资源,而不是用线程池伪装资源闸门。

阻塞 I/O 一定会释放平台线程吗?

JDK 支持的阻塞 I/O 通常可以挂起虚拟线程,但长时间持有某些锁、调用不配合的本地代码或外部库时,仍要通过 JFR 和压测确认是否出现 pinning。

虚拟线程会降低单次 CPU 任务耗时吗?

不会。它主要改善高并发等待型任务的吞吐;CPU 密集任务仍受处理器核心数限制,应该使用有界的计算执行器。

落地时可以先选择一条以 I/O 等待为主的接口,记录连接池利用率、P95/P99 延迟、CPU 和取消率,再用虚拟线程替换原来的请求执行模型。只要把请求并发、外部资源并发和 CPU 并发拆成三个可以独立观测的数字,线程模型就不容易在扩容时失控。

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