JVM相关及其拓展(六) -- Java与线程
Java与线程
线程的实现
线程是比进程更轻量级的调度执行单位,线程的引入可以把一个进程的资源分配和执行调度分开,各个线程间既可以共享进程资源,又可以独立调度(线程是CPU调度的基本单位)。
实现线程主要有三种方式:
使用内核线程实现
直接由操作系统内核支持的线程。
由内核来完成切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。
多线程内核:有能力处理多件事情,支持多线程的内核轻量级进程:内核线程的一种高级接口。只有先支持内核线程,才能有轻量级进程- 优点:每个轻量级进程都是一个独立的调度单元,即使有一个在系统调用中堵塞了,也不影响整个进程继续工作。
- 缺点:各种线程操作都需要进行系统调用,代价相对高,需要在用户态和内核态中来回切换。另外轻量级进程的数量是有限的。
- 轻量级进程与内核线程是1:1的关系
使用用户线程实现
广义:一个线程只要不是内核线程,就可以认为是用户线程
狭义:完全建立在用户空间的线程库上,而系统内核不能感知线程存在的实现
- 优点:线程的建立、同步、销毁和调度都在用户态中完成,不需要内核参与,所以操作时非常快速且低消耗,还支持更大的线程数量
- 缺点:没有系统内核的支持,所有线程操作都需要用户程序自己处理,实现较复杂
- 进程与用户线程之间是1:N的关系
使用用户线程加轻量级进程混合实现
既存在用户线程,也存在轻量级进程。
- 优点:用户线程还是在用户空间中,还可以支持大规模的用户线程并发;轻量级进程可以作为内核线程和用户线程之间的桥梁,用户线程的系统调用需要轻量级进程来完成,大大降低了系统被阻塞的危险。
- 采用多对多的线程模型。
Java线程的实现是不能确定的。由于操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射的。
从实际情况看,上面更多是线程模型的理论分类。现代HotSpot在主流操作系统上,Java线程通常还是采用接近1:1的内核线程映射方式,因此线程的创建、阻塞、唤醒和调度,最终大多都会落到操作系统线程调度层去完成。
这也意味着线程并不是一种“可以无限便宜创建”的资源。线程创建、销毁、上下文切换、阻塞唤醒都会带来成本,所以在工程实践里,通常不会把“直接开新线程”当成默认方案,而更倾向于通过线程池复用线程,减少频繁创建和回收带来的额外开销。
线程调度
系统为线程分配处理器使用权的过程。
主要调度方式有两种:
协同式线程调度
线程的执行时间有 线程本身 控制,线程把自己的工作执行完后,要主动通知系统切换到另一个线程上。
- 优点:实现简单,切换操作可知,基本不存在线程同步问题
- 缺点:线程执行时间不可控
抢占式线程调度
每个线程由系统分配执行时间,线程的切换不由线程本身决定
线程执行时间是可控的,不存在因为一个线程而堵塞整个系统的问题
可以设置线程优先级,优先级越高的线程越容易被系统选择执行
线程优先级并不是太靠谱,一方面线程调度还是取决于操作系统,优先级的实现不会太一致。另一方面优先级会被系统自行改变。
补充:Java对线程调度的控制能力其实很有限。
Thread.yield()、线程优先级更多只是“提示”,并不是强保证。- 开发者通常无法精确控制某个线程“下一次一定何时运行”。
- 也正因为如此,并发程序设计通常更依赖同步规则和状态协作,而不是试图精确操控调度顺序。
线程状态转换
在任意时间点,一个线程有且只有一个状态
这里还需要注意:Java层的线程状态和操作系统层的线程状态并不是简单一一对应的。
- Java里的
RUNNABLE既可能表示线程正在CPU上运行,也可能表示线程已经就绪、正在等待CPU时间片。 BLOCKED、WAITING、TIMED_WAITING是JVM为了描述Java并发语义而抽象出来的状态。
所以在分析线程栈或线程状态时,更适合把这些状态理解为“Java语义视角下线程正在做什么”,而不是直接等同于操作系统调度器内部的全部状态。
如果把线程状态迁移主线抽象成一条链路,可以大致理解为:
NEW -> RUNNABLE -> WAITING / TIMED_WAITING / BLOCKED -> RUNNABLE -> TERMINATED
当然真实运行过程中线程可能会在RUNNABLE和多种等待状态之间多次切换,但通常不会脱离这条主线去随意跳转。
新建
线程创建后尚未启动的线程状态
运行
包括正在执行和等待着CPU为它分配执行时间
无限期等待
不会被分配CPU执行时间,要等待被其他线程显示的唤醒。以下方法会让线程陷入无限期的等待状态:
- 没有设置Timeout参数的
Object,wait() - 没有设置Timeout参数的
Thread.join() LockSupport.park()
- 没有设置Timeout参数的
限期等待
不会被分配CPU执行时间,但在一定时间后会被系统唤醒。以下方法会让线程进入限期等待状态:
Thread.sleep()- 设置Timeout参数的
Object,wait() - 设置Timeout参数的
Thread.join() LockSupport.parkNanos()LockSupport.parkUntil()
这些常见等待方法虽然都会让线程暂时不继续往下执行,但语义并不一样:
sleep():让当前线程休眠一段时间,不会释放已经持有的监视器锁。wait():必须在synchronized保护下调用,会释放当前监视器锁,并等待其他线程notify/notifyAll或超时唤醒。join():本质上是等待目标线程执行结束,常用于线程间顺序协作。park():基于许可机制阻塞线程,不要求一定放在synchronized块中,LockSupport体系里很常见。阻塞
线程被阻塞了 在程序等待进入同步区域的时候进入这种状态。
阻塞状态:等待着获取到一个排他锁,将在另一个线程放弃这个锁的时候发生
等待状态:在等待一段时间或者唤醒动作的发生
如果把这几个状态再压缩成最容易区分的判断标准,可以这样记:
BLOCKED:想进synchronized保护区,但没抢到monitor锁WAITING:主动等待别人显式唤醒,不设置超时时间TIMED_WAITING:主动等待,并且设置了超时时间
所以BLOCKED更偏“锁竞争失败”,而WAITING/TIMED_WAITING更偏“当前线程自己进入等待协议”。
守护线程
Java线程还可以分为用户线程和守护线程(Daemon Thread)。
- 用户线程:会真正决定JVM进程是否继续存活
- 守护线程:主要为其他线程提供后台服务,本身不阻止JVM退出
当JVM中只剩下守护线程时,虚拟机就可以退出了。像垃圾回收线程这类后台线程,通常就属于守护线程。
这也是为什么守护线程更适合做“服务型后台任务”,而不适合承载那些必须确保完整执行结束的核心业务逻辑。
补充:interrupt()并不是“强制杀死线程”,而是一种协作式中断信号。
- 调用
interrupt()通常只是给目标线程设置中断标记,或者唤醒某些可中断的阻塞点。 - 线程是否真正结束,取决于代码是否检测并正确响应这个中断信号。
- 如果任务内部完全忽略中断,那么线程依然可能继续运行。
因此中断更像“请求你尽快结束/停止等待”,而不是操作系统层面那种强制终止线程的能力。
从实践角度看,更推荐把中断当成一种协作式退出协议来使用:
- 阻塞方法里收到
InterruptedException后,要么结束任务,要么在清理后重新设置中断标记 - 非阻塞循环中,要主动检查中断状态,决定是否退出
- 不要随意吞掉中断异常,否则上层线程管理逻辑可能失效
与并发工具类的关系
这一篇讲的线程实现、调度和状态,本质上是并发工具类的底层语义背景。像synchronized、ReentrantLock、Condition、BlockingQueue、线程池这些上层并发工具,最终都离不开线程的阻塞、唤醒、调度以及状态迁移。
所以理解线程模型和线程状态的意义,不只是为了记住几个枚举值,而是为了更好地理解:
- 线程为什么会阻塞
- 被唤醒后为什么还不一定立刻执行
- 为什么线程池能减少线程创建成本
- 为什么中断、锁、等待队列会共同影响并发行为