JDK19 - synchronized关键字导致的虚拟线程PINNED

JDK19 - synchronized关键字导致的虚拟线程PINNED

  • 前言
  • 一. PINNED是什么意思
    • 1.1 synchronized 绑定测试
    • 1.2 synchronized 关键字的替代
  • 二. -Djdk.tracePinnedThreads的作用和坑
    • 2.1 死锁案例测试
    • 2.2 发生原因的推测
    • 2.3 总结

前言

在 虚拟线程详解 这篇文章里面,我们详解了虚拟线程的一个执行原理和底层执行顺序。那么这里我们分享一下一个使用虚拟线程的坑点。

一. PINNED是什么意思

PINNED指的是绑定,意思是虚拟线程无法在阻塞操作期间卸载,而被固定到其运载线程。 JEP425给出的说明中,提到了两种发生pinned的情况:

在这里插入图片描述

  1. 当调用的代码中被synchronized关键字修饰。
  2. 执行native methodforeign function

1.1 synchronized 绑定测试

案例代码:

public class Main {/*** 用于测试同步锁的对象*/private static volatile Object instance = new Object();/*** 用于格式化时间*/private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");/*** 执行任务*/private static void runTask(int threadNum) {realRunTask(threadNum);}/*** 执行任务(加锁)* @param threadNum*/private static void runTaskWithSynchronized(int threadNum) {synchronized (instance) {realRunTask(threadNum);}}// Calendar 转 yyyy-MM-dd HH:mm:sspublic static String format(Calendar calendar) {return sdf.format(calendar.getTime());}private static void realRunTask(int threadNum) {System.out.printf("%s|Test is start ThreadNum is %s %s%n", Thread.currentThread(), threadNum, format(Calendar.getInstance()));try {Thread.sleep(1000);} catch (Exception e) {}System.out.printf("%s|Test is Over ThreadNum is  %s %s%n", Thread.currentThread(), threadNum, format(Calendar.getInstance()));}private static ExecutorService getExecutorService(boolean isVirtualThread, boolean useThreadPool) {if (useThreadPool) {return new ThreadPoolExecutor(50, 50, 1, TimeUnit.MINUTES,new ArrayBlockingQueue<>(100000),isVirtualThread ? Thread.ofVirtual().factory() : Thread.ofPlatform().factory());} else {ThreadFactory factory = isVirtualThread ?Thread.ofVirtual().name("This-Test-Virtual-Thread-", 0).factory() : Thread.ofPlatform().name("This-Test-Platform-Thread-", 0).factory();return Executors.newThreadPerTaskExecutor(factory);}}/*** -Djdk.tracePinnedThreads=full* -Djdk.virtualThreadScheduler.parallelism=1* -Djdk.virtualThreadScheduler.maxPoolSize=1* -Djdk.virtualThreadScheduler.minRunnable=1** -Djdk.tracePinnedThreads=full -Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=2 -Djdk.virtualThreadScheduler.minRunnable=1*/public static void main(String[] args) throws Exception{ExecutorService executorService = getExecutorService(true, false);Future task1 = executorService.submit(() -> runTaskWithSynchronized(1));Future task2 = executorService.submit(() -> runTask(2));executorService.close();task1.get();task2.get();}
}

分析:

  1. 我们有用于测试同步锁的对象instance,专门拿来给synchronized关键字用的。
  2. 两种执行任务方式:一种普通的,一种加锁的。
  3. 执行的任务做了什么:睡眠了一秒钟,并且打印相关数据。
  4. 我们同时启动两个task,看看最终的结果是什么。

我们在启动之前,给Main函数添加一些参数:

  1. -Djdk.tracePinnedThreads=full:开启对虚拟线程的跟踪。设置为"full"表示输出详细的虚拟线程信息,包括线程ID、状态和执行时间等。这样被pinned的时候,我们就可以通过打印的信息观察到了 (后面有惊喜)

  2. -Djdk.virtualThreadScheduler.parallelism=1:这个参数指定了虚拟线程调度器的并行度。并行度表示同时执行虚拟线程的最大数量。在这里,设置为1表示只允许一个虚拟线程同时执行。

  3. -Djdk.virtualThreadScheduler.maxPoolSize=1:这个参数指定了虚拟线程调度器的最大线程池大小。线程池是用于存放虚拟线程的容器。在这里,设置为1表示线程池的大小为1,即最多只能容纳一个虚拟线程。

  4. -Djdk.virtualThreadScheduler.minRunnable=1:这个参数指定了虚拟线程调度器的最小可运行虚拟线程数。当虚拟线程池中的可运行线程数低于这个值时,调度器会尝试创建新的虚拟线程以填充线程池。在这里,设置为1表示最小可运行线程数为1。

