从单线程到多线程,再到虚拟线程

线程

线程 (英语:thread)在计算机科学中,是将进程划分为两个或多个线程(实例)或子进程,由单处理器(单线程)或多处理器(多线程)或多核处理系统并发执行。

Java 中的线程

直接编写的 Java 代码都是运行在单个线程上的。

在 Java 中,线程被封装在 Thread 类中。通过创建 Thread 的方式可以创建新的线程。然后通过调用对应的方法可以启动线程执行任务。

下面是最简单的线程使用例子:

arduino 复制代码
public static void main(String[] args) {
  Thread thread = new Thread(() -> {
    while (true) {
      System.out.println("Hello, World!");
      try {
        Thread.sleep(1000);
      } catch (InterruptedException e) {
        e.printStackTrace();
        Thread.currentThread().interrupt();
      }
    }
  });
  thread.start();
}

上面就启动了一个线程 thread 。通过调用 start() 方法启动线程。该线程约每隔 1000ms 执行一次打印 Hello, World! 的操作。

多线程

多线程的概念比较简单,就是多个线程一起运行。

因为现代的 CPU 基本都是具有多个核心的,是支持多个线程同时运行的。

如果还是使用单个线程去运行任务,对 CPU 的利用率会比较低。通过多线程技术,可以大幅度的提高系统的并发度进而提高吞吐量。

Java 中的多线程

Java 中多线程即创建多个 Thread 对象即可。一个简单的例子如下:

scss 复制代码
public static void main(String[] args) {
  Thread thread1 = new Thread(() -> {
    System.out.println("Hello, World from thread "
      + Thread.currentThread().getName());
  });
  Thread thread2 = new Thread(() -> {
    System.out.println("Hello, World from thread "
      + Thread.currentThread().getName());
  });
  thread1.start();
  thread2.start();
}

输出结果为:

arduino 复制代码
Hello, World from thread Thread-1
Hello, World from thread Thread-0

输出结果并不是固定的,取决于哪条线程先被调度、执行的时间长短,以及线程的名字。

线程池

池化技术指的是提前准备 一些资源,在需要时可以重复使用这些预先准备的资源。

在 Linux 上进行线程创建需要由用户态切换至内核态,且需要分配内存资源、执行调度。如果存在不节制的创建,则还有可能导致资源耗尽。分散在代码各处创建的线程更加是无法统一管理的,会大大降低系统的稳定性。

所以,几乎所有的框架,容器等,都是通过池化技术来对线程进行创建和管理的。

Java 中线程池相关接口继承关系如下:

其中 ExecutorService 从 JDK19 开始继承 AutoCloseable 接口,无需在代码中手动调用 shutdown() 方法,通过 try-with-resources 语句块即可自动关闭。

一个使用线程池的简单例子:

typescript 复制代码
public static void main(String[] args) {
  try (ExecutorService executorService = Executors.newFixedThreadPool(10)){
    IntStream.range(0, 100).forEach(i -> {
      executorService.submit(() -> {
        System.out.println(Thread.currentThread().getName() + " " + i);
      });
    });
  }
}

执行结果:

arduino 复制代码
pool-1-thread-6 5
pool-1-thread-5 4
pool-1-thread-4 3
pool-1-thread-2 1
pool-1-thread-9 8
pool-1-thread-6 10
pool-1-thread-10 9
............

虚拟线程

虽然池化技术可以帮助我们解决掉线程的创建、销毁、管理等。但是创建线程池时的开销仍然是无法避免的。如果使用 try-with-resources 语句块进行即用即丢弃的方式使用线程池,那成本可想而知。

而且由于一般 CPU 只有几个核心,至多百个级别核心。想要无节制的创建线程仍然是不可行的。比如下面的代码:

csharp 复制代码
public static void main(String[] args) {
  long l = System.currentTimeMillis();
  try (ExecutorService executorService = Executors.newFixedThreadPool(10000)) {
    // 通过线程池提交任务
    IntStream.range(0, 10000).forEach(i -> executorService.submit(() -> {
      try {
        Thread.sleep(1000);
      } catch (InterruptedException e) {
        throw new RuntimeException(e);
      }
      System.out.println(i);
    }));
  }
  System.out.println("耗时:" + (System.currentTimeMillis() - l) + "ms");
}

大概创建到 4000 个线程左右的时候,抛出如下异常:

bash 复制代码
Exception in thread "main" java.lang.OutOfMemoryError:
unable to create native thread: possibly out of memory or process/resource limits reached
at java.base/java.lang.Thread.start0(Native Method)
at java.base/java.lang.Thread.start(Thread.java:1593)
at java.base/java.lang.System$2.start(System.java:2543)
at java.base/jdk.internal.vm.SharedThreadContainer.start(SharedThreadContainer.java:160)
at java.base/java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:953)
at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1364)
at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:123)
......

随着 CPU 的核心数和内存增加,理论上是可以创建更多的平台线程的。但是总会有 OOM 的时候。

即使可以创建很多线程,随着线程数比 CPU 核心数大很多的时候,频繁的进行上下文切换,速度反而会下降。

实际上,多线程是在用资源换取吞吐量的做法。

想要提升更高的吞吐量,就需要虚拟线程来处理了。虚拟线程本质上相当于是运行在平台线程上的一个特殊的函数。创建时不需要进行系统调用,进入内核态。是完全运行在用户态的。分配,调度等策略都是依赖于应用程序的调度,在 Java 里就是依赖于 JVM 的调度。

为什么需要虚拟线程

线程是应用程序运行的基石。

一个请求一个线程

服务端应用程序通常是彼此独立的并发处理用户请求,在整个请求持续时间内,使用一个专有线程去处理,这种方式易于理解、开发、调试、分析。

但是由于利特尔法则

L =λW ,其中 L = 系统中平均请求数量(吞吐量);λ 为请求有效到达速率,如 5/s 表示每秒有五个请求到达(并发度);W 表示请求的平均等待时间(延时)。

的存在。延时是几乎无法提高的,即使通过升级机器性能,网络请求等延时仍然是客观存在的。也就是吞吐量是和并发度成正比的。

不幸的是,可用线程的数量是有限的,如果每个请求消耗一个 JDK 线程,进而消耗一个操作系统线程,在 CPU 利用率和网络连接资源耗尽之前,往往先耗尽的是线程数。这就导致实际上 JDK 能产生的线程数是远低于硬件可支持的水平。即使线程被池化,减少的也只是启动新线程和复用的成本,不会突破可使用线程的上限。

异步多线程

通过异步方式,处理请求的代码不是在一个线程上从头到尾进行执行的。而是通过多线程实现的异步执行。对于具有大量 I/O 操作的程序,使用异步多线程可以充分的利用计算机的资源,能够有效的弥补操作系统线程稀缺对吞吐量的限制。

但是,异步编程的代价很高。需要采用一组独特的 I/O 方法,这些方法无需等待 I/O 完成,而是稍后通过回调发出完成信号。开发人员需要将处理逻辑拆分成多个小阶段,使用异步 API 组合这些阶段(如 CompletableFuture 或一些 Reactive 框架)。

另外,在异步编程风格中,一个请求的不同阶段可能是在不同的线程上执行的,并且互相之间可能是交错运行的。这对调试和最终都产生了比较大的困难。

虚拟线程

Java 的线程和操作系统的线程是采用 1:1 的方式进行建立的。但是在运行时实际上是可以切断这一机制的,就像操作系统可以使用很大的 Swap 空间地址来映射到有限的 RAM 地址来提供充足的内存一样。Java 也可以将大量的虚拟线程映射到少量的平台线程,到达能够提供充足线程的假象。

这样一来,每个请求就可以在每个虚拟线程中进行运行,编程方式和追踪等,都可以保持原有的模式而不受影响。

虚拟线程是十分廉价的,因此不应该被池化。每次使用时创建新的就可以了。这样可以保持其调用栈很浅。而平台线程由于创建成本高,通常需要池化处理,往往具有很长的寿命,且调用栈比较深。

