Java 新技术:虚拟线程使用指南(二)

虚拟线程是在 Java 21 版本中实现的一种轻量级线程。它由 JVM 进行创建以及管理。虚拟线程和传统线程(我们称之为平台线程)之间的主要区别在于,我们可以轻松地在一个 Java 程序中运行大量、甚至数百万个虚拟线程。

由于虚拟线程的数量众多,也就赋予了 Java 程序强大的力量。虚拟线程适合用来处理大量请求,它们可以更有效地运行 "一个请求一个线程" 模型编写的 web 应用程序,可以提高吞吐量以及减少硬件浪费。

由于虚拟线程是 java.lang.Thread 的实现,并且遵守自 Java SE 1.0 以来指定 java.lang.Thread 的相同规则,因此开发人员无需学习新概念即可使用它们。

但是虚拟线程才刚出来,对我们来说有一些陌生。由于 Java 历来版本中无法生成大量平台线程(多年来 Java 中唯一可用的线程实现),已经让程序员养成了一套关于平台线程的使用习惯。这些习惯做法在应用于虚拟线程时会适得其反,我们需要摒弃。

此外虚拟线程和平台线程在创建成本上的巨大差异,也提供了一种新的关于线程使用的方式。Java 的设计者鼓励使用虚拟线程而不必担心虚拟线程的创建成本。

本文无意全面涵盖虚拟线程的每个重要细节,目的是给大家使用虚拟线程提供一套使用指南,帮助大家能更好使用的虚拟线程,发挥其作用并避免踩坑。

本文完整大纲如下,

使用信号量限制并发

在某些场景下,我们需要限制某个操作的并发数。例如某些外部服务可能无法同时处理超过 10 个并发请求。

由于平台线程是一种宝贵的资源,通常在线程池中进行管理,因此线程池的使用对于如今的程序员相当普遍。

比如上面例子要限制并发请求数,某些人会使用线程池来处理,代码如下,

java 复制代码
ExecutorService es = Executors.newFixedThreadPool(10);
...
Result foo() {
    try {
        var fut = es.submit(() -> callLimitedService());
        return f.get();
    } catch (...) { ... }
}

上面代码示例可以确保外部服务最多只有 10 个并发请求,因为我们的线程池中只有最多 10 个线程。

限制并发只是使用线程池的副产品。线程池旨在共享稀缺资源,而虚拟线程并不稀缺,因此永远不应该池化虚拟线程!

使用虚拟线程时,如果要限制访问某些服务的并发请求,则应该使用专门为此目的设计的 Semaphore 类。示例代码如下,

java 复制代码
Semaphore sem = new Semaphore(10);
...
Result foo() {
    sem.acquire();
    try {
        return callLimitedService();
    } finally {
        sem.release();
    }
}

在这个示例中,同一时刻只有 10 个虚拟线程可以进入 foo() 方法取得锁,而其他虚拟线程将会被阻塞。

简单地使用信号量阻塞某些虚拟线程可能看起来与将任务提交到固定数量线程池有很大不同,但事实并非如此。

将任务提交到等待任务池会将它们排队处理,信号量在内部(或任何其他阻塞同步构造)构造了一个阻塞线程队列,这些任务在阻塞线程队列上也会进行排队处理。

我们可以将平台线程池认作是从等待任务队列中提取任务进行处理的工作人员,然后将虚拟线程视为任务本身,在任务或者线程可以执行之前将会被阻塞,但它们在计算机中的底层表示上实际是相同的。

这里想告诉大家的就是不管是线程池的任务排队,还是信号量内部的线程阻塞,它们之间是由等效性的。在虚拟线程某些需要限制并发数场景下,直接使用信号量即可。

不要在线程局部变量中缓存可重用对象

虚拟线程支持线程局部变量,就像平台线程一样。通常线程局部变量用于将一些特定于上下文的信息与当前运行的代码关联起来,例如当前事务和用户 ID。

对于虚拟线程来说,使用线程局部变量是完全合理的。但是如果考虑更安全、更有效的线程局部变量,可以使用 Scoped Values。

更多有关 Scoped Values 介绍,请参阅 https://docs.oracle.com/en/java/javase/21/core/scoped-values.html#GUID-9A4565C5-82AE-4F03-A476-3EAA9CDEB0F6

线程局部变量有一种用途与虚拟线程是不太适合的,那就是缓存可重用对象。

