Java 虚拟线程:从基础使用到任务执行器实践

摘要:这篇文章集中回答五个问题:虚拟线程有什么作用、应该怎么使用、使用时要注意什么、容量与业务并发如何控制,以及它相对传统线程池改进了什么。示例基于 JDK 21,并贯穿一个完整的本地任务执行器。

先理解虚拟线程

它的作用是什么

传统 Java 平台线程通常对应一个操作系统线程。线程需要独立的栈空间,创建、销毁和上下文切换都有成本,所以服务端常用固定大小的线程池来控制数量。这个模型对 CPU 密集型工作很自然,但对大量阻塞 I/O 不够灵活:线程在等待数据库、HTTP 响应或文件操作时,仍然占着一个平台线程,线程池很快就会被“正在等待”的任务填满。

所以虚拟线程的作用不是“让任务跑得更快”,而是让大量会等待的任务可以用更低的线程成本同时挂起。它把“一个请求长期占着一个平台线程”的约束,变成“一个请求保留自己的执行上下文,等待时暂时不占 carrier thread”。

虚拟线程是由 JVM 管理的轻量级线程。它仍然使用熟悉的 ThreadRunnableFuture 和阻塞式 API,但单个虚拟线程的创建成本和栈内存占用低得多,因此可以让每个请求或任务拥有自己的执行上下文,而不必把所有工作压进少量平台线程里。它主要改善的是高并发阻塞场景下的可扩展性和代码模型,不是让 CPU 计算本身变快。

它是怎么工作的

虚拟线程不会一一对应地绑定操作系统线程。JVM 会把它们调度到少量平台线程(也叫 carrier thread)上运行:

  1. 虚拟线程开始执行 Java 代码时,JVM 把它挂载到一个 carrier thread 上。
  2. 遇到可挂起的阻塞操作时,虚拟线程会被暂停并让出 carrier thread,JVM 可以马上调度另一个虚拟线程。
  3. I/O 就绪后,原来的虚拟线程再恢复执行,不要求回到同一个 carrier thread。

因此,“可以创建很多虚拟线程”与“系统可以同时处理很多外部请求”是两件事。数据库连接数、下游 QPS、堆内存、文件句柄等资源仍然有限。另一个需要留意的点是 pinning:长时间持有 synchronized 监视器,或进入某些 native/外部调用时,虚拟线程可能暂时无法让出 carrier thread,这类代码仍需要单独评估。

虚拟线程在 carrier thread 上挂载、阻塞时让出、就绪后恢复

Caption: 虚拟线程降低的是等待期间占用平台线程的成本;它不会替下游资源做容量规划。

常见使用方式

使用虚拟线程通常不需要改写业务代码。JDK 21 及之后,可以直接为单个任务启动虚拟线程:

Thread thread = Thread.startVirtualThread(() -> {
    fetchFromDatabase();
});
thread.join();

更常见的是使用“每个任务一个虚拟线程”的执行器,让生命周期由 try-with-resources 管理:

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<Result> future = executor.submit(this::loadResult);
    Result result = future.get();
}

如果需要统一命名或接入自定义线程工厂,可以使用 Thread.ofVirtual()

ThreadFactory factory = Thread.ofVirtual()
    .name("local-worker-", 0)
    .factory();
ExecutorService executor = Executors.newThreadPerTaskExecutor(factory);

这里有一个容易混淆的区别:虚拟线程执行器通常不应该像传统线程池那样复用线程或堆积任务队列。任务来了就创建一个虚拟线程,资源上限应该通过数据库连接池、信号量、速率限制等独立边界表达。

什么时候值得使用

虚拟线程适合单个任务会等待外部资源、并且并发量较高的服务,例如 HTTP 调用、数据库访问、消息消费和文件 I/O。它可以保留同步代码的直观控制流,同时减少“一个阻塞请求占用一个平台线程”的压力。

它不适合用来掩盖 CPU 饱和、无限增长的重试队列或下游服务配额不足。CPU 密集型任务仍应按处理器能力控制并发;外部 API 仍需要超时和 rate limit;大对象任务仍需要内存预算。明确这些边界后,再把虚拟线程接入本地 worker,收益才是可控的。

与传统线程池相比,改进在哪里

虚拟线程不是把固定线程池“扩容到更大”,而是把执行模型和资源容量拆开:

维度 固定平台线程池 虚拟线程执行器
一个任务的成本 需要长期占用一个平台线程 每个任务一个轻量线程,阻塞时可让出 carrier
阻塞 I/O 很快耗尽 worker,任务排在队列里 可以承载更多等待中的任务
排队位置 通常隐藏在线程池队列 默认没有任务数量上限,需要显式准入
容量控制 常把池大小当作所有资源的代理 用 semaphore、连接池和 rate limit 分别控制
CPU 密集任务 按 CPU 核数设置池大小 仍然按 CPU 核数限并发,不会因线程变轻而变快

