忒长,你要看得下去就继续:

执行虚拟线程

要用上虚拟线程,不必重写现有代码。虚拟线程不要求或者不期待应用代码显式将控制交给调度器;换句话说,虚拟线程不是协作式的。用户代码不得再假设虚拟线程是如何或什么时候被分派给平台线程,超过对平台线程是如何或什么时候分派给处理核心。

为了在虚拟线程里运行代码,JDK 的虚拟线程调度器通过将虚拟线程挂载到平台线程来实现将虚拟线程分配到平台线程执行的目的。这样将平台线程变成虚拟线程的载体。接着,运行完一段代码后,虚拟线程可以从载体解挂载。平台线程重获自由后,调度器又可以将其他虚拟线程挂载给它,从而又称为载体。

通常,当被 I/O 阻塞或者进行 JDK 里的一些其他阻塞操作时 —— 如 BlockingQueue.take() ,会被解挂载。当阻塞操作即将完成时(如从套接字接收完所有的字节),虚拟线程就被重新提交给调度器,由调度器将其挂载到一个载体继续执行。

虚拟线程的挂载和解挂载发生得频繁而且透明,不阻塞任何操作系统线程。例如,前面展示的服务应用代码,包含如下阻塞调用:

response.send(future1.get() + future2.get());

这些操作将引发虚拟线程的多次挂载和解挂载,通常调用 get() 会引发一次,在 send(…) 中执行 I/O 可能会引发多次。

JDK 里的大量阻塞操作将解挂载虚拟线程,释放载体和底层操作系统线程去处理新的任务。但是,JDK 里的一些阻塞操作不会解挂载虚拟线程,这样就会阻塞载体和底层的操作系统线程。这是由于操作系统层(如 许多文件系统操作)或者 JDK 层(如 Object.wait())的限制。这些阻塞操作将通过临时扩展调度器的并发性来弥补对操作系统线程的占用。结果就是,调度器的 ForkJoinPool 中的平台线程可能短暂超过可用处理器的数量。调度器支持的最大平台线程数量可以通过系统属性 jdk.virtualThreadScheduler.maxPoolSize 调优。

有两种情况下,执行阻塞操作的虚拟线程不能被卸载,因为和载体固定在一起了:

  1. 当在同步块或方法中执行代码时,或
  2. 当执行本地方法或外部功能时。

被固定住不会导致程序错误,但会损害扩展性。如果一个虚拟线程在固定模式下执行了一个阻塞操作,如 I/O 或 BlockingQueue.take(),那么在这个操作期间,它的载体及底层操作系统线程都会被阻塞。频繁长时间固定,占用载体,会损害应用的扩展性。

调度器不会通过扩展并行度来补偿线程固定。取而代之的是,通过修改频繁运行的同步块或同步方法,避免频繁和长时间的线程固定,管控潜在长时 I/O 操作使用 java.util.concurrent.locks.ReentrantLock。没有必要替换不频繁用到的同步块和同步方法(如只在启动时用到)或者管控内存操作。应一如既往,追求锁策略的简洁。

在迁移代码到虚拟线程和评估是否应该使用 java.util.concurrent 锁替换对同步块的特殊使用时,新的诊断助手如下:

  • 当固定线程被阻塞时,会发出一个 JDK 飞行记录时间(见 JDK Flight Recorder)。
  • 当固定线程被阻塞时,系统属性 jdk.tracePinnedThreads 会触发一个堆栈。-Djdk.tracePinnedThreads=full 会打印完整的堆栈,包含本地帧,高亮本地帧持有的监视器。-Djdk.tracePinnedThreads=short 只会输出问题帧。

未来版本里,将移除第一个限制(同步中的线程固定)。第二个限制需要和本地代码更妥善的交互。

内存使用和 GC 交互

虚拟线程的栈以栈块对象的形式,被存储在 Java 垃圾收集堆中。程序运行期间,栈会伸缩,既保障了内存高效,也兼顾了容纳任意深度的栈(直到 JVM 配置的平台线程栈上限)。这个有效性支撑了大量的虚拟线程,和服务端一请求一线程模式的可持续性。

在上文的第 2 个例子里,请回忆一下,假设了一个框架,会为每个请求创建一个虚拟线程,在虚拟线程中调用处理方法;即使在很深的调用栈底部调用处理方法(经过认证、事务等),处理方法本身也会派生多个虚拟线程,每个虚拟线程执行一个很短的任务。因此,每个调用栈很深的虚拟线程,都有多个内存占用小、调用栈很浅的线程。

