Java 虚拟线程:案例研究

server/2024/10/18 10:15:24/

一. 关键要点

  • 虚拟线程是 Java 并发编程的一个重要进步,但在运行典型的云原生 Java 工作负载方面,它们并不比 Open Liberty 现有的自主线程池具有明显的优势。
  • 对于 CPU 密集型工作负载,由于目前尚不清楚的原因,虚拟线程的吞吐量低于 Open Liberty 的线程池。
  • 由于采用了每个请求一个线程的模型,虚拟线程从空闲到最大吞吐量的加速时间比 Open Liberty 的线程池更快。
  • Open Liberty 部署中的内存占用量会因应用程序设计、工作负载级别和垃圾收集行为等因素而有很大差异,因此虚拟线程占用量的减少可能不会导致内存使用量的整体减少。
  • 虚拟线程在某些用例中表现出一些意外的性能问题,Java 开发人员应该注意这一点。

JDK 21 的发布带来了一个广为人知的新功能——Java 虚拟线程。该功能标志着 Java 开发人员在更好地处理应用程序中的并行性方面取得了重大飞跃。Java 虚拟线程的一些目标包括:

  • 轻量级、可扩展且用户友好的并发模型
  • 高效利用系统资源
  • “大大减少编写、维护和观察高吞吐量并发应用程序的工作量”(JEP425)

虚拟线程引起了 Java 开发者社区的极大兴趣,其中包括应用程序框架,例如Open Liberty,这是一个开源、模块化、云原生的 Java 应用程序运行时。评估了这项新的 Java 功能是否可以为我们的用户带来好处,甚至可能取代 Liberty 应用程序运行时本身使用的当前线程池逻辑。至少,我们希望更好地了解虚拟线程技术及其性能,以便我们能够为 Liberty 用户提供明智的指导。

本文报告了我们的发现。其中包括:

  • Java 虚拟线程实现的概述。
  • 当前 Liberty 线程池技术的概述。
  • 我们对一些绩效指标进行了评估,包括一些意外的观察结果。
  • 我们的研究结果的摘要。

二. Java 虚拟线程

虚拟线程首次在 JDK 19 中引入,在 JDK 20 中得到增强,并在 JDK 21 中最终确定(如JDK 增强提案 (JEP) 444中所述)。

过去,Java 开发人员使用“每个请求一个线程”模型实现应用程序,其中每个请求在其生命周期内由专用线程处理。这些线程(称为平台线程)作为操作系统线程(OS 线程)的包装器实现。但是,OS 线程占用大量系统内存并由 OS 层调度,随着部署的线程越来越多,这可能会导致扩展问题。

虚拟线程的主要动机之一是保持线程请求模型的简单性,同时避免专用操作系统线程的高成本。虚拟线程通过最初将每个线程创建为 Java 堆上的轻量级对象,并仅在需要时使用操作系统线程,最大限度地减少了此问题。这种操作系统线程的“共享”可以更好地利用系统资源。从理论上讲,这是虚拟线程的一个优势:开发人员现在可以在单个 JVM 中有效地使用“数百万个线程”。

下图显示了 Java 虚拟线程和 OS 线程之间的多对一关系,然后这些线程被调度在 CPU 级别运行。

三. Open Liberty 的自主线程池

Open Liberty 的共享线程池方法还最大限度地降低了专用 OS 线程的高成本。Liberty 使用共享线程(称为“Liberty 线程池”)来执行应用程序业务逻辑功能,并为 I/O 功能分配单独的线程。此外,Liberty 线程池具有自适应性,并且可以自动调整大小(如本文所述)。对于大多数用例,无需进行额外调整,但可以选择配置最小和最大池大小。

与 Web 服务器(例如使用虚拟线程实现的 Helidon Web 服务器)不同,Liberty 之类的应用程序运行时不仅仅是建立 I/O 连接,然后长时间处于空闲状态。在 Liberty 上运行的应用程序通常会执行大量业务逻辑,这需要 CPU 资源。Liberty 部署通常不会使用数千或数百万个线程,因为 CPU 资源被几百个线程(或更少)完全消耗,尤其是在仅分配了几个甚至一小部分 CPU 的容器或 pod 中。

四. 性能测试

我们的评估主要侧重于 Liberty 客户常用的用例和配置。我们使用现有的基准测试应用程序来比较 Liberty 的线程池和虚拟线程的相对性能。这些基准测试应用程序使用 REST 和 MicroProfile,并在事务期间执行一些基本的业务逻辑。

我们的目标是模拟如果我们用虚拟线程替换 Liberty 中的自主线程池,大多数 Liberty 用户将看到的情况。因此,我们的评估主要侧重于具有 10 到 100 个线程的配置。但是,我们扩展了评估范围,还比较了 Liberty 的线程池和具有几千个线程的虚拟线程行为,因为使用多线程运行是虚拟线程的一大优势。

为了评估执行虚拟线程卸载和挂载操作的用例,我们使用了一款在线银行模拟应用,该应用会向远程系统发出请求,并在可配置的延迟后做出响应。延迟响应意味着测试系统中的线程在 I/O 上被阻塞,并且在一段时间内未被 CPU 使用。此应用会生成允许在事务中途卸载虚拟线程,然后在收到远程系统的回复后重新挂载的工作类型(即,它允许共享操作系统线程)。

1. 测试用例环境

我们使用Eclipse Temurin(带有 HotSpot JVM 的 OpenJDK)和IBM Semeru Runtimes(带有 OpenJ9 JVM 的 OpenJDK)运行了这些性能测试。我们观察到 Liberty 的线程池和虚拟线程在这两个 JDK 上的性能差异相似。除非另有说明,否则下面显示的结果都是在运行 Liberty 23.0.0.10 GA 和 Temurin 21.0.1_12 版本时生成的。

免责声明:我们对虚拟线程的评估侧重于如果使用上述“每个请求一个线程”模型实现虚拟线程来取代自主线程池,Liberty 用户是否会获得性能优势。在阅读测试用例时,需要牢记这一重要背景,因为对于没有像 Liberty 那样具有自调节线程池的其他应用程序运行时,结果可能会完全不同。

测试案例 1:CPU 吞吐量

**目标:**评估 CPU 吞吐量以发现使用虚拟线程与 Liberty 的线程池时是否存在性能损失。

**发现:**对于某些配置,使用虚拟线程时工作负载的吞吐量比使用 Liberty 的线程池时低 10-40%。

在本测试中,我们运行了多个 CPU 密集型应用程序,并比较了在给定数量的 CPU 上(使用虚拟线程与 Liberty 的线程池运行)每秒可以完成多少事务 (tps)。我们使用Apache JMeter来驱动各种负载,以使小型系统达到越来越高的 CPU 利用率。

在一个示例中,我们以短暂的 2 毫秒延迟运行网上银行应用程序,以便虚拟线程功能(在 OS 线程上挂载/卸载/重新挂载)在每个单独的任务上执行,而应用程序整体仍然相当占用 CPU。负载逐渐增加,在每个负载级别运行足够长的时间(150 秒),以获得稳定的平均吞吐量测量值。

在低负载水平下,网上银行应用程序的虚拟线程吞吐量大致等于 Liberty 的线程池吞吐量(见图),虚拟线程使用的 CPU 稍多一些(未显示 CPU 利用率)。随着负载的增加,使用虚拟线程的每秒交易量逐渐落后于 Liberty 的线程池。


我们预计虚拟线程在这种 CPU 密集型应用程序中可能会稍微慢一些,因为虚拟线程不会使代码的运行速度比在传统 Java 平台线程上运行的速度更快,并且虚拟线程会产生一些开销,包括:

  • 挂载和卸载:虚拟线程挂载在平台线程上以在阻塞点和执行完成时运行和卸载。此外,每次挂载或卸载操作都会发出JVM 工具接口(JVMTI) 通知。这些操作很轻量,但并非零成本。
  • 垃圾收集:每次事务都会创建并丢弃一个虚拟线程对象,并产生分配和垃圾收集成本。
  • 线程链接上下文丢失:Liberty 使用ThreadLocal变量在请求之间共享公共信息。使用池化线程时,这种方法的效率会因虚拟线程而降低,因为ThreadLocal虚拟线程会消失。作为该项目的一部分,我们将主要ThreadLocal用途转换为其他非线程链接共享机制,但仍存在一些影响较小的实例。

然而,CPU 分析表明,这些可能的虚拟线程开销都不足以解释观察到的吞吐量差异。我们将在后面的“意外的虚拟线程性能发现”部分讨论其他可能的原因。

对于少数 CPU 上的 CPU 密集型应用程序(Liberty 的典型用例),与在 Liberty 线程池中的常规 Java 平台线程上运行相同代码相比,虚拟线程并没有使 Java 代码的执行速度更快。

测试案例 2:启动时间

**目标:**量化虚拟线程与 Liberty 的线程池相比达到完全吞吐量的速度。

**发现:**当突然施加重负载时,在虚拟线程上运行的应用程序达到最大吞吐量的速度明显快于在 Liberty 的线程池上运行时。

虚拟线程使用的简单模型是,每个任务都有自己的(虚拟)线程来运行,因此我们的 Liberty 虚拟线程原型启动了一个新的虚拟线程来执行从负载驱动程序收到的每个任务。因此,使用虚拟线程,每个任务都会立即有一个线程可以运行,而使用 Liberty 的线程池,任务可能必须等待线程可用。

为了充分测试此场景,我们需要运行具有足够长响应延迟的在线银行应用程序,以导致数千个并发交易使 CPU 饱和。此工作负载需要数千个线程来处理交易,无论是每个交易的虚拟线程还是 Liberty 线程池中的传统 Java 平台线程。

处理 Liberty 线程池中的数千个线程

我们发现 Liberty 的线程池在几千个线程的情况下运行良好。由于各种虚拟线程讨论中都提到了使用许多平台线程的问题,因此我们一直在寻找 Liberty 线程池中出现问题的迹象。例如,它在处理几千个线程时可能会变得不稳定,或者出现其他“线程过多”问题的迹象。我们没有看到此类问题。

相反,我们发现 Liberty 线程池的吞吐量实际上比虚拟线程略快 (2-3%)。Liberty 线程池的 CPU 使用率降低了约 10%,而 Liberty 线程池的每 CPU 事务利用率提高了 12-15%(主要是由于自主控制的设计决定了 Liberty 的线程池大小)。Liberty 线程池的自主控制允许池在工作负载需要时增长到数千个线程,同时保持稳定运行。

使用 Liberty 线程池与虚拟线程的启动时间

在扩展评估中,虚拟线程从低负载到满负荷的上升时间非常快。Liberty 的线程池上升速度较慢,因为它会根据观察到的吞吐量逐渐调整;Liberty 的线程池以 1500 毫秒的间隔决定是增加、缩小还是保持相同的大小,并且需要数十分钟才能逐渐决定应添加越来越多的线程来处理提供的负载。

经过这次测试,我们修改了 Liberty 的线程池自主性,以便在有更多空闲 CPU 资源可用且 Liberty 线程池请求队列较深时更积极地扩大线程池。通过此修复(在Open Liberty 23.0.0.10 及更高版本中可用),当在 Liberty 线程池上运行的在线银行应用程序突然受到重负载(超过 30 秒)时,该应用程序现在仅在虚拟线程上运行同一应用程序后约 20-30 秒(而不是数十分钟)即可达到峰值吞吐量,即使工作负载需要空闲 JVM 上大约 6000 个线程(见图)。虚拟线程原型仍然能够更快地启动,因为它在到达时为每个请求提供一个新的虚拟线程,但虚拟线程和 Liberty 线程池之间的加速差异已大大缩小。

测试用例3:内存占用

**目标:**确定在恒定负载下,Java 进程(包括虚拟线程和 Liberty 的线程池)使用了多少内存。

**发现:**虚拟线程较小的每个线程占用空间在需要几百个线程的配置中仅具有相对较小的直接影响,并且可能会被 JVM 中其他内存使用的影响所抵消。

虚拟线程使用的内存(Java 进程大小)比传统平台线程少,因为它们不需要专用的后备操作系统线程。此测试用例测量了虚拟线程的这种每线程内存优势如何转化为典型 Liberty 工作负载级别下 JVM 的总内存使用量。我们发现了一组相当复杂的结果。

我们原本以为使用虚拟线程运行的负载会比使用 Liberty 线程池运行相同负载时占用更少的内存。但我们发现,有时虚拟线程配置占用的内存较少,但有时占用的内存较多。

这种变化的出现是因为线程实现以外的因素也影响了 Java 进程的内存使用。在我们的测试中,对内存使用变化产生重大影响的一个因素是 DirectByteBuffers (DBB),它是 Java 网络基础架构的一部分。(有关 Direct ByteBuffers 的背景知识,请参阅ByteBuffer API 。)

DirectByteBuffers 是一种由两部分组成的结构,堆上有一个小型 Java 引用对象,本机或堆外区域中有一个大小可变(通常大得多)的内存区域。Java 引用对象在不再需要后被释放并被垃圾回收,之后关联的本机内存被清除。如果 DirectByteBuffers 引用对象存活的时间足够长,可以提升到旧代区域(在典型的 Java 分代 GC 模型中),则本机内存分配将保留到全局 GC。由于全局 GC(根据设计)并不频繁,因此这种分配和保留模式可能会导致 Java 进程占用空间的增长显著大于活动运行时使用量。

注意:此测试在最小堆大小较小和最大堆大小相对较大的情况下运行。这是为了让堆内存使用率的变化明显成为影响 JVM 总内存使用率的因素之一。

在某些情况下,使用虚拟线程运行的负载比使用 Liberty 线程池运行的负载占用更多内存,我们发现差异归因于 DirectByteBuffers 保留。这并不表示虚拟线程存在问题:DirectByteBuffers 内存保留多长时间取决于多个因素的相互作用,包括事务持续时间、Java 堆临时内存大小和保有权提升时间。我们可以使用略有不同的配置或调整运行相同的测试,并使虚拟线程使用的内存少于 Liberty 线程池,差异来自 DirectByteBuffers 保留。

例如,工作负载略微增加 10% 就会导致在 Liberty 线程池上运行的网上银行应用程序使用的内存减少 25%,但导致在虚拟线程上运行的同一应用程序使用的内存增加 185%(参见图表)。


避免为每个虚拟线程分配一个操作系统线程可以显著减少本机内存,但与应用程序运行时使用的其他内存相比,这可能相对较小。在只需要几百个线程的配置中,使用虚拟线程带来的本机内存减少可能会被其他难以预测的影响所掩盖,例如 Java 堆的增长速度和释放相关本机内存的垃圾收集的及时性,例如 DirectByteBuffers。

在性能工作中,人们常说“YMMV”(“你的里程可能会有所不同”)这句话。有些虚拟线程用户会发现系统的总内存使用量减少,而有些用户则会看到增加。内存使用量的变化中,只有一小部分可归因于虚拟线程

意外的虚拟线程性能发现

我们对虚拟线程的调查涉及对基准应用程序进行的许多实验,改变了 CPU 数量、负载量、远程延迟(对于网上银行应用程序)、堆大小等。这些实验产生了一些非常出乎意料的发现,这些发现与前面的部分不太吻合。

具体来说,在两个 CPU 上运行短时间任务时,我们有时会发现虚拟线程的性能非常差。我们将其追溯到 Linux 内核调度程序与 Java 的 ForkJoinPool 线程管理的交互方式。较新版本的 Linux 内核调度程序改变了与 ForkJoinPool 的交互方式,但我们仍然发现虚拟线程的性能很差,只是方式不同。虚拟线程用户可能会遇到类似的问题,应该注意,升级到较新的 Linux 内核只会改变行为,而不是修复它。

在此测试中,我们使用了 MicroProfile 基准测试应用程序 mp-ping,它对 REST 服务执行简单的“ping”。负载驱动程序在 Liberty 上运行的 mp-ping 应用程序上点击 REST URL,并立即收到“ping”响应(0.05-0.10 毫秒)。

虚拟线程上运行时吞吐量低且 CPU 低

我们发现,在虚拟线程上运行 2-CPU 配置的短时任务 (mp-ping) 产生的吞吐量比在 Liberty 线程池上运行的吞吐量低得多,因此 CPU 利用率也较低。虚拟线程上的吞吐量低至 Liberty 线程池吞吐量的 50-55%,如下图所示。

在执行持续时间较长的任务时(最长可达 1 毫秒),性能也会较差,但使用更多 CPU 时,情况会好一些,只是不那么严重。

我们在具有不同 Linux 内核级别的多个不同硬件平台上重现了虚拟线程的低吞吐量和低 CPU 利用率问题,以确保该行为不是原始测试系统上的某些怪癖造成的。我们还创建了一个简单的独立应用程序,该应用程序生成在可配置时间段内消耗 CPU 的任务,它显示了类似的低吞吐量和低 CPU 利用率行为,因此性能不佳不是由 Liberty 造成的。

ForkJoinPool 和 Linux 内核调度程序

虚拟线程性能不佳的根本原因进行调查后发现,Java 的 ForkJoinPool(用于管理支撑虚拟线程的平台线程)在有大量工作需要完成时会将其中一个平台线程暂停 10-13 毫秒。如果一个平台线程暂停,虚拟线程就无法及时运行,从而导致我们观察到的低吞吐量和低 CPU 利用率。

进一步的调查表明 Linux 线程调度程序存在问题:跟踪显示 ForkJoinPool 代码中调用了取消停放的平台线程,但并未立即取消停放。我们得出结论,性能不佳是由 Linux 线程调度程序和 ForkJoinPool 工作线程管理之间的交互引起的。这种交互对 Liberty 的线程池来说不是问题,因为它不使用 ForkJoinPool 来管理平台线程。

我们尝试了可用的 ForkJoinPool 调整选项、Linux 调度程序调整选项以及对 ForkJoinPool 实现的各种修改,产生了一些小的性能改进,但并没有显著缩小与 Liberty 线程池性能的差距。

注意:我们的调查显示,对于我们在 4.18 Linux 内核中发现的虚拟线程问题,使用 2 个 CPU 运行可能是最糟糕的情况。在具有 1 个 CPU 或 4 个或更多 CPU 的测试系统上运行相同的工作负载时,性能问题仍然存在,但不那么突出。

运行虚拟线程时吞吐量低且 CPU 使用率高

前两节描述的测试主要针对 Linux 内核 4.18,这是 Red Hat Enterprise Linux(RHEL)8 中当前可用的内核。当我们在较新的 Linux 内核 5.14(RHEL 9)和内核 6.2(Ubuntu 22.04)上运行相同的测试时,我们发现虚拟线程存在不同的性能问题。

使用较新的 Linux 内核,在虚拟线程上运行 mp-ping 应用程序产生的吞吐量仍然比 Liberty 线程池略低,但 CPU 利用率更高。随着负载的增加,虚拟线程的吞吐量比 Liberty 线程池的吞吐量低 20-30%,如下图所示。


这些发现表明,对于某些工作负载,虚拟线程可能存在不同的性能问题,这取决于 Linux 内核级别。

调查这些行为原因的后续步骤

我们与 OpenJDK 社区成员讨论了这些发现,并将继续与他们一起研究和测试修改。两个图表中显示的运行均使用了 Temurin 22 的最新夜间版本,以利用最新版本的 ForkJoinPool(目前正在修订中),以防 ForkJoinPool 修订版纠正了我们最初在 Temurin 21 中观察到的问题(但事实并非如此)。

需要进一步调查才能完全确定根本原因并找到解决方案,我们正在积极与 OpenJDK 社区合作。我们非常感谢Doug Lea(Java 并发工作领域的领导者和 ForkJoinPool 类的作者)和 OpenJDK 社区的其他成员帮助我们调查这些虚拟线程性能问题。我们在此报告这些问题,以便提醒可能遇到类似问题的虚拟线程用户,具体取决于他们的虚拟线程用例。

总结和结论

我们使用一些代表 Liberty 典型客户用途的简单应用程序研究了虚拟线程的性能,以及三个主要性能方面:

  • 吞吐量:在我们尝试的应用中,虚拟线程的性能比 Liberty 的线程池差。根据 CPU 数量、任务持续时间、Linux 内核级别和 Linux 调度程序调整的不同,这种糟糕的性能在不同级别上都有所体现。
  • 加速:当工作负载突然增加,且任务持续时间较长且需要许多线程时,虚拟线程会比 Liberty 的线程池更快地达到满吞吐量,但这种优势很快就会消失。
  • 内存占用:在需要几百个线程的配置中,虚拟线程较小的每个线程占用空间的影响相对较小,并且可能会被 JVM 中其他内存使用的影响所抵消。