传统线程池与虚拟线程执行器的容量边界对比

Caption: 传统线程池把等待和容量混在一个池子里;虚拟线程把执行方式与 worker、数据库和 HTTP 的边界拆开。

因此,“更好”只针对阻塞 I/O 并发和编程模型:代码仍是同步写法,但不必为了等待请求而长期占住平台线程。它不是吞吐量保证,也不是取消容量规划。

ExecutorService platformPool = Executors.newFixedThreadPool(32);
ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();

第一行把“32”同时当成线程数、排队入口和容量边界;第二行只改变任务的执行方式,不回答“最多接收多少任务”。这正是后面还需要加准入控制的原因。

使用虚拟线程时要注意什么

  1. 先确认任务是阻塞 I/O 主导。 CPU 密集型任务应按处理器数量限并发;虚拟线程不会增加 CPU 周期。
  2. 不要把虚拟线程再放进固定大小的虚拟线程池。 newVirtualThreadPerTaskExecutor() 的设计就是每个任务创建一个虚拟线程,资源上限应放在任务入口或具体依赖上。
  3. 检查 pinning。 长时间持有 synchronized 监视器、执行 native/foreign 调用时,虚拟线程可能占住 carrier;这类临界区应缩短,并用 JFR 或线程 dump 验证。
  4. 为外部调用设置 timeout。 虚拟线程让等待更便宜,但不会让失联的数据库或 HTTP 服务自动返回。
  5. 谨慎使用 ThreadLocal。 单个虚拟线程便宜,不代表上下文对象、请求体和日志 MDC 没有内存成本。
  6. 把取消当成业务协议。 handler、数据库驱动和 HTTP 客户端都要能响应中断或自己的 timeout,关闭时才不会留下“线程已停、请求还在”的半完成状态。

没有固定线程池时,任务是怎么被控制的

这里容易产生一个误解:不用固定大小的平台线程池,并不等于没有执行器。Executors.newVirtualThreadPerTaskExecutor() 仍然是一个 ExecutorService,只是它的职责变成“收到一个任务,就启动一个虚拟线程”。虚拟线程执行完任务就结束,JVM 内部的 carrier thread 负责被多个虚拟线程反复调度和复用。

这个执行器本身没有“线程数达到上限”这一状态,因此它也不会像 ThreadPoolExecutor 那样因为队列满而触发拒绝。它通常只会在 shutdown() 之后拒绝提交。要控制任务数量,必须在 delegate.execute() 之前加一道准入:

if (!permits.tryAcquire()) {
    rejections.increment();
    throw new RejectedExecutionException("worker is full");
}

try {
    delegate.execute(() -> {
        try {
            task.run();
        } finally {
            permits.release();
        }
    });
} catch (RuntimeException submitFailure) {
    permits.release();
    throw submitFailure;
}

这段顺序决定了三件事:拿不到 permit 时根本不会创建虚拟线程;任务结束、抛异常或取消时都会归还 permit;线程创建本身失败时也不会泄漏容量。也就是说,拒绝策略不在虚拟线程对象里,而在任务进入执行器前的准入层。

如果业务需要排队,队列应该放在更明确的位置:例如可靠消息队列、持久化重试表或一个有界的本地队列。不要把“无限提交虚拟线程”当成队列使用,否则只是把排队从线程池内存搬到了大量任务对象和阻塞调用上。

下面把这个执行模型接入一个真实的 dispatch 入口,再分别解决任务并发和资源容量。

把虚拟线程接入一个真实的任务入口

前面的例子只解决了“怎样启动虚拟线程”。在调度系统里,任务不会直接从 main 方法启动,而是经过一个 dispatch 入口。把这个入口写出来,虚拟线程和并发控制的关系就清楚了:

enum ConcurrencyPolicy { ALLOW, FORBID }

record LocalJob(String id, ConcurrencyPolicy policy, Runnable action) {}

final class LocalWorker {
    private final Executor executor;
    private final ConcurrentHashMap<String, AtomicBoolean> running =
        new ConcurrentHashMap<>();

    LocalWorker(Executor executor) {
        this.executor = executor;
    }

    CompletionStage<Void> dispatch(LocalJob job) {
        AtomicBoolean state = running.computeIfAbsent(
            job.id(), ignored -> new AtomicBoolean());

        if (job.policy() == ConcurrencyPolicy.FORBID
                && !state.compareAndSet(false, true)) {
            return CompletableFuture.failedFuture(
                new IllegalStateException("job is already running: " + job.id()));
        }

        CompletableFuture<Void> completion = new CompletableFuture<>();
        try {
            executor.execute(() -> {
                try {
                    job.action().run();
                    completion.complete(null);
                } catch (Throwable error) {
                    completion.completeExceptionally(error);
                } finally {
                    if (job.policy() == ConcurrencyPolicy.FORBID) {
                        state.set(false);
                    }
                }
            });
            return completion;
        } catch (RuntimeException submitFailure) {
            if (job.policy() == ConcurrencyPolicy.FORBID) {
                state.set(false);
            }
            return CompletableFuture.failedFuture(submitFailure);
        }
    }

}

