CodeGym /课程 /JAVA 25 SELF /使用带虚拟线程的 Executor

使用带虚拟线程的 Executor

JAVA 25 SELF
第 57 级, 课程 3
可用

1. 要点速览

你已经对 java.util.concurrent 包中的类有所了解,尤其是 ExecutorService。它就像一个“任务管理器”:你把工作提交给它(例如通过 submit()),它会自行决定何时以及由哪个线程来执行。通常其底层是固定大小的线程池,从而节省资源,不会为每个任务都新建线程。

然而,有了虚拟线程,一切都变了!现在可以豪横地做到:每个任务一个线程,而且不用担心 JVM 会因为“吃撑”而崩溃。

新方式:Executors.newVirtualThreadPerTaskExecutor()

在 Java 21 中,出现了一种创建 ExecutorService 的新方式,它会为每个任务启动一个独立的虚拟线程:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

重要区别:

  • 老的线程池(Executors.newFixedThreadPool、Executors.newCachedThreadPool)会因为操作系统线程开销高而限制并发任务数量。
  • 新的虚拟 Executor 几乎不受限:每个任务都有其轻量级的虚拟线程。

简单示例

向虚拟 Executor 提交 10 个任务:

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

public class VirtualExecutorDemo {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

        for (int i = 1; i <= 10; i++) {
            int taskId = i; // 捕获用于 lambda 的变量
            executor.submit(() -> {
                System.out.println("Task " + taskId + " is running in thread: " +
                        Thread.currentThread());
            });
        }

        executor.shutdown();
    }
}

发生了什么?
每个任务都会在自己的虚拟线程中启动,你会看到类似如下的输出:

Task 1 is running in thread: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
...

2. 大规模并行:成千上万的任务——不成问题!

为了感受虚拟线程的威力,试着向 ExecutorService 提交不是 10,而是比如 100_000 个任务。在经典线程池中,这就像把大象塞进冰箱:JVM 会很快耗尽内存或变得非常卡顿。虚拟线程则完全不同!

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

public class VirtualExecutorMassiveDemo {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

        for (int i = 1; i <= 100_000; i++) {
            int taskId = i;
            executor.submit(() -> {
                // 作为示例——只睡眠 1 毫秒
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                // System.out.println("Task " + taskId + " done."); // 不打印,否则行数会太多!
            });
        }

        executor.shutdown();
    }
}

注意:向控制台输出 100_000 行是个坏主意:控制台会比虚拟线程更早“撑不住”。最好不要打印,或只打印前几条任务。

3. newVirtualThreadPerTaskExecutor 如何工作

简而言之:该 ExecutorService 会为你提交的每个任务创建一个新的虚拟线程。与固定大小的线程池不同,它没有任务队列和严格的并发线程数限制(除非受制于你的 JVM 与硬件)。

从架构上看:

  • 虚拟线程会被“映射”到少量真正的操作系统(carrier)线程。
  • JVM 会自行决定何时启动、挂起与恢复哪个虚拟线程。
  • 如果线程被阻塞(例如读取文件或等待网络),JVM 可以“冻结”该虚拟线程,并释放 carrier 线程去处理其他任务。

4. 示例:使用 Future 处理结果

如果任务有返回结果,ExecutorService 会返回一个 Future 对象。这与普通线程的用法相同:

import java.util.concurrent.*;

public class VirtualExecutorWithResult {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

        Future<String> future = executor.submit(() -> {
            Thread.sleep(500);
            return "Hello from virtual thread!";
        });

        System.out.println("Result: " + future.get()); // 等待结果

        executor.shutdown();
    }
}

一切都很熟悉:可以提交有返回值的任务、通过 get() 等待结果,异常处理也与标准流程一致。

5. 正确关闭 Executor

务必记得关闭 ExecutorService,否则程序可能挂起(即便线程是虚拟的而非“真实的”)。

shutdown() 与 awaitTermination

executor.shutdown(); // 表示:不再接受新任务
executor.awaitTermination(1, TimeUnit.MINUTES); // 等待所有任务完成(最多 1 分钟)

为什么这很重要?
如果不调用 shutdown(),虚拟线程可能会继续存活,即使 main() 执行完毕,程序也不会退出。这是新手常见错误。

6. 实用细节

对比:虚拟 Executor vs 经典线程池

经典线程池(newFixedThreadPool) 虚拟 Executor(newVirtualThreadPerTaskExecutor)
线程数量 受池大小限制 每个任务一个虚拟线程,几乎无限制
任务排队 会的,如果所有线程都忙 通常不会:任务会立即获得线程
线程成本 高(栈、操作系统资源) 很低(由 JVM 负责调度)
可扩展性 受限 几乎不受限
适用场景 CPU 密集型任务、受限并行度 I/O 密集型任务、大规模并行

与 Web 服务器集成

现代 Web 服务器(例如 Tomcat、Jetty、Undertow)已经开始支持虚拟线程。这意味着可以在单独的虚拟线程中处理每个 HTTP 请求,而无需担心在用户激增时“被淹没”。

优势:无需设计复杂的回调式异步方案和 CompletableFuture;代码更简单——可以编写熟悉的阻塞式代码,但应用依然具备良好的可扩展性。

大规模测试与压力仿真

虚拟线程非常适合用于需要“模拟”成千上万并发用户、请求或操作的测试。比如,一个测试向服务器发送 10_000 个并发请求,每个请求在其自己的虚拟线程中处理。

文件与网络连接的并行处理

如果应用处理大量文件或网络连接,可以让每个连接在单独的虚拟线程中处理,无需手动管理线程池。

7. 使用虚拟 Executor 的常见错误

错误 #1:忘记调用 shutdown()。 如果不关闭 Executor,程序将不会退出——虚拟线程仍会等待新任务。必要时请配合 awaitTermination(...) 使用。

错误 №2:把虚拟线程用于重型计算。 虚拟线程并不会加速完全占用 CPU 的任务。对于 CPU 密集型任务,最好使用固定大小的线程池(Executors.newFixedThreadPool),并仔细选择合适的大小。

错误 №3:忽略任务内部的异常。 如果任务抛出异常,它不会自动传到主线程——请通过 Future(方法 get())处理,或在 lambda 内部使用 try/catch。

错误 №4:混淆旧语法/旧 JDK 版本与新特性。 请确认你使用的是正确的 JDK 版本(Java 21+),并确保 IDE 已配置支持虚拟线程。具体方法是 Executors.newVirtualThreadPerTaskExecutor()。

错误 №5:依赖 ThreadLocal 传递上下文。 虚拟线程经常被创建与销毁;ThreadLocal 的行为可能与预期不符。要传递上下文请使用 ScopedValue(Scoped Values;详见下一讲)。

1
任务
JAVA 25 SELF, 第 57 级, 课程 3
已锁定
星际探测器数据传输 🛰️
星际探测器数据传输 🛰️
1
任务
JAVA 25 SELF, 第 57 级, 课程 3
已锁定
机器人装配线监控 🦾
机器人装配线监控 🦾
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION