概述
运行的JVM本身是个进程,在JVM进程中有许多线程。
线程的引入, 可以把一个进程的资源分配和执行调度分开, 各个线程既可以共享进程资源(内存地址、 文件I/O等) , 又可以独立调度。
目前线程是Java里面进行处理器资源调度的最基本单位。
主流的操作系统都提供了线程实现, Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理, 每个已经调用过start()方法且还未结束的java.lang.Thread类的实例就代表着一个线程。
实现线程主要有三种方式: 使用内核线程实现(1: 1实现) , 使用用户线程实现(1: N实现) ,使用用户线程加轻量级进程混合实现(N: M实现) 。
内核线程实现
用户线程实现
混合实现
java线程的实现
“主流”平台上的“主流”商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现, 即采用1: 1的线程模型。
以HotSpot为例, 它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的, 而且中间没有额外的间接结构, 所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议) , 全权交给底下的操作系统去处理, 所以何时冻结或唤醒线程、 该给线程分配多少处理器执行时间、 该把线程安排给哪个处理器核心去执行等, 都是由操作系统完成的, 也都是由操作系统全权决定的。
Java线程调度
线程调度是指系统为线程分配处理器使用权的过程, 调度主要方式有两种, 分别是协同式(Cooperative Threads-Scheduling) 线程调度和抢占式(Preemptive Threads-Scheduling) 线程调度。
如果使用协同式调度的多线程系统, 线程的执行时间由线程本身来控制, 线程把自己的工作执行完了之后, 要主动通知系统切换到另外一个线程上去。 协同式多线程的最大好处是实现简单, 而且由于线程要把自己的事情干完后才会进行线程切换, 切换操作对线程自己是可知的, 所以一般没有什么线程同步的问题。 Lua语言中的“协同例程”就是这类实现。 它的坏处也很明显: 线程执行时间不可控制, 甚至如果一个线程的代码编写有问题, 一直不告知系统进行线程切换, 那么程序就会一直阻塞在那里。 很久以前的Windows 3.x系统就是使用协同式来实现多进程多任务的, 那是相当不稳定的, 只要有一个进程坚持不让出处理器执行时间, 就可能会导致整个系统崩溃。
如果使用抢占式调度的多线程系统, 那么每个线程将由系统来分配执行时间, 线程的切换不由线程本身来决定。 譬如在Java中, 有Thread::yield()方法可以主动让出执行时间, 但是如果想要主动获取执行时间, 线程本身是没有什么办法的。 在这种实现线程调度的方式下, 线程的执行时间是系统可控的, 也不会有一个线程导致整个进程甚至整个系统阻塞的问题。 Java使用的线程调度方式就是抢占式调度。 与前面所说的Windows 3.x的例子相对, 在Windows 9x/NT内核中就是使用抢占式来实现多进程的, 当一个进程出了问题, 我们还可以使用任务管理器把这个进程杀掉, 而不至于导致系统崩溃。
虽然说Java线程调度是系统自动完成的, 但是我们仍然可以“建议”操作系统给某些线程多分配一点执行时间, 另外的一些线程则可以少分配一点——这项操作是通过设置线程优先级来完成的。 Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY) 。 在两个线程同时处于Ready状态时, 优先级越高的线程越容易被系统选择执行。
不过, 线程优先级并不是一项稳定的调节手段, 很显然因为主流虚拟机上的Java线程是被映射到系统的原生线程上来实现的, 所以线程调度最终还是由操作系统说了算。 尽管现代的操作系统基本都提供线程优先级的概念, 但是并不见得能与Java线程的优先级一一对应, 如Solaris中线程有2147483648(2的31次幂) 种优先级, 但Windows中就只有七种优先级。 如果操作系统的优先级比Java线程优先级更多, 那问题还比较好处理, 中间留出一点空位就是了, 但对于比Java线程优先级少的系统, 就不得不出现几个线程优先级对应到同一个操作系统优先级的情况了。 表12-1显示了Java线程优先级与Windows线程优先级之间的对应关系, Windows平台的虚拟机中使用了除
THREAD_PRIORITY_IDLE之外的其余6种线程优先级, 因此在Windows下设置线程优先级为1和2、 3和4、 6和7、 8和9的效果是完全相同的。
新利器之协程
内核线程的局限
Java目前的并发编程机制就与上述架构趋势产生了一些矛盾, 1: 1的内核线程模型是如今Java虚拟机线程实现的主流选择, 但是这种映射到操作系统上的线程天然的缺陷是切换、 调度成本高昂, 系统能容纳的线程数量也很有限。 以前处理一个请求可以允许花费很长时间在单体应用中, 具有这种线程切换的成本也是无伤大雅的, 但现在在每个请求本身的执行时间变得很短、 数量变得很多的前提下,用户线程切换的开销甚至可能会接近用于计算本身的开销, 这就会造成严重的浪费。
传统的Java Web服务器的线程池的容量通常在几十个到两百之间, 当程序员把数以百万计的请求往线程池里面灌时, 系统即使能处理得过来, 但其中的切换损耗也是相当可观的。
内核线程的调度成本主要来自于用户态与核心态之间的状态转换, 而这两种状态转换的开销主要来自于响应中断、 保护和恢复执行现场的成本。
假设发生了这样一次线程切换:
线程A -> 系统中断 -> 线程B
处理器要去执行线程A的程序代码时, 并不是仅有代码程序就能跑得起来, 程序是数据与代码的组合体, 代码执行时还必须要有上下文数据的支撑。 而这里说的“上下文”, 以程序员的角度来看, 是方法调用过程中的各种局部的变量与资源; 以线程的角度来看, 是方法的调用栈中存储的各类信息;而以操作系统和硬件的角度来看, 则是存储在内存、 缓存和寄存器中的一个个具体数值。 物理硬件的各种存储设备和寄存器是被操作系统内所有线程共享的资源, 当中断发生, 从线程A切换到线程B去执行之前, 操作系统首先要把线程A的上下文数据妥善保管好, 然后把寄存器、 内存分页等恢复到线程B挂起时候的状态, 这样线程B被重新激活后才能仿佛从来没有被挂起过。 这种保护和恢复现场的工作, 免不了涉及一系列数据在各种寄存器、 缓存中的来回拷贝, 当然不可能是一种轻量级的操作。
如果说内核线程的切换开销是来自于保护和恢复现场的成本, 那如果改为采用用户线程, 这部分开销就能够省略掉吗? 答案是“不能”。 但是, 一旦把保护、 恢复现场及调度的工作从操作系统交到程序员手上, 那我们就可以打开脑洞, 通过玩出很多新的花样来缩减这些开销。
有一些古老的操作系统(譬如DOS) 是单人单工作业形式的, 天生就不支持多线程, 自然也不会有多个调用栈这样的基础设施。 而早在那样的蛮荒时代, 就已经出现了今天被称为栈纠缠(StackTwine) 的、 由用户自己模拟多线程、 自己保护恢复现场的工作模式。 其大致的原理是通过在内存里划出一片额外空间来模拟调用栈, 只要其他“线程”中方法压栈、 退栈时遵守规则, 不破坏这片空间即可, 这样多段代码执行时就会像相互缠绕着一样, 非常形象。到后来, 操作系统开始提供多线程的支持, 靠应用自己模拟多线程的做法自然是变少了许多, 但也并没有完全消失, 而是演化为用户线程继续存在。 由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling) 的, 所以它有了一个别名——“协程”(Coroutine) 。 又由于这时候的协程会完整地做调用栈的保护、 恢复工作, 所以今天也被称为“有栈协程”(Stackfull Coroutine) , 起这样的名字是为了便于跟后来的“无栈协程”(Stackless Coroutine) 区分开。 无栈协程不是本节的主角, 不过还是可以简单提一下它的典型应用, 即各种语言中的await、 async、 yield这类关键字。 无栈协程本质上是一种有限状态机, 状态保存在闭包里, 自然比有栈协程恢复调用栈要轻量得多, 但功能也相对更有限。
协程的主要优势是轻量, 无论是有栈协程还是无栈协程, 都要比传统内核线程要轻量得多。 如果进行量化的话, 那么如果不显式设置-Xss或-XX: ThreadStackSize, 则在64位Linux上HotSpot的线程栈容量默认是1MB, 此外内核数据结构(Kernel Data Structures) 还会额外消耗16KB内存。 与之相对的, 一个协程的栈通常在几百个字节到几KB之间, 所以Java虚拟机里线程池容量达到两百就已经不算小了, 而很多支持协程的应用中, 同时并存的协程数量可数以十万计。
Java的解决方案
对于有栈协程, 有一种特例实现名为纤程(Fiber) 。
Loom项目背后的意图是重新提供对用户线程的支持, 但与过去的绿色线程不同, 这些新功能不是为了取代当前基于操作系统的线程实现, 而是会有两个并发编程模型在Java虚拟机中并存, 可以在程序中同时使用。 新模型有意地保持了与目前线程模型相似的API设计, 它们甚至可以拥有一个共同的基类, 这样现有的代码就不需要为了使用纤程而进行过多改动, 甚至不需要知道背后采用了哪个并发编程模型。 Loom团队在JVMLS 2018大会上公布了他们对Jetty基于纤程改造后的测试结果, 同样在5000QPS的压力下, 以容量为400的线程池的传统模式和每个请求配以一个纤程的新并发处理模式进行对比, 前者的请求响应延迟在10000至20000毫秒之间, 而后者的延迟普遍在200毫秒以下。
在新并发模型下, 一段使用纤程并发的代码会被分为两部分——执行过程(Continuation) 和调度器(Scheduler) 。 执行过程主要用于维护执行现场, 保护、 恢复上下文状态, 而调度器则负责编排所有要执行的代码的顺序。 将调度程序与执行过程分离的好处是, 用户可以选择自行控制其中的一个或者多个, 而且Java中现有的调度器也可以被直接重用。 事实上, Loom中默认的调度器就是原来已存在的用于任务分解的Fork/Join池(JDK 7中加入的ForkJoinPool) 。
Loom项目目前仍然在进行当中, 还没有明确的发布日期, 上面笔者介绍的内容日后都有被改动的可能。 如果读者现在就想尝试协程, 那可以在项目中使用Quasar协程库[1], 这是一个不依赖Java虚拟机的独立实现的协程库。 不依赖虚拟机来实现协程是完全可能的, Kotlin语言的协程就已经证明了这一点。 Quasar的实现原理是字节码注入, 在字节码层面对当前被调用函数中的所有局部变量进行保存和恢复。 这种不依赖Java虚拟机的现场保护虽然能够工作, 但很影响性能, 对即时编译器的干扰也非常大, 而且必须要求用户手动标注每一个函数是否会在协程上下文被调用, 这些都是未来Loom项目要解决的问题。