Java 虚拟线程学习
  dBFTbkVLMBge 2023年11月02日 26 0

1. Java线程模型和虚线程

Java 虚拟线程学习_虚拟线程

近几十年来,我们一直依靠上述多线程模型来解决 Java 中的并发编程问题。为了提高系统的吞吐量,我们必须不断增加线程的数量,但是机器的线程很昂贵,可用线程的数量是有限的。尽管我们使用各种线程池来最大限度地提高线程的成本效益,但在 CPU、网络或内存资源被耗尽之前,线程往往成为我们应用程序性能的瓶颈,无法释放硬件应具有的最大性能。

Java 虚拟线程学习_虚拟线程_02

为了解决这个问题,Java19 引入了虚拟线程。在 Java19 中,我们以前使用的线程称为平台线程,仍然与系统内核线程一一对应。大量 (M个) 的虚拟线程,运行在少量 (N个) 的平台线程上(与 OS 线程一一对应)(M:N 调度)。JVM调度多个虚拟线程在特定平台线程上执行,并且在平台线程上一次只执行一个虚拟线程


1.1 平台线程

在 Java 中,经典线程是 java.lang.Thread 类的实例。后面我们也将它们称为平台线程。

传统上,Java 将平台线程视为围绕操作系统 (OS) 线程的瘦包装器。创建这样的平台线程一直很昂贵(由于操作系统维护的堆栈和其他资源很大),因此 Java 一直使用线程池来避免线程创建的开销。

平台线程的数量也必须受到限制,因为这些非常消耗资源的线程会影响整个机器的性能,这主要是因为平台线程被 1:1 映射到 OS 线程。通常,在CPU,网络连接等成为系统瓶颈前,相当数量的平台线程会首先成为系统的瓶颈。

换句话说,在硬件资源稍有冗余的前提下,平台线程首先成为了系统吞吐量的瓶颈。

1.2 平台线程的可扩展性问题

平台线程一直很容易建模、编程和调试,因为它们使用平台的并发单元来表示应用程序的并发单元。它被称为一个线程一个请求的模式。

但是这种模式限制了服务器的吞吐量,因为并发请求的数量(服务器可以处理)与服务器的硬件性能成正比。因此,即使在多核处理器中,可用线程的数量也必须受到限制。

除了线程数量之外,延迟也是一个大问题。如果我们仔细观察,在当今的微服务世界中,请求是通过在多个系统和服务器上获取/更新数据来服务的。在应用程序等待来自其他服务器的信息时,当前平台线程保持在空闲状态。这是对计算资源的浪费,也是实现高吞吐量应用程序的主要障碍。

1.3 反应式编程 (Reactive Programming) 的问题

反应式编程解决了平台线程等待其他系统响应的问题。异步 API 不等待响应,而是通过回调工作。每当线程调用异步 API 时,平台线程都会返回到池中,直到响应从远程系统或数据库返回。稍后,当响应到达时,JVM 将从池中分配另一个线程来处理响应,依此类推。这样,多个线程参与处理单个异步请求。

在异步编程中,延迟被消除了,但由于硬件限制,平台线程的数量仍然有限,因此我们对可扩展性有限制。另一个大问题是这样的异步程序在不同的线程中执行,因此很难调试或分析它们。

此外,我们必须采用一种新的编程风格,远离典型的循环和条件语句。新的 lambda 样式语法使得理解现有代码和编写程序变得困难,因为我们现在必须将程序分解为多个可以独立和异步运行的代码块。

所以我们可以说,虚拟线程还通过适应传统语法来提高代码质量,同时具有反应式编程的好处。


1.4 虚拟线程的前景

与传统线程类似,虚拟线程也是 java.lang.Thread 的一个实例,它在底层 OS 线程上运行其代码,但它不会在代码的整个生命周期内阻塞 OS 线程。保持操作系统线程空闲意味着许多虚拟线程可以在同一个操作系统线程上运行它们的 Java 代码,从而有效地共享它。

