本文已收录【修炼内功】跃迁之路
自从踏入程序猿这条不归路,便摆脱不了(进程)线程这只粘人的小妖精,尤其在硬件资源“过剩”的今天
不论你在使用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两个特权等级,分别对应于内核态及用户态,运行于用户态的进程可以执行的操作及访问的资源会受到很大的限制,而运行在内核态的进程则可以执行任何操作,并且在资源的访问上也不会受到任何限制
一般应用程序一开始运行时都会处于用户态,当一些操作需要在内核权限下才能执行时,则会涉及一次从用户态到内核态的切换过程,当该操作执行完毕后,又会涉及一次从内核态到用户态的切换过程
回过头来,从系统层面聊一聊线程的实现模型
简单来讲
用户线程
由应用程序创建、调度、撤销,不需要内核的支持(内核不感知)
内核线程
由内核创建、调用、撤销,并由内核维护线程的上下文信息及线程切换
在linux操作系统中,往往都是通过fork函数创建一个子进程来代表内核中的线程,在fork完一个子进程后,还需要将父进程中大部分的上下文信息复制到子进程中,消耗大量cpu时间用来初始化内存空间,产生大量冗余数据
为了避免上述情况,轻量级进程(Light Weight Process, LWP)便出现了,其使用clone系统调用创建子进程,过程中只将部分父进程数据进行复制,没有被复制的资源可以通过指针进行数据共享,这样一来LWP的运行单元更小、运行速度更快
LWP与内核线程一一映射,每个LWP都由一个内核线程支持
1:1 模型,即每一个用户线程都对应一个内核线程,每个线程的创建、调度、销毁都需要内核的支持,每次线程的创建、切换都会设计用户状态/内核状态的切换,性能开销比较大,并且单个进程能够创建的LWP的数量是有限的,但能够充分里用多核的优势
N:1模型,即所有的用户线程都会对应到一个内核线程中,该模型可以在用户空间完成线程的创建、调度、销毁,不需要内核的支持,同样也就不涉及用户状态/内核状态的切换,线程的操作较快且消耗较低,并且线程数量不受操作系统的限制,但不能发挥多核的优势,只能在一个核中分时复用,并且由于内核不能感知用户态的线程,在某一线程被阻塞时,会导致整个所属进程阻塞
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
线程处于可运行的状态,但还没有被系统调度器选中,即还没有分配到CPU时间片
线程处于运行状态,即线程分配到了时间片,正在执行机器指令
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 Thread.join LockSupport.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 call Object.notify()
or Object.notifyAll()
on that object. A thread that has called Thread.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 Thread.join LockSupport.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包中的算法
使用notify()代替notifyAll()
众所周知,notifyAll会唤醒所有相关的线程,而notify则会唤醒指定线程,以减少过多不相关线程的上下文切换
使用Lock+Condition组合的方式替代wait/notify
synchronized是基于系统层面实现的,而Lock则是应用程序层面实现的,不会造成用户态/内核态的切换
Condition会避免类似notifyAll提前唤醒过多无关线程的问题
线程池数量不宜设置过大,线程池数量设置过大容易导致大量线程处于等待CPU时间片的状态( RUNNABLE
),同时也会导致过多的上下文切换
协程可以看做是一种轻量级线程
前文介绍到,Java线程使用1:1线程模型,每个用户线程都会映射到一个系统线程,线程由内核来管理
协程则使用N:M线程模型,协程完全由应用程序来管理,避免了众多的上下文切换
(协程不等于没有系统线程,只是会大大减少系统线程上下文切换的次数)
1:1
、 N:1
、 N:M
三种,Java在window及linux上采用1:1线程模型,即每个用户线程都会对应一个内核线程 synchronized
wait
join
park
sleep
等常见操作均会引起线程间的上下文切换