虚拟线程和协程
平台线程
在Java25中对虚拟线程做了非常多的优化。那么到底虚拟线程解决了什么问题呢?哪些场景下能用到呢?和GO语言的go routine有什么区别呢?
我们从小学二年级就学过,在JDK21之前Java中只有平台线程。所谓平台线程就是将线程所有的调度权交给操作系统进行处理。操作系统会有时间片轮转,多级队列等机制来决定当前让哪个线程执行,这让写程序的人觉得操作系统的线程资源几乎是无限的。问题就出在了这里,操作系统的调度是有代价的,操作系统内核直接调度,导致线程切换都需要进入内核态,这涉及了完整的上下文的切换,系统需要保存和恢复寄存器,恢复上下文,进行调度决策,这部分的开销较大。当我们创建的线程数远大于实际上操作系统逻辑核心数时,操作系统为了这些线程更公平的调度就需要频繁的挂起和恢复线程,确保每个任务都能向前推进。线代的操作系统使用的都是抢占式调度,操作系统可以通过中断的方式抢占当前的CPU并进行上下文切换’这种切换是强制的称为抢占式多任务处理模式。
我们这里以一个tomcat服务器为例,如果CPU有10个核心,那么这台机器如果只能同时处理10个http请求,这显然是不能接受的。但我们发现,这个http请求其实大部分的时间,并不是在使用CPU做计算,这个HTTP请求在处理过程中可能需要调用第三方的服务接口,或者是查询数据库,redis缓存等操作。在执行这些操作的时候,我们自然希望让操作系统能够挂起这些线程切换到其他线程来执行。当然同时处理过多的线程还是会出现上面提到的CPU资源被抢光,因此tomcat设置了一个默认值,也就是200作为线程池的大小。也就是说最多同时调度处理200个请求。当然具体的参数应该按照负载的类型和CPU的型号进行修改,比如CPU如果配置好,或者当前服务只是单纯的一个请求转发服务的话我们就可以将这个参数调的更高。是不是听起来非常的熟悉,没错,这就是我们经常背的八股文IO密集型和CPU密集型线程池的资源设置逻辑。
虚拟线程-协作式调度
所谓IO密集型的潜在含义是,这个服务不太用CPU,经常可以把CPU出让给其他线程使用。如果线程池中线程数设置的太多了,容易导致操作系统频繁的挂起程序导致性能下降,但设置的太少了也会导致在线程数被打满的情况下,处理器依旧没有满负荷运行导致性能浪费。那有没有什么方式可以解决这个问题呢?有的兄弟有的,那就是Java的虚拟线程。虚拟线程采用的解决方式十分的简单。既然操作系统的调度那么浪费时间,我把这个调度放到用户态不就好了?JVM可以自己调度线程。在Java中对虚拟线程采用的调度方式叫做协作式调度。协作式调度是当一个程序出现挂起或者阻塞,比如sleep函数时,那么当前的CPU资源就会默认被分配给另一个需要执行的线程。
在这段java代码中,程序中创建了比当系统可支配调度的线程数多一个的线程。这种情况下,多的那一个线程仍然可以被运行。
比如运行如下代码时,程序能够成功的输出这里的New virtual thread
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) throws InterruptedException {
int busyThreads = Runtime.getRuntime().availableProcessors();
int newThreadIndex = 0;
List<Thread> threads = new ArrayList<>();
// 创建并启动"死循环虚拟线程"
for (int i = 0; i < busyThreads; i++) {
int id = i;
Thread virtualThread = Thread.ofVirtual()
.name("busy-virtual-thread-" + id)
.start(() -> {
System.out.println("Busy virtual thread " + id + " started");
while (true) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
threads.add(virtualThread);
}
// 等待一会儿,让死循环线程占满平台线程
Thread.sleep(1000);
// 创建并启动一个新虚拟线程尝试执行
Thread newVirtualThread = Thread.ofVirtual()
.name("new-virtual-thread-" + newThreadIndex)
.start(() -> {
System.out.println("New virtual thread " + newThreadIndex + " started");
});
threads.add(newVirtualThread);
// 等待一会儿查看结果
Thread.sleep(10000);
}
}因为其他线程的循环中的sleep函数将会使其他虚拟线程将当前执行权让给这个新来的线程。
但是如果我们稍微改一下代码把Sleep删掉,重新运行这段代码
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) throws InterruptedException {
int busyThreads = Runtime.getRuntime().availableProcessors();
int newThreadIndex = 0;
List<Thread> threads = new ArrayList<>();
// 创建并启动"死循环虚拟线程"
for (int i = 0; i < busyThreads; i++) {
int id = i;
Thread virtualThread = Thread.ofVirtual()
.name("busy-virtual-thread-" + id)
.start(() -> {
System.out.println("Busy virtual thread " + id + " started");
while (true) {
// 纯 CPU 循环,不阻塞
}
});
threads.add(virtualThread);
}
// 等待一会儿,让死循环线程占满平台线程
Thread.sleep(1000);
// 创建并启动一个新虚拟线程尝试执行
Thread newVirtualThread = Thread.ofVirtual()
.name("new-virtual-thread-" + newThreadIndex)
.start(() -> {
System.out.println("New virtual thread " + newThreadIndex + " started");
});
threads.add(newVirtualThread);
// 等待一会儿查看结果
Thread.sleep(10000);
}
}我们会发现new virtual thread这段永远不会输出。无论是在JDK21还是25都是如此。这就是协作式调度,相互之间需要商量什么时候能把资源给其他线程。
这种调度模式的,好处就是不需要让jvm定期中断操作,因为这会产生一定的性能开销。坏处就是如果你有11个完全不休息的任务也就是11个线程,要运行在一个10线程的CPU上,其中一个任务就会持续无法被调度运行。
go混合调度

go中的虚拟线程我们通常叫做go routine ,它选择了不一样的套路。我们都知道go的线程调度模型是基于GMP模型。相同的,GO也是把调度带到了用户态程序中。我们通常把Goroutine称作协程,主要这里的协就是协作的协。早期的go选择的是和现在java相似的纯协作式调度也就是说,
在go1.13及之前如果你运行如下代码
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
// 获取 CPU 核心数
numCPU := runtime.NumCPU()
fmt.Println("Number of CPUs:", numCPU)
busyGoroutines := numCPU + 1 // 创建比 CPU 多的 goroutine
fmt.Println("Starting", busyGoroutines, "busy goroutines")
// 启动大量 CPU-bound goroutine
for i := 0; i < busyGoroutines; i++ {
id := i
go func() {
fmt.Printf("Busy goroutine %d started\n", id)
for {
// 纯 CPU 循环
}
}()
}
// 等待一秒,让 busy goroutine 占满 CPU
time.Sleep(time.Second)
// 再启动一个新 goroutine
newGoroutineIndex := 0
go func() {
fmt.Printf("New goroutine %d started\n", newGoroutineIndex)
time.Sleep(10 * time.Second)
}()
fmt.Println("Submitted new goroutine, waiting 5 seconds...")
time.Sleep(5 * time.Second)
fmt.Println("Main function exits")
}最终的会和java一样,无法输出New Goroutine,甚至会直接卡死。但是在1.14及以后版本,运行相同的代码时,New Goroutine又能够成功的输出了。go语言,很神奇吧。
这是因为在后续的版本中Go语言采用了协作式加抢占式调度。如果一个goroutine在同一个逻辑处理器上连续运行10毫秒,就会被标记为可抢占。也就是说,在go中高版本有俩种可能性导致协程切换,一个是协程阻塞或者挂起,另一个是运行太久会被其他协程抢占。
总结一下,我们不难得出虚拟线程的主要好处是
- 创建和销毁线程的开销非常小,而且都是用户态的操作。因此大多数情况下,不需要和平台线程一样对虚拟线程进行池化。因此我们也不需要担心具体的线程池参数配置,这在以往,通常要根据业务属性和压测结果来进行设置。
- 虚拟线程的切换开销也是用户态的操作,相对于系统级别的陷入才能进行的线程切换,虚拟线程在频繁需要切换线程的情况下性能会更好。
在知道了这些原理后
如果你觉得线程调度太麻烦,想程序员每次执行的代码都占用一个线程,所有异步操作都默认在一个线程上调度那么你就实现了python的,GIL锁。
如果你想通过异步事件驱动来避免操作系统的频繁上下文切换,那你就实现了springcloud getway中的webflux。