探索虚拟线程:原理与实现

京东云开发者
• 阅读 297

虚拟线程的引入与优势

在Loom项目之前,Java虚拟机(JVM)中的线程是通过java.lang.Thread类型来实现的,这些线程被称为平台线程。

然而,平台线程的创建和维护在资源使用上存在显著的开销。首先,创建成本不菲,因为每当操作系统需要创建一个新的平台线程时,它必须分配大量的内存(通常以兆字节计)来存储线程的上下文信息、本机栈和Java调用栈。这一过程受到固定大小堆栈的限制,导致创建和调度平台线程时的开销在空间和时间上都相当巨大。此外,当调度器需要从当前执行的线程中抢占时,必须处理大量内存的移动,这进一步增加了操作的复杂性和成本。这种开销不仅限制了可以同时创建的线程数量,而且也容易导致内存资源的耗尽。以下是一个示例,展示了在Java中如何通过不断实例化新的平台线程,迅速达到内存耗尽的情况:

private static void stackOverflowErrorDemo() {
    try {
        int threadCount = 0;
        // 尝试创建高达百万级的线程数量
        while (threadCount++ < 100000000) {
            // 创建并启动一个新线程
            Thread thread = new Thread(() -> {
                try {
                    // 线程休眠1秒,模拟长时间运行的任务
                    Thread.sleep(Duration.ofSeconds(1));
                } catch (InterruptedException e) {
                    // 如果线程被中断,将其转换为运行时异常
                    throw new RuntimeException(e);
                }
            });
            // 启动线程
            thread.start();
        }
    } catch (RuntimeException e) {
        // 捕获并处理由线程启动过程中可能抛出的运行时异常
        e.printStackTrace();
    }
}

在实际操作中,达到OutOfMemoryError的时间会根据操作系统和硬件的不同而有所差异。然而,通常情况下,这个过程可以在极短的时间内完成。

为了解决这些问题,虚拟线程应运而生。

虚拟线程的优势

资源效率:虚拟线程在内存使用上更为高效,初始内存占用通常只有几百字节,远小于平台线程所需的几兆字节。

简化线程管理:虚拟线程的创建和管理过程更为简便,通过工厂方法可以轻松创建,无需手动管理线程资源。

避免线程爆炸:由于资源消耗低,虚拟线程可以处理大量并发任务,而不必担心资源耗尽。

协作调度:虚拟线程采用协作调度模型,减少了锁竞争和上下文切换的开销,提升了多线程程序的性能。

避免阻塞:虚拟线程在遇到阻塞操作时可以释放执行权,允许其他线程执行,提高了程序的响应性。

虚拟线程如何创建

创建虚拟线程是Java中的一项新特性,它旨在解决传统平台线程所面临的资源限制问题。虚拟线程作为java.lang.Thread的一个替代实现,其独特之处在于将线程的调用堆栈存储在Java堆内存中,而不是传统的本地线程堆栈中。这种方式显著减少了每个线程所需的初始内存占用,通常仅为几百字节,而不是几兆字节。更进一步,虚拟线程的堆栈大小是动态可变的,这使得我们无需为各种用例预分配大量内存。以下是创建虚拟线程的两种方法:

使用工厂方法创建虚拟线程

通过java.lang.ThreadofVirtual静态工厂方法,我们可以轻松创建虚拟线程。首先,定义一个辅助函数来创建并启动一个带有指定名称的虚拟线程:

private static Thread createVirtualThread(String name, Runnable runnable) {
    return Thread.ofVirtual()
            .name(name)
            .start(runnable);
}

使用ThreadPerTaskExecutor创建虚拟线程

另一种方法是使用专为虚拟线程设计的java.util.concurrent.ExecutorService实现,即ThreadPerTaskExecutor。这个执行器为提交的每个任务创建一个新的虚拟线程:

@SneakyThrows
static void createVirtualThreadUsingExecutorsWithName() {
  final ThreadFactory factory = Thread.ofVirtual().name("worker-", 0).factory();
  try (var executor = Executors.newThreadPerTaskExecutor(factory)) {
    var cleanTime =
      executor.submit(
        () -> {
          log.info("我要打扫卫生");
          sleep(Duration.ofMillis(500L));
          log.info("卫生打扫完了");
         });
    var boilingWater =
      executor.submit(
        () -> {
          log.info("我要去烧一些水");
          sleep(Duration.ofSeconds(1L));
          log.info("水烧好了");
        });
    cleanTime.get();
    boilingWater.get();
  }
}

