醋醋百科网

Good Luck To You!

Java修炼终极指南:213 解释虚拟线程的工作原理

现在我们知道了如何创建和启动一个虚拟线程,让我们看看它实际上是如何工作的。让我们从一张有意义的图表开始:


图 10.7 – 虚拟线程的工作原理

正如您所见,图 10.7 与 10.6 类似,只是我们添加了一些更多的元素。首先,请注意,平台线程在 ForkJoinPool 的保护伞下运行。这是一个先进先出(FIFO)的专用分叉/合并池,专门用于调度和协调虚拟线程和平台线程之间的关系(Java 分叉/合并框架的详细覆盖可在《Java 编码问题》第一版,第 11 章中找到)。

这个专用的 ForkJoinPool 由 JVM 控制,它基于 FIFO 队列充当虚拟线程调度器。其初始容量(线程数量)等于可用核心的数量,并且可以增加到 256。默认的虚拟线程调度器是在 java.lang.VirtualThread 类中实现的:

private static ForkJoinPool createDefaultScheduler() {...}

不要将这个 ForkJoinPool 与用于并行流的 ForkJoinPool(公共 Fork Join Pool - ForkJoinPool.commonPool())混淆。

在虚拟线程和平台线程之间,存在一对多的关联。然而,JVM 调度虚拟线程在平台线程上运行,以使每次只有一个虚拟线程在平台线程上运行。当 JVM 将虚拟线程分配给平台线程时,所谓的虚拟线程的栈块对象从堆内存复制到平台线程上。如果运行在虚拟线程上的代码遇到应由 JVM 处理的阻塞(I/O)操作,则通过将虚拟线程的栈块对象复制回堆内存来释放虚拟线程(在堆内存和平台线程之间复制栈块的操作是阻塞虚拟线程的成本——这比阻塞平台线程便宜得多)。与此同时,平台线程可以运行其他虚拟线程。当释放的虚拟线程的阻塞(I/O)完成时,JVM 重新调度虚拟线程在平台线程上执行。这可以是同一个平台线程或另一个平台线程。

将虚拟线程分配给平台线程的操作称为挂载(mounting)。从平台线程上取消分配虚拟线程的操作称为卸载(unmounting)。运行分配的虚拟线程的平台线程称为载体线程(carrier thread)。

让我们来看一个示例,揭示了虚拟线程是如何挂载的:

private static final int NUMBER_OF_TASKS
  = Runtime.getRuntime().availableProcessors();
Runnable taskr = () ->
  logger.info(Thread.currentThread().toString());       
try (ExecutorService executor
     = Executors.newVirtualThreadPerTaskExecutor()) {
  for (int i = 0; i < NUMBER_OF_TASKS + 1; i++) {
    executor.submit(taskr);
  }
}

在这段代码中,我们创建了等于可用核心数 + 1 的虚拟线程数量。在我的机器上,我有 8 个核心(因此,8 个载体),每个核心都携带一个虚拟线程。由于我们有 + 1,一个载体将工作两次。输出揭示了这个场景(查看这里的 workers,worker-8 运行了虚拟线程 #30 和 #31):

VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3
VirtualThread[#30]/runnable@ForkJoinPool-1-worker-8
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-6
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#24]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#29]/runnable@ForkJoinPool-1-worker-7
VirtualThread[#26]/runnable@ForkJoinPool-1-worker-4
VirtualThread[#27]/runnable@ForkJoinPool-1-worker-5
VirtualThread[#31]/runnable@ForkJoinPool-1-worker-8

但是,我们可以通过以下三个系统属性配置 ForkJoinPool:

  • jdk.virtualThreadScheduler.parallelism – CPU 核心数
  • jdk.virtualThreadScheduler.maxPoolSize – 最大池大小(256)
  • jdk.virtualThreadScheduler.minRunnable – 最小运行线程数(池大小的一半)

在后续问题中,我们将使用这些属性来更好地塑造虚拟线程上下文切换(挂载/卸载)的详细信息。

捕获虚拟线程 到目前为止,我们了解到虚拟线程由 JVM 挂载到平台线程上,该平台线程成为其载体线程。此外,载体线程运行虚拟线程,直到它遇到一个阻塞(I/O)操作。在这一点上,虚拟线程从载体线程上卸载,阻塞(I/O)操作完成后将重新调度虚拟线程。虽然这种场景对于大多数导致卸载虚拟线程并释放平台线程(和底层 OS 线程)的阻塞操作是正确的,但有一些异常情况,虚拟线程不会被卸载。这种行为有两个主要原因:

  1. 操作系统的限制(例如,大量的文件系统操作)
  2. JDK 的限制(例如,Object#wait())

当虚拟线程不能从其载体线程上卸载时,意味着载体线程和底层的 OS 线程被阻塞。这可能会影响应用程序的可扩展性,因此,如果平台线程池允许,JVM 可以决定添加一个额外的平台线程。因此,在一段时间内,平台线程的数量可能会超过可用核心的数量。

固定虚拟线程 还有两种用例,虚拟线程不能被卸载:

  1. 当虚拟线程在同步方法/块内运行代码时
  2. 当虚拟线程调用外部函数或本地方法时(第 7 章涵盖的主题)

在这种情况下,我们说虚拟线程被固定到载体线程上。这可能会影响应用程序的可扩展性,但 JVM 不会增加平台线程的数量。相反,我们应该采取行动,重构同步块,确保锁定代码简单、清晰、简短。尽可能优先使用 java.util.concurrent 锁而不是同步块。如果我们能够避免长时间和频繁的锁定周期,那么我们就不会面临任何重大的可扩展性问题。在未来的版本中,JDK 团队的目标是消除在同步块内的固定。

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言