在 虚拟线程详解 这篇文章里面,我们详解了虚拟线程的一个执行原理和底层执行顺序。那么这里我们分享一下一个使用虚拟线程的坑点。
PINNED
指的是绑定,意思是虚拟线程无法在阻塞操作期间卸载,而被固定到其运载线程。 JEP425
给出的说明中,提到了两种发生pinned
的情况:
synchronized
关键字修饰。native method
或foreign function
。案例代码:
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:ss
public 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();
}
}
分析:
instance
,专门拿来给synchronized
关键字用的。task
,看看最终的结果是什么。我们在启动之前,给Main函数添加一些参数:
-Djdk.tracePinnedThreads=full
:开启对虚拟线程的跟踪。设置为"full"
表示输出详细的虚拟线程信息,包括线程ID
、状态和执行时间等。这样被pinned
的时候,我们就可以通过打印的信息观察到了 (后面有惊喜)
-Djdk.virtualThreadScheduler.parallelism=1
:这个参数指定了虚拟线程调度器的并行度。并行度表示同时执行虚拟线程的最大数量。在这里,设置为1表示只允许一个虚拟线程同时执行。
-Djdk.virtualThreadScheduler.maxPoolSize=1
:这个参数指定了虚拟线程调度器的最大线程池大小。线程池是用于存放虚拟线程的容器。在这里,设置为1表示线程池的大小为1,即最多只能容纳一个虚拟线程。
-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
。而因为task1
被synchronized
关键字修饰,因此线程被pinned
:
我们设置可执行的线程数为2:maxPoolSize=2
,那么此时两个任务可以同时提交,但是task1
被synchronized
关键字修饰,因此线程同样被pinned
:
-Djdk.tracePinnedThreads=full -Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=2 -Djdk.virtualThreadScheduler.minRunnable=1
从上面的结果上来看,直观的结论就是:
synchronized
关键字,那么这个线程将会被pinned
。即任务1所在的虚拟线程无法卸载,而是被固定到了运载线程。pinned
的线程)执行完毕,再去启动任务2。因为任务2只能等待任务1执行完毕才能够继续执行。那么针对这种情况,我们如何解决?官方建议是使用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
参数来打印虚拟线程pinned
时相关的堆栈信息。让我们非常直观的观察到pinned
的行为。
那么试想一下,我们为了去使用虚拟线程这个新特性,而进行JDK
的升级。这个升级难以避免的是带来一定的风险。例如上文的synchronized
关键字。它的存在可能导致你的虚拟线程无法被卸载,而进入pinned
状态。那么,你的代码又有哪些隐藏的风险需要你关注呢?
synchronized
关键字?synchronized
关键字?前者我们可以通过全局搜索,自己去在项目里面解决,但是要命的是后者,你很难做到全面排查所有的第三方依赖对synchronized
关键字的使用情况。那么我们就可以增加这个参数去打印可能发生的pinned
情况,一旦有,我们就可以通过堆栈信息去定位代码,然后解决。
-Djdk.tracePinnedThreads=full
但是这个情况仅仅适用于本地开发或者是测试环境的灰度阶段,并不适合发到生产。为什么呢?因为这个VM参数同样可能导致虚拟线程不可用,发生死锁。这是本文想分享的第二个重点。
添加两个依赖:
<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpclientartifactId>
<version>4.5.13version>
dependency>
<dependency>
<groupId>commons-logginggroupId>
<artifactId>commons-loggingartifactId>
<version>1.2version>
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 client
client = 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();
}
}
}
分析如下:
CountDownLatch
,总数为2。如果这个数量不为0,那么就会循环打印waiting
信息。IO
,等待IO
结束的时候,会触发countDownLatch.countDown();
end success
值得注意的是:
jstack
和jconsole
我都试过了。而这个打印堆栈的功能和-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));
}
@Override
protected 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 pinned
stack.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 seen
if (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
打印。
IO
阻塞,那么Loom
会调用park()
进行yield
调用。IO
相关的函数,因此进入yield
(第一点)。yield
是不会释放锁的。那么虚拟线程B抢不到锁,由于synchronized
关键字的作用,状态进入pinned
。导致无法卸载,固定在运载线程。最后,如果把下面的这行代码:
VIRTUAL_THREAD_COUNT = PLATFORM_THREAD_COUNT + 1;
替换成:
VIRTUAL_THREAD_COUNT = PLATFORM_THREAD_COUNT;
进行虚拟线程的代码改造的时候,我们要注意一个点:
synchronized
关键字对虚拟线程pinned
的副作用,我们要考虑到如何兼容和更改,可以使用重入锁进行替代。synchronized
关键字我们难以排查完全,我们可以增加-Djdk.tracePinnedThreads
参数信息打印pinned
发生时候的堆栈信息,助于我们排查,但是这个操作建议只在本地或者测试环境进行。因为他可能会导致你的程序发生死锁。pinned
的情况不再发生的时候,可以再发到生产环境进行灰度。最后的最后,附上堆栈信息的获取方式:
jps
,找到你自己运行程序的pid
。jstack
命令:jstack -l 你自己的pid > 1.txt
。这样就可以在这个文本中看到发生死锁时候的堆栈信息了。