大水怪

一只专注技术的水怪

0%

Loom(虚拟线程)相关

JDK21的 虚拟线程 目前存在许多严重bug,尽量不要在可靠性要求较高的服务中使用!

简单介绍

虚拟线程使用一个专用的,FIFO模式下的ForkJoin线程池(parallelStream使用的是LIFO模式)。

它的并行度就是承载虚拟线程的平台线程数,默认与可用核心数相等,也可以使用参数 jdk.virtualThreadScheduler.parallelism 来调整。

部分实现细节

[JVMLS][精翻+个人补充说明]虚拟线程Continuation实现原理 - 原视频 https://inside.java/2023/08/26/continuations-under-the-covers/

推荐资料

以下资料主要是对java虚拟线程的设计和思考,具体实现以jdk为准

https://cr.openjdk.org/~rpressler/loom/Loom-Proposal.html

https://cr.openjdk.org/~rpressler/loom/loom/sol1_part1.html

https://cr.openjdk.org/~rpressler/loom/loom/sol1_part2.html

https://inside.java/2021/05/10/networking-io-with-virtual-threads/

https://inside.java/2020/08/07/loom-performance/

https://blogs.oracle.com/javamagazine/post/going-inside-javas-project-loom-and-virtual-threads

如果对异步发展史感兴趣,可以观看 【[Devoxx2023]深入理解Java异步编程发展历史-从线程到响应式再到虚拟线程】 -  原视频 https://youtu.be/1zSF1259s6w?si=tJGrNK-Uh6Hgw4B9

风险

虚拟线程支持ThreadLocal和synchronized,但是存在一些缺陷,有一些问题需要我们额外注意

