Java虚拟线程不能使用同步synchronized锁!


Project Loom将虚拟线程的概念引入了 Java 运行时,并将在 9 月份作为JDK 21中的稳定功能提供。Loom 项目旨在将异步编程的性能优势与直接“同步”编程风格的简单性结合起来。

为了实现性能目标,任何阻塞操作都需要由 Loom 的运行时以特殊的方式处理。

定义阻塞:它是程序线程的状态,它不执行任何有意义的工作(不消耗 CPU),而是等待某些外部条件发生。示例包括等待信号量或等待套接字中的数据变得可用。


载体线程和虚拟线程
首先,我们简要介绍一下 Loom 的主要概念。在基础上,我们有平台线程——也称为内核线程。这些线程在 Java 中已经存在很长时间了;到目前为止,每个运行Thread实例对应一个内核线程。这些线程重量级、创建和切换成本高昂。它们是一种稀缺资源,需要仔细管理,例如通过使用线程池。

虚拟线程是Loom引入的一个新概念。无论是在内存还是切换上下文所需的时间方面,它们都是轻量级且创建成本低廉的。在启用虚拟线程的 JDK 中,Thread实例可以代表平台线程或虚拟线程。API 是相同的,但运行每个 API 的成本差异很大。

在幕后,JVM+Loom 运行时保留了一个平台线程池,称为载体线程,在其之上复用虚拟线程。
也就是说,使用少量的平台线程来运行许多虚拟线程。每当虚拟线程调用阻塞操作时,它都应该被“搁置”,直到满足它正在等待的任何条件,并且另一个虚拟线程可以在现在释放的承载线程上运行。

这个想法并不新鲜:它是在各种反应式库中使用线程池和非阻塞 I/O 实现的。

一件重要的事情是,为了使系统取得稳定的进展(当使用大量虚拟线程时),载体线程必须经常变得空闲,以便可以将虚拟线程调度到它们上。
因此,最大的收益应该出现在 I/O 密集型系统中,而 CPU 密集型应用程序不会从使用 Loom 中看到太多改进。

这里上下文的一个上文假设前提是:任何阻塞操作最终都会放开载体线程,以便供另外一个虚拟线程使用。

但事实是这样吗?

线程钉住(thread pinning)问题
并非所有 Java 结构都以这种方式进行了改造。
例如synchronized方法和代码块,使用它们会导致虚拟线程会钉住(thread pinning)到载体线程。当线程被钉住时,阻塞操作将阻塞底层的载体线程——就像在 Loom 之前的时代发生的那样。

var e = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 1000; i++) {
    e.submit(() -> { new Test().test(); });
}
e.shutdown();
e.awaitTermination(1, TimeUnit.DAYS);

class Test {
    synchronized void test() {
        sleep(4000);
    }
}

请注意,每个提交的任务都创建了一个新的 Test 实例,因此在调用同步 void test() 方法时使用了不同的监视器。

然而,由于我们使用了 synchronized,线程被钉住固定在载体线程上。
如果载体线程池有5个线程,那么只有5个任务会同时运行(因为它们会阻塞整个线程池)。

因此,这段代码虽然与我们之前看到的没有太大区别,但却需要1000/5*4 = 800秒才能完成。

解决方法是使用锁而不是synchronized关键字:

var e = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 1000; i++) {
    e.submit(() -> { new Test().test(); });
}
e.shutdown();
e.awaitTermination(1, TimeUnit.DAYS);

class Test {
    private ReentrantLock lock = new ReentrantLock();
    void test() {
        lock.tryLock();
        try {
            sleep(4000);
        } finally {
            lock.unlock();
        }
    }
}

使用 Loom 时,这将再次在 4 秒内完成。

在 JDK 中用锁(在可能的情况下)替换synchronized 块是 Loom 项目范围内的另一个领域,也是 JDK 21 中将要发布的内容。这些变化也是各种 Java 和 JVM 库已经实现或正在实现的(如 JDBC 驱动程序)。但是,使用synchronized 的应用代码需要格外小心。


总结
载体线程和虚拟线程之间的区别,以及Loom在一个小的载体线程池上运行许多虚拟线程。

Loom包括对阻塞操作进行改造,使其具有虚拟线程感知能力。

并发原语,如锁lock、semaphores和基于套接字的网络操作,已经迁移到正确使用虚拟线程。(除了synchronized )

然而,JDK 中的所有阻塞 API 并非如此。当载体线程被阻塞时,我们会处理线程钉住(thread pinning)问题。
当使用synchronized关键字、进行文件I/O、使用数据报套接字或解析域名时,会出现线程钉住现象。

一些解决方案有助于避免线程钉死:

从同步迁移到使用锁,以及产生补偿的载体线程。
当调用载体线程阻塞I/O操作时,JDK会自动生成补偿线程(达到配置的限制)。

有些操作在内核级别本身就是阻塞的(如文件I/O),Loom不可能以非阻塞的方式实现它们。
io_uring是这个问题的部分解决方案,因为在内部,它使用线程池来运行阻塞操作(如文件I/O)。

统一接口
一个开放性问题:试图为所有阻塞和非阻塞操作提供统一接口的抽象是否是一个好主意?

答案可能是 "视情况而定"。

在一定规模上,这些问题开始变得重要。
在较小的规模上,它们可能根本不重要。像Loom或io_uring这样的抽象是有漏洞的,可能会产生误导。也许从类型系统中获得一些指导会很好。或者以另一种方式传达这类信息?

最后,如果I/O操作不能以指定的方式运行,我们可能希望有一种方式来指示我们的运行时失败。

Loom确实极大地推动了JVM的发展,并实现了其性能目标,同时还简化了编程模型;但我们不能盲目地相信它能消除应用程序中的所有内核线程阻塞源。这可能会在解决其他问题的同时,给我们的应用程序带来新的性能相关问题。

您对这个问题有什么看法--是否应该区分阻塞和非阻塞操作?如果需要,如何区分--在类型级别,使用命名约定,或者其他方式?我们的运行时应该提供什么样的配置,以便我们能够确定给定的I/O操作将如何执行?