可重用对象的创建成本通常很高,通常消耗大量内存且可变,还不是线程安全的。它们被缓存在线程局部变量中,以减少它们实例化的次数以及它们在内存中的实例数量,好处是它们可以被线程上不同时间运行的多个任务重用,避免昂贵对象的重复创建。

例如 SimpleDateFormat 的实例创建成本很高,而且不是线程安全的。为了解决创建成本、线程不安全问题,通常是将此类实例缓存在 ThreadLocal 中,如下例所示:

java 复制代码
static final ThreadLocal<SimpleDateFormat> cachedFormatter =
       ThreadLocal.withInitial(SimpleDateFormat::new);

void foo() {
  ...
	cachedFormatter.get().format(...);
	...
}

仅当线程(以及因此在线程本地缓存的昂贵对象)被多个任务共享和重用时(就像平台线程被池化时的情况一样),这种缓存才有用。许多任务在线程池中运行时可能会调用 foo,但由于池中仅包含几个线程,因此该对象只会被实例化几次(每个池线程一次)并被缓存和重用。

但是虚拟线程永远不会被池化,也不会被不相关的任务重用。因为每个任务都有自己的虚拟线程,所以每次从不同任务调用 foo 都会触发新 SimpleDateFormat 的实例化。而且由于可能有大量的虚拟线程同时运行,昂贵的对象可能会消耗相当多的内存。这些结果与线程本地缓存想要实现的结果恰恰相反。

对于线程局部变量缓存可重用对象的问题,没有什么好的通用替代方案,但对于 SimpleDateFormat,我们应该将其替换为 DateTimeFormatter。DateTimeFormatter 是不可变的,因此单个实例就可以由所有线程共享,

java 复制代码
static final DateTimeFormatter formatter = DateTimeFormatter....;

void foo() {
  ...
	formatter.format(...);
	...
}

需要注意的是,使用线程局部变量来缓存共享的昂贵对象有时是由一些异步框架在幕后完成的,其隐含的假设是这些可重用对象只会由极少数池线程使用。

所以混合虚拟线程和异步框架一起使用可能不是一个好主意,对某些方法的调用可能会导致可重用对象被重复创建。

避免长时间和频繁的 synchronized

当前虚拟线程实现由一个限制是,在同步块或方法内执行 synchronized 阻塞操作会导致 JDK 的虚拟线程调度程序阻塞宝贵的操作系统线程,而如果阻塞操作是在同步块或方法外完成的,则不会被阻塞。我们称这种情况为 "Pinning"。

如果阻塞操作既长期又频繁,则 "Pinning" 可能会对服务器的吞吐量产生不利影响。如果阻塞操作短暂(例如内存中操作)或不频繁则可能不会产生不利影响。

为了检测可能有害的 "Pinning" 实例,(JDK Flight Recorder (JFR) 在 "Pinning" 阻塞时间超过 20 毫秒时,会发出 jdk.VirtualThreadPinned 事件。

或者我们可以使用系统属性 jdk.tracePinnedThreads 在线程被 "Pinning" 阻塞时发出堆栈跟踪。

启动 Java 程序时添加 -Djdk.tracePinnedThreads=full 运行,会在线程被 "Pinning" 阻塞时打印完整的堆栈跟踪,突出显示本机帧和持有监视器的帧。使用 -Djdk.tracePinnedThreads=short 运行,会将输出限制为仅有问题的帧。

如果这些机制检测到既长期又频繁 "Pinning" 的地方,请在这些特定地方将 synchronized 替换为 ReentrantLock。以下是长期且频繁使用 synchronized 的示例,

java 复制代码
synchronized(lockObj) {
    frequentIO();
}

我们可以将其替换为以下内容:

java 复制代码
lock.lock();
try {
    frequentIO();
} finally {
    lock.unlock();
}

参考资料

最后说两句

针对虚拟线程的使用,相信大家心里已经有了答案。在对虚拟线程需要限制并发数的场景,使用信号量即可。在虚拟线程中使用线程局部变量时要注意避免缓存昂贵的可重用对象。对于使用到 synchronized 同步块或者方法的虚拟线程,建议替换为 ReentrantLock,避免影响吞吐量。

关注公众号【waynblog】每周分享技术干货、开源项目、实战经验、国外优质文章翻译等,您的关注将是我的更新动力!