【多线程及高并发 番外篇】虚拟线程怎么被 synchronized 阻塞了?

发布时间:2023年12月25日

👏作者简介:大家好,我是若明天不见,BAT的Java高级开发工程师,CSDN博客专家,后端领域优质创作者
📕系列专栏:多线程及高并发系列
📕其他专栏:微服务框架系列MySQL系列Redis系列Leetcode算法系列GraphQL系列
📜如果感觉博主的文章还不错的话,请👍点赞收藏关注👍支持一下博主哦??
?时间是条环形跑道,万物终将归零,亦得以圆全完美


多线程及高并发系列

虚拟线程是 Project Loom 的核心概念,它是一种轻量级的线程实现,具有以下关键特点:协作式调度、轻量级堆栈、阻塞不占用线程、与现有代码兼容。虚拟线程的引入旨在提高并发性能和可伸缩性,简化并发编程,并最大程度地利用系统资源

  1. 轻量级线程:虚拟线程是一种轻量级的线程实现,相对于传统的 Java 线程(即操作系统线程)来说,创建和切换虚拟线程的开销更低。因此,可以创建大量的虚拟线程,而不会造成过多的线程开销和资源消耗
  2. 协作式调度:传统的 Java 线程是通过抢占式调度进行线程切换的,由操作系统负责调度线程的执行。而虚拟线程采用的是协作式调度,即线程主动让出执行权,将控制权交给其他线程。这种调度方式可以避免不必要的上下文切换,提高线程的执行效率
  3. 堆栈轻量级:与传统线程相比,虚拟线程的堆栈是轻量级的,占用更少的内存空间。这使得可以创建大量的虚拟线程,而不会导致堆栈溢出或内存消耗过大的问题
  4. 阻塞不占用线程:传统的 Java 线程在执行阻塞操作时,会一直占用底层的操作系统线程,导致资源浪费。而虚拟线程在执行阻塞操作时,会主动释放底层线程,让其可用于执行其他虚拟线程,从而最大程度地利用系统资源
  5. 与现有代码的兼容性:虚拟线程的引入不需要对现有的 Java 代码进行修改。虚拟线程通过Fibers API提供了一种新的编程模型,可以在现有的 Java 应用程序中使用,而无需更改现有的线程管理代码

虚拟线程(Virtual Thread)它不与特定的操作系统线程相绑定。它在平台线程上运行 Java 代码,但在代码的整个生命周期内不独占平台线程。**这意味着许多虚拟线程可以在同一个平台线程上运行他们的 Java 代码,共享同一个平台线程。详见虚拟线程原理及性能分析

虚拟线程实现原理

此段自虚拟线程原理及性能分析

虚拟线程是由 Java 虚拟机调度,而不是操作系统。虚拟线程占用空间小,同时使用轻量级的任务队列来调度虚拟线程,避免了线程间基于内核的上下文切换开销,因此可以极大量地创建和使用

简单来看,虚拟线程实现如下:virtual thread = Continuation + Scheduler + Runnable

  • 虚拟线程会把任务(java.lang.Runnable实例)包装到一个Continuation实例中:
    • 当任务需要阻塞挂起的时候,会调用Continuation.yield()操作进行阻塞,虚拟线程会从平台线程卸载
    • 当任务解除阻塞继续执行的时候,调用Continuation.run()会从阻塞点继续执行
  • Scheduler 也就是执行器,由它将任务提交到具体的载体线程池中执行
    • 它是java.util.concurrent.Executor的子类
    • 虚拟线程框架提供了一个默认的FIFOForkJoinPool用于执行虚拟线程任务
  • Runnable 则是真正的任务包装器,由 Scheduler 负责提交到载体线程池中执行

JVM 把虚拟线程分配给平台线程的操作称为 mount(挂载),取消分配平台线程的操作称为 unmount(卸载):

  • mount 操作:虚拟线程挂载到平台线程,虚拟线程中包装的 Continuation 堆栈帧数据会被拷贝到平台线程的线程栈,这是一个从堆复制到栈的过程
  • unmount 操作:虚拟线程从平台线程卸载,此时虚拟线程的任务还没有执行完成,所以虚拟线程中包装的 Continuation 栈数据帧会留在堆内存中

从 Java 代码的角度来看,其实是看不到虚拟线程及载体线程共享操作系统线程的,会认为虚拟线程及其载体都在同一个线程上运行,因此,在同一虚拟线程上多次调用的代码可能会在每次调用时挂载的载体线程都不一样

启用虚拟线程

spring.threads.virtual.enabled: true

在 Spring Boot 3.2 中启用虚拟线程,只需在application.ymlapplication.properties文件中设置这个属性

  • Tomcat 将使用虚拟线程处理 HTTP 请求。这意味着处理 Web 请求的代码(如 Controller 中的方法)将在虚拟线程上运行。
  • 在调用 @Async方法时,Spring MVC 的异步请求处理和 Spring WebFlux 的阻塞执行支持现在将使用虚拟线程
  • 注解了@Scheduled的方法将在虚拟线程上运行

JDK 的虚拟线程调度器是一个工作窃取 ForkJoinPool,以先进先出(FIFO )模式运行。调度器的并行性(parallelism)是指可用来调度虚拟线程的平台线程数。

ForkJoinPool的原理及核心概念可见 parallelStream/ForkJoinPool 详解

虚拟线程创建

方法一:直接创建虚拟线程

Thread vt = Thread.startVirtualThread(() -> {
    System.out.println("hello wolrd virtual thread");
});

方法二:创建虚拟线程但不自动运行,手动调用start()开始运行

Thread.ofVirtual().unstarted(() -> {
    System.out.println("hello wolrd virtual thread");
});
vt.start();

方法三:通过虚拟线程的 ThreadFactory 创建虚拟线程

ThreadFactory tf = Thread.ofVirtual().factory();
Thread vt = tf.newThread(() -> {
    System.out.println("Start virtual thread...");
    Thread.sleep(1000);
    System.out.println("End virtual thread. ");
});
vt.start();

方法四:Executors.newVirtualThreadPer -TaskExecutor()

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
    System.out.println("Start virtual thread...");
    Thread.sleep(1000);
    System.out.println("End virtual thread.");
    return true;
});

synchronized 阻塞问题分析

了解了虚拟线程的原理及使用,准备将服务从JDK 17 升级到 JDK 21,准备用虚拟线程尝尝鲜。然而一压测发现,性能竟然急剧下降l了?!

加上JVM 参数jdk.tracePinnedThreads当线程在block下发生阻塞时会触发堆栈跟踪

  • full 为打印完整的堆栈跟踪,突出显示 native 堆栈帧和持有监视器的堆栈帧
  • short 为输出限制为仅出现问题的堆栈帧
-Djdk.tracePinnedThreads=full

当线程在固定状态下发生阻塞时,会发出JDK Flight Recorder (JFR)事件(请参阅Java Flight Record 详解

block 线程详细堆栈:

Thread[#57,ForkJoinPool-1-worker-2,5,CarrierThreads]
    java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:185)
    java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
    java.base/java.lang.VirtualThread.park(VirtualThread.java:592)
    java.base/java.lang.System$2.parkVirtualThread(System.java:2639)
    java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:54)
    java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:369)
    java.base/sun.nio.ch.Poller$Request.awaitFinish(Poller.java:215)
    java.base/sun.nio.ch.Poller.pollIndirect(Poller.java:143)
    java.base/sun.nio.ch.Poller.poll(Poller.java:102)
    java.base/sun.nio.ch.Poller.poll(Poller.java:89)
    java.base/sun.nio.ch.NioSocketImpl.park(NioSocketImpl.java:175)
    java.base/sun.nio.ch.NioSocketImpl.timedFinishConnect(NioSocketImpl.java:548)
    java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:592)
    java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
    java.base/java.net.Socket.connect(Socket.java:751)
    java.base/sun.security.ssl.SSLSocketImpl.connect(SSLSocketImpl.java:304)
    java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
    java.base/java.lang.reflect.Method.invoke(Method.java:580)
    org.apache.commons.httpclient.protocol.ReflectionSocketFactory.createSocket(ReflectionSocketFactory.java:140)
    org.apache.commons.httpclient.protocol.SSLProtocolSocketFactory.createSocket(SSLProtocolSocketFactory.java:130)
    org.apache.commons.httpclient.HttpConnection.open(HttpConnection.java:707)
    org.apache.commons.httpclient.MultiThreadedHttpConnectionManager$HttpConnectionAdapter.open(MultiThreadedHttpConnectionManager.java:1361)
    org.apache.commons.httpclient.HttpMethodDirector.executeWithRetry(HttpMethodDirector.java:387)
    org.apache.commons.httpclient.HttpMethodDirector.executeMethod(HttpMethodDirector.java:171)
    org.apache.commons.httpclient.HttpClient.executeMethod(HttpClient.java:397)
    org.apache.commons.httpclient.HttpClient.executeMethod(HttpClient.java:323)
    net.oauth.client.httpclient3.HttpClient3.executeOnce(HttpClient3.java:149)
    net.oauth.client.httpclient3.HttpClient3.execute(HttpClient3.java:90)
    net.oauth.client.OAuthClient.access(OAuthClient.java:325)
    net.oauth.client.OAuthClient.invoke(OAuthClient.java:306)
    net.oauth.client.OAuthClient.invoke(OAuthClient.java:262)
    com.test.auth.TestSync.test(TestSync.java:120) <== monitors:1

