不能直接用Semaphore控制虚拟线程数量,因为其acquire()是同步阻塞调用,会占用载体线程等待许可,违背Loom“阻塞即让出”原则,导致调度器失效、CPU打满甚至OOM。

为什么不能直接用 Semaphore 控制虚拟线程数量
很多人一上来就写 semaphore.acquire() 然后启动虚拟线程,结果发现并发数完全失控,CPU 打满、GC 频繁、甚至 OOM。根本原因是:虚拟线程本身不阻塞载体线程,但 Semaphore.acquire() 是**同步阻塞调用**——它会让当前虚拟线程挂起,同时**占用一个载体线程等待许可释放**,违背了 Loom “阻塞即让出”的设计初衷。尤其当大量虚拟线程在 acquire() 处排队时,载体线程被锁死,调度器无法腾挪,反而形成隐形瓶颈。
Semaphore 必须配合 tryAcquire() + 重试/降级逻辑
真正可行的做法是避免同步等待,改用非阻塞探查 + 显式调度控制。核心思路是:先试探获取许可,失败则退避或交由后备队列处理,绝不卡住虚拟线程。
- 用
semaphore.tryAcquire()判断是否可立即执行,返回false就不进业务逻辑 - 对拒绝的任务,可丢弃、延迟重试(用
Thread.sleep()或ScheduledExecutorService),或转投限流队列(如ArrayBlockingQueue) - 若必须保证不丢任务,建议用带超时的
tryAcquire(long, TimeUnit),但超时值不宜设长(如 >100ms),否则仍会拖慢载体线程周转
示例:
Semaphore semaphore = new Semaphore(100); // 允许最多 100 个并发执行
ExecutorService vThreads = Executors.newVirtualThreadPerTaskExecutor();
vThreads.submit(() -> {
if (!semaphore.tryAcquire()) {
System.err.println("限流触发,任务被跳过");
return;
}
try {
// 执行真实业务:HTTP 调用、DB 查询等
doHeavyWork();
} finally {
semaphore.release();
}
});
更稳的方案:用 StructuredTaskScope + Semaphore 组合做结构化限流
单纯靠 Semaphore 管理虚拟线程,容易漏掉异常场景下的 release(),导致许可永久泄漏。Java 21+ 推荐结合 StructuredTaskScope 实现作用域内自动清理,再叠加 Semaphore 控制入口并发度。
- 外层用
Semaphore控制“新任务准入”,类似闸机 - 内层用
StructuredTaskScope启动子任务,确保即使子任务抛异常,也能触发scope.close()并释放许可 - 注意:必须在
scope.fork()前完成acquire(),且release()放在finally块中
关键片段:
try (var scope = new StructuredTaskScope<Void>()) {
if (!semaphore.tryAcquire()) {
throw new RuntimeException("rejected by semaphore");
}
try {
scope.fork(() -> {
doHeavyWork();
return null;
});
scope.join(); // 等待完成
} finally {
semaphore.release(); // 无论成功失败都释放
}
}
容易被忽略的三个细节
实际压测时,以下三点常被跳过,却直接决定限流是否生效:
-
Semaphore的公平性设为false(默认)——虚拟线程调度本就是非公平的,开启公平模式会引入额外锁竞争,降低吞吐 - 监控不是看线程数,而是看
semaphore.getQueueLength()和semaphore.availablePermits(),它们反映真实排队压力 - 如果业务本身含同步 I/O(如 JDBC 直连),
Semaphore限流只是表层,必须同步替换为异步驱动(如 R2DBC)或包裹进CompletableFuture.supplyAsync(..., vThreads),否则载体线程仍会被钉死
限流不是加个 Semaphore 就完事;在虚拟线程语境下,它必须退居为“准入控制器”,而真正的资源弹性,靠的是 JVM 调度器对阻塞点的自动卸载能力。

