1.绑定问题

  1. 虚拟线程执行时 在 synchronized 块或方法中执行代码时,会 与平台线程绑定(同步块内,以及等待同步锁都会pin),因此需要使用 ReentrantLock替代(未来有望从底层解决-目前已经有相关JEP,会在JDK24上 https://openjdk.org/jeps/491

  2. 虚拟线程执行native代码或外来函数( JEP424 )时是无法被中止的(无法从底层解决,目前典型的是类初始化)。

故障实例

1[P1].死锁(这块风险在JEP中实际上被低估了)

1.最经典场景(大部分死锁本质都是这类问题)

这个问题触发概率高,而且影响大,目前已经有许多第三方开源组件都出现了这个问题

https://www.reddit.com/r/java/comments/1512xuo/virtual_threads_interesting_deadlock/ (这里的分析实际上有点问题,但是无伤大雅,下面会说到)

https://mail.openjdk.org/pipermail/loom-dev/2023-July/005993.html

复现代码

1
2
3
4
5
6
7
8
for (int i = 0; i < Runtime.getRuntime().availableProcessors() + 10; i++) {
Thread.startVirtualThread(() -> {
System.out.println(Thread.currentThread() + ": Before synchronized block");
synchronized (Main.class) {
System.out.println(Thread.currentThread() + ": Inside synchronized block");
}
});
}

原理分析

大体原理可以参考上面的这个讨论内容

https://www.reddit.com/r/java/comments/1512xuo/virtual_threads_interesting_deadlock/

代码可以简化为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for (int i = 0; i < Runtime.getRuntime().availableProcessors() + 1; i++) {
Thread.startVirtualThread(() -> {
doSthWithLock();
synchronized (Main.class) {
doSthWithLock()
}
});
}

private static void doSthWithLock() {
lock.lock();
// doSomeThing
lock.unlock();
}

但是帖子里说到:

这里的分析略有偏差。如果 [#30]能得到锁的话,说明已经开始运行了,如果它没有yield让出平台线程的话应该是会执行到释放锁逻辑的,所以这个说法应该是不太准确的。

这里我们取这两个阻塞在System.out.println方法上的线程进行分析,即[#26](阻塞在同步代码块内部),[#30](阻塞在同步代码块外部)。

Lock内部实现的AQS使用 FIFO 队列来进行线程的排队进行资源抢占(对于已经在队列中的线程来说无论是公平锁还是非公平锁都是遵循FIFO的)。

这个死锁的场景实际上是FIFO队列中, [#30]排在[#26]前面,而当有一个线程执行完同步块外部的方法释放锁时,会将队列前面的Node,即[#30]进行unpark,然后自身马上停靠在synchronized同步块上被绑定

此时其他虚拟线程都在pinned状态,占用了所有平台线程(假设8核,即7个线程阻塞在同步块的锁上,1个线程阻塞在同步块内部的锁上)

因此这个unpark是无效的,[#30]此时没有平台线程可以拿来绑定,因此也就无法上锁,而[#26]又由于排在[#30]后面而无法得到unpark通知,因此死锁。

对于以上分析,我们可以通过debug来佐证

1.首先验证锁的情况,可以看到锁的确是并没有被抢占到的

2.当前AQS队列中#32排在#25前面,而#32阻塞在同步块外部的lock上,而#25阻塞在同步块内部的lock上,其他虚拟线程都在同步块上被绑定着

vt.json

如果摒弃ReentrantLock的影响,实际状态可以简化为类似以下代码情况

下面情况需要只有一个载体线程,即 -Djdk.virtualThreadScheduler.parallelism=1,另外需要模块化参数–add-opens java.base/jdk.internal.misc=ALL-UNNAMED

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import jdk.internal.misc.VirtualThreads;

public class TestThreadDeadLock {
private volatile static int vt1state = 0;
private volatile static int vt2state = 0;

public static void main(String[] args) throws InterruptedException {
Thread vt1 = Thread.startVirtualThread(() -> {
VirtualThreads.park();
vt1state++;
});
// 等待t1进入park
while (!Thread.State.WAITING.equals(vt1.getState())) {
Thread.onSpinWait();
}

Thread vt2 = Thread.startVirtualThread(() -> {
// 唤醒t1
VirtualThreads.unpark(vt1);
// 唤醒t1后立即pin住当前唯一的载体线程
syncPark();
vt2state++;
});

while (true) {
Thread.sleep(1000);
System.out.println("vt1state:" + vt1state + " vt2state: " + vt2state);
}
}

private static synchronized void syncPark() {
VirtualThreads.park();
}
}

运行后输出应该如下,vt1被unpark也无法执行到vt1state++的代码

相关问题

https://bugs.openjdk.org/browse/JDK-8320211

https://www.javaspecialists.eu/archive/Issue302-Virtual-Thread-Deadlocks.html

https://www.reddit.com/r/java/comments/17xkkoc/java_virtual_threads_pitfalls_to_look_out_for/

conf相关问题(conf上面这几个问题实际上也类似上面提到的)

2.单线程的死锁(更简单)

一个简化场景是单线程调度器 vt1拿到了lock A然后遇到io让出 vt2拿到sync然后去拿lock A pin住

3.Native或FFI导致的死锁

这个可能比较难发生,但是与同步块的死锁是同理的

例如在类加载内与类加载外存在ReentrantLock,或者其他资源的抢占,就可能发生。

2[P2].平台线程资源耗尽

How we switched to Java 21 virtual threads and got a deadlock in TPC-C for PostgreSQL

https://news.ycombinator.com/item?id=39008026

这个问题源于线程的pinning,需要注意。

2.ThreadLocal

ThreadLocal的问题主要在于线程模型的变化

没有虚拟线程前我们可以假定线程数不会太多,但是虚拟线程打破了这个限制,一个系统可能同时存在非常多虚拟线程

因此ThreadLocal的数据量就可能非常非常多,这也就是ThreadLocal的风险所在了。用的时候注意一下就好。

这里cue一下ScopedValue,有些人可能期望使用ScopedValue完全替代ThreadLocal,其实这是不现实的。

最典型的就是ThreadLocal用于资源池化的场景,例如jackson中和log4j2中都有这种用法。例如 log4j2的ReusableMessage,现在老实换成普通资源池了 https://github.com/apache/logging-log4j2/pull/1401

故障实例

1.[P2].线程变量使用不当导致OOM

但是这个其实跟 ThreadLocal关系不大。。。。

监控

可以使用JFR来观察虚拟线程的 在绑定时的挂起情况

jdk.VirtualThreadStart  和 jdk.VirtualThreadEnd

记录了虚拟线程的启动和结束,默认关闭

jdk.VirtualThreadPinned

 表示虚拟线程绑定在平台线程上时,如果阻塞那么会产生这个事件(即没有释放其平台线程),阈值为20ms,默认开启

jdk.VirtualThreadSubmitFailed

启动或取消unparking虚拟线程失败,默认开启

https://openjdk.org/jeps/425#JDK-Flight-Recorder-JFR

2.【推荐】JFR EventStream

由于 Djdk.tracePinnedThreads本身会导致pin线程(下面会讲到),因此JFR是我们监控pin线程的首选方案

但是如果使用通过命令行的方式使用JFR会存在以下问题

1. dump文件较大,获取监控数据不方便,难以一次获取长期分析数据

2. dump文件需要上机器控制台dump文件,比较麻烦

3. 一次只能查看单机数据

而如果我们自己去解析JFR文件实际上也能做到持续监控,但是会很麻烦

因此使用JFR event stream来解决这个问题

JFR的事件流是jdk14引入的特性 JEP 349: JFR Event Streaming

这边用的是封装过的监控埋点组件,替换成自己的就好

JFR event stream

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import com.google.common.collect.Maps;
import jdk.jfr.consumer.RecordedEvent;
import jdk.jfr.consumer.RecordingStream;
import jdk.jfr.internal.tool.PrettyWriter;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.time.Duration;
import java.util.List;
import java.util.Map;

/**
* JFR 事件流监控配置
*
* @date 2024/04/20
*/
public class JFRMonitorConfiguration {
private static final LogFacade LOGGER = LogFacadeFactory.getLogFacade(JFRMonitorConfiguration.class);
private static final String TITLE = "JFR_LOG";
private static final String VIRTUAL_THREAD_PINNED = "jdk.VirtualThreadPinned";
private static final String JAVA_MONITOR_ENTER = "jdk.JavaMonitorEnter";

public static void init() {
Thread.ofVirtual().name("JFRMonitorThread").start(JFRMonitorConfiguration::startJFREventStream);
}

private static void startJFREventStream() {
try (var rs = new RecordingStream()) {
rs.enable(VIRTUAL_THREAD_PINNED).withoutThreshold();
rs.enable(JAVA_MONITOR_ENTER).withThreshold(Duration.ofMillis(5));
List.of(VIRTUAL_THREAD_PINNED, JAVA_MONITOR_ENTER)
.forEach(name -> rs.onEvent(name, event -> doLog(event, name)));
rs.start();
}
}

private static void doLog(RecordedEvent event, String eventName) {
Map<String, String> tags = Maps.newHashMapWithExpectedSize(2);
tags.put("eventName", eventName);
tags.put("isVirtual", String.valueOf(event.getThread().isVirtual()));
MetricClient.withTags(tags).recordOne("JFRDuration", event.getDuration().toMillis());
LOGGER.build(TITLE, eventToString(event))
.tag("eventName", eventName)
.tag("threadId", event.getThread().getJavaThreadId())
.tag("isVirtual", event.getThread().isVirtual())
.warn();
}

private static String eventToString(RecordedEvent event) {
StringWriter s = new StringWriter();
PrettyWriter p = new PrettyWriter(new PrintWriter(s));
p.setStackDepth(64);
p.print(event);
p.flush(true);
return s.toString();
}
}

需要加上以下参数

1.maven-surefire-plugin 中加上

--add-opens jdk.jfr/jdk.jfr.internal.tool=ALL-UNNAMED

2.maven-compiler-plugin 中加上

<compilerArgs> <arg>–add-exports</arg> <arg>jdk.jfr/jdk.jfr.internal.tool=ALL-UNNAMED</arg> </compilerArgs>

--add-exports jdk.jfr/jdk.jfr.internal.tool=ALL-UNNAMED

这个事件的触发条件是:线程在pinned情况下出现park操作的耗时超过duration(默认20ms)

当然,这样依然会有问题存在
1.在JFR启动之前,可能有一些其他的pin事件发生了,例如一些类加载,会被忽略掉
2.JFR的启动在premain之后,也就是说对于启动期间静态agent premain操作中的pin事件我们也是不知道的
对于问题1,我们可以使用命令行启动JFR的方式解决,随后去机器上dump启动期间的jfr记录就好。或者我们可以自定义一个agent,确保jfr监控在其他类加载之前启动。
问题2目前是无解的,这是在JVM里写死的
当然,如果启动期间有严重问题会直接导致机器启动不起来,并不会造成太大的影响。

3. jdk.tracePinnedThreads ( 只能在非生产使用! )

当线程固定在平台线程上阻塞时

-Djdk.tracePinnedThreads=full 打印完整的堆栈跟踪,并突出显示本机帧和持有锁的帧。

-Djdk.tracePinnedThreads=short 输出限制为仅有问题的帧。

目前存在以下两个严重bug

bug1(deadlock): https://bugs.openjdk.org/browse/JDK-8322846

这个问题实质上就是lock和sync的嵌套导致的死锁问题, bug1已经在jdk21.0.4修复

  • 解决

可以尝试使用- Djdk.io .useMonitors=true来将内部的ReentrantLock替换成使用synchronized,但是这样有饮鸩止渴的意味,不推荐

bug2(假死问题,hang住虚拟机): https://bugs.openjdk.org/browse/JDK-8325521

这里对bug2展开说说

  • 根因

JVM里的JVMTI存在bug,jdk20时就有相关pr( https://github.com/openjdk/jdk/pull/10321 ) ,应该是漏了部分逻辑,将会在23以后完成修复,原因如下

  • 触发条件

1.开启了参数-Djdk.tracePinnedThreads;
2.使用某个内部 agent增强了Runnable;
3.在某个内部 agent attach之前没有触发过java.lang.PinnedThreadPrinter类里lambda表达式的加载

  • 解决(如果不想关闭 -Djdk.tracePinnedThreads )

1.关闭JVM里对JVMTI事件的处理 -XX:-DoJVMTIVirtualThreadTransitions

2.在某个内部 agent attach之前自己手动触发一次pin

  • 复现代码(参数 -Djdk.attach.allowAttachSelf=true -Djdk.tracePinnedThreads=full )

hang JVM复现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package org.example;

import java.util.concurrent.locks.LockSupport;

public class TestAgentJVMHang {

public static void main(String[] args) throws InterruptedException, ClassNotFoundException {
// 1. 启动Agent
System.out.println("Agent loaded");

// 2. 启动VirtualThread park
Thread.ofVirtual().start(TestAgentJVMHang::testPinnedPrinter);

// 3. 进行类加载 - hang
Thread.sleep(1000);
System.out.println("finished");
Class<?> aClass = Class.forName("org.example.TestAgentJVMHang$TestClass");
System.out.println(aClass);
}

private static synchronized void testPinnedPrinter() {
LockSupport.parkNanos(1);
}

private static class TestClass {
}
}

解决措施

  • 最好的解决方案是官方提供synchronized对虚拟线程的支持(在jdk24中已经实现了适配 JEP 491

  • 尽量从synchronized迁移到ReentrantLock

自动档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<dependency>
  <groupId>mysql</groupId>
  <artifactId>mysql-connector-java</artifactId>
  <version>8.3.0-vt.1</version>
</dependency>

<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.4-alpha2</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.core5</groupId>
<artifactId>httpcore5</artifactId>
<version>5.3-alpha2</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.core5</groupId>
<artifactId>httpcore5-h2</artifactId>
<version>5.3-alpha2</version>
</dependency>

以及以下依赖:

具体如何更好地适配

1.可以参考 https://dzone.com/articles/relearning-java-thread-primitives ,更优雅地使用Lock进行替换
2.对于ConcurrentHashMap这类使用ReentrantLock替换需要付出代价(需要在Node上加lock属性,会导致内存膨胀)的场景,可以考虑使用ConcurrentSkipListMap替换(性能略差,但是是无锁的线程安全HashMap实现)
3.由于JFR的event并不包含持有Monitor的帧( https://bugs.openjdk.org/browse/JDK-8314591 ),因此上线前的排查阶段使用 -Djdk.tracePinnedThreads排查比较方便

排查时需要关注的场景

1.sync块内外出现了同一资源的竞争,典型的例如上面的sync块内外出现了对同一lock的抢占,这种可能造成死锁

2.sync里面有park但是无内外资源竞争的,这种只会影响性能

3.sync里面没有park操作,这种情况无竞争的情况下无影响,有竞争的情况下会导致性能变差(通过JFR事件jdk.JavaMonitorEnter来观察)

4.其他绑定问题(Native或FFI代码,例如类加载场景)

TIPS

https://www.javaperformancetuning.com/news/newtips274.shtml

虚拟线程不再需要池化,虚拟线程是非常轻量的,随用随建就行。

但是线程池 部分思想依然有效,例如使用线程池的思想控 制访问资源的并发量

这这种场景线程池内部可能用Semaphore简单地控制并发量,而不需要有复杂的线程管理逻辑

也就是说虚拟线程池的目的将不再包含资源的复用,而应该只包含一些更轻量的附加控制

其他

线程转储

在jdk21中使用jstack -l [pid] > [file]是拿不到虚拟线程的

使用idea中的线程转储也获取不到虚拟线程

原因:虚拟线程数量非常多,原本扁平化的数据结构无法很好地组织线程之间的结构关系,引入结构化并发后更是如此

方案:

提供json结构来输出包含虚拟线程的堆栈转储,而不会在原来的线程转储输出中包含虚拟线程

1
2
3
jcmd <pid> Thread.dump_to_file -format=json <file>

新版idea也对此提供了一定支持

参考

Virtual Threads: An Adoption Guide

The Ultimate Guide to Java Virtual Threads