此外,我们惊讶地发现,在某些用例中,在虚拟线程上运行时存在性能问题。我们将此问题追溯到 Linux 内核调度程序与 Java 的 ForkJoinPool 线程管理之间的交互。即使使用较新版本的内核,此问题仍然存在,尽管方式有所不同。

在将 Liberty 现有的线程管理与新的 Java 虚拟线程进行比较后,我们发现,现有的 Liberty 线程池在中等高(1000 个线程)并发级别下为 Liberty(以及在 Liberty 上运行的任何应用程序)提供相当或通常更好的性能。虽然与 Liberty 的线程池相比,虚拟线程可以在更高的并发级别上显示出优势,但这取决于正确的条件、高任务延迟、大量 CPU 或这些因素的组合。

Java 应用程序开发人员仍然可以在自己的 Liberty 上运行的应用程序中 使用虚拟线程,但我们暂时决定不使用虚拟线程替换 Liberty 线程池。如本文前面所述,在很多用例中,虚拟线程可能非常有用,有助于简化多线程应用程序的开发。但是,如上所述,开发人员还应该注意某些类型的应用程序中的一些问题。通过在本文中分享我们的经验,我们希望 Java 开发人员能够更好地了解何时以及是否在自己的应用程序中实现虚拟线程


http://www.ppmy.cn/server/62301.html

相关文章

基于redis的分布式锁

目前业务组新参与了一个项目,涉及到用户积分的增删改查核销等等,预计上线后qps大概在20k左右,我们使用了基于redis的分布式锁来保证数据的一致性。 Slf4j Service public class RedisLockService {/*** 加锁lua脚本*/private static final S…

通过命令行工作流提升工作效率的实战教程(持续更新)

大家好,我是herosunly。985院校硕士毕业,现担任算法研究员一职,热衷于机器学习算法研究与应用。曾获得阿里云天池比赛第一名,CCF比赛第二名,科大讯飞比赛第三名。拥有多项发明专利。对机器学习和深度学习拥有自己独到的见解。曾经辅导过若干个非计算机专业的学生进入到算法…

记一次饱经挫折的阿里云ROS部署经历

前言 最近在参加的几个项目测评里,我发现**“一键部署”这功能真心好用,省下了不少宝贵时间和力气,再加上看到阿里云现在有个开源上云**的活动。趁着这波热潮,今天就聊聊怎么从头开始,一步步搞定阿里云的资源编排服务…

Python数据分析~~美食排行榜

目录 1.模块的导入和路径的选择 2.访问前面五行数据 3.按照条件进行筛选 4.获取店铺评分里面的最高分 5.打印对应的店铺的名字 1.模块的导入和路径的选择 # 导入pandas模块,简称为pd import pandas as pd # 使用read_csv()函数 # TODO 读取路径"/Users/fe…

基于LSTM及其变体的回归预测

1 所用模型 代码中用到了以下模型: 1. LSTM(Long Short-Term Memory):长短时记忆网络,是一种特殊的RNN(循环神经网络),能够解决传统RNN在处理长序列时出现的梯度消失或爆炸的问题。L…

STM32HAL库+ESP8266+cJSON+微信小程序_连接华为云物联网平台

STM32HAL库ESP8266cJSON微信小程序_连接华为云物联网平台 实验使用资源:正点原子F407 USART1:PA9P、A10(串口打印调试) USART3:PB10、PB11(WiFi模块) DHT11:PG9(采集数据…

Github 2024-07-13 Rust开源项目日报 Top10

根据Github Trendings的统计,今日(2024-07-13统计)共有10个项目上榜。根据开发语言中项目的数量,汇总情况如下: 开发语言项目数量Rust项目10C项目1Zed: 由Atom和Tree-sitter的创建者开发的高性能多人代码编辑器 创建周期:1071 天开发语言:Rust协议类型:OtherStar数量:94…

Objective-C 中字符串的保存位置

在 Objective-C 中,字符串常量和动态创建的字符串(例如通过 stringWithFormat:、initWithString: 等方法创建的字符串)在内存中保存的位置一样么 ? 在 Objective-C 中,字符串常量和动态创建的字符串在内存中的保存位置…