前言
在JDK19中引入了一项新技术,虚拟线程,但你可能也听说过绿色线程,那是在Java 1.0时代的事了,我们现在也不无从体验,只能从一些上了年纪的书中找到一些关于他的一丝事迹,根据古书记载可得知,虚拟线程与绿色线程是有一些相似之处的,它们都由 JVM 而不是操作系统管理,这是一些题外话。
我们知道JVM会把用户创建的线程映射到操作系统上,由操作系统进行调度,关系为1:1,但为了提高应用程序的性能,我们会添加越来越多的线程,但操作系统在调度Java线程时会占用大量资源来处理线程上下文切换,会导致性能下降,所谓高了不行,低了也不行。
而对于虚拟线程,不再直接映射到系统线程,而是JDK有自己的一套调度系统进行调度,但遗憾的是,我无法搞清楚他是如何调度的,也并没有找到一些详细的文章,所以在本文中所展示的只是一些使用方法以及他的特性。
注意
因为虚拟线程是Java19中的一个预览特性,所以本文出现的代码需要加入如下参数。
- 使用 编译程序
javac --release 19 --enable-preview Main.java并使用java --enable-preview Main. - 或者使用
java --source 19 --enable-preview Main.java.
如果在IDEA中运行,可以选择下面选项。
创建虚拟线程
- 方法一
Thread.ofVirtual().start(() -> System.out.println(Thread.currentThread())).join();
- 方法二
Thread.startVirtualThread(()-> System.out.println(Thread.currentThread())).join();
- 方法三
上面两种方法创建后会立即启动虚拟线程,而下面需要手动启动,这里的un表示未启动的意思,但如果后面没有进行join()调用,那么在主线程结束后,这个虚拟线程也可能来不及被调度。
Thread unstarted = Thread.ofVirtual().unstarted(() -> System.out.println(Thread.currentThread()));
unstarted.start();
unstarted.join();
上述三种方式输出的结果可能如下所示。
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
可以看到有ForkJoinPool的踪影,他是JDK的一个工具,也就是说,虚拟线程的调度器是由带有工作窃取算法的ForkJoinPool来完成的,在默认情况下,ForkJoinPool会创建和CPU核数一样的线程来执行任务,但可以通过jdk.virtualThreadScheduler.parallelism 进行调整。
我使用的CPU是一个8核的CPU,在执行下面代码后,会看到输出的结果中存在ForkJoinPool-1-worker-1到ForkJoinPool-1-worker-8。
public class Main {
public static void main(String[] args) throws InterruptedException {
List<Thread> threads = IntStream.range(0, 20).mapToObj(index -> Thread.ofVirtual().unstarted(() -> {
System.out.println(Thread.currentThread());
try {
Thread.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread());
})).toList();
threads.forEach(Thread::start);
for (Thread thread : threads) {
thread.join();
}
}
}
阻塞
那么此时有个问题,如果这8个线程所调度的任务同时被阻塞,那么当第9个计算型的任务被加入时,会得到执行吗?
答案是会的,如下代码。
public static void main(String[] args) throws IOException, InterruptedException {
ServerSocket serverSocket = new ServerSocket(7070);
List<Thread> threads = IntStream.range(0, 8).mapToObj(value -> Thread.ofVirtual().unstarted(() -> {
try {
System.out.println("accept.."+Thread.currentThread());
Socket accept = serverSocket.accept();
System.out.println("ok");
} catch (IOException e) {
throw new RuntimeException(e);
}
})).toList();
System.out.println(threads.size());
threads .forEach(Thread::start);
Thread.sleep(2000);
Thread.ofVirtual().start(()-> System.out.println(Thread.currentThread())).join();
for (;;){}
}
Java也不会创建一个新线程来执行新任务,这就是Java虚拟线程的厉害之处,当虚拟线程在执行I/O或其他阻塞操作时,会卸载虚拟线程,例如BlockingQueue.take(),当阻塞操作准备好时,调度程序将虚拟线程挂载并恢复执行。
但是,有些阻塞操作不会卸载虚拟线程,这是因为操作系统级别或JDK级别的限制,其中一种情况是在synchronized中运行。
还有就是,假如一个任务开始是由A线程执行,那么当阻塞一段时间后恢复运行,那么可能会在B中执行,可以看下面例子。
private static String read() {
try {
URLConnection urlConnection = new URL("http://www.houxinlin.com/").openConnection();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
String temp = null;
while ((temp = bufferedReader.readLine()) != null) {
}
bufferedReader.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
return "OK";
}
public static void main(String[] args) {
List<Thread> threads =new ArrayList<>();
for (int i = 0; i < 10; i++) {
final int value =i;
threads.add(Thread.ofVirtual().unstarted(() -> {
try {
System.out.println(Thread.currentThread()+" start task="+value);
read();
System.out.println(Thread.currentThread() +" end task="+value);
} catch (Exception e) {
throw new RuntimeException(e);
}
}));
}
System.out.println(threads.size());
threads .forEach(Thread::start);
for (;;){}
}
输出如下
VirtualThread[#24]/runnable@ForkJoinPool-1-worker-3 start task=2
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-4 start task=3 //看这行
VirtualThread[#23]/runnable@ForkJoinPool-1-worker-2 start task=1
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-7 start task=6
VirtualThread[#29]/runnable@ForkJoinPool-1-worker-8 start task=7
VirtualThread[#26]/runnable@ForkJoinPool-1-worker-5 start task=4
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1 start task=0
VirtualThread[#27]/runnable@ForkJoinPool-1-worker-6 start task=5
VirtualThread[#31]/runnable@ForkJoinPool-1-worker-1 start task=9
VirtualThread[#30]/runnable@ForkJoinPool-1-worker-5 start task=8
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-2 end task=3 //与这行
VirtualThread[#29]/runnable@ForkJoinPool-1-worker-8 end task=7
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-5 end task=6
VirtualThread[#23]/runnable@ForkJoinPool-1-worker-2 end task=1
VirtualThread[#31]/runnable@ForkJoinPool-1-worker-8 end task=9
VirtualThread[#30]/runnable@ForkJoinPool-1-worker-2 end task=8
VirtualThread[#24]/runnable@ForkJoinPool-1-worker-3 end task=2
VirtualThread[#26]/runnable@ForkJoinPool-1-worker-5 end task=4
VirtualThread[#27]/runnable@ForkJoinPool-1-worker-3 end task=5
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-3 end task=0
你会发现task3一开始是由ForkJoinPool-1-worker-4 所执行,但end时却是ForkJoinPool-1-worker-2,就这种情况,我无法得知这是如何做到的,猜测是在阻塞后,虚拟线程被卸载,当有数据到达时,Java会把刚才堆栈信息复制到一个空闲的调度线程,从被阻塞后的地方重新执行。
速度对比
在官方介绍中说明虚拟线程适合大部分时间被阻塞以及经常等待 I/O的操作,不适用于长时间运行的 CPU 密集型操作。下面我们使用普通线程池做一个测试。
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(100);
long l = System.currentTimeMillis();
ExecutorService executorService = Executors.newFixedThreadPool(8);
for (int i = 0; i < 100; i++) {
final int value = i;
executorService.submit(() -> {
try {
System.out.println(Thread.currentThread() + " start task=" + value);
read();
System.out.println(Thread.currentThread() + " end task=" + value);
countDownLatch.countDown();
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
try {
executorService.shutdown();
countDownLatch.await();
System.out.println(System.currentTimeMillis() - l);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
和使用虚拟线程做对比。
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(100);
long l = System.currentTimeMillis();
List<Thread> threads =new ArrayList<>();
for (int i = 0; i < 100; i++) {
final int value = i;
Runnable runnable =() -> {
try {
System.out.println(Thread.currentThread() + " start task=" + value);
read();
System.out.println(Thread.currentThread() + " end task=" + value);
countDownLatch.countDown();
} catch (Exception e) {
throw new RuntimeException(e);
}
};
threads.add(Thread.ofVirtual().unstarted(runnable));
}
try {
threads .forEach(Thread::start);
countDownLatch.await();
System.out.println(System.currentTimeMillis() - l);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
在这两个对比中,使用线程池最快一次为656毫秒,而使用虚拟线程最快一次为253毫秒。
线程池
虚拟线程也提供了一个虚拟线程池,和以往的使用方式一样。
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
sleep
看下面一个例子。
IntStream.range(0,1000).forEach(value -> Thread.ofVirtual().start(() -> {
System.out.println(Thread.currentThread()+" task="+value);
try {
Thread.sleep(5000);
System.out.println(Thread.currentThread()+" task="+value+" ok");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}));
for (;;){}
这个例子启动了1000个虚拟线程,但让你意外的是,这1000个虚拟线程会同时sleep,调度线程会出现还在睡这个的同时,睡其他任务的现象,即使把创建的线程数量调大一点也一样,但如果你使用原始创建线程的方式,那可能会同时创建1000个系统线程,但使用虚拟线程完成同样的任务时,系统只用了不到20个,并且花费的时间也一样,如果想问起原因,可以看JDK19重写的sleep方法,为了完成虚拟线程,JDK中有很多地方都需要对虚拟线程做兼容,不单单是sleep,比如LockSupport中会根据Thread.isVirtual来判断是不是虚拟线程,并调用针对虚拟线程所特有的方法进行阻塞。
但如果加上synchronized,即使每个虚拟线程锁的对象都不一样,不存在锁竞争,Java默认只会启动同处理器核数一样的线程去调度,并且同时最大的调度数也是处理器核数。
IntStream.range(0,1000).forEach(value -> Thread.ofVirtual().start(() -> {
System.out.println(Thread.currentThread()+" task="+value);
try {
synchronized (new Object()){
Thread.sleep(5000);
System.out.println(Thread.currentThread()+" task="+value+" ok");
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}));
for (;;){}
假设现在处理器核数是8,那么这8个任务都在synchronized块内阻塞,那么会导致新的虚拟线程任务得不到执行。