我们设置可执行的线程数为1:maxPoolSize=1

-Djdk.tracePinnedThreads=full -Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1 -Djdk.virtualThreadScheduler.minRunnable=1

那么此时由于最大只有一个可执行线程,因此按照逻辑顺序,应该是带有synchronized关键字的task1先执行,再执行task2。而因为task1synchronized关键字修饰,因此线程被pinned
在这里插入图片描述

我们设置可执行的线程数为2:maxPoolSize=2 ,那么此时两个任务可以同时提交,但是task1synchronized关键字修饰,因此线程同样被pinned

-Djdk.tracePinnedThreads=full -Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=2 -Djdk.virtualThreadScheduler.minRunnable=1

在这里插入图片描述
注意:这两个返回结果的顺序是不一样的!

从上面的结果上来看,直观的结论就是:

  1. 如果执行代码中包含了synchronized关键字,那么这个线程将会被pinned。即任务1所在的虚拟线程无法卸载,而是被固定到了运载线程。
  2. 哪怕两个任务是“同时”提交,也会优先将任务1(被pinned的线程)执行完毕,再去启动任务2。因为任务2只能等待任务1执行完毕才能够继续执行。
  3. 那么也就失去了异步的一个概念了。

那么针对这种情况,我们如何解决?官方建议是使用Synchronized关键字的地方可以利用其他锁,比如重入锁来替代。

1.2 synchronized 关键字的替代

我们再写一个函数:

private static void runTaskWithReentrantLock(int threadNum) {ReentrantLock reentrantLock = new ReentrantLock();reentrantLock.lock();try {realRunTask(threadNum);} catch (Exception e) {} finally {reentrantLock.unlock();}
}

同时将maxPoolSize 重新设置为1,然后启动的时候变更如下:

public static void main(String[] args) throws Exception{ExecutorService executorService = getExecutorService(true, false);Future task1 = executorService.submit(() -> runTaskWithReentrantLock(1));Future task2 = executorService.submit(() -> runTask(2));executorService.close();task1.get();task2.get();
}

结果如下:可见,哪怕我们可执行的线程只有1个,但是两个任务也几乎是同时并发执行的。同时pinned的情况也不复存在。
在这里插入图片描述

二. -Djdk.tracePinnedThreads的作用和坑

我们先来说下这个参数的作用吧。在上文中,我们使用了-Djdk.tracePinnedThreads参数来打印虚拟线程pinned时相关的堆栈信息。让我们非常直观的观察到pinned的行为。

那么试想一下,我们为了去使用虚拟线程这个新特性,而进行JDK的升级。这个升级难以避免的是带来一定的风险。例如上文的synchronized关键字。它的存在可能导致你的虚拟线程无法被卸载,而进入pinned状态。那么,你的代码又有哪些隐藏的风险需要你关注呢?

  • 你的代码中是否有显式地使用synchronized关键字?
  • 你引入的外部第三方依赖中,内部操作是否同样地使用了synchronized关键字?

前者我们可以通过全局搜索,自己去在项目里面解决,但是要命的是后者,你很难做到全面排查所有的第三方依赖对synchronized关键字的使用情况。那么我们就可以增加这个参数去打印可能发生的pinned情况,一旦有,我们就可以通过堆栈信息去定位代码,然后解决。

-Djdk.tracePinnedThreads=full

但是这个情况仅仅适用于本地开发或者是测试环境的灰度阶段,并不适合发到生产。为什么呢?因为这个VM参数同样可能导致虚拟线程不可用,发生死锁。这是本文想分享的第二个重点。

2.1 死锁案例测试

添加两个依赖:

