why
为什么需要或者说要搞这么一个虚拟线程那?
主要就是近些年来计算机的发展的问题,计算机其实主要的问题就是两个,算力和IO,近些年来算力和IO的发展差别越来越大了,算力的增长是指数型的,而IO却一直都是在线性的增长,那么为了进一步提高系统或者说计算机的效率,就只能从软件方面来着手了。
java的这个虚拟线程 其实就是参考的go语言的协程,其实也就是所谓的结构化并发
结构化并发
非结构化
可以想想这几个问题在 Java 中要怎么解决:
结束一个线程时,怎么同时结束这个线程中创建的子线程?
当某个子线程在执行时需要结束兄弟线程要做怎么做?
如何等待所有子线程都执行完了再结束父线程?
这些问题都可以通过共享标记位、CountDownLatch 等方式实现。但这两个例子让我们意识到,线程间没有级联关系;所有线程执行的上下文都是整个进程,多个线程的并发是相对整个进程的,而不是相对某一个父线程。
这就是线程的「非结构化」。
结构化
通常,每个并发操作都是在处理一个任务单元,这个任务单元可能属于某个父任务单元,同时它也可能有子任务单元。而每个任务单元都有自己的生命周期,子任务的生命周期理应继了父任务的生命周期。
这就是业务的「结构化」。
what
在结构化的并发中,每个并发操作都有自己的作用域,并且:
在父作用域内新建作用域都属于它的子作用域;
父作用域和子作用域具有级联关系;
父作用域的生命周期持续到所有子作用域执行完;
当主动结束父作用域时,会级联结束它的各个子作用域。
例如 Kotlin 的协程就是 结构化的并发,它有 「协程作用域(CoroutineScope)」 的角色。全局的 GlobalScope 是一个作用域,每个协程自身也是一个作用域。新建的协程对象和父协程保持着级联关系。
可以看出 协程的并发 和 业务的并发 更相似,它们都具有结构上的关联。
what
虚拟线程是java.lang.Thread的一个实例,它需要一个OS线程来做CPU工作–但在等待其他资源的时候,并不占用OS线程。你看,当在虚拟线程中运行的代码在JDK API中调用一个阻塞的I/O操作时,运行时会执行一个非阻塞的OS调用,并自动暂停虚拟线程,直到该操作完成。
在此期间,其他虚拟线程可以在该操作系统线程上执行计算,因此它们有效地共享它。
至关重要的是,Java 的虚拟线程产生的开销最小,因此可以有很多很多很多。
因此,正如操作系统通过将大的虚拟地址空间映射到有限的物理 RAM 来产生大量内存的错觉一样,JDK 通过将许多虚拟线程映射到少量操作系统线程来产生大量线程的错觉。
就像程序几乎不关心虚拟内存和物理内存一样,并发 Java 代码很少需要关心它是在虚拟线程还是平台线程中运行。
您可以专注于编写简单的、潜在的阻塞代码——运行时负责共享可用的操作系统线程,以将阻塞成本降低到接近于零。
虚拟线程支持线程局部变量、同步块和线程中断;因此,代码可以使用Thread并且currentThread不必更改。实际上,这意味着现有的 Java 代码将很容易在虚拟线程中运行,而无需任何更改甚至重新编译!
优点
提高吞吐量
永远不要忘记虚拟线程不是更快的线程。虚拟线程每秒执行的指令不会比平台线程多。
虚拟线程真正擅长的是等待。
因为虚拟线程不需要或阻塞操作系统线程,潜在的数百万虚拟线程可以耐心等待对文件系统、数据库或 Web 服务的请求完成。
通过最大限度地利用外部资源,虚拟线程提供了更大的规模,而不是更快的速度。换句话说,它们提高了吞吐量。
除了硬数字,虚拟线程还可以提高代码质量。
原理
操作系统调度 OS 线程,因此调度平台线程,但虚拟线程由 JDK 调度。JDK 通过在称为mount的过程中将虚拟线程分配给平台线程来间接实现这一点。JDK 稍后取消分配平台线程;这称为卸载。
运行虚拟线程的平台线程称为它的载体线程,从Java代码的角度来看,虚拟和它的载体暂时共享一个OS线程的事实是不可见的。例如,堆栈跟踪和线程局部变量是完全分离的。
运营商线程然后留给操作系统照常调度;就操作系统而言,载体线程只是一个平台线程。
为了实现这个过程,JDK 使用了专用ForkJoinPool的先进先出 (FIFO) 模式作为虚拟线程调度程序。(注意:这与并行流使用的公共池不同。)
默认情况下,JDK 的调度程序使用与可用处理器内核一样多的平台线程,但可以使用系统属性调整该行为。
未挂载的虚拟线程的栈帧去哪了?它们作为堆栈块对象存储在堆上。
一些虚拟线程会有很深的调用栈(例如从 Web 框架调用的请求处理程序),但由它们产生的那些通常会更浅一些(例如从文件中读取的方法)。
JDK 可以通过将其所有帧从堆复制到堆栈来挂载虚拟线程。当虚拟线程被卸载时,大多数帧都留在堆上并根据需要延迟复制。
因此,堆栈会随着应用程序的运行而增长和缩小。这对于使虚拟线程足够便宜以拥有如此多的线程并在它们之间频繁切换至关重要。未来的工作很有可能进一步降低内存需求。
通常,当虚拟线程在 I/O 上阻塞时(例如,当它从套接字读取时)或当它调用 JDK 中的其他阻塞操作(例如take在 a 上BlockingQueue)时,它会卸载。
当阻塞操作准备完成时(套接字接收到字节或队列可以分发一个元素),该操作将虚拟线程提交回调度程序,调度程序将按照 FIFO 顺序最终将其挂载以恢复执行。
然而,尽管之前在 JDK 增强提案中进行了工作,例如JEP 353(重新实现遗留SocketAPI)和JEP 373(重新实现遗留DatagramSocketAPI),但并非JDK 中的所有阻塞操作都会卸载虚拟线程。相反,一些捕获载体线程和底层操作系统平台线程,从而阻塞两者。
这种不幸的行为可能是由操作系统级别(影响许多文件系统操作)或 JDK 级别(例如Object.wait()调用)的限制引起的。
通过向调度程序临时添加一个平台线程来补偿对操作系统线程的捕获,因此偶尔会超过可用处理器的数量;可以使用系统属性指定最大值。
不幸的是,最初的虚拟线程提案还有一个不完美之处:当虚拟线程执行本地方法或外部函数或执行同步块或方法内的代码时,虚拟线程将被固定到其载体线程。固定线程不会在其他情况下卸载。
不过,在这种情况下,不会将平台线程添加到调度程序中,因为您可以做一些事情来最小化固定的影响——稍后会详细介绍。
这意味着捕获操作和固定线程将重新引入正在等待完成的平台线程。这不会使应用程序不正确,但可能会阻碍其可扩展性。
幸运的是,未来的工作可能会使同步不固定。而且,重构java.io包的内部结构并在 Linux 上实现 OS 级 APIio_uring可能会减少捕获操作的数量。
建议
不要池化虚拟线程
池化只对昂贵的资源有意义,而虚拟线程并不昂贵。相反,只要您需要同时做一些事情,就创建新的虚拟线程。