Administrator
发布于 2022-01-17 / 8886 阅读
112

虚拟线程初探:JDK 19 预览版体验

「线程数加到 800 了,还是上不去」

1 月中旬看压测报告,开放平台那个网关服务的并发一直卡在 4000 左右。压测的同学在群里说:「Tomcat 线程数已经从 200 加到 800 了,加不动了,再加 Full GC 就开始频繁。」

这个症状太典型了——线程成了并发的天花板。我把之前攒的 Loom 资料翻出来,下了一个 Loom 的 early-access 构建跑了一轮。说明一下:虚拟线程在 JDK 19 才作为预览特性(JEP 425)进主线,我这次用的是 Loom 的 EA 构建,命令和 API 都还不是最终形态,仅供体验,不能上生产。

先量化平台线程的天花板在哪

Java 里的 Thread 一直是一对一映射到操作系统线程的。这个模型的成本有三项:

1. 内存

每个线程要预留栈空间,-Xss 默认 1 MB(我们线上配的是 -Xss512k)。注意这是预留的虚拟地址空间,不是立刻占用的物理内存,但上限是实打实的:

$ ulimit -s
8192                 # 8 MB,操作系统栈

$ jcmd 1 VM.native_memory | grep -A 5 "Thread"
-                    Thread (reserved=1048576KB, committed=124928KB)
                            (thread #1000)          # 1000 个线程
                            (stack: reserved=1042432KB, committed=118784KB)
                            (malloc=5794KB #6005)

1000 个线程 reserved 了 1 GB 虚拟内存,committed 122 MB。我们压到 800 个 Tomcat 线程时,光线程栈的 committed 就接近 100 MB,这还没算每个线程的 TLAB 和内核侧的 task_struct

2. 上下文切换

我用 vmstat 抓了压测期间的上下文切换次数:

$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
34  0      0 3821444 212304 8892340    0    0     0   284 61234 184233 62 27 11  0  0
41  1      0 3712882 212304 8894122    0    0     0   312 66012 202188 65 25 10  0  0

cs 是每秒上下文切换次数,18 万到 20 万。sy(内核态 CPU)占 25%~27%,这部分基本都是线程调度和系统调用的开销。四分之一 CPU 花在调度上,不是在干活。

3. 创建成本

// 测量创建 10000 个线程的耗时
long t0 = System.nanoTime();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
    Thread t = new Thread(() -> LockSupport.park());
    t.start();
    threads.add(t);
}
System.out.printf("platform thread: %d ms%n", (System.nanoTime() - t0) / 1_000_000);

// platform thread: 4187 ms

10000 个平台线程 4.19 秒,平均每个 0.42 ms。而且到 6000 多个的时候开始报 OutOfMemoryError: unable to create new native thread

虚拟线程:JVM 接手调度

虚拟线程是 M:N 调度:大量虚拟线程跑在少量"载体线程"(carrier thread)上,载体线程默认是一个 ForkJoinPool,并行度等于 CPU 核数。虚拟线程在执行阻塞操作(比如 LockSupport.parksocket.read)时,会自动从载体线程上卸载(unmount),载体线程立刻去跑别的虚拟线程。

创建方式有三种:

// 1. 直接启动
Thread vThread = Thread.startVirtualThread(() -> {
    System.out.println("hello from " + Thread.currentThread());
});

// 2. 更精细的控制(命名、未捕获异常处理器)
Thread.ofVirtual()
      .name("order-task-", 0)
      .uncaughtExceptionHandler((t, e) -> log.error("vt error", e))
      .start(task);

// 3. 最常用:每个任务一个虚拟线程的 Executor
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}   // close() 会等待所有任务完成

第三种是我最推荐的用法。虚拟线程不需要池化——创建一个的成本极低,池化反而会限制并发。以前"线程池大小怎么配"这个经典问题,在虚拟线程世界里直接消失了。

压测对比

我写了个模拟场景:每个任务阻塞 1 秒(模拟一次外部 HTTP 调用),看 10 万个任务全部完成要多久,以及内存占用。

// 平台线程版,200 个线程的池
ExecutorService pool = Executors.newFixedThreadPool(200);

// 虚拟线程版
ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();
方案线程数10 万任务耗时峰值 RSS上下文切换(cs/s)
FixedThreadPool(200)200502.3 s412 MB18,400
FixedThreadPool(1000)1000101.7 s688 MB94,200
VirtualThreadPerTask1000004.9 s1.24 GB3,100

耗时从 502 秒降到 4.9 秒,102 倍。但别被这个数字迷惑——这个测试是纯阻塞场景(任务什么都不干,就睡 1 秒),是虚拟线程最理想的情况。换成纯计算任务,虚拟线程不会比平台线程快,因为 CPU 核数没变。

内存这块值得单独说:10 万个虚拟线程占用 1.24 GB,平均每个约 13 KB。它的栈是可增长的 chunk 栈,存在 Java 堆里,随调用深度增长,GC 可以回收,不像平台线程那样一次性预留 1 MB。