值得一提的是,我们可以在一个应用程序中创建非常多的虚拟线程(数百万),而不依赖于平台线程的数量。这些虚拟线程由 JVM 管理,因此它们也不会增加额外的上下文切换开销,因为它们作为普通 Java 对象存储在 RAM 中。

与传统线程类似,应用程序的代码在请求的整个持续时间内都在虚拟线程中运行(以每个请求线程的方式),但虚拟线程仅在 CPU 上执行计算时才消耗操作系统线程。它们在等待或休眠时不会阻塞 OS 线程。

虚拟线程有助于实现与具有相同硬件配置的异步 API 相同的高可扩展性和吞吐量,而不会增加语法复杂性。

虚拟线程最适合执行大部分时间都处于阻塞状态的代码,例如等待数据到达网络套接字或等待队列中的元素

2.平台线程和虚拟线程的区别

虚拟线程始终是守护线程。 Thread.setDaemon(false) 方法不能将虚拟线程更改为非守护线程。请注意,当所有启动的非守护线程都终止时,JVM 终止。这意味着 JVM 在退出之前不会等待虚拟线程完成。

Java 虚拟线程学习_虚拟线程_03

3.比较平台线程和虚拟线程的性能

先看下传统的平台线程,100个线程的线程池 执行任务 最后耗时100秒左右

import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class TestVirtualThread {


    public static  void main(String[] args) {
        // 先定义任务 sleep1秒
        final AtomicInteger atomicInteger = new AtomicInteger();
        Runnable runnable = () -> {
            try {
                Thread.sleep(Duration.ofSeconds(1));
            } catch(Exception e) {
                System.out.println(e);
            }
            System.out.println("Work Done - " + atomicInteger.incrementAndGet());
        };

       // 开10000个线程的线程池执行任务
        Instant start = Instant.now();
        try (var executor = Executors.newFixedThreadPool(100)) {
            for(int i = 0; i < 10_000; i++) {
                executor.submit(runnable);
            }
        }

        Instant finish = Instant.now();
        long timeElapsed = Duration.between(start, finish).toMillis();
        System.out.println("Total elapsed time : " + timeElapsed);

    }

}

Java 虚拟线程学习_虚拟线程_04

再使用虚拟线程执行,代码如下

import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class TestVirtualThread {


    public static  void main(String[] args) {
        // 先定义任务 sleep1秒
        final AtomicInteger atomicInteger = new AtomicInteger();
        Runnable runnable = () -> {
            try {
                Thread.sleep(Duration.ofSeconds(1));
            } catch(Exception e) {
                System.out.println(e);
            }
            System.out.println("Work Done - " + atomicInteger.incrementAndGet());
        };

        Instant start = Instant.now();
     

        // 虚拟线程池
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for(int i = 0; i < 10_000; i++) {
                executor.submit(runnable);
            }
        }

        Instant finish = Instant.now();
        long timeElapsed = Duration.between(start, finish).toMillis();
        System.out.println("Total elapsed time : " + timeElapsed);

    }

}

命令行编译这段java代码 因为是预览,需要设置参数开启才能成功

javac --release 20 --enable-preview D:\workcode\virtualThread\TestVirtualThread.java

然后命令行运行,也是需要设置参数

java --source 20 --enable-preview D:\workcode\virtualThread\TestVirtualThread.java

Java 虚拟线程学习_虚拟线程_05

耗时1.5秒完成 ,比传统平台线程实现快了很多倍

4.创建虚拟线程的方式

4.1 使用 Thread.startVirtualThread()

此方法创建一个新的虚拟线程来执行给定的 Runnable 任务

Runnable runnable = () -> System.out.println("Virtual Thread");
Thread.startVirtualThread(runnable);

//or

Thread.startVirtualThread(() -> {
	//Code to execute in virtual thread
	System.out.println("Virtual Thread");
});

4.2 使用 Thread.Builder

如果我们想在创建线程后显式启动它,我们可以使用 Thread.ofVirtual() 返回一个 VirtualThreadBuilder 实例。它的 start() 方法启动一个虚拟线程。

Java 虚拟线程学习_虚拟线程_06

4.3 使用 虚拟线程池

Executors.newVirtualThreadPerTaskExecutor()

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}