这段代码对应一个很具体的行为:

  • ALLOW 任务每次触发都会创建一个虚拟线程;
  • FORBID 任务用 compareAndSet(false, true) 抢占运行标记,同一任务的第二次触发会立即失败;
  • 任务正常结束或抛出异常时,finally 会清除运行标记;
  • 执行器提交失败时也会回滚标记,否则任务没有执行过,却会一直被当成“正在运行”。

任务级策略和全局容量在同一个 dispatch 入口依次生效

Caption: 任务先通过自己的并发策略,再进入虚拟线程执行器;全局容量拒绝时,任务级状态必须回滚。

先用 JDK 自带的执行器运行这段代码,验证的是最基础的虚拟线程用法:

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    LocalWorker worker = new LocalWorker(executor);
    worker.dispatch(new LocalJob(
        "refresh-cache", ConcurrencyPolicy.FORBID, this::refreshCache));
}

此时仍有一个缺口:newVirtualThreadPerTaskExecutor() 对任务数量没有上限。假设调度器每秒发现 1,000 个到期任务,而每个任务都要等待数据库连接,那么系统会创建 1,000 个虚拟线程。虚拟线程本身很轻,但连接池、任务上下文和结果缓冲并不轻。

线程容量如何控制:限制在途任务,不限制虚拟线程创建

并发控制不是替换虚拟线程,而是限制 dispatch 可以接收多少个任务。下面的执行器仍然“每个通过准入的任务创建一个虚拟线程”,只是在线程创建前增加了 Semaphore

final class BoundedVirtualThreadExecutor implements Executor, AutoCloseable {
    private final int maxConcurrency;
    private final Semaphore permits;
    private final ExecutorService delegate;
    private final AtomicInteger active = new AtomicInteger();
    private final LongAdder rejections = new LongAdder();

    BoundedVirtualThreadExecutor(int maxConcurrency) {
        if (maxConcurrency < 1) {
            throw new IllegalArgumentException("maxConcurrency must be positive");
        }
        this.maxConcurrency = maxConcurrency;
        this.permits = new Semaphore(maxConcurrency);
        this.delegate = Executors.newThreadPerTaskExecutor(
            Thread.ofVirtual().name("local-worker-", 0).factory());
    }

    @Override
    public void execute(Runnable task) {
        if (delegate.isShutdown() || !permits.tryAcquire()) {
            rejections.increment();
            throw new RejectedExecutionException(
                "local worker is full: " + maxConcurrency);
        }

        active.incrementAndGet();
        try {
            delegate.execute(() -> {
                try {
                    task.run();
                } finally {
                    active.decrementAndGet();
                    permits.release();
                }
            });
        } catch (RuntimeException submitFailure) {
            active.decrementAndGet();
            permits.release();
            throw submitFailure;
        }
    }

    int active() { return active.get(); }
    long rejected() { return rejections.sum(); }
    int capacity() { return maxConcurrency; }

    @Override
    public void close() {
        delegate.close();
    }
}

然后只替换 LocalWorker 注入的执行器,任务代码和 dispatch 入口都不变:

try (var executor = new BoundedVirtualThreadExecutor(16)) {
    LocalWorker worker = new LocalWorker(executor);
    worker.dispatch(new LocalJob(
        "refresh-cache", ConcurrencyPolicy.FORBID, this::refreshCache));
}

现在这条链路是闭合的:任务先按业务策略判断是否允许执行,再尝试拿全局容量;容量拿不到时不会创建虚拟线程,而是返回 RejectedExecutionException。上层可以把它写入可靠队列并稍后重试,也可以记录节点过载,而不是让任务在内存里无限等待。

并发处理如何控制:四种限制不要混在一起

这里的“并发”至少有四个对象,应该分别建模:

控制对象 要解决的问题 常用手段
单个任务定义 同一个任务能否同时运行两次 FORBID + AtomicBoolean、分布式锁或幂等键
当前节点 这个进程同时承接多少个任务 Semaphore,满时立即拒绝
下游资源 数据库或 HTTP 服务能承受多少并发 连接池、资源专用 semaphore
时间速率 一秒内允许发出多少请求 token bucket、rate limiter

例如一个任务既访问数据库又调用外部接口,不能只因为 worker 还有空位就直接放行:

CompletionStage<Void> runWithDatabasePermit(Runnable job) {
    if (!workerPermits.tryAcquire()) {
        return CompletableFuture.failedFuture(
            new RejectedExecutionException("worker capacity"));
    }
    if (!databasePermits.tryAcquire()) {
        workerPermits.release();
        return CompletableFuture.failedFuture(
            new RejectedExecutionException("database capacity"));
    }

    CompletableFuture<Void> completion = new CompletableFuture<>();
    try {
        virtualExecutor.execute(() -> {
            try {
                job.run();
                completion.complete(null);
            } catch (Throwable error) {
                completion.completeExceptionally(error);
            } finally {
                databasePermits.release();
                workerPermits.release();
            }
        });
        return completion;
    } catch (RuntimeException submitFailure) {
        databasePermits.release();
        workerPermits.release();
        throw submitFailure;
    }
}

这段代码表达了一个重要原则:虚拟线程数量、任务并发数、数据库连接数和接口 QPS 不是同一个指标。哪个资源最紧,哪个就要在对应入口设置边界;否则单一的 maxConcurrency 只能制造一种“看起来有限”的假象。

用一个数字说明上限怎么来

假设节点的数据库连接池最大连接数是 20,其中 4 个连接要留给管理接口和其他业务;同一任务还会调用一个只允许 10 个并发请求的外部服务。那么这个 worker 的初始上限不应配置成 256,而应先取 min(20 - 4, 10) = 10。如果任务分成“只访问数据库”和“只访问外部服务”两类,可以拆成两个执行器分别配置,而不是用一个大池子掩盖最紧的依赖。

调参时至少记录下面三个值:

worker.active();    // 当前已经进入虚拟线程的任务数
worker.capacity();  // permit 总数
worker.rejected();  // 因容量或关闭而拒绝的提交数

如果 active 长期等于 capacityrejected 持续增长,说明到达速率超过处理速率;如果 active 很低但仍大量拒绝,优先检查 permit 是否泄漏或执行器是否已经关闭。只看 CPU 利用率无法区分这两种情况,因为 I/O 等待可能让 CPU 保持很低。

关闭和测试也要围绕这个入口

关闭时先停止新任务,再等待在途任务收尾,并给最终中断一个时间预算:

void close(Duration timeout) {
    delegate.shutdown();
    try {
        if (!delegate.awaitTermination(timeout.toMillis(), MILLISECONDS)) {
            delegate.shutdownNow();
        }
    } catch (InterruptedException interrupted) {
        delegate.shutdownNow();
        Thread.currentThread().interrupt();
    }
}

测试不需要压出一个漂亮的吞吐数字,先验证入口的不变量:

@Test
void rejectsAfterCapacityIsFull() throws Exception {
    try (var worker = new BoundedVirtualThreadExecutor(2)) {
        var release = new CountDownLatch(1);
        var done = new CountDownLatch(2);
        worker.execute(() -> {
            try { release.await(); }
            catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally { done.countDown(); }
        });
        worker.execute(() -> {
            try { release.await(); }
            catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally { done.countDown(); }
        });

        assertThrows(RejectedExecutionException.class,
            () -> worker.execute(() -> {}));

        release.countDown();
        assertTrue(done.await(1, TimeUnit.SECONDS));
        assertEquals(0, worker.active());
    }
}