<dependency><groupId>org.apache.httpcomponents</groupId><artifactId>httpclient</artifactId><version>4.5.13</version>
</dependency>
<dependency><groupId>commons-logging</groupId><artifactId>commons-logging</artifactId><version>1.2</version>
</dependency>

贴出代码:

public class LockTest {/*** 平台线程数*/static int PLATFORM_THREAD_COUNT;/*** 虚拟线程数*/static int VIRTUAL_THREAD_COUNT;static CloseableHttpClient client;public static void main(String[] args) throws Exception {PLATFORM_THREAD_COUNT = 1;VIRTUAL_THREAD_COUNT = PLATFORM_THREAD_COUNT + 1;// 替换为这个即可解决死锁// VIRTUAL_THREAD_COUNT = PLATFORM_THREAD_COUNT;// 初始化apache http clientclient = initClient();// 设置虚拟线程池大小,最大线程数,最小可运行线程数 为平台线程数String strSize = Integer.toString(PLATFORM_THREAD_COUNT);System.setProperty("jdk.virtualThreadScheduler.parallelism", strSize);System.setProperty("jdk.virtualThreadScheduler.maxPoolSize", strSize);System.setProperty("jdk.virtualThreadScheduler.minRunnable", strSize);// 启动测试test();}public static void test() throws Exception {// 设置栅栏数为虚拟线程数CountDownLatch countDownLatch = new CountDownLatch(VIRTUAL_THREAD_COUNT);// 启动对应数量的虚拟线程任务for (int j = 0; j < VIRTUAL_THREAD_COUNT; j++) {Thread.ofVirtual().start(() -> apachePoolingHttpClient(client, countDownLatch));}// 如果任务没有执行完毕,等待,会循环打印等待信息while (countDownLatch.getCount() != 0) {System.out.println("waiting " + countDownLatch.getCount());Thread.sleep(2000);}// 只有虚拟线程执行完毕,才会执行下面的代码System.out.println("end success");}/*** 初始化apache http client,没什么好看的** @return*/private static CloseableHttpClient initClient() {PoolingHttpClientConnectionManager poolingConnManager = new PoolingHttpClientConnectionManager();poolingConnManager.setMaxTotal(PLATFORM_THREAD_COUNT);return HttpClients.custom().setConnectionManager(poolingConnManager).build();}/*** apache http client 发送请求,关注点在最后一行代码,执行IO完毕,会调用countDownLatch.countDown(),表示当前虚拟线程执行完毕*/private static void apachePoolingHttpClient(CloseableHttpClient client, CountDownLatch countDownLatch) {HttpGet request = new HttpGet("https://www.google.com");try (CloseableHttpResponse execute = client.execute(request)) {StatusLine statusLine = execute.getStatusLine();System.out.println(statusLine.getStatusCode());} catch (Throwable e) {throw new RuntimeException(e);} finally {countDownLatch.countDown();}}
}

分析如下:

  1. 我们设置平台线程数为1个,虚拟线程数为2个。然后启动两个虚拟线程任务。
  2. 启动任务之前,我们初始化了一个栅栏CountDownLatch,总数为2。如果这个数量不为0,那么就会循环打印waiting信息。
  3. 每个任务会进行网络IO,等待IO结束的时候,会触发countDownLatch.countDown();
  4. 直到两个任务都执行完毕,才会停止循环,打印end success

运行结果如下:无限打印2,可见发生了死锁。在这里插入图片描述

值得注意的是:

  1. 虚拟线程发生的死锁,常规的检测工具是检测不出来的。jstackjconsole我都试过了。
  2. 我们只能从结果的现象发现:两个虚拟线程都无法结束,这个循环会永远的进行下去。

2.2 发生原因的推测

而这个打印堆栈的功能和-Djdk.tracePinnedThreads这个VM参数息息相关。

我们全局搜索这个参数:

private static final int TRACE_PINNING_MODE = tracePinningMode();private static int tracePinningMode() {String propValue = GetPropertyAction.privilegedGetProperty("jdk.tracePinnedThreads");if (propValue != null) {if (propValue.length() == 0 || "full".equalsIgnoreCase(propValue))return 1;if ("short".equalsIgnoreCase(propValue))return 2;}return 0;
}

可以看到,只要设置了这个参数,这个返回值就是大于0的。还记得我在 虚拟线程详解 这篇文章里面提到的VThreadContinuation吗。那么我们再看看虚拟线程底层对Continuation的封装:这里面重写了一个onPinned函数,也就是说,发生pinned的时候,打印相关的堆栈信息

private static class VThreadContinuation extends Continuation {VThreadContinuation(VirtualThread vthread, Runnable task) {super(VTHREAD_SCOPE, () -> vthread.run(task));}@Overrideprotected void onPinned(Continuation.Pinned reason) {if (TRACE_PINNING_MODE > 0) {boolean printAll = (TRACE_PINNING_MODE == 1);PinnedThreadPrinter.printStackTrace(System.out, printAll);}}
}

我们往下跟进:

static void printStackTrace(PrintStream out, boolean printAll) {List<LiveStackFrame> stack = STACK_WALKER.walk(s ->s.map(f -> (LiveStackFrame) f).filter(f -> f.getDeclaringClass() != PinnedThreadPrinter.class).collect(Collectors.toList()));// find the closest frame that is causing the thread to be pinnedstack.stream().filter(f -> (f.isNativeMethod() || f.getMonitors().length > 0)).map(LiveStackFrame::getDeclaringClass).findFirst().ifPresent(klass -> {int hash = hash(stack);Hashes hashes = HASHES.get(klass);synchronized (hashes) {// print the stack trace if not already seenif (hashes.add(hash)) {printStackTrace(stack, out, printAll);}}});
}
private static void printStackTrace(List<LiveStackFrame> stack,PrintStream out,boolean printAll) {out.println(Thread.currentThread());for (LiveStackFrame frame : stack) {var ste = frame.toStackTraceElement();int monitorCount = frame.getMonitors().length;if (monitorCount > 0 || frame.isNativeMethod()) {out.format("    %s <== monitors:%d%n", ste, monitorCount);} else if (printAll) {out.format("    %s%n", ste);}}
}

看到没,上面有一个synchronized 关键字,里面的代码也是一个IO打印。

  1. 结合上下文来看,我们知道,在虚拟线程中,如果有IO阻塞,那么Loom会调用park()进行yield调用。
  2. 我们假设虚拟线程A抢到了锁。然后调用了IO相关的函数,因此进入yield(第一点)。
  3. 而众所周知,调用yield是不会释放锁的。那么虚拟线程B抢不到锁,由于synchronized关键字的作用,状态进入pinned。导致无法卸载,固定在运载线程。
  4. 那么运载线程被占用,卡在这,所以程序永远无法执行完毕。

最后,如果把下面的这行代码:

VIRTUAL_THREAD_COUNT = PLATFORM_THREAD_COUNT + 1;

替换成:

VIRTUAL_THREAD_COUNT = PLATFORM_THREAD_COUNT;

就不会产生死锁的情况了
在这里插入图片描述
执行结果如下:
在这里插入图片描述

2.3 总结

进行虚拟线程的代码改造的时候,我们要注意一个点:

  1. synchronized关键字对虚拟线程pinned的副作用,我们要考虑到如何兼容和更改,可以使用重入锁进行替代。
  2. 由于这个synchronized关键字我们难以排查完全,我们可以增加-Djdk.tracePinnedThreads参数信息打印pinned发生时候的堆栈信息,助于我们排查,但是这个操作建议只在本地或者测试环境进行。因为他可能会导致你的程序发生死锁。
  3. 建议测试环境进行灰度测试,保证pinned的情况不再发生的时候,可以再发到生产环境进行灰度。

最后的最后,附上堆栈信息的获取方式:

  1. 输入命令jps,找到你自己运行程序的pid
  2. 输入jstack命令:jstack -l 你自己的pid > 1.txt。这样就可以在这个文本中看到发生死锁时候的堆栈信息了。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/61881.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

树莓派安装Ubuntu系统(无屏幕)

树莓派安装ubuntu系统 前言 软件需要: 1.方案一 win32diskimager-1.0.0-install.exe SDFormatterha Ubuntu镜像&#xff08;可以官网下载也可以清华源&#xff09; 方案二: 树莓派镜像烧录器 树莓派镜像烧录器直达下载 硬件需要: 64GB内存卡&#xff08;推荐Sanddisk Ultra&am…

Java多线程(3)---锁策略、CAS和JUC

目录 前言 一.锁策略 1.1乐观锁和悲观锁 ⭐ 两者的概念 ⭐实现方法 1.2读写锁 ⭐概念 ⭐实现方法 1.3重量级锁和轻量级锁 1.4自旋锁和挂起等待锁 ⭐概念 ⭐代码实现 1.5公平锁和非公平锁 1.6可重入锁和不可重入锁 二.CAS 2.1为什么需要CAS 2.2CAS是什么 ⭐CAS…

c基础扫雷

和三子棋一样&#xff0c;主函数先设计游戏菜单界面&#xff0c;这里就不做展示了。 初始化棋盘 初级扫雷大小为9*9的棋盘&#xff0c;但排雷是周围一圈进行排雷(8格)&#xff0c;而边界可能会越界。数组扩大了一圈,行和列都加了2&#xff0c;所以我们用一个11*11的数组来初始化…

leetcode 343. 整数拆分

2023.8.10 本题用dp算法来做&#xff0c;dp[i]代表的含义是&#xff1a;当前数字i 在拆分之后所能获得的最大乘积。然后由于n>2&#xff0c;所以dp[0]和dp[1]没有意义&#xff0c;不用初始化&#xff0c;直接初始化dp[2] 1。 然后再遍历给dp数组赋值&#xff1a;dp[i]的来源…

Untiy Json和Xml的序列化和反序列化

Json的序列化和反序列化 1.定义数据类 [Serializable] public class ZoomPoint {// 点名称, 将作为Key被字典存储public string name;// 轴心X坐标public Vector2 pivot Vector2.one / 2;// 放大倍率&#xff0c;小于1是为缩小倍率&#xff0c;小于0是取绝对值&#xff0c;不…

竞赛项目 酒店评价的情感倾向分析

前言 &#x1f525; 优质竞赛项目系列&#xff0c;今天要分享的是 酒店评价的情感倾向分析 该项目较为新颖&#xff0c;适合作为竞赛课题方向&#xff0c;学长非常推荐&#xff01; &#x1f9ff; 更多资料, 项目分享&#xff1a; https://gitee.com/dancheng-senior/post…

推荐两本书《JavaRoadmap》、《JustCC》

《JavaRoadmap》 前言 本书的受众 如果你是一名有开发经验的程序员&#xff0c;对 Java 语言语法也有所了解&#xff0c;但是却一直觉得自己没有入门&#xff0c;那么希望这本书能帮你打通 Java 语言的任督二脉。 本书的定位 它不是一本大而全的书&#xff0c;而是一本打通、…

嵌入式:C高级 Day4

一、整理思维导图 二、写一个函数&#xff0c;获取用户的uid和gid并使用变量接收 三、整理冒泡排序、简单选择排序和快速排序的代码 冒泡排序 #include <myhead.h>void output(int arr[], int len); void bubble_sort(int arr[], int len);int main(int argc, const ch…

数字化时代,如何做好用户体验与应用性能管理

引言 随着数字化时代的到来&#xff0c;各个行业的应用系统从传统私有化部署逐渐转向公有云、行业云、微服务&#xff0c;这种变迁给运维部门和应用部门均带来了较大的挑战。基于当前企业 IT 运维均为多部门负责&#xff0c;且使用多种运维工具&#xff0c;因此&#xff0c;当…

Linux命名管道进程通信

文章目录 前言一、什么是命名管道通信二、创建方式三、代码示例四、文件进程通信总结 前言 命名管道 是实现进程间通信的强大工具&#xff0c;它提供了一种简单而有效的方式&#xff0c;允许不同进程之间进行可靠的数据交换。不仅可以在同一主机上的不相关进程间进行通信&…

桌面端UI自动化测试如何让SplitButtonControl展开

原始SplitButtonControl图 从图中鼠标所指的控件属性为&#xff1a; ControlType&#xff08;控件类型&#xff09;: SplitButtonControl ClassName&#xff08;类名&#xff09;: SplitButton AutomationId&#xff08;自动化ID&#xff09;: esri_geoprocessing_Pyt…