在这个示例中,我们使用了submit方法来启动虚拟线程,它需要一个RunnableCallable任务。submit方法返回一个Future对象,该对象可以用来跟踪和控制虚拟线程的执行。

虚拟线程的启动和同步

与平台线程相比,虚拟线程的启动和同步方式略有不同,因为它们是通过ExecutorService来管理的。每个submit调用都返回一个Future对象,这允许我们跟踪任务的状态,甚至在必要时阻塞当前线程直到虚拟线程完成其任务。

虚拟线程的原理

探索虚拟线程:原理与实现

如上图所示展示虚拟线程与平台线程之间的关系:

JVM维护了一个由专用ForkJoinPool创建和维护的平台线程池。最初,平台线程的数量等于CPU核心的数量,最多不能超过256个。

对于每个创建的虚拟线程,JVM都会将其执行调度到一个平台线程上,临时将虚拟线程的堆栈块从堆复制到平台线程的堆栈中。我们说平台线程变成了虚拟线程的载体线程。

我们可以通过运行使用ThreadPerTaskExecutor创建虚拟线程的用例,观察其中的一条日志来说明执行过程:

10:30:35.390 [worker-1] INFO in.rcard.virtual.threads.App - VirtualThread[#23,worker-1]/runnable@ForkJoinPool-1-worker-2 | 我要去烧一些水

从日志中进行观察

  1. 线程标识与命名:每个虚拟线程都有一个唯一的标识符和名称,例如 VirtualThread[#23,worker-1]。这里的 #23 表示线程的编号,而 worker-1 是线程的名称,它们共同帮助开发者识别和调试线程。

  2. 载体线程的分配:虚拟线程执行时,会绑定到一个特定的载体线程(即平台线程)。例如,ForkJoinPool-1-worker-2 表示该虚拟线程正在由默认的ForkJoinPool中的第二个工作线程执行。

  3. 阻塞与释放:当虚拟线程遇到阻塞操作时,其载体线程会被释放,以便能够执行其他就绪的虚拟线程。同时,虚拟线程的堆栈块会从载体线程的堆栈复制回Java堆中,以等待阻塞操作的完成。

  4. 再次调度:一旦虚拟线程完成其阻塞操作,调度器会将其重新排入执行队列。虚拟线程可能会继续在先前的载体线程上执行,或者根据调度器的决策,在不同的载体线程上继续执行。

刚才我们提到,默认情况下,JVM会创建与cpu核心数量相等的载体线程(平台线程),以确保每个物理核心都能被有效利用。那么假如计算机上配备了2个物理核心和通过超线程技术支持的4个逻辑核心,基于此硬件配置,我们可以设计一个程序,该程序旨在生成与逻辑核心数相匹配的虚拟线程数量,即4个虚拟线程。然而,为了探索线程调度的灵活性,我们可以增加一个额外的虚拟线程,使得总数达到5个,即期望5个虚拟线程在4个载体线程上执行,那么至少会有一个载体线程会被重复使用。执行以下程序

static void viewCarrierThreadPoolSize() {
  final ThreadFactory factory = Thread.ofVirtual().name("worker-", 0).factory();
  try (var executor = Executors.newThreadPerTaskExecutor(factory)) {
    IntStream.range(0, numberOfCores() + 1)
        .forEach(i -> executor.submit(() -> {
          log.info("virtual thread number " + i);
          sleep(Duration.ofSeconds(1L));
        }));
  }
}
[worker-0] INFO in.rcard.virtual.threads.App - VirtualThread[#21,worker-0]/runnable@ForkJoinPool-1-worker-1 | virtual thread number 0
[worker-1] INFO in.rcard.virtual.threads.App - VirtualThread[#23,worker-1]/runnable@ForkJoinPool-1-worker-2 | virtual thread number 1
[worker-2] INFO in.rcard.virtual.threads.App - VirtualThread[#24,worker-2]/runnable@ForkJoinPool-1-worker-3 | virtual thread number 2
[worker-4] INFO in.rcard.virtual.threads.App - VirtualThread[#26,worker-4]/runnable@ForkJoinPool-1-worker-4 | virtual thread number 4
[worker-3] INFO in.rcard.virtual.threads.App - VirtualThread[#25,worker-3]/runnable@ForkJoinPool-1-worker-4 | virtual thread number 3

观察日志,有四个载体线程,分别是ForkJoinPool-1-worker-1、ForkJoinPool-1-worker-2、ForkJoinPool-1-worker-3和ForkJoinPool-1-worker-4,ForkJoinPool-1-worker-4被重复使用了两次,以上假设正确。

点赞
收藏
评论区
推荐文章
DevOpSec DevOpSec
4年前
python多线程原理和详解(一)
python多线程原理和详解线程概念1.线程是什么?线程也叫轻量级进程,是操作系统能够进行运算调度的最小单位,它被包涵在进程之中,是进程中的实际运作单位。线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其他线程共享进程所拥有的全部资源。一个线程可以创建和撤销另一个线程,同一个进程中的多个线程之间可以并发执行。
Wesley13 Wesley13
3年前
java四大线程池
一、为什么需要使用线程池  1、减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。2、可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为消耗过多的内存,而把服务器累趴下(每个线程需要大约1MB内存,线程开的越多,消耗的内存也就越大,最后死机)。Java中创建和销毁一个线程是比较
Stella981 Stella981
3年前
Executor线程池
线程池为线程生命周期的开销和资源不足问题提供了解决方案。通过对多个任务重用线程,线程创建的开销被分摊到了多个任务上。_0_|_1_线程实现方式Thread、Runnable、Callable//实现Runnable接口的类将被Thread执行,表示一个基本任务p
Stella981 Stella981
3年前
JNIEnv解析
1.关于JNIEnv和JavaVM JNIEnv是一个与线程相关的变量,不同线程的JNIEnv彼此独立。JavaVM是虚拟机在JNI层的代表,在一个虚拟机进程中只有一个JavaVM,因此该进程的所有线程都可以使用这个JavaVM。当后台线程需要调用JNInative时,在native库中使用全局变量保存JavaVM
Wesley13 Wesley13
3年前
Java基础教程——线程池
启动新线程,需要和操作系统进行交互,成本比较高。使用线程池可以提高性能——线程池会提前创建大量的空闲线程,随时待命执行线程任务。在执行完了一个任务之后,线程会回到空闲状态,等待执行下一个任务。(这个任务,就是Runnable的run()方法,或Callable的call()方法)。Java5之前需要手动实现线程池,Java5之
Wesley13 Wesley13
3年前
Java 线程池原理分析
1.简介线程池可以简单看做是一组线程的集合,通过使用线程池,我们可以方便的复用线程,避免了频繁创建和销毁线程所带来的开销。在应用上,线程池可应用在后端相关服务中。比如Web服务器,数据库服务器等。以Web服务器为例,假如Web服务器会收到大量短时的HTTP请求,如果此时我们简单的为每个HTTP请求创建一个处理线程,那么服务器
Wesley13 Wesley13
3年前
Java变成思想
Executor:线程池CatchedThreadPool:创建与所需数量相同的线程,在回收旧线程是停止创建新县城。FixedThreadPool:创建一定数量的线程,所有任务公用这些线程。SingleThreadPool:线程数量为1的FixedThreadPool,并且执行有序。如果需要得到线程返回值,要实现Callbale接口
Wesley13 Wesley13
3年前
Java命令学习系列(二)——Jstack
jstack是java虚拟机自带的一种堆栈跟踪工具。功能jstack用于生成java虚拟机当前时刻的线程快照。线程快照是当前java虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。线程出现停顿的时候通过jstack来查看各个线程的调用堆
Wesley13 Wesley13
3年前
Java面试官都爱问的多线程和并发面试题汇总,多刷一题,多份安心!
Java多线程面试问题1、进程和线程之间有什么不同?一个进程是一个独立(selfcontained)的运行环境,它可以被看作一个程序或者一个应用。而线程是在进程中执行的一个任务。Java运行环境是一个包含了不同的类和程序的单一进程。线程可以被称为轻量级进程。线程需要较少的资源来创建和驻留在进
Wesley13 Wesley13
3年前
Java中的线程池
java中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。在开发过程中,合理使用线程池能够带来三个好处。第一:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。第二:提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。第三:提高线程的可管理性。线程是稀缺