@Test
void forbidJobAdmitsOnlyOneRun() throws Exception {
    try (var executor = new BoundedVirtualThreadExecutor(4)) {
        var worker = new LocalWorker(executor);
        var release = new CountDownLatch(1);
        var job = new LocalJob("rebuild", ConcurrencyPolicy.FORBID, () -> {
            try { release.await(); }
            catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        worker.dispatch(job).toCompletableFuture();
        assertThrows(ExecutionException.class,
            () -> worker.dispatch(job).toCompletableFuture().get());

        release.countDown();
    }
}

这两个测试分别证明了全局容量和任务级语义。它们也提醒我们,虚拟线程的正确接入点是 dispatch:所有任务都从这里进入,业务互斥、容量准入、拒绝处理和指标才能保持同一条生命周期。

结语

虚拟线程的核心作用,是降低大量阻塞任务等待时占用平台线程的成本,让同步代码也能承载更高的 I/O 并发。推荐的使用方式是“每个任务一个虚拟线程”,而不是重新设计一个固定大小的虚拟线程池。

落到工程里,需要把四件事分开:虚拟线程负责怎么执行Semaphore 负责节点同时接多少任务;连接池和 rate limiter 负责下游能承受多少压力FORBID、锁或幂等键负责同一业务能否并发。同时检查 pinning、ThreadLocal、timeout、中断和关闭流程。

相较传统线程池,真正的改进不是“线程更多”,而是阻塞等待不再长期占用平台线程,并且执行模型与资源边界可以分别配置。对于 CPU 密集型工作或缺少下游容量控制的系统,虚拟线程不会自动带来更高吞吐。

摘要:这篇文章集中回答五个问题:虚拟线程有什么作用、应该怎么使用、使用时要注意什么、容量与业务并发如何控制,以及它相对传统线程池改进了什么。示例基于 JDK 21,并贯穿一个完整的本地任务执行器。

先理解虚拟线程

它的作用是什么

传统 Java 平台线程通常对应一个操作系统线程。线程需要独立的栈空间,创建、销毁和上下文切换都有成本,所以服务端常用固定大小的线程池来控制数量。这个模型对 CPU 密集型工作很自然,但对大量阻塞 I/O 不够灵活:线程在等待数据库、HTTP 响应或文件操作时,仍然占着一个平台线程,线程池很快就会被“正在等待”的任务填满。

所以虚拟线程的作用不是“让任务跑得更快”,而是让大量会等待的任务可以用更低的线程成本同时挂起。它把“一个请求长期占着一个平台线程”的约束,变成“一个请求保留自己的执行上下文,等待时暂时不占 carrier thread”。

虚拟线程是由 JVM 管理的轻量级线程。它仍然使用熟悉的 ThreadRunnableFuture 和阻塞式 API,但单个虚拟线程的创建成本和栈内存占用低得多,因此可以让每个请求或任务拥有自己的执行上下文,而不必把所有工作压进少量平台线程里。它主要改善的是高并发阻塞场景下的可扩展性和代码模型,不是让 CPU 计算本身变快。

它是怎么工作的

虚拟线程不会一一对应地绑定操作系统线程。JVM 会把它们调度到少量平台线程(也叫 carrier thread)上运行:

  1. 虚拟线程开始执行 Java 代码时,JVM 把它挂载到一个 carrier thread 上。
  2. 遇到可挂起的阻塞操作时,虚拟线程会被暂停并让出 carrier thread,JVM 可以马上调度另一个虚拟线程。
  3. I/O 就绪后,原来的虚拟线程再恢复执行,不要求回到同一个 carrier thread。

因此,“可以创建很多虚拟线程”与“系统可以同时处理很多外部请求”是两件事。数据库连接数、下游 QPS、堆内存、文件句柄等资源仍然有限。另一个需要留意的点是 pinning:长时间持有 synchronized 监视器,或进入某些 native/外部调用时,虚拟线程可能暂时无法让出 carrier thread,这类代码仍需要单独评估。

虚拟线程在 carrier thread 上挂载、阻塞时让出、就绪后恢复

Caption: 虚拟线程降低的是等待期间占用平台线程的成本;它不会替下游资源做容量规划。

常见使用方式

使用虚拟线程通常不需要改写业务代码。JDK 21 及之后,可以直接为单个任务启动虚拟线程:

Thread thread = Thread.startVirtualThread(() -> {
    fetchFromDatabase();
});
thread.join();

更常见的是使用“每个任务一个虚拟线程”的执行器,让生命周期由 try-with-resources 管理:

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<Result> future = executor.submit(this::loadResult);
    Result result = future.get();
}

如果需要统一命名或接入自定义线程工厂,可以使用 Thread.ofVirtual()

ThreadFactory factory = Thread.ofVirtual()
    .name("local-worker-", 0)
    .factory();
ExecutorService executor = Executors.newThreadPerTaskExecutor(factory);

这里有一个容易混淆的区别:虚拟线程执行器通常不应该像传统线程池那样复用线程或堆积任务队列。任务来了就创建一个虚拟线程,资源上限应该通过数据库连接池、信号量、速率限制等独立边界表达。

什么时候值得使用

虚拟线程适合单个任务会等待外部资源、并且并发量较高的服务,例如 HTTP 调用、数据库访问、消息消费和文件 I/O。它可以保留同步代码的直观控制流,同时减少“一个阻塞请求占用一个平台线程”的压力。

它不适合用来掩盖 CPU 饱和、无限增长的重试队列或下游服务配额不足。CPU 密集型任务仍应按处理器能力控制并发;外部 API 仍需要超时和 rate limit;大对象任务仍需要内存预算。明确这些边界后,再把虚拟线程接入本地 worker,收益才是可控的。

与传统线程池相比,改进在哪里

虚拟线程不是把固定线程池“扩容到更大”,而是把执行模型和资源容量拆开:

维度 固定平台线程池 虚拟线程执行器
一个任务的成本 需要长期占用一个平台线程 每个任务一个轻量线程,阻塞时可让出 carrier
阻塞 I/O 很快耗尽 worker,任务排在队列里 可以承载更多等待中的任务
排队位置 通常隐藏在线程池队列 默认没有任务数量上限,需要显式准入
容量控制 常把池大小当作所有资源的代理 用 semaphore、连接池和 rate limit 分别控制
CPU 密集任务 按 CPU 核数设置池大小 仍然按 CPU 核数限并发,不会因线程变轻而变快

传统线程池与虚拟线程执行器的容量边界对比

Caption: 传统线程池把等待和容量混在一个池子里;虚拟线程把执行方式与 worker、数据库和 HTTP 的边界拆开。

因此,“更好”只针对阻塞 I/O 并发和编程模型:代码仍是同步写法,但不必为了等待请求而长期占住平台线程。它不是吞吐量保证,也不是取消容量规划。

ExecutorService platformPool = Executors.newFixedThreadPool(32);
ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();

第一行把“32”同时当成线程数、排队入口和容量边界;第二行只改变任务的执行方式,不回答“最多接收多少任务”。这正是后面还需要加准入控制的原因。

使用虚拟线程时要注意什么

  1. 先确认任务是阻塞 I/O 主导。 CPU 密集型任务应按处理器数量限并发;虚拟线程不会增加 CPU 周期。
  2. 不要把虚拟线程再放进固定大小的虚拟线程池。 newVirtualThreadPerTaskExecutor() 的设计就是每个任务创建一个虚拟线程,资源上限应放在任务入口或具体依赖上。
  3. 检查 pinning。 长时间持有 synchronized 监视器、执行 native/foreign 调用时,虚拟线程可能占住 carrier;这类临界区应缩短,并用 JFR 或线程 dump 验证。
  4. 为外部调用设置 timeout。 虚拟线程让等待更便宜,但不会让失联的数据库或 HTTP 服务自动返回。
  5. 谨慎使用 ThreadLocal。 单个虚拟线程便宜,不代表上下文对象、请求体和日志 MDC 没有内存成本。
  6. 把取消当成业务协议。 handler、数据库驱动和 HTTP 客户端都要能响应中断或自己的 timeout,关闭时才不会留下“线程已停、请求还在”的半完成状态。

没有固定线程池时,任务是怎么被控制的

这里容易产生一个误解:不用固定大小的平台线程池,并不等于没有执行器。Executors.newVirtualThreadPerTaskExecutor() 仍然是一个 ExecutorService,只是它的职责变成“收到一个任务,就启动一个虚拟线程”。虚拟线程执行完任务就结束,JVM 内部的 carrier thread 负责被多个虚拟线程反复调度和复用。

这个执行器本身没有“线程数达到上限”这一状态,因此它也不会像 ThreadPoolExecutor 那样因为队列满而触发拒绝。它通常只会在 shutdown() 之后拒绝提交。要控制任务数量,必须在 delegate.execute() 之前加一道准入:

if (!permits.tryAcquire()) {
    rejections.increment();
    throw new RejectedExecutionException("worker is full");
}

try {
    delegate.execute(() -> {
        try {
            task.run();
        } finally {
            permits.release();
        }
    });
} catch (RuntimeException submitFailure) {
    permits.release();
    throw submitFailure;
}

这段顺序决定了三件事:拿不到 permit 时根本不会创建虚拟线程;任务结束、抛异常或取消时都会归还 permit;线程创建本身失败时也不会泄漏容量。也就是说,拒绝策略不在虚拟线程对象里,而在任务进入执行器前的准入层。

如果业务需要排队,队列应该放在更明确的位置:例如可靠消息队列、持久化重试表或一个有界的本地队列。不要把“无限提交虚拟线程”当成队列使用,否则只是把排队从线程池内存搬到了大量任务对象和阻塞调用上。

下面把这个执行模型接入一个真实的 dispatch 入口,再分别解决任务并发和资源容量。

把虚拟线程接入一个真实的任务入口

前面的例子只解决了“怎样启动虚拟线程”。在调度系统里,任务不会直接从 main 方法启动,而是经过一个 dispatch 入口。把这个入口写出来,虚拟线程和并发控制的关系就清楚了:

enum ConcurrencyPolicy { ALLOW, FORBID }

record LocalJob(String id, ConcurrencyPolicy policy, Runnable action) {}

final class LocalWorker {
    private final Executor executor;
    private final ConcurrentHashMap<String, AtomicBoolean> running =
        new ConcurrentHashMap<>();

    LocalWorker(Executor executor) {
        this.executor = executor;
    }

    CompletionStage<Void> dispatch(LocalJob job) {
        AtomicBoolean state = running.computeIfAbsent(
            job.id(), ignored -> new AtomicBoolean());

        if (job.policy() == ConcurrencyPolicy.FORBID
                && !state.compareAndSet(false, true)) {
            return CompletableFuture.failedFuture(
                new IllegalStateException("job is already running: " + job.id()));
        }

        CompletableFuture<Void> completion = new CompletableFuture<>();
        try {
            executor.execute(() -> {
                try {
                    job.action().run();
                    completion.complete(null);
                } catch (Throwable error) {
                    completion.completeExceptionally(error);
                } finally {
                    if (job.policy() == ConcurrencyPolicy.FORBID) {
                        state.set(false);
                    }
                }
            });
            return completion;
        } catch (RuntimeException submitFailure) {
            if (job.policy() == ConcurrencyPolicy.FORBID) {
                state.set(false);
            }
            return CompletableFuture.failedFuture(submitFailure);
        }
    }

}

这段代码对应一个很具体的行为:

  • ALLOW 任务每次触发都会创建一个虚拟线程;
  • FORBID 任务用 compareAndSet(false, true) 抢占运行标记,同一任务的第二次触发会立即失败;
  • 任务正常结束或抛出异常时,finally 会清除运行标记;
  • 执行器提交失败时也会回滚标记,否则任务没有执行过,却会一直被当成“正在运行”。

任务级策略和全局容量在同一个 dispatch 入口依次生效

Caption: 任务先通过自己的并发策略,再进入虚拟线程执行器;全局容量拒绝时,任务级状态必须回滚。

先用 JDK 自带的执行器运行这段代码,验证的是最基础的虚拟线程用法:

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    LocalWorker worker = new LocalWorker(executor);
    worker.dispatch(new LocalJob(
        "refresh-cache", ConcurrencyPolicy.FORBID, this::refreshCache));
}

此时仍有一个缺口:newVirtualThreadPerTaskExecutor() 对任务数量没有上限。假设调度器每秒发现 1,000 个到期任务,而每个任务都要等待数据库连接,那么系统会创建 1,000 个虚拟线程。虚拟线程本身很轻,但连接池、任务上下文和结果缓冲并不轻。

线程容量如何控制:限制在途任务,不限制虚拟线程创建

并发控制不是替换虚拟线程,而是限制 dispatch 可以接收多少个任务。下面的执行器仍然“每个通过准入的任务创建一个虚拟线程”,只是在线程创建前增加了 Semaphore

final class BoundedVirtualThreadExecutor implements Executor, AutoCloseable {
    private final int maxConcurrency;
    private final Semaphore permits;
    private final ExecutorService delegate;
    private final AtomicInteger active = new AtomicInteger();
    private final LongAdder rejections = new LongAdder();

    BoundedVirtualThreadExecutor(int maxConcurrency) {
        if (maxConcurrency < 1) {
            throw new IllegalArgumentException("maxConcurrency must be positive");
        }
        this.maxConcurrency = maxConcurrency;
        this.permits = new Semaphore(maxConcurrency);
        this.delegate = Executors.newThreadPerTaskExecutor(
            Thread.ofVirtual().name("local-worker-", 0).factory());
    }