结构化并发

Loom 还带了一个 StructuredTaskScope(孵化阶段),解决的是"多个并发任务的生命周期管理"问题。

以前我们这样写:提交三个子任务到线程池,用 CompletableFuture.allOf 等待。问题在于,如果其中一个失败了,另外两个还在后台跑,你得自己记得取消,否则它们会一直占着资源。

// 传统写法
CompletableFuture<Order> f1 = supplyAsync(() -> queryOrder(id), pool);
CompletableFuture<User>  f2 = supplyAsync(() -> queryUser(uid), pool);
CompletableFuture<Risk>  f3 = supplyAsync(() -> queryRisk(uid), pool);
try {
    allOf(f1, f2, f3).join();     // f1 失败时,f2 f3 还在跑
} catch (Exception e) {
    // 忘了 cancel,两个任务白跑
}

StructuredTaskScope 用代码块界定作用域,离开作用域时所有子任务必定已经结束或被取消:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<Order> order = scope.fork(() -> queryOrder(id));
    Subtask<User>  user  = scope.fork(() -> queryUser(uid));
    Subtask<Risk>  risk  = scope.fork(() -> queryRisk(uid));

    scope.join();                    // 等全部结束
    scope.throwIfFailed();           // 有失败就抛

    // 这里三个结果都能拿到,且子任务已全部终止
    return new Detail(order.get(), user.get(), risk.get());
}

ShutdownOnFailure 的语义是:任意一个子任务失败,立刻中断其他子任务。ShutdownOnSuccess 则相反,任意一个成功就取消其余的(适合"多个数据源取最快那个")。

这个 API 在 JDK 19 里放在 jdk.incubator.concurrent 模块,需要 --add-modules jdk.incubator.concurrent,运行时也会打 incubator 警告。

踩到的两个坑

1. synchronized 会把虚拟线程钉在载体线程上

这是当前实现最大的限制。虚拟线程在 synchronized 块里发生阻塞时,无法卸载,会把载体线程一起占住。我实测了一下:

static final Object LOCK = new Object();

// 场景:10 万个任务,每个都在 synchronized 块里 sleep 1 秒
executor.submit(() -> {
    synchronized (LOCK) {
        Thread.sleep(1000);        // 这里会 pin 住 carrier
    }
});
8 核机器上,10 万个任务耗时 501.4 s(跟 200 线程池一个量级)
jcmd Thread.dump_to_file -format=json /tmp/vt.json
# 看到大量 " pinned to carrier " 的线程

验证 pin 是否发生,可以用这个 JVM 参数,它会在 pin 时打印堆栈:

-Djdk.tracePinnedThreads=full
Thread[#31,ForkJoinPool-1-worker-1,5,CarrierThreads]
    java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
    java.base/jdk.internal.vm.Continuation.onPinned(Continuation.java:393)
    ...
    com.xxx.Demo.lambda$main$1(Demo.java:42)   <-- monitors:1

解决办法是把 synchronized 换成 ReentrantLock。我在改完之后的复测:

改前(synchronized):501.4 s
改后(ReentrantLock):5.2 s

2. ThreadLocal 会被放大十万倍

虚拟线程也支持 ThreadLocal,语法上完全一样。但以前一个 200 线程池最多 200 份 ThreadLocal 副本,现在可能有 10 万份。我们有个埋点框架在 ThreadLocal 里放了一个 2000 大小的数组,10 万虚拟线程算下来就是 800 MB 往上。

Loom 提供了 ScopedValue 作为替代,但它在 JDK 19 里还是孵化状态,我没测。当前阶段的建议:虚拟线程里慎用 ThreadLocal,尤其是放大数据结构的那种。

现在能用吗

我的判断是 2022 年不能上生产,理由有三:

  • API 未定稿。虚拟线程在 JDK 19 是预览(JEP 425),StructuredTaskScope 是孵化(JEP 428),两者都要加 --enable-preview / --add-modules 才能跑,且后续版本还会改。
  • 生态没跟上。我们用的 Tomcat 10.1、Undertow、Netty 5 都还没适配虚拟线程;HikariCP 这类池化组件在虚拟线程下反而成了瓶颈——池子只有 20 个连接,10 万个虚拟线程全在等这 20 个连接,等于把瓶颈从线程转移到了连接池。
  • 调试工具不成熟。jstack 打印出来的虚拟线程堆栈是扁平的,看不出 Continuation 的层次;SkyWalking 9 那会儿的探针也没适配,链路会断。

真正适合虚拟线程的场景是:IO 密集、阻塞式写法、并发量高。我们那个开放平台网关就符合——它调外部三方接口,平均等待 380 ms,纯阻塞调用。等 JDK 19 转正确认之后,我打算先在它上面试点,把 Tomcat 的执行器换成虚拟线程版:

@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerCustomizer() {
    return protocolHandler ->
        protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}

先到这

《虚拟线程初探:JDK 19 预览版体验》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考