Java协程
近三十年来,Java 开发人员一直依赖线程作为并发服务器应用程序的构建块。每个方法中的每个语句都在线程内执行,并且由于 Java 是多线程的,因此多个执行线程同时发生。线程是Java的并发单位:一段与其他此类单元同时运行且在很大程度上独立于其他此类单元的顺序代码。每个线程都提供一个堆栈来存储局部变量和协调方法调用,以及出错时的上下文:异常是由同一线程中的方法抛出和捕获的,因此开发人员可以使用线程的堆栈跟踪来找出发生了什么。线程也是工具的核心概念:调试器逐步执行线程方法中的语句,分析器可视化多个线程的行为以帮助了解它们的性能。
Why
thread-per-request 风格
- 每一个请求一个线程跟容易理解、易于编程、易于调试和分析;
- 线程很大程度决定了并发,但线程无法无限增长,线程的摧毁&建立&切换耗时长;
使用异步风格提高可扩展性
- IO操作更多的时候复用线程才能高效利用线程
- 颗粒度更小
- 但是处理一个请求使用多个线程交替-栈追踪不行&调试器不发处理
虚拟线程的意义
- 便宜又充足
- 不用被池化
- 创建线程的代价变小
- 虚拟线程保留了可靠的 thread-per-request 风格
- 非常适合IO密集型’
- 降低线程切换损耗
案例代码
案例一
轻松创建10000个线程
- 如果使用传统方案创建10000个线程会大概率崩溃
- 协程每秒处理10000个任务
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // executor.close() is called implicitly, and waits
案例二
io密集型的操作性能损耗更小,系统更加高效
void handle(Request request, Response response) {
var url1 = ...
var url2 = ...
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var future1 = executor.submit(() -> fetchURL(url1));
var future2 = executor.submit(() -> fetchURL(url2));
response.send(future1.get() + future2.get());
} catch (ExecutionException | InterruptedException e) {
response.fail(e);
}
}
String fetchURL(URL url) throws IOException {
try (var in = url.openStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
详解
独立的调度系统
线程需要被调度,即分配给在处理器内核上执行。对于作为操作系统线程实现的平台线程,JDK 依赖于操作系统中的调度程序。
虚拟线程,JDK 有自己的调度程序。JDK 的调度器不是直接将虚拟线程分配给处理器,而是将虚拟线程分配给平台线程,平台线程然后像往常一样由操作系统调度。
JDK 的虚拟线程调度程序是一种ForkJoinPool以 FIFO 模式运行的工作窃取。调度程序的并行度是可用于调度虚拟线程的平台线程数。默认情况下,它等于可用处理器的数量,但可以使用系统属性对其进行调整jdk.virtualThreadScheduler.parallelism。
也就是说,它默认并不是只基于一个线程,在一个线程上创建虚拟线程,而是处理器数量的基础线程数上创建虚拟线程
- 虽然可能在不同的线程但被屏蔽了
- 运行中获取的Thread.currentThread()始终是虚拟线程, 栈也与实际线程无关
执行虚拟线程
JDK 中的绝大多数阻塞操作都会卸载虚拟线程,释放它的载体和底层 OS 线程来承担新的工作。然而,JDK 中的一些阻塞操作并没有卸载虚拟线程,从而阻塞了它的载体和底层 OS 线程。这是因为操作系统级别(例如,许多文件系统操作)或 JDK 级别(例如,Object.wait())的限制。这些阻塞操作的实现将通过临时扩展调度程序的并行性来补偿操作系统线程的捕获。因此,调度程序中的平台线程数ForkJoinPool可能会暂时超过可用处理器的数量。可以使用系统属性调整调度程序可用的最大平台线程数jdk.virtualThreadScheduler.maxPoolSize。
有两种情况下虚拟线程无法在阻塞操作期间卸载,因为它被固定到其载体:
- 当它在块或方法内执行代码时synchronized,或者
- 当它执行一个native方法或一个外部函数时。
在案例中[10000次睡眠]执行结果可以预见的是不会阻塞或挂起线程,如果挂起线程可能就会出现睡眠10000秒的情况
也就是说这种阻塞操作会卸载虚拟线程(释放资源)执行其他;
也就是说有些操作会阻止虚拟线程的释放(不释放虚拟线程就会一直占用一个实际线程),从而导致虚拟线程的实际线程池超出上面说的核心数量, 比如调用native方法和Synchronized,;
初步测试下来wait&Synchronized是不会阻止卸载的
内存使用和与垃圾回收的交互
虚拟线程的堆栈作为堆栈块对象存储在 Java 的垃圾收集堆中。堆栈随着应用程序的运行而增长和收缩,既要提高内存效率,又要适应任意深度的堆栈(直到 JVM 配置的平台线程堆栈大小)。这种效率使大量虚拟线程成为可能,从而使服务器应用程序中每个请求线程样式的持续生存能力得以实现。
- 虚拟线程所需的栈帧布局比紧凑对象更浪费
- 虚拟线程不是GC根(也就是不是长活跃对象),所以只要没有被引用就会被回收
- 虚拟线程的堆栈达到区域大小的一半(可能小至 512KB),则StackOverflowError可能会抛出
核心: vs协程
核心区别
- 协程是基于一个线程,而虚拟线程是基于多个线程
- 虚拟线程需要注意多线程之间的同步&可见性问题, 协程基于单线程不需要注意
优势
基于多线程虽然要考虑多线的同步和可见性问题,但带了的是更高的性能水平
后续
- 相比传统线程,VirtualThread没有调用private native void start0();
- VirtualThread将task投递进入一个共享的ForkJoinPool,先进先出原则等待后续处理
- ForkJoinPool中是多个ForkJoinPool(独立线程),里面队列处理任务
- VirtualThread的sleep不是调用 native void sleep0(long millis)
- VirtualThread在sleep时投递unpark任务至共享的ScheduledExecutorService,并通知jvm卸载
- 在延迟任务到期后,通过虚拟机通知继续允许
- 等待unpark调用后通知重新提交执行
传统的线程挂起后就等待jvm通知重新允许,做不到阻塞状态释放当前线程至执行其他,通过软定义阻塞(如sleep等)在虚拟线程堵塞后,释放实际OS线程去ForkJoinPool中获取其他任务执行这种虚拟阻塞需要JVM的协助核心以下通知JVM释放线程给ForkJoinPool执行其他任务
源码
在常见阻塞(锁、队列)操作下jdk已经重写了源码用于支持虚拟线程的释放OS线程
也就是说需要阻塞等待唤醒的情况下,虚拟线程可以及时释放OS线程给其他任务执行
意义
- 阻塞操作多,提高线程利用率
- 避免线程爆炸
传统的情况下,我们会创建大量的线程,做个种类型的操作,大量的线程并非每个线程都是一直被使用的状态,而是在等待任务
- 如果实时创建线程: 可以避免大量的线程等待中,但创建销毁线程的成本高
- 如果创建大量线程: 线程的爆炸,cpu在线程切换的成本变高
- 使用虚拟线程: 本质上执行效率不会提高,但在利用率上和在高并发IO密集型下能有效提升