Java 中的虚拟线程

Java 中的虚拟线程是由 Loom 项目孵化的,最早的 JEP(JDK Enhancement Proposal JDK 增强建议)是 JEP 425 提出的。目标如下:

  1. 实现简单的按照线程风格编写的应用程序能够接近最佳的硬件利用率并能进行扩展。
  2. 在对 java.lang.Thread 的 API 进行最小的改动的前提下,实现引入虚拟线程。
  3. 现有的 JDK 工具能够轻松地对虚拟线程进行故障排除、调试和分析。

而以下目标不在本次 JEP 中:

  1. 删除现有的线程模型或者静默迁移现有的模型到虚拟线程。
  2. 改变 Java 的基本并发模型。
  3. 提供新的并发数据结构。

有这几个目标和非目标可以看出来,JCP 在极力保证 Java 的向下兼容性,并且不断地增加新的功能和特性。

虽然 JDK 19 开始,虚拟线程进入了 Java,但是截止到 JDK 20,这个功能仍然是一个预览特性。23 年 9 月的下一个版本(JDK 21)开始,不出意外的话,会纳入到正式版本特性。具体可以参考 JEP 436JEP 444

单个虚拟线程

基于对 Thread API 的改造,单个虚拟线程的使用方法如下:

scss 复制代码
public static void main(String[] args) {
  // 创建新的虚拟线程并启动
  Thread virtualThread1 = Thread.ofVirtual().start(() ->
    System.out.println("Hello, World!"));
  Thread virtualThread2 = Thread.startVirtualThread(() ->
    System.out.println("Hello, World!"));
  // sleep(),join() 等方法跟平台线程一致
  try {
    Thread.startVirtualThread(() -> {
      try {
        Thread.sleep(3000);
        System.out.println("Hello, World!");
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
      }
    })
  .join(0);
  } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
  }
}

看起来和普通的平台线程没有任何区别,只是调用 ofVirtual() 方法进行创建或者 startVirtualThread() 方法直接启动即可。其他的 API,比如 sleepyield~~stop~~ 等,都是一致的。

虚拟线程调度器

由于虚拟线程的创建,销毁等开销是很低的,无需花费精力在管理上,直接可以做到随用随取,用完即丢。就像一个普通的 Java 对象一样。这是 ExecutorService 接口实现了 AutoCloseable 接口的重要原因。目前 JDK 中只提供了两种种创建虚拟线程调度器的方法(底层是一种),简单使用如下:

csharp 复制代码
public static void main(String[] args) {
  // 创建一个虚拟线程池,API 和平台线程池是一致的
  ExecutorService executorService1
    = Executors.newVirtualThreadPerTaskExecutor();
  System.out.println("--------------");
  // Loom 对线程池接口进行了扩展,现在是继承自 AutoCloseable 的
  // 由于虚拟线程的低创建销毁开销,可以做到用完即丢弃
  // 且不需要通过 get,join 等手段阻塞等待任务执行完成,直接通过 try-with-resources 语句块即可
  try (ExecutorService executorService2 = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100000).forEach(i -> executorService2.submit(() -> {
      try {
        Thread.sleep(10);
      } catch (InterruptedException e) {
        throw new RuntimeException(e);
      }
      System.out.println("Hello from " + Thread.currentThread());
    }));
  }
  // 将一定在 executorService2 关闭后执行
  System.out.println("--------------");
}

Executors.newVirtualThreadPerTaskExecutor() 调用创建的调度器,会为每一个提交的任务创建一个新的虚拟线程来执行,因为虚拟线程的创建和销毁成本低廉,不需要进行池化处理。

上面的执行结果如下:

csharp 复制代码
Hello from VirtualThread[#84147]/runnable@ForkJoinPool-1-worker-7
Hello from VirtualThread[#84151]/runnable@ForkJoinPool-1-worker-4
Hello from VirtualThread[#84152]/runnable@ForkJoinPool-1-worker-7
Hello from VirtualThread[#84153]/runnable@ForkJoinPool-1-worker-8
Hello from VirtualThread[#84155]/runnable@ForkJoinPool-1-worker-8
Hello from VirtualThread[#84148]/runnable@ForkJoinPool-1-worker-7
Hello from VirtualThread[#84154]/runnable@ForkJoinPool-1-worker-8

可以看到每个虚拟线程的 ID 都是不一样的。

另外,还有一点值得注意,所有的虚拟线程都是运行在线程池 ForkJoinPool-1 上的。这个线程池是一个基于工作窃取模式的 ForkJoinPool ,以 FIFO 的模式运行,默认线程数量为 CPU 核心数。可以通过 jdk.virtualThreadScheduler.parallelism 参数进行更改。

默认的情况下,CompletableFutureParallel Streams 都是运行在 ForkJoinPool.commonPool() 中的线程上的,区别是 CompletableFuture 可以指定运行的线程池,而 Parallel Streams 不能。目前来说,虚拟线程也不能指定。

调试虚拟线程

断点方式调试和 JDK 之前的平台线程是一致的,无需额外工作。

通过 Thread Dump 方式调试也是和原来一样的,但是由于虚拟线程的数量可能是庞大的,文本的方式去查看信息会比较麻烦,JDK 19 中提供了新的 JSON 格式的方式进行线程转储。

具体命令如下:

ini 复制代码
jcmd <pid> Thread.dump_to_file -format=json <file>

得到的内容大致如下:

局部变量

在平台线程中,使用 ThreadLocalInheritableThreadLocal 来存储局部变量。

同样的在虚拟线程中也是支持的,但是由于创建的虚拟线程的数量可能会很大,所以创建局部变量的时候需要谨慎。再者,虚拟线程如果使用一个请求一个线程()的方式处理请求,所有的内容在当前的上下文即可获取,并不需要保存在局部变量中。

虚拟线程甚至可以禁用使用局部变量:

scss 复制代码
Thread.ofVirtual()
    .allowSetThreadLocals(false);

在虚拟线程中,比起局部变量,更推荐的做法是使用 Scoped Values 。只是这个特性仍在孵化阶段。具体可以参考 JEP 429

参考资料

  1. zh.wikipedia.org/zh-hans/线程
  2. openjdk.org/jeps/425
  3. openjdk.org/jeps/444
  4. openjdk.org/jeps/429

推荐阅读

分布式事务解决方案-seata

浅析ThreadLocal

图可视化探索与实践

ES亿级商品索引拆分实战

在 ARM 环境下搭建原生 Hadoop 集群

招贤纳士

政采云技术团队(Zero),Base 杭州,一个富有激情和技术匠心精神的成长型团队。规模 500 人左右,在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。

如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊......如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com

微信公众号

文章同步发布,政采云技术团队公众号,欢迎关注

相关推荐
罗政2 小时前
[附源码]超简洁个人博客网站搭建+SpringBoot+Vue前后端分离
vue.js·spring boot·后端
架构文摘JGWZ3 小时前
Java 23 的12 个新特性!!
java·开发语言·学习
拾光师4 小时前
spring获取当前request
java·后端·spring
aPurpleBerry4 小时前
neo4j安装启动教程+对应的jdk配置
java·neo4j
我是苏苏4 小时前
Web开发:ABP框架2——入门级别的增删改查Demo
java·开发语言
xujinwei_gingko4 小时前
Spring IOC容器Bean对象管理-Java Config方式
java·spring
2301_789985944 小时前
Java语言程序设计基础篇_编程练习题*18.29(某个目录下的文件数目)
java·开发语言·学习
IT学长编程4 小时前
计算机毕业设计 教师科研信息管理系统的设计与实现 Java实战项目 附源码+文档+视频讲解
java·毕业设计·springboot·毕业论文·计算机毕业设计选题·计算机毕业设计开题报告·教师科研管理系统
m0_571957584 小时前
Java | Leetcode Java题解之第406题根据身高重建队列
java·leetcode·题解
程序猿小D4 小时前
第二百三十五节 JPA教程 - JPA Lob列示例
java·数据库·windows·oracle·jdk·jpa