5. 最佳实践

5.1 不要建虚拟线程池

Java 线程池旨在避免创建新操作系统线程的开销,因为创建它们是一项昂贵的操作。但是创建虚拟线程并不昂贵,因此永远不需要将它们池化。建议每次需要时创建一个新的虚拟线程。

请注意,使用虚拟线程后,我们的应用程序可能能够处理数百万个线程,但其他系统或平台一次只能处理几个请求。例如,我们可以只有几个数据库连接或与其他服务器的网络连接。

在这些情况下,也不要使用线程池。相反,使用信号量来确保只有指定数量的线程正在访问该资源。

private static final Semaphore SEMAPHORE = new Semaphore(50);

SEMAPHORE.acquire();

try {
  // 信号量被控制在 50 来访问请求
  // 访问数据库或资源
} finally {
  SEMAPHORE.release();
}

5.2 避免使用线程局部变量 (ThreadLocal)

虚拟线程支持线程局部行为的方式与平台线程相同,但由于虚拟线程可以创建数百万个,因此只有在仔细考虑后才能使用线程局部变量。

例如,如果我们在应用程序中扩展一百万个虚拟线程,那么将有一百万个 ThreadLocal 实例以及它们所引用的数据。如此大量的实例会给内存带来很大的负担,应该避免。

在以后的 Java 版本中或许可以使用 Extent-Local 变量。

5.3 使用 ReentrantLock 而不是同步块

有两种特定场景,虚拟线程可以阻塞平台线程(称为 OS 线程的固定)。

当它在同步块或同步方法内执行代码时

当它执行本地方法或外部函数时

这种同步块不会使应用程序出错,但它会限制应用程序的可扩展性,类似于平台线程。

作为最佳实践,如果一个方法使用非常频繁并且它使用同步块,则考虑将其替换为 ReentrantLock 机制。

Java 虚拟线程学习_虚拟线程_07

长期以来,传统的 Java 线程一直很好用。随着微服务领域对可扩展性和高吞吐量的需求不断增长,虚拟线程将被证明是 Java 历史上的一个里程碑特性。

使用虚拟线程,一个程序可以用少量的物理内存和计算资源处理数百万个线程,这是传统平台线程无法做到的。当与结构化并发相结合时,它还将导致编写更好的程序。

6.Java虚拟线程对比go协程

GO GMP模型

Go 语言采用两级线程模型,其中 Goroutine 是 M:N 与系统内核线程,符合 Java 虚拟线程。 最终的 goroutine 仍然交给 OS 线程执行,但需要一个中介来提供上下文。 这是 G-M-P 模型。

G:goroutine,类似于进程控制块,持有栈、状态、id、函数等。G只有绑定到P才能被调度。

M:机器,系统线程,绑定到一个有效的 P 然后被调度。

P:逻辑处理器,持有各种队列G。对于G来说,P是cpu核心。对于 M,P 是上下文。

sched:调度器,保存GRQ(全局运行队列)、M空闲队列、P空闲队列、锁等信息。

Java 虚拟线程学习_虚拟线程_08

Go 调度程序有两个不同的运行队列。

GRQ,全局运行队列,尚未分配给 G for P(在 Go 1.1 之前只有 GRO 全局运行队列,但由于全局队列锁定的性能问题,添加了 LRQ 以减少锁定等待)。

LRQ,本地运行队列,每个P都有一个LRQ,负责管理分配给P的G。当LRQ中没有G要执行时,从GRQ中取出。

交接机制

当G执行阻塞操作时,GMP调度空闲M执行阻塞M LRQ中的其他G,以防止阻塞M影响LRQ中其他G的执行。

G1 在 M1 上运行,P 的 LRQ 有 3 个其他 G。

G1 进行同步调用,阻塞 M。

当 M1 下只有 G1 运行且没有 P 时,Scheduler 将 M1 与 P 分离。

将P绑定到空闲的M2,M2从LRQ中选择其他G运行。

G1 结束阻塞操作并返回到 LRQ。M1 被放置在空闲队列中进行备份。

偷工减料机制