    @Override
    public void execute(Runnable task) {
        if (delegate.isShutdown() || !permits.tryAcquire()) {
            rejections.increment();
            throw new RejectedExecutionException(
                "local worker is full: " + maxConcurrency);
        }

        active.incrementAndGet();
        try {
            delegate.execute(() -> {
                try {
                    task.run();
                } finally {
                    active.decrementAndGet();
                    permits.release();
                }
            });
        } catch (RuntimeException submitFailure) {
            active.decrementAndGet();
            permits.release();
            throw submitFailure;
        }
    }

    int active() { return active.get(); }
    long rejected() { return rejections.sum(); }
    int capacity() { return maxConcurrency; }

    @Override
    public void close() {
        delegate.close();
    }
}

然后只替换 LocalWorker 注入的执行器,任务代码和 dispatch 入口都不变:

try (var executor = new BoundedVirtualThreadExecutor(16)) {
    LocalWorker worker = new LocalWorker(executor);
    worker.dispatch(new LocalJob(
        "refresh-cache", ConcurrencyPolicy.FORBID, this::refreshCache));
}

现在这条链路是闭合的:任务先按业务策略判断是否允许执行,再尝试拿全局容量;容量拿不到时不会创建虚拟线程,而是返回 RejectedExecutionException。上层可以把它写入可靠队列并稍后重试,也可以记录节点过载,而不是让任务在内存里无限等待。

并发处理如何控制:四种限制不要混在一起

这里的“并发”至少有四个对象,应该分别建模:

控制对象 要解决的问题 常用手段
单个任务定义 同一个任务能否同时运行两次 FORBID + AtomicBoolean、分布式锁或幂等键
当前节点 这个进程同时承接多少个任务 Semaphore,满时立即拒绝
下游资源 数据库或 HTTP 服务能承受多少并发 连接池、资源专用 semaphore
时间速率 一秒内允许发出多少请求 token bucket、rate limiter

例如一个任务既访问数据库又调用外部接口,不能只因为 worker 还有空位就直接放行:

