到底是什么让 Java 虚拟线程变得更好

Bor*_*lov 27 java multithreading project-loom

我对 Loom 项目非常感兴趣,但有一件事我无法完全理解。

\n

大多数 Java 服务器使用具有一定线程限制(200、300 ..)的线程池,但是,您不受操作系统的限制可以产生更多线程,我已经读到,通过针对 Linux 的特殊配置,您可以达到巨大的数量。

\n

操作系统线程成本更高,启动/停止速度更慢,必须处理上下文切换(按数量放大),并且您依赖于操作系统,而操作系统可能拒绝为您提供更多线程。

\n

话虽如此,虚拟线程也消耗类似数量的内存(或者至少我是这么理解的)。使用 Loom,我们可以进行尾部调用优化,这应该会减少内存使用。另外,同步和线程上下文复制应该仍然是一个类似大小的问题。

\n

事实上,您可以生成数百万个虚拟线程

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

当我使用平台线程时,上面的代码在 25k 左右中断并出现 OOM 异常。

\n

我的问题是,到底是什么让这些线程如此轻量,是什么阻止我们生成 100 万个平台线程并使用它们,是否只是上下文切换使常规线程如此“重”。

\n

一个非常相似的问题

\n

到目前为止我发现的事情:

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

Lun*_*tic 8

虚拟线程包装在平台线程上,因此您可能会认为它们是 JVM 提供的幻觉,整个想法是将线程的生命周期设置为CPU 绑定操作。

究竟是什么让 Java 虚拟线程变得更好?

虚拟线程的优点

  • 表现出与平台线程完全相同的行为。
  • 一次性的,并且可以扩展到数百万个。
  • 比平台线程轻得多。
  • 创建时间快,与创建字符串对象一样快。
  • JVM 对 IO 操作进行定界延续,而虚拟线程则不进行 IO。
  • 但可以像以前一样使用顺序代码,但更有效。
  • JVM 给人一种虚拟线程的错觉,实际上整个故事都在平台线程上进行。
  • 随着虚拟线程CPU核心的使用变得更加并发,虚拟线程和多核CPU与ComputableFutures的结合对于并行化代码来说是非常强大的

虚拟线程使用注意事项

  • 不要使用监视器,即同步块,但这将在新版本的 JDK 中修复,替代方法是使用带有 try-final 语句的“ReentrantLock”。

  • 使用堆栈上的本机帧、JNI 进行阻塞。非常罕见

  • 控制每个堆栈的内存(减少线程区域设置并且无深度递归)

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

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

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

第四层架构有更好的理解。

在此输入图像描述

中央处理器

  • 多核 CPU 多核在 cpu 中执行操作。

操作系统

  • 操作系统线程 操作系统调度程序将 CPU 时间分配给占用的操作系统线程。

虚拟机

  • 平台线程完全包裹在具有两个任务操作的操作系统线程上
  • 虚拟线程在每个CPU绑定操作中与平台线程相关联,每个虚拟线程可以在不同时间与多个平台线程相关联。

具有执行器服务的虚拟线程

  • 使用执行器服务更有效,因为它与线程池关联,并且仅限于与其适用的线程,但是与虚拟线程相比,使用执行器服务和虚拟包含的我们不需要处理或管理关联的线程池。

     try(ExecutorService service = Executors.newVirtualThreadPerTaskExecutor()) {
         service.submit(ExecutorServiceVirtualThread::taskOne);
         service.submit(ExecutorServiceVirtualThread::taskTwo);
     }
    
    Run Code Online (Sandbox Code Playgroud)
  • 执行器服务在 JDK 19 中实现了 Auto Closable 接口,因此当在“try with resources”中使用时,一旦到达“try”块的末尾,就会调用“close”api,或者主线程将等待所有提交的任务及其专用虚拟线程完成其生命周期,并且关联的线程池被关闭。

     ThreadFactory factory = Thread.ofVirtual().name("user thread-", 0).factory();
     try(ExecutorService service = Executors.newThreadPerTaskExecutor(factory)) {
         service.submit(ExecutorServiceThreadFactory::taskOne);
         service.submit(ExecutorServiceThreadFactory::taskTwo);
     }
    
    Run Code Online (Sandbox Code Playgroud)
  • 执行器服务也可以使用虚拟线程工厂创建,只需将线程工厂与其构造函数参数放在一起即可。

  • 可以受益于执行器服务的功能,例如 Future 和 Completable Future。

了解有关JEP-425的更多信息


pve*_*jer 7

协程(即虚拟线程)的一大优点是它们可以生成高水平的并发性,而没有回调的缺点。

首先介绍一下利特尔定律:

concurrency = arrival_rate * latency
Run Code Online (Sandbox Code Playgroud)

我们可以将其重写为:

arrival_rate = concurrency/latency
Run Code Online (Sandbox Code Playgroud)

在稳定的系统中,到达率等于吞吐量。

throughput = concurrency/latency
Run Code Online (Sandbox Code Playgroud)

要提高吞吐量,您有 2 个选择:

  1. 减少延迟;这通常非常困难,因为您对远程调用或磁盘请求所花费的时间几乎没有影响。
  2. 增加并发性

对于常规线程,由于上下文切换开销,很难通过阻塞调用达到高并发水平。在某些情况下可以异步发出请求(例如NIO + Epoll或Netty io_uring绑定),但随后您需要处理回调和回调地狱。

使用虚拟线程,可以异步发出请求并停放虚拟线程并调度另一个虚拟线程。一旦收到响应,虚拟线程就会被重新调度,并且这是完全透明地完成的。该编程模型比使用带回调的经典线程直观得多。