GMP 为了最大化硬件的性能,任务窃取机制用于在 M 空闲时执行其他等待的 G。

有两个P,P1和P2。

如果 P1 的 Gs 都被执行,并且 LRQ 为空,则 P1 开始任务窃取。

在第一种情况下,P1 从 GRQ 获得 G。

在第二种情况下,P1 没有从 GRQ 获得 G,然后 P1 从 P2 LRQ 窃取 G。

切换机制是为了防止 M 阻塞,任务窃取是为了防止 M 空闲

Java 虚拟线程调度模型

JDK 依赖于操作系统中的线程调度器来调度基于操作系统线程实现的平台线程。对于虚拟线程,JDK 有自己的调度程序。JDK的调度器不是直接将虚拟线程分配给系统线程,而是将虚拟线程分配给平台线程(这就是前面提到的虚拟线程的M:N调度)。平台线程由操作系统的线程调度系统调度。

JDK 的虚拟线程调度器是一个类似ForkJoinPoolFIFO 模式的线程池。调度程序中的并行量取决于调度程序虚拟线程中的平台线程数。默认值是可用的 CPU 内核数,但可以使用系统属性进行调整jdk.virtualThreadScheduler.parallelism。注意ForkJoinPool这里与 不同ForkJoinPool.commonPool(),后者用于实现并行流,以 LIFO 模式运行。

ForkJoinPool并ExecutorService以不同的方式工作。ExecutorService有一个等待队列来存储其任务,其中的线程将接收并处理这些任务。虽然ForkJoinPool每个线程都有一个等待队列,但当一个线程运行的任务生成另一个任务时,该任务会添加到该线程的等待队列中,当我们运行Parallel Stream并将一个大任务分为两个较小的任务时会发生这种情况。

为了防止线程饥饿问题,当一个线程的等待队列中没有更多任务时,ForkJoinPool还实现了另一种称为任务窃取的模式,这意味着一个饥饿的线程可以从另一个线程的等待队列中窃取一些任务。这类似于 Go GMP 模型中的工作窃取机制。

Java 虚拟线程学习_虚拟线程_09

虚拟线程执行 

通常,当虚拟线程在 JDK 中执行 I/O 或其他阻塞操作时,会从平台线程中卸载虚拟线程,例如BlockingQueue.take(). 当阻塞操作准备好完成时(例如,网络 IO 已接收到字节数据),调度程序将虚拟线程挂载到平台线程上以恢复执行。

JDK 中的大多数阻塞操作从平台线程中卸载虚拟线程,从而允许平台线程执行其他工作任务。但是,JDK 中的一些阻塞操作不会卸载虚拟线程,因此会阻塞平台线程。这是因为操作系统级别(例如,许多文件系统操作)或 JDK 级别(例如Object.wait())的限制。当这些阻塞操作阻塞平台线程时,它们会通过临时增加平台线程的数量来补偿其他平台线程阻塞的损失。因此,调度程序中的平台线程数ForkJoinPool可能会暂时超过 CPU 可用的内核数。可以使用系统属性调整调度程序可用的最大平台线程数jdk.virtualThreadScheduler.maxPoolSize. 这种阻塞补偿机制类似于 Go GMP 模型中的切换机制。

在以下两种情况下,虚拟线程固定在运行它的平台线程上,并且在阻塞操作期间无法卸载。

  1. synchronized块或方法中执行代码时。
  2. 执行native方法或外部函数时。

虚拟线程是固定的,并不影响程序运行的正确性,但可能会影响系统的并发和吞吐量。如果虚拟线程执行诸如 I/O 之类的阻塞操作,或者BlockingQueue.take()在它被固定时,负责运行它的平台线程将在操作期间被阻塞。(如果虚拟线程不固定,在执行I/O等阻塞操作时,会从平台线程中卸载掉)。

为什么虚拟线程在休眠后会从一个平台线程跳转到另一个?

Java 虚拟线程学习_虚拟线程_10

【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2023年11月08日 0

暂无评论

推荐阅读
  dBFTbkVLMBge   2023年11月02日   27   0   0 虚拟线程
dBFTbkVLMBge