CompletionStage<Void> runWithDatabasePermit(Runnable job) {
    if (!workerPermits.tryAcquire()) {
        return CompletableFuture.failedFuture(
            new RejectedExecutionException("worker capacity"));
    }
    if (!databasePermits.tryAcquire()) {
        workerPermits.release();
        return CompletableFuture.failedFuture(
            new RejectedExecutionException("database capacity"));
    }

    CompletableFuture<Void> completion = new CompletableFuture<>();
    try {
        virtualExecutor.execute(() -> {
            try {
                job.run();
                completion.complete(null);
            } catch (Throwable error) {
                completion.completeExceptionally(error);
            } finally {
                databasePermits.release();
                workerPermits.release();
            }
        });
        return completion;
    } catch (RuntimeException submitFailure) {
        databasePermits.release();
        workerPermits.release();
        throw submitFailure;
    }
}

这段代码表达了一个重要原则:虚拟线程数量、任务并发数、数据库连接数和接口 QPS 不是同一个指标。哪个资源最紧,哪个就要在对应入口设置边界;否则单一的 maxConcurrency 只能制造一种“看起来有限”的假象。

用一个数字说明上限怎么来

假设节点的数据库连接池最大连接数是 20,其中 4 个连接要留给管理接口和其他业务;同一任务还会调用一个只允许 10 个并发请求的外部服务。那么这个 worker 的初始上限不应配置成 256,而应先取 min(20 - 4, 10) = 10。如果任务分成“只访问数据库”和“只访问外部服务”两类,可以拆成两个执行器分别配置,而不是用一个大池子掩盖最紧的依赖。

调参时至少记录下面三个值:

worker.active();    // 当前已经进入虚拟线程的任务数
worker.capacity();  // permit 总数
worker.rejected();  // 因容量或关闭而拒绝的提交数

如果 active 长期等于 capacityrejected 持续增长,说明到达速率超过处理速率;如果 active 很低但仍大量拒绝,优先检查 permit 是否泄漏或执行器是否已经关闭。只看 CPU 利用率无法区分这两种情况,因为 I/O 等待可能让 CPU 保持很低。

关闭和测试也要围绕这个入口

关闭时先停止新任务,再等待在途任务收尾,并给最终中断一个时间预算:

void close(Duration timeout) {
    delegate.shutdown();
    try {
        if (!delegate.awaitTermination(timeout.toMillis(), MILLISECONDS)) {
            delegate.shutdownNow();
        }
    } catch (InterruptedException interrupted) {
        delegate.shutdownNow();
        Thread.currentThread().interrupt();
    }
}

测试不需要压出一个漂亮的吞吐数字,先验证入口的不变量:

@Test
void rejectsAfterCapacityIsFull() throws Exception {
    try (var worker = new BoundedVirtualThreadExecutor(2)) {
        var release = new CountDownLatch(1);
        var done = new CountDownLatch(2);
        worker.execute(() -> {
            try { release.await(); }
            catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally { done.countDown(); }
        });
        worker.execute(() -> {
            try { release.await(); }
            catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally { done.countDown(); }
        });

        assertThrows(RejectedExecutionException.class,
            () -> worker.execute(() -> {}));

        release.countDown();
        assertTrue(done.await(1, TimeUnit.SECONDS));
        assertEquals(0, worker.active());
    }
}

@Test
void forbidJobAdmitsOnlyOneRun() throws Exception {
    try (var executor = new BoundedVirtualThreadExecutor(4)) {
        var worker = new LocalWorker(executor);
        var release = new CountDownLatch(1);
        var job = new LocalJob("rebuild", ConcurrencyPolicy.FORBID, () -> {
            try { release.await(); }
            catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        worker.dispatch(job).toCompletableFuture();
        assertThrows(ExecutionException.class,
            () -> worker.dispatch(job).toCompletableFuture().get());

        release.countDown();
    }
}

这两个测试分别证明了全局容量和任务级语义。它们也提醒我们,虚拟线程的正确接入点是 dispatch:所有任务都从这里进入,业务互斥、容量准入、拒绝处理和指标才能保持同一条生命周期。

结语

虚拟线程的核心作用,是降低大量阻塞任务等待时占用平台线程的成本,让同步代码也能承载更高的 I/O 并发。推荐的使用方式是“每个任务一个虚拟线程”,而不是重新设计一个固定大小的虚拟线程池。

落到工程里,需要把四件事分开:虚拟线程负责怎么执行Semaphore 负责节点同时接多少任务;连接池和 rate limiter 负责下游能承受多少压力FORBID、锁或幂等键负责同一业务能否并发。同时检查 pinning、ThreadLocal、timeout、中断和关闭流程。

相较传统线程池,真正的改进不是“线程更多”,而是阻塞等待不再长期占用平台线程,并且执行模型与资源边界可以分别配置。对于 CPU 密集型工作或缺少下游容量控制的系统,虚拟线程不会自动带来更高吞吐。

posted @ 2026-08-11 00:15  青柠_fisher  阅读(16)  评论(0)    收藏  举报