TestSync是依赖其他业务的 jar 包,堆栈中的monitors:1可见方法获取了监视器锁,即进入了synchronized方法。该synchronized方法里执行了I/O操作。

虚拟线程官方文档,在两种情况下,虚拟线程在阻塞操作期间无法卸载,因为它绑定了载体线程:

  1. 当它在synchronized块 或 synchronized方法内执行代码
  2. 当执行native方法 或 外部函数

也就是说,TestSync 类中 test 方法是synchronized方法,在方法里执行了较耗时的 I/O 操作,导致 JVM 无法把虚拟线程从平台线程中卸载,使得虚拟线程及其载体线程都被阻塞了,最终导致 Tomcat 容器中无平台线程来执行其他操作

synchronized test {
  // I/O操作
}

原理分析

那究竟是为什么 Project Loom 中,synchronized为什么会导致虚拟线程不能卸载,从而导致虚拟线程及其载体线程都被阻塞了呢?

在当前的Java平台中,synchronized 关键字在底层是通过 Java 对象监视器实现的,其使用的是操作系统线程(OS Thread)。而在 Project Loom 中,为了实现轻量级的并发操作,引入了虚拟线程(virtual threads)的概念,也称为纤程(fibers)。虚拟线程是基于协程(coroutine)的概念,在VM层面上进行管理,而不是依赖于操作系统线程。

为了实现这种轻量级的并发,synchronized关键字需要绑定到操作系统线程(OS Thread)。每个虚拟线程都会被映射到一个操作系统线程,这样可以避免虚拟线程之间的锁竞争和资源争用。

public static void main(String[] args){
	ReentrantLock lock = new ReentrantLock();
	Thread.startVirtualThread(() -> {
	    lock.lock();    
	});
	// 确保锁已经被上面的虚拟线程持有
	Thread.sleep(1000);  
	Thread.startVirtualThread(() -> {
	    System.out.println("first");
	    // 会触发Continuation的yield操作
	    lock.lock(); 
	    try {
	        System.out.println("second");
	    } finally {
	        lock.unlock();
	    }
	    System.out.println("third");
	});
	Thread.sleep(Long.MAX_VALUE);
}

虚拟线程中任务执行时候调用Continuation#run()先执行了部分任务代码,然后尝试获取锁,该操作是阻塞操作会导致 Continuation 的 yield 操作让出控制权

  • 如果 yield 操作成功,会从载体线程 unmount,载体线程栈数据会移动到 Continuation 栈的数据帧中,保存在堆内存中,虚拟线程任务完成,此时虚拟线程和 Continuation 还没有终结和释放,载体线程被释放到执行器中等待新的任务;
  • 如果 Continuation 的 yield 操作失败,则会对载体线程进行 park 调用,阻塞在载体线程上,此时虚拟线程和载体线程同时会被阻塞

会导致 yield 失败的为上面所述的在synchronized块 或 synchronized方法、native方法 或 外部函数


由此可见,在高并发的情况下,许多虚拟线程都挂载到了平台线程,进入了synchronized方法,会导致Continuationyield操作失败,则会对作为载体的平台线程进行park调用,阻塞在载体线程上,此时虚拟线程和平台线程同时会被阻塞

所以在JEPS 444中,官方推荐使用 ReentrantLock 来替换频繁调用的synchronized块 或 synchronized方法。对于使用不频繁(如启动时使用)的synchronized,可以无需替换。


参考资料:

  1. 虚拟线程官方文档
  2. 虚拟线程原理及性能分析
  3. parallelStream/ForkJoinPool 详解
文章来源:https://blog.csdn.net/why_still_confused/article/details/135175556
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。