设计新颖的网站建站,深圳建设网站的公司哪家好,哈尔滨cms建站系统,百度提交网站收录查询JDK19 - synchronized关键字导致的虚拟线程PINNED 前言一. PINNED是什么意思1.1 synchronized 绑定测试1.2 synchronized 关键字的替代 二. -Djdk.tracePinnedThreads的作用和坑2.1 死锁案例测试2.2 发生原因的推测2.3 总结 前言
在 虚拟线程详解 这篇文章里面#xff0c;我们… JDK19 - synchronized关键字导致的虚拟线程PINNED 前言一. PINNED是什么意思1.1 synchronized 绑定测试1.2 synchronized 关键字的替代 二. -Djdk.tracePinnedThreads的作用和坑2.1 死锁案例测试2.2 发生原因的推测2.3 总结 前言
在 虚拟线程详解 这篇文章里面我们详解了虚拟线程的一个执行原理和底层执行顺序。那么这里我们分享一下一个使用虚拟线程的坑点。
一. PINNED是什么意思
PINNED指的是绑定意思是虚拟线程无法在阻塞操作期间卸载而被固定到其运载线程。 JEP425给出的说明中提到了两种发生pinned的情况 当调用的代码中被synchronized关键字修饰。执行native method或foreign 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.tracePinnedThreadsfull* -Djdk.virtualThreadScheduler.parallelism1* -Djdk.virtualThreadScheduler.maxPoolSize1* -Djdk.virtualThreadScheduler.minRunnable1** -Djdk.tracePinnedThreadsfull -Djdk.virtualThreadScheduler.parallelism1 -Djdk.virtualThreadScheduler.maxPoolSize2 -Djdk.virtualThreadScheduler.minRunnable1*/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();}
}分析
我们有用于测试同步锁的对象instance专门拿来给synchronized关键字用的。两种执行任务方式一种普通的一种加锁的。执行的任务做了什么睡眠了一秒钟并且打印相关数据。我们同时启动两个task看看最终的结果是什么。
我们在启动之前给Main函数添加一些参数 -Djdk.tracePinnedThreadsfull开启对虚拟线程的跟踪。设置为full表示输出详细的虚拟线程信息包括线程ID、状态和执行时间等。这样被pinned的时候我们就可以通过打印的信息观察到了 后面有惊喜 -Djdk.virtualThreadScheduler.parallelism1这个参数指定了虚拟线程调度器的并行度。并行度表示同时执行虚拟线程的最大数量。在这里设置为1表示只允许一个虚拟线程同时执行。 -Djdk.virtualThreadScheduler.maxPoolSize1这个参数指定了虚拟线程调度器的最大线程池大小。线程池是用于存放虚拟线程的容器。在这里设置为1表示线程池的大小为1即最多只能容纳一个虚拟线程。 -Djdk.virtualThreadScheduler.minRunnable1这个参数指定了虚拟线程调度器的最小可运行虚拟线程数。当虚拟线程池中的可运行线程数低于这个值时调度器会尝试创建新的虚拟线程以填充线程池。在这里设置为1表示最小可运行线程数为1。
我们设置可执行的线程数为1maxPoolSize1
-Djdk.tracePinnedThreadsfull -Djdk.virtualThreadScheduler.parallelism1 -Djdk.virtualThreadScheduler.maxPoolSize1 -Djdk.virtualThreadScheduler.minRunnable1那么此时由于最大只有一个可执行线程因此按照逻辑顺序应该是带有synchronized关键字的task1先执行再执行task2。而因为task1被synchronized关键字修饰因此线程被pinned
我们设置可执行的线程数为2maxPoolSize2 那么此时两个任务可以同时提交但是task1被synchronized关键字修饰因此线程同样被pinned
-Djdk.tracePinnedThreadsfull -Djdk.virtualThreadScheduler.parallelism1 -Djdk.virtualThreadScheduler.maxPoolSize2 -Djdk.virtualThreadScheduler.minRunnable1注意这两个返回结果的顺序是不一样的
从上面的结果上来看直观的结论就是
如果执行代码中包含了synchronized关键字那么这个线程将会被pinned。即任务1所在的虚拟线程无法卸载而是被固定到了运载线程。哪怕两个任务是“同时”提交也会优先将任务1被pinned的线程执行完毕再去启动任务2。因为任务2只能等待任务1执行完毕才能够继续执行。那么也就失去了异步的一个概念了。
那么针对这种情况我们如何解决官方建议是使用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.tracePinnedThreadsfull但是这个情况仅仅适用于本地开发或者是测试环境的灰度阶段并不适合发到生产。为什么呢因为这个VM参数同样可能导致虚拟线程不可用发生死锁。这是本文想分享的第二个重点。
2.1 死锁案例测试
添加两个依赖
dependencygroupIdorg.apache.httpcomponents/groupIdartifactIdhttpclient/artifactIdversion4.5.13/version
/dependency
dependencygroupIdcommons-logging/groupIdartifactIdcommons-logging/artifactIdversion1.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个虚拟线程数为2个。然后启动两个虚拟线程任务。启动任务之前我们初始化了一个栅栏CountDownLatch总数为2。如果这个数量不为0那么就会循环打印waiting信息。每个任务会进行网络IO等待IO结束的时候会触发countDownLatch.countDown();直到两个任务都执行完毕才会停止循环打印end success
运行结果如下无限打印2可见发生了死锁。
值得注意的是
虚拟线程发生的死锁常规的检测工具是检测不出来的。jstack和jconsole我都试过了。我们只能从结果的现象发现两个虚拟线程都无法结束这个循环会永远的进行下去。
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) {ListLiveStackFrame 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(ListLiveStackFrame 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打印。
结合上下文来看我们知道在虚拟线程中如果有IO阻塞那么Loom会调用park()进行yield调用。我们假设虚拟线程A抢到了锁。然后调用了IO相关的函数因此进入yield第一点。而众所周知调用yield是不会释放锁的。那么虚拟线程B抢不到锁由于synchronized关键字的作用状态进入pinned。导致无法卸载固定在运载线程。那么运载线程被占用卡在这所以程序永远无法执行完毕。
最后如果把下面的这行代码
VIRTUAL_THREAD_COUNT PLATFORM_THREAD_COUNT 1;替换成
VIRTUAL_THREAD_COUNT PLATFORM_THREAD_COUNT;就不会产生死锁的情况了 执行结果如下
2.3 总结
进行虚拟线程的代码改造的时候我们要注意一个点
synchronized关键字对虚拟线程pinned的副作用我们要考虑到如何兼容和更改可以使用重入锁进行替代。由于这个synchronized关键字我们难以排查完全我们可以增加-Djdk.tracePinnedThreads参数信息打印pinned发生时候的堆栈信息助于我们排查但是这个操作建议只在本地或者测试环境进行。因为他可能会导致你的程序发生死锁。建议测试环境进行灰度测试保证pinned的情况不再发生的时候可以再发到生产环境进行灰度。
最后的最后附上堆栈信息的获取方式
输入命令jps找到你自己运行程序的pid。输入jstack命令jstack -l 你自己的pid 1.txt。这样就可以在这个文本中看到发生死锁时候的堆栈信息了。