什么使得Java虚拟线程更优秀

21

我对Project Loom感到非常兴奋,但有一件事我无法完全理解。

大多数Java服务器使用线程池,并设定了线程数量限制(200、300...),然而,你并不受操作系统的限制,可以生成更多的线程。我读过,在Linux上通过特殊配置可以达到巨大的数字。

操作系统线程更加昂贵,它们启动/停止的速度较慢,必须处理上下文切换(由它们的数量放大),你要依赖于操作系统,而操作系统可能拒绝给你更多的线程。

话虽如此,虚拟线程也会消耗类似的内存(或者至少是我所理解的)。使用Loom,我们获得了尾调用优化,这应该可以减少内存使用量。此外,同步和线程上下文复制仍然会带来类似规模的问题。

事实上,你能够生成数百万个虚拟线程

public static void main(String[] args) {
    for (int i = 0; i < 1_000_000; i++) {
        Thread.startVirtualThread(() -> {
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }
}

代码在使用平台线程时,大约在25k处会产生OOM异常。

我的问题是,到底是什么让这些线程如此轻便,阻止我们生成100万个平台线程并与它们一起工作,是仅仅因为上下文切换使得普通线程如此“重”吗?

一个非常相似的问题

到目前为止我发现:

  • 上下文切换是昂贵的。一般来说,即使在操作系统知道线程行为的理想情况下,它仍然必须给予每个线程平等的执行机会,假设它们具有相同的优先级。如果我们产生10k个操作系统线程,操作系统将不得不在它们之间不断切换,而这个任务本身可能占用CPU时间的80%,所以我们必须非常小心地处理数字。对于虚拟线程,上下文切换由JVM完成,因此基本上是免费的。
  • 廉价的启动/停止。当我们中断线程时,我们实际上告诉任务:“杀死你正在运行的操作系统线程”。但是,例如,如果该线程在线程池中,则当我们询问时,该线程可能已被当前任务释放,然后给另一个任务,并且其他任务可能会收到中断信号。这使得中断过程非常复杂。虚拟线程只是存在于堆中的对象,我们可以让GC在后台收集它们。
  • 硬性上限(最多几万个)的线程,由于操作系统如何处理它们。操作系统无法针对特定应用程序和编程语言进行微调,因此必须为最坏情况下的内存做好准备。它必须分配更多的内存来容纳所有需求。在做所有这些事情的同时,它必须确保关键的操作系统进程仍在工作。使用VT,您只受到便宜的内存的限制。
  • 执行事务的线程与执行视频处理的线程行为非常不同,再次,操作系统必须为最坏情况做好准备并尽力适应这两种情况,这意味着在大多数情况下我们会获得次优的性能。由于VT由Java自身产生和管理,因此允许完全控制它们和任务特定的优化,这些优化不受操作系统约束。
  • 可调整大小的堆栈。操作系统为线程提供一个大堆栈以适应所有用例,虚拟线程具有可调整大小的堆栈,该堆栈位于堆空间中,根据问题动态调整大小使其更小。
  • 较小的元数据大小。平台线程使用1MB,而虚拟线程需要200-300字节来存储它们的元数据。

2
也许可以看看这个链接:https://dev59.com/F1IG5IYBdhLWcg3wrziG#65217077 - akuzminykh
3
也许这个链接可以帮助你理解:https://www.baeldung.com/java-virtual-thread-vs-thread。我不知道Project Loom,但看起来“虚拟”线程大多只是“绿色”线程、“用户模式”线程、“轻量级”线程或者你想怎么称呼它们就怎么叫的另一种形式。只是猜测(因此不是实际答案),如果你的程序需要成百上千个线程,并且你的平台只有几十个处理器,那么使用池化的几十个操作系统线程来运行你的成百上千个“虚拟”线程可能会获得性能优势。 - Solomon Slow
似乎这两篇文章都强调了上下文切换。 - Borislav Stoilov
1
什么使虚拟线程更好?你为什么得出这个结论?Sun Microsystems在近20年前就放弃了用户空间线程。另请参见https://www.reddit.com/r/ProgrammingLanguages/comments/6mm4v1/what_are_the_disadvantages_of_an_mn_threading/?ref=share&ref_source=link。底线是:如果用户空间线程如此优秀,操作系统会使用它们。 - Andrew Henle
1
相关链接:https://dev59.com/rmkw5IYBdhLWcg3wVpFi。(这个问题并不特定于Java - 它早在Project Loom之前就存在了 - 但我认为它仍然非常相关。) - ruakh
显示剩余5条评论
4个回答

6
虚拟线程被包装在平台线程之上,因此您可以将它们视为JVM提供的“幻觉”,整个想法是使线程的生命周期变成 CPU bound 操作。
“Java虚拟线程”的优点:
- 表现出与平台线程完全相同的行为。 - 可以被处理并且可以扩展到数百万个。 - 比平台线程更轻量级。 - 创建速度快,就像创建字符串对象一样快。 - JVM对IO操作进行了分隔连续性处理,虚拟线程没有IO操作。 - 可以像以前一样拥有顺序代码,但更加有效。 - JVM给出了虚拟线程的幻觉,在底层整个故事都在平台线程上进行。 - 使用虚拟线程后,CPU核心变得更加并发,虚拟线程和多核CPU结合使用ComputableFutures来并行化代码非常强大。
“虚拟线程”的使用注意事项。
  • 不要使用监视器即同步块,但这将在JDK的新版本中修复,替代方法是使用“ReentrantLock”和try-final语句。

  • 堆栈上带有本地帧的阻塞,JNI非常罕见。

  • 控制每个堆栈的内存(减少线程本地变量和不深递归)。

  • 监视工具尚未更新,如调试器、JConsole、VisualVM等。

  • 平台线程与虚拟线程。平台线程在基于IO的任务中占用操作系统线程,并限制适用线程池和操作系统线程的数量,默认情况下它们是非守护线程。

  • 虚拟线程是使用JVM实现的,在CPU绑定操作中关联到平台线程,并将其返回到线程池,IO绑定操作完成后,将从线程池调用新线程,因此在这种情况下没有人质。

第四级体系结构以更好地理解。

enter image description here

CPU:中央处理器
多核 CPU:在 CPU 内部执行操作的多核心处理器。
OS:操作系统
操作系统线程:操作系统调度程序分配 CPU 时间给已经启动的操作系统线程。
JVM:Java 虚拟机
平台线程:完全包装在操作系统线程上,具有任务操作。
虚拟线程:与每个 CPU 绑定的操作相关联的虚拟线程,在不同的时间可以将每个虚拟线程与多个平台线程关联起来。
使用 Executor 服务的虚拟线程。
  • More effective to use executer service cause it associated to thread pool an limited to applicable threads with it, however in compare of virtual threads, with Executer service and virtual contained we do not ned to handle or manage the associated thread pool.

     try(ExecutorService service = Executors.newVirtualThreadPerTaskExecutor()) {
         service.submit(ExecutorServiceVirtualThread::taskOne);
         service.submit(ExecutorServiceVirtualThread::taskTwo);
     }
    
  • Executer service implements Auto Closable interface in JDK 19, thus when used with in 'try with resource', once it reach to end of 'try' block the 'close' api being called, alternatively main thread will wait till all submitted task with their dedicated virtual threads finish their lifecycle and associated thread pool being shutdown.

     ThreadFactory factory = Thread.ofVirtual().name("user thread-", 0).factory();
     try(ExecutorService service = Executors.newThreadPerTaskExecutor(factory)) {
         service.submit(ExecutorServiceThreadFactory::taskOne);
         service.submit(ExecutorServiceThreadFactory::taskTwo);
     }
    
  • Executer service can be created with virtual thread factory as well, just putting thread factory with it constructor argument.

  • Can benefits features of Executer service like Future and Completable Future.

JEP-425中查找更多信息。

在堆栈上使用本地帧进行阻塞时,请小心处理一些库,这些库通过网络进行长时间轮询,例如Kafka消费者。在JDK 20中,我还能够从具有混合使用同步块和ReentrantLocks的代码中创建死锁场景。由于虚拟线程缺少堆栈信息,目前调试起来相当困难。 - jocull

5

协程(即虚拟线程)的一个巨大优势是它们可以在没有回调缺陷的情况下生成高并发水平。

首先让我介绍一下 Little 定律:

concurrency = arrival_rate * latency

我们可以将其重写为:

arrival_rate = concurrency/latency

在一个稳定的系统中,到达率等于吞吐量。
throughput = concurrency/latency

为增加吞吐量,你有两个选择:
  1. 减少延迟;但是,由于对于远程调用或请求磁盘所需时间你没有太大的控制力,这通常非常困难。
  2. 增加并发
使用常规线程时,由于上下文切换开销,通过阻塞调用达到高水平的并发通常很困难。在某些情况下可以异步发出请求(例如NIO + Epoll 或 Netty io_uring 绑定),但此时你需要处理回调和回调地狱。
使用虚拟线程,请求可以异步发出并挂起虚拟线程并安排另一个虚拟线程。一旦接收到响应,虚拟线程将被重新安排,这完全透明。编程模型比使用经典线程和回调更直观。

1
有时人们必须构建能够处理大量同时客户端的系统。原生线程由于RAM消耗和上下文切换成本不足以胜任此任务。
虚拟线程使我们能够在不改变我们的心理模型的情况下同时运行数百万个I/O绑定任务。
这就是为什么Golang进入了工业界(除了Google支持之外)。 Goroutines与Java的虚拟线程非常相似,它们解决了同样的问题。
还有其他实现虚拟线程所需要的方法(例如NIO和相关的反应器模式)。然而,这需要使用消息循环和回调,这会扭曲你的思维(这就是为什么很多人讨厌JavaScript)。它们有一些抽象层使事情变得更容易,但它们也有一个代价。

0
真希望大家在讨论问题时能说明使用的操作系统。我强烈怀疑在Windows上,Java线程具有性能优势,但在Linux上则不然。

你能详细说明一下吗?是因为Windows线程更轻量级吗? - undefined
正好相反。我认为Windows线程很重,创建它们或在上下文切换期间存在很大的开销。在Linux上,线程和进程之间几乎没有太大区别,除了进程内线程的共享内存。调度程序以相同的方式对待它们。对我来说,一直很清楚,在Linux上创建进程比在Windows上便宜得多。 - undefined
1
即使它们很轻量级,也不能比VT更轻。Java线程需要1MB的堆栈内存才能启动。在Java中生成一个进程更加丑陋,而线程具有更好的仪器功能,我看不出使用进程而不是线程的任何理由,即使资源相等。 - undefined
在做出判断之前,我想看一些关于Java线程与平台线程(而非进程)在Linux上的数据。 - undefined
Java线程与平台线程一对一映射。虚拟线程在Java线程之上增加了一层抽象。 - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接