截止目前 Java 21 虚拟线程一些比较严重的 Bug:
1. `Thread.HoldsLock(Object)` 这个方法,如果是虚拟线程调用,会在平台线程获取到锁之后,就算切换虚拟线程,也会返回 true:https://bugs.openjdk.org/browse/JDK-8281642
2. 默认使用的 ForkJoinPool.common 线程池,如果全部 pin 住,问题很严重。比如synchronized 块外有与块内争用的资源,可能会导致死锁(这个与原来的线程池池化死锁类似)。主要是 monitor enter 的机制与虚拟线程的 Continuation 设计以及 ForkJoinPool 的设计目前不太兼容:https://bugs.openjdk.org/browse/JDK-8320211
目前看来,synchronized pin 线程的问题比预期的更严重,很多库,包括 JDK 库本身可能都要兼容:
1. JDK 库本身:
(1)需要重新看看 AQS 的设计与虚拟线程的兼容性,尤其是队列,防止出现队列调度死锁
(2)需要审查下 ForkJoinPool 的设计,将 ForkJoinPool 作为默认的 Carrier 线程池,是否合适。因为工作窃取导致调度设计困难,并且,ForkJoinPool 天生不支持 Go 那种遇到 Pin 线程新起一个线程的解决方案。我个人觉得,需要那种能手动指定虚拟线程的负载线程池的方案
(3)很多 synchronized 的代码是否要重写,尤其是常用的数据结构以及输出流的地方。
2. 各种 Java 库的兼容:日常开发离不开 JDBC 库,但是官方的 JDBC 库里面很多 synchronized 以及与虚拟线程的设计不好兼容的没必要的同步队列。
3. 其实可以考虑 Java 重构 synchronized 不 pin 线程,但是不知道要什么时候了。
(1)非抢占式设计:虚拟线程只会在遇到阻塞的时候与底层平台线程分离切换,否则不会切换。比如你有 4 核,同时启动 8 个平台线程的计算任务,每个任务基本上进度是一样的。同时启动 8 个虚拟线程的计算任务,则是先执行 4 个,之后再执行 4 个。
(2)虚拟线程切换的消耗比较大,虽然已经做了很多优化(Continuation 的堆栈增量复制,按需复制,优化虚拟线程 GC 根引用扫描),但是消耗还是很大,下面是一个平台线程执行与虚拟线程执行计算任务的 CPU 采样对比:
第一个问题是内存占用太大导致吞吐量下降:ThreadLocal 虽然底层的 Map 是 WeakReference 的,但是设计之初是考虑 Thread 数量有限。在有虚拟线程很大量的时候,这个 Map 是非常消耗内存的。ScopedValue 通过限制作用域,以及值不可变的方式,优化了内存占用的问题。但是,ScopedValue 还处于预览阶段,并且没有解决 ThreadLocal 的所有问题。
没有解决的问题就是第二个问题:之前有很多使用 ThreadLocal 作为资源池的场景(很多库都这么用)。比如说,最早的线程不安全的 SimpleDateFormat(虽然现在已经不怎么用了)。它解决线程不安全的方式,就是或者每次新建一个 SimpleDateFormat,或者使用 ThreadLocal<SimpleDateFormat> 针对每个线程创建一个独立的。后者肯定消耗比前者小。但是,引入了虚拟线程,就相当于回到了最原来的做法。针对这种资源池的场景(即限制某个线程不安全的资源,每个平台线程创建一个独立使用不并发就行了),其实我们还是想对于平台线程创建。这个对于很多库的影响很多,比如 jackson,jackson 针对这个问题的解决方式是:https://github.com/FasterXML/jackson-core/pull/1064/files#diff-0d3d4113de19d16bfce8a0fffa471b3f90096602b45d598eca91c6b226f7cf2d
其实也不是说用新的 ThreadLocal 去替换,而是换种思路,将对象池化的同时,让程序从池子里获取并在用完的时候放回。相当于明确控制生命周期。但是我们也可以看出,这个对于三方库的改造,也是很大的。