虚拟线程需要的堆空间和垃圾收集器活动的规模,通常来说,很难和异步代码需要的相比。百万虚拟线程至少需要百万对象,但百万任务也是共享一个平台线程池。另外,处理请求的代码通常通过 I/O 操作保存数据。一请求一线程代码可以把数据保存在本地变量中,本地变量被保存在堆中的虚拟线程栈里,但异步代码必须把相同的数据保存在堆对象中,这样才可以从管道的一个阶段传递给下一个阶段。一方面,虚拟线程需要的栈帧结构比紧凑对象更耗费内存;另一方面,许多情况下,虚拟线程可以变异并重用其栈(取决于底层的 GC 交互),但异步管道总是需要分配新对象,所以虚拟线程请求分配的情况更少。总的来说,一请求一线程对比异步代码,堆消耗和垃圾收集活动差不多。假以时日,我们期望让虚拟线程栈的内部表示明显变得更紧凑。

不同于平台线程栈,虚拟线程栈不是 GC 的根节点,所以它包含的引用不会在垃圾收集器(如 G1)执行并发堆扫描的一次世界暂停中被遍历到。这同样意味着如果虚拟线程被阻塞,如 BlockingQueue.take(),且其他线程不持有虚拟线程或者队列的引用,虚拟线程就会被垃圾回收掉 —— 这没关系,因为虚拟线程永远不会被中断或解除阻塞。当然,虚拟线程处于运行状态或有望恢复的阻塞状态时,不会被垃圾回收。

虚拟线程当前的一个局限是 G1 GC 不支持巨型栈块对象。当一个虚拟线程栈增长到区大小的一半,小到 512KB,就会触发一个 StackOverflowError 错误。

变更明细

下述条目详细描述了我们提出的涉及 Java 平台及其实现的变更:

java.lang.Thread

我们更新了 java.lang.Thread API:

java.lang.Thread API 的其他内容保持不变。Thread 类中的构造方法还和之前一样创建出平台线程。没有新增公共构造方法。

虚拟线程和平台线程主要的 API 差异在于:

  • 公共构造方法不能出创建虚拟线程。
  • 虚拟线程始终是守护线程。Thread.setDaemon(boolean) 不能把虚拟线程变为非守护线程。
  • 虚拟线程有一个固定优先级 Thread.NORM_PRIORITYThread.setPriority(int) 对虚拟线程无效。这个限制将在未来版本中回顾。
  • 虚拟线程不是线程组的可用成员。当调用一个虚拟线程,Thread.getThreadGroup() 将返回一个 “VirtualThreads” 的占位符。Thread.Builder API 不会定义一个方法用来设置虚拟线程的线程组。
  • 当在 SecurityManager 环境下运行时,虚拟线程没有任何权限。
  • 虚拟线程不支持 stop()suspend(),或者 resume() 方法。在虚拟线程中调用这些方法将抛出异常。

Thread-local 变量

虚拟线程支持线程本地变量(ThreadLocal)和可继承的线程本地变量(InheritableThreadLocal),和平台线程一样,所以可以兼容现有使用了线程本地变量的代码。由于虚拟线程可能非常多,对线程本地变量的使用要深思熟虑。尤其是,不要使用线程本地变量,在多个任务共享线程池中同一线程时池化昂贵的资源。永远不要池化虚拟线程,因为每一个虚拟线程在其生命周期都只会执行一个任务。为了准备虚拟线程,已经从 java.base 模块中移除了很多对线程本地变量的使用,以减少运行数百万线程时的内存开销。

另外:

Scope-local 变量可能比线程本地变量更适合某些场景。

java.util.concurrent

提供锁机制的原语 API,java.util.concurrent.LockSupport,现已支持虚拟线程:挂起一个虚拟线程以释放底层平台线程执行其他任务,恢复一个虚拟线程以调度其继续执行。对 LockSupport 的这些调整使得所有使用它的 API(Locks, Semaphores, blocking queues 等),在虚拟线程中被调用时,能优雅挂起线程。

另外:

网络

java.net 和 java.nio.channels 包中的网络 API 现已支持虚拟线程:虚拟线程中的一个阻塞操作,如建立网络连接或读取套接字,将释放底层平台线程。

为支持中断和取消,在 java.net.Socket,ServerSocket 和 DatagramSocket 中定义的阻塞 I/O 方法,当在虚拟线程中调用时,现规范为可被中断:中断一个阻塞在套接字上的虚拟线程将恢复该线程并关闭套接字。从 InterruptibleChannel 获取的这些类型的套接字上的阻塞 I/O 操作总是可被中断的,所以这个调整统一了使用构造器创建来的这些 API 的行为和从 channel 获取时的行为。


0 条评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注