共计 5900 个字符,预计需要花费 15 分钟才能阅读完成。
本文已收录【修炼内功】跃迁之路
自从踏入程序猿这条不归路,便摆脱不了 (进程) 线程这只粘人的小妖精,尤其在硬件资源“过剩”的今天
不论你在使用 c、C++、.Net,还是 Java、Python、Golang,都免不了要踏过这一关,即使使用以“单线程”著称的 Node.js,也要借助 pm2 类似的进程管理工具 fork 一批进程,来榨干机器资源
早些年使用 c 编写多线程时,需要使用宏定义来兼容多平台下不同库的函数,而 Java 从一开始便宣称的 ”Write Once, Run Anywhere” 从虚拟机层面帮我们屏蔽了众多平台差异,那,Java 线程与 OS 线程间有什么关系?
系统架构
以 *nix 类系统为例,其系统体系架构主要分为用户态 (user context) 和内核态(kernel context)
内核,本质上讲是一种较为底层的控制计算机硬件资源的软件
用户态,即上层应用程序的活动空间,应用程序的执行依托于内核提供的资源,为了使上层资源访问内核资源,内核提供系统调用接口以供上层应用访问
系统调用,可以看作是操作系统的最小功能单元,一种不能再简化的操作,而函数库则是对一组系统调用的封装,以降低应用程序调用内核的复杂度
用户态与内核态切换
在 *nix 类系统中,为了有效减少内核资源的访问及冲突,对不同的操作赋予了不同的执行等级,越是与系统相关的关键操作,越是需要高特权来执行
linux 操作系统中主要采用了 0 和 3 两个特权等级,分别对应于内核态及用户态,运行于用户态的进程可以执行的操作及访问的资源会受到很大的限制,而运行在内核态的进程则可以执行任何操作,并且在资源的访问上也不会受到任何限制
一般应用程序一开始运行时都会处于用户态,当一些操作需要在内核权限下才能执行时,则会涉及一次从用户态到内核态的切换过程,当该操作执行完毕后,又会涉及一次从内核态到用户态的切换过程
线程模型
回过头来,从系统层面聊一聊线程的实现模型
用户线程 v.s. 内核线程
简单来讲
用户线程
由应用程序创建、调度、撤销,不需要内核的支持(内核不感知)
- 由于不需要内核的支持,便不涉及用户态 / 内核态的切换,消耗的资源较少,速度也较快
- 由于需要应用程序控制线程的轮换调度,当有一个用户线程被阻塞时,整个所属进程便会被阻塞,同时在多核处理器下只能在一个核内分时复用,不能充分利用多核优势
内核线程
由内核创建、调用、撤销,并由内核维护线程的上下文信息及线程切换
- 由于内核线程由内核进行维护,当一个内核线程被阻塞时,不会影响其他线程的正常运行,并且多核处理器下,一个进程内的多个线程可以充分利用多核的优势同时执行
- 由于需要内核进行维护,在线程创建、切换过程中便会涉及用户态 / 内核态的切换,增加系统消耗
轻量级进程 LWP
在 linux 操作系统中,往往都是通过 fork 函数创建一个子进程来代表内核中的线程,在 fork 完一个子进程后,还需要将父进程中大部分的上下文信息复制到子进程中,消耗大量 cpu 时间用来初始化内存空间,产生大量冗余数据
为了避免上述情况,轻量级进程 (Light Weight Process, LWP) 便出现了,其使用 clone 系统调用创建子进程,过程中只将部分父进程数据进行复制,没有被复制的资源可以通过指针进行数据共享,这样一来 LWP 的运行单元更小、运行速度更快
LWP 与内核线程一一映射,每个 LWP 都由一个内核线程支持
1:1 线程模型
1:1 模型,即每一个用户线程都对应一个内核线程,每个线程的创建、调度、销毁都需要内核的支持,每次线程的创建、切换都会设计用户状态 / 内核状态的切换,性能开销比较大,并且单个进程能够创建的 LWP 的数量是有限的,但能够充分里用多核的优势
N:1 线程模型
N:1 模型,即所有的用户线程都会对应到一个内核线程中,该模型可以在用户空间完成线程的创建、调度、销毁,不需要内核的支持,同样也就不涉及用户状态 / 内核状态的切换,线程的操作较快且消耗较低,并且线程数量不受操作系统的限制,但不能发挥多核的优势,只能在一个核中分时复用,并且由于内核不能感知用户态的线程,在某一线程被阻塞时,会导致整个所属进程阻塞
N:M 线程模型
N:M 模型是基于以上两种模型的一种混合实现,多个用户线程对应于多个内核线程,即解决了 1:1 模型中性能开销及线程数量的问题,也解决了 N:1 模型中阻塞问题,同时也能充分利用 CPU 的多核优势,这也是大部分协程实现的基础
Java 在 1.2 之前基于用户线程实现(N:1 线程模型),在 1.2 之后 windows 及 linux 平台下采用 1:1 线程模型,在 solaris 平台使用 1:1 或 N:M 线程模型实现(可配置)
线程状态
以下以 linux 平台为例
linux 平台下,JVM 采用 1:1 的线程模型,那 Java 中的线程状态与 OS 的线程状态是否也是一一对应的?
系统线程状态 & 生命周期
linux 系统的线程状态及生命周期如上图,每种状态的详细解释不再一一赘述,这里简单介绍下 RUNNABLE 与RUNNING
- RUNNABLE
线程处于可运行的状态,但还没有被系统调度器选中,即还没有分配到 CPU 时间片
- RUNNING
线程处于运行状态,即线程分配到了时间片,正在执行机器指令
Java 线程状态 & 生命周期
Java 中的线程状态并没有使用系统线程状态一一对应的方式,而是提供了与之不同的 6 种状态
以下,linux 系统线程状态会使用 斜体 加以区分
linux 系统中的 RUNNABLE
与RUNNING
被 Java 合并成了 RUNNABLE
一种状态,而 linux 系统中的 BLOCKED
被 Java 细化成了 WAITING
、TIMED_WAITING
及BLOCKED
三种状态
Java 中的线程状态与系统中的线程状态大体相似,但又略有不同,最明显的一点是,如果由于 I / O 阻塞会使 Java 线程进入 BLOCKED
状态么?NO!I/ O 阻塞在系统层面会使线程进入 BLOCKED
状态,但在 Java 里线程状态依然是RUNNABLE
!
系统中的 RUNNABLE
表示线程正在等待 CPU 资源,在在 Java 中被认为同样是在运行中,只是在排队等待而已,故 Java 中将系统的 RUNNABLE
与RUNNING
合并成了 RUNNABLE
一种状态
而对于系统中 I / O 阻塞引起的 BLOCKED
状态,在 Java 中被认为同样是在等待一种资源,故也认为是 RUNNABLE
的一种情况
Java 线程的状态在 Thread.State
枚举中可以查看,其每种状态的释义写的非常清楚,这里不再一一解释
NEW
Thread state for a thread which has not yet started.
RUNNABLE
Thread state for a runnable thread. A thread in the runnable state is executing in the Java virtual machine but it may be waiting for other resources from the operating system such as processor.
BLOCKED
Thread state for a thread blocked waiting for a monitor lock. A thread in the blocked state is waiting for a monitor lock to enter a synchronized block/method or reenter a synchronized block/method after calling
Object.wait
.WAITING
Thread state for a waiting thread. A thread is in the waiting state due to calling one of the following methods:
Object.wait
with no timeoutThread.join
with no timeoutLockSupport.park
A thread in the waiting state is waiting for another thread to perform a particular action. For example, a thread that has called
Object.wait()
on an object is waiting for another thread to callObject.notify()
orObject.notifyAll()
on that object. A thread that has calledThread.join()
is waiting for a specified thread to terminate.TIMED_WAITING
Thread state for a waiting thread with a specified waiting time. A thread is in the timed waiting state due to calling one of the following methods with a specified positive waiting time:
Thread.sleep
Object.wait
with timeoutThread.join
with timeoutLockSupport.parkNanos
LockSupport.parkUntil
TERMINATED
Thread state for a terminated thread. The thread has completed execution.
上下文切换与调优
上下文切换涉及到进程间上下文切换与线程间上下文切换
用户态与内核态的每一次切换都会导致进程间上限文的切换,比如 java 中在使用重量级锁的时候会依赖系统底层的mutex lock
,而该系统操作会导致用户态 / 内核态的切换,进而引起进程间的上下文切换
这里重点讨论下线程间的上下文切换
什么情况会触发线程间上下文切换
一个线程由 RUNNING
转为 BLOCKED
时(线程暂停),系统会保存线程的上下文信息
当该线程由 BLOCKED
转为 RUNNABLE
时(线程唤醒),系统会获取上次的上下文信息以保证线程能够继续执行
以上的一个过程线程上下文的一次切换过程
同样,一个线程由 RUNNING
转为 RUNNABLE
,再由RUNNABLE
转为 RUNNING
时也会发生线程间的上下文切换
即,多线程的上下文切换实际上就是由多线程两个运行状态的互相切换导致的
那,什么情况下会触发 RUNNING
-> BLOCKED
-> RUNNABLE
(对应 Java 种 RUNNABLE
-> BLOCKED
/WAITING
/TIMED_WAITING
-> RUNNABLE
)的状态转变呢?
一种为程序本身触发,一种为操作系统或虚拟机触发
程序本身触发很容易理解,所有会导致 RUNNABLE
-> BLOCKED
/WAITING
/TIMED_WAITING
的逻辑均会触发线程间上下文切换,如synchronized
、wait
、join
、park
、sleep
等
操作系统触发,最常见的比如线程时间片的分配
虚拟机触发,最常见的在于进行垃圾回收时的 ‘stop the world’
如何优化
既然所有会导致 RUNNABLE
-> BLOCKED
/WAITING
/TIMED_WAITING
的逻辑均会触发线程间上下文切换,那便从诱因入手
锁竞争
锁其实并不是性能开销的根源,竞争锁才是
- 减少锁的持有时间
锁的持有时间越长,就意味着可能有越多的线程在等待锁的释放,如果是同步锁,除了会造成线程间上下文切换外,还会有进程间的上下文切换 (
mutex lock
)优化方法有很多,比如将
synchronized
关键字从方法修饰移到方法体内,将synchronized
修饰的代码块中无关的逻辑移到synchronized
代码块外,等等 降低锁的粒度
- 锁分离
对于读操作大于写操作的逻辑,可以将传统的同步锁拆分为读写锁,即读锁与写锁,在多线程中,只有读写与写写是互斥的,避免读读情况下锁的竞争
- 锁分段
对于大集合或者大对象的锁操作,可以考虑将锁进一步分离,将大集合或者大对象分隔成多个段,对每一个段分别上锁,以避免对不同段进行操作时锁的竞争,如
ConcurrentHashMap
中对锁的实现
- 锁分离
非阻塞乐观锁代替竞争锁
- 使用 volatile
volatile 的读写操作不会导致上下文切换,开销较小,但 volatile 只保证可见性,不保证原子性
- 使用 CAS
CAS 是一个原子的 if-then-act 操作,可以在我外部锁的情况下来保证读写操作的一致性,如 Atomic 包中的算法
- 其它非阻塞乐观锁
- 使用 volatile
wait/notify 优化
- 使用 notify()代替 notifyAll()
众所周知,notifyAll 会唤醒所有相关的线程,而 notify 则会唤醒指定线程,以减少过多不相关线程的上下文切换
- 使用 Lock+Condition 组合的方式替代 wait/notify
synchronized 是基于系统层面实现的,而 Lock 则是应用程序层面实现的,不会造成用户态 / 内核态的切换
Condition 会避免类似 notifyAll 提前唤醒过多无关线程的问题
合理设置线程池大小
线程池数量不宜设置过大,线程池数量设置过大容易导致大量线程处于等待 CPU 时间片的状态(RUNNABLE
),同时也会导致过多的上下文切换
使用协程实现非阻塞等待
协程可以看做是一种轻量级线程
前文介绍到,Java 线程使用 1:1 线程模型,每个用户线程都会映射到一个系统线程,线程由内核来管理
协程则使用 N:M 线程模型,协程完全由应用程序来管理,避免了众多的上下文切换
(协程不等于没有系统线程,只是会大大减少系统线程上下文切换的次数)
总结
- 操作系统体系架构主要分为用户态 (user context) 和内核态(kernel context)
- 由于系统操作分不同的执行等级,应用程序在执行一些高等级操作时会发生用户态 / 内核态的切换
- 用户线程由应用程序创建、调度、撤销,不需要内核的支持
- 内核线程由内核创建、调用、撤销,并由内核维护线程的上下文信息及线程切换
- 线程模型分为
1:1
、N:1
、N:M
三种,Java 在 window 及 linux 上采用 1:1 线程模型,即每个用户线程都会对应一个内核线程 - Java 中的线程状态并没有使用系统线程状态一一对应的方式,而是使用 NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED 六种状态
- 用户态 / 内核态的切换会导致进程间上下文切换
- 多线程两个运行状态的互相切换会导致线程间的上下文切换,诸如
synchronized
wait
join
park
sleep
等常见操作均会引起线程间的上下文切换 - 理解线程上下文切换的原因,合理优化程序,减少上下文切换,减轻系统负担