Java 知识 -- Java 与多线程

本文最后更新于:4 天前

实现线程的集中方式

实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)

内核级线程(1:1)

内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核(Multi-Threads Kernel)

轻量级线程(1:1)(Java 使用的方式)

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(LightWeight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型, 对应了下图轻量级线程和内核线程的关系

轻量级线程

即一个轻量级线程对应一个内核线程,线程调度器通过调度内核线程从而来调度轻量级线程。

由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程在系统调用中被阻塞了,也不会影响整个进程继续工作。

轻量级进程也具有它的局限性:

  1. 首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。

  2. 其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的

用户线程(1:N)

广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程(User Thread,UT)的一种,因此从这个定义上看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会受到限制,并不具备通常意义上的用户线程的优点

狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型,下图是进程和用户线程 1:N 的关系

用户线程

用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。

线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如"阻塞如何处理"、“多处理器系统中如何将线程映射到其他处理器上”,这类问题解决起来将会异常困难,甚至有些是不可能实现的。

因为使用用户线程实现的程序通常都比较复杂,除了有明确的需求外(譬如以前在不支持多线程的操作系统中的多线程程序、需要支持大规模线程数量的应用),一般的应用程序都不倾向使用用户线程。Java、Ruby等语言都曾经使用过用户线程,最终又都放弃了使用它。但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang、Erlang等,使得用户线程的使用率有所回升

混合实现(M:N)

线程还有一种将内核线程与用户线程一起使用的实现方式。在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是 M:N 的关系, 下图就是用户线程与轻量级进程之间M:N的关系

混合方式实现线程

Java 中的线程

以 hotspot 为例,它的每个线程都是直接映射到系统原生线程的,中间没有额外的间接结构,这样做的好处就是 JVM 不需要去管理线程的调度,全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。

Java 线程调度

分为两种,一种是协同式调度(线程运行多久由线程自己决定,当线程运行完毕,会告诉系统让它切换到另一个线程)另一种是抢占式调度(线程调度由系统决定)

协同式调度会出现 CPU 一直卡在线程这里一直运行的情况, 可能导致系统崩溃

抢占式调度线程只能让出自己的执行权限,想要抢占 CPU 时间片确是不可能了,但是可以另辟蹊径,使用线程优先级来做,但是说到线程优先级却无法完全保证线程优先运行,比如: windows 就存在"优先级推进器(Priority Boosting)"的功能,即使线程优先级最高也没用,这个功能的大致作用是一个线程被执行的特别频繁时,window可能会放弃正在运行的高优先级线程,转而去运行这个频繁被执行的线程。

并且 Java 的线程优先级也存在一个问题,那就是如果系统的优先级等级数量比较多,那么 Java 的优先级不会出现很多问题,但是如果系统的优先级只有1个(极端一点),那么 Java 的所有优先级就一点用都没有了

Java 线程的状态

new,runnable,waiting,time waiting,blocked,terminated

操作系统的线程状态:

新建、可运行、运行、阻塞、终止

Java 与协程

协程的优势

  1. 切换、调度成本高昂

    以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的,但现在在每个请求本身的执行时间变得很短、数量变得很多的前提下,用户线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。

  2. 系统能容纳的线程数量也很有限。

    协程的优势主要在于它比LWP(Java 的线程——轻量级线程)要轻的多, 在64位Linux上HotSpot的线程栈容量默认是1MB,此外内核数据结构(Kernel Data Structures)还会额外消耗16KB内存, 而一个协程的栈通常在几百个字节到几KB之间,所以Java虚拟机里线程池容量达到两百就已经不算小了,而很多支持协程的应用中,同时并存的协程数量可数以十万计

协程的劣势

协程当然也有它的局限,需要在应用层面实现的内容(调用栈、调度器这些)特别多, 除此之外,协程在最初,甚至在今天很多语言和框架中会被设计成协同式调度,这样在语言运行平台或者框架上的调度器就可以做得非常简单, 但是这种方法存在很严重的问题。

具体到Java语言,还会有一些别的限制,譬如 HotSpot 这样的虚拟机,Java调用栈跟本地调用栈是做在一起的。如果在协程中调用了本地方法,还能否正常切换协程而不影响整个线程?另外,如果协程中遇传统的线程同步措施会怎样?譬如虽然 Kotlin 提供的协程实现,一旦遭遇 synchronize 关键字,那挂起来的仍将是整个线程

协程的实现

不依赖虚拟机来实现协程是完全可能的,Kotlin 语言的协程就已经证明了这一点。

Quasar 协程库的实现原理是字节码注入,在字节码层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖Java虚拟机的现场保护虽然能够工作,但很影响性能,对即时编译器的干扰也非常大,而且必须要求用户手动标注每一个函数是否会在协程上下文被调用。