15讲多线程调优(上):哪些操作导致了上下⽂切换
你好,我是刘超。
我们常说“实践是检验真理的唯⼀标准”,这句话不光在社会发展中可⾏,在技术学习中也同样适⽤。
记得我刚⼊职上家公司的时候,恰好赶上了⼀次抢购活动。这是系统重构上线后经历的第⼀次⾼并发考验,如期出现了⼤量超时报警,不过⽐我预料的要好⼀点,起码没有挂掉重启。
通过⼯具分析,我发现 cs(上下⽂切换每秒次数)指标已经接近了 60w ,平时的话最⾼5w。再通过⽇志分析,我发现了⼤量带有 wait() 的 Exception,由此初步怀疑是⼤量线程处理不及时导致的,进⼀步锁定问题是连接池⼤⼩设置不合理。后来我就模拟了⽣产环境配置,对连接数压测进⾏调节,降低最⼤线程数,最后系统的性能就上去了。
从实践中总结经验,我知道了在并发程序中,并不是启动更多的线程就能让程序最⼤限度地并发执⾏。线程数量设置太⼩,会导致程序不能充分地利⽤系统资源;线程数量设置太⼤,⼜可能带来资源的过度竞争,导致上下⽂切换带来额外的系统开销。
你看,其实很多经验就是这么⼀点点积累的。那么今天,我就想和你分享下“上下⽂切换”的相关内容,希望也能让你有所收获。
初识上下⽂切换
我们⾸先得明⽩,上下⽂切换到底是什么。
其实在单个处理器的时期,操作系统就能处理多线程并发任务。处理器给每个线程分配 CPU 时间⽚(Time Slice),线程在分配获得的时间⽚内执⾏任务。
CPU 时间⽚是 CPU 分配给每个线程执⾏的时间段,⼀般为⼏⼗毫秒。在这么短的时间内线程互相切换,我们根本感觉不到, 所以看上去就好像是同时进⾏的⼀样。
时间⽚决定了⼀个线程可以连续占⽤处理器运⾏的时⻓。当⼀个线程的时间⽚⽤完了,或者因⾃身原因被迫暂停运⾏了,这个
时候,另外⼀个线程(可以是同⼀个线程或者其它进程的线程)就会被操作系统选中,来占⽤处理器。这种⼀个线程被暂停剥
夺使⽤权,另外⼀个线程被选中开始或者继续运⾏的过程就叫做上下⽂切换(Context Switch)。
具体来说,⼀个线程被剥夺处理器的使⽤权⽽被暂停运⾏,就是“切出”;⼀个线程被选中占⽤处理器开始或者继续运⾏,就是“切⼊”。在这种切出切⼊的过程中,操作系统需要保存和恢复相应的进度信息,这个进度信息就是“上下⽂”了。
那上下⽂都包括哪些内容呢?具体来说,它包括了寄存器的存储内容以及程序计数器存储的指令内容。CPU 寄存器负责存储已经、正在和将要执⾏的任务,程序计数器负责存储CPU 正在执⾏的指令位置以及即将执⾏的下⼀条指令的位置。
在当前 CPU 数量远远不⽌⼀个的情况下,操作系统将 CPU 轮流分配给线程任务,此时的上下⽂切换就变得更加频繁了,并且存在跨 CPU 上下⽂切换,⽐起单核上下⽂切换,跨核切换更加昂贵。
多线程上下⽂切换诱因
在操作系统中,上下⽂切换的类型还可以分为进程间的上下⽂切换和线程间的上下⽂切换。⽽在多线程编程中,我们主要⾯对的就是线程间的上下⽂切换导致的性能问题,下⾯我们就重点看看究竟是什么原因导致了多线程的上下⽂切换。开始之前,先看下系统线程的⽣命周期状态。
结合图示可知,线程主要有“新建”(NEW)、“就绪”(RUNNABLE)、“运⾏”(RUNNING)、“阻塞”(BLOCKED)、“死 亡”(DEAD)五种状态。到了Java层⾯它们都被映射为了NEW、RUNABLE、BLOCKED、WAITING、TIMED_WAITING、
TERMINADTED等6种状态。
在这个运⾏过程中,线程由RUNNABLE转为⾮RUNNABLE的过程就是线程上下⽂切换。
⼀个线程的状态由 RUNNING 转为 BLOCKED ,再由 BLOCKED 转为 RUNNABLE ,然后再被调度器选中执⾏,这就是⼀个上下⽂切换的过程。
当⼀个线程从 RUNNING 状态转为 BLOCKED 状态时,我们称为⼀个线程的暂停,线程暂停被切出之后,操作系统会保存相应的上下⽂,以便这个线程稍后再次进⼊ RUNNABLE 状态时能够在之前执⾏进度的基础上继续执⾏。
当⼀个线程从 BLOCKED 状态进⼊到 RUNNABLE 状态时,我们称为⼀个线程的唤醒,此时线程将获取上次保存的上下⽂继续完成执⾏。
通过线程的运⾏状态以及状态间的相互切换,我们可以了解到,多线程的上下⽂切换实际上就是由多线程两个运⾏状态的互相切换导致的。
那么在线程运⾏时,线程状态由 RUNNING 转为 BLOCKED 或者由 BLOCKED 转为 RUNNABLE,这⼜是什么诱发的呢?
我们可以分两种情况来分析,⼀种是程序本身触发的切换,这种我们称为⾃发性上下⽂切换,另⼀种是由系统或者虚拟机诱发
的⾮⾃发性上下⽂切换。
⾃发性上下⽂切换指线程由 Java 程序调⽤导致切出,在多线程编程中,执⾏调⽤以下⽅法或关键字,常常就会引发⾃发性上下⽂切换。
sleep() wait() yield() join()
park() synchronized lock
⾮⾃发性上下⽂切换指线程由于调度器的原因被迫切出。常⻅的有:线程被分配的时间⽚⽤完,虚拟机垃圾回收导致或者执⾏优先级的问题导致。
这⾥重点说下“虚拟机垃圾回收为什么会导致上下⽂切换”。在 Java 虚拟机中,对象的内存都是由虚拟机中的堆分配的,在程序运⾏过程中,新的对象将不断被创建,如果旧的对象使⽤后不进⾏回收,堆内存将很快被耗尽。Java 虚拟机提供了⼀种回收机制,对创建后不再使⽤的对象进⾏回收,从⽽保证堆内存的可持续性分配。⽽这种垃圾回收机制的使⽤有可能会导致stop-the-world 事件的发⽣,这其实就是⼀种线程暂停⾏为。
发现上下⽂切换
我们总说上下⽂切换会带来系统开销,那它带来的性能问题是不是真有这么糟糕呢?我们⼜该怎么去监测到上下⽂切换?上下
⽂切换到底开销在哪些环节?接下来我将给出⼀段代码,来对⽐串联执⾏和并发执⾏的速度,然后⼀⼀解答这些问题。
public class DemoApplication {
public static void main(String[] args) {
//运⾏多线程
MultiThreadTester test1 = new MultiThreadTester(); test1.Start();
//运⾏单线程
SerialTester test2 = new SerialTester(); test2.Start();
}
static class MultiThreadTester extends ThreadContextSwitchTester {
@Override
public void Start() {
long start = System.currentTimeMillis(); MyRunnable myRunnable1 = new MyRunnable(); Thread[] threads = new Thread[4];
//创建多个线程
for (int i = 0; i < 4; i++) {
threads[i] = new Thread(myRunnable1); threads[i].start();
}
for (int i = 0; i < 4; i++) { try {
//等待⼀起运⾏完
threads[i].join();
} catch (InterruptedException e) {
// TODO Auto-generated catch block e.printStackTrace();
}
}
long end = System.currentTimeMillis();
System.out.println(“multi thread exce time: “ + (end - start) + “s”); System.out.println(“counter: “ + counter);
}
// 创建⼀个实现Runnable的类
class MyRunnable implements Runnable { public void run() {
while (counter < 100000000) { synchronized (this) {
if(counter < 100000000) { increaseCounter();
}
}
}
}
}
}
//创建⼀个单线程
static class SerialTester extends ThreadContextSwitchTester{
@Override
public void Start() {
long start = System.currentTimeMillis(); for (long i = 0; i < count; i++) {
increaseCounter();
}
long end = System.currentTimeMillis(); System.out.println(“serial exec time: “ + (end - start) + “s”); System.out.println(“counter: “ + counter);
}
}
//⽗类
static abstract class ThreadContextSwitchTester { public static final int count = 100000000; public volatile int counter = 0;
public int getCount() {
return this.counter;
}
public void increaseCounter() {
this.counter += 1;
}
public abstract void Start();
}
}
执⾏之后,看⼀下两者的时间测试结果:
通过数据对⽐我们可以看到:串联的执⾏速度⽐并发的执⾏速度要快。这就是因为线程的上下⽂切换导致了额外的开销,使⽤
Synchronized 锁关键字,导致了资源竞争,从⽽引起了上下⽂切换,但即使不使⽤ Synchronized 锁关键字,并发的执⾏速度也⽆法超越串联的执⾏速度,这是因为多线程同样存在着上下⽂切换。Redis、NodeJS的设计就很好地体现了单线程串⾏的 优势。
在 Linux 系统下,可以使⽤ Linux 内核提供的 vmstat 命令,来监视 Java 程序运⾏过程中系统的上下⽂切换频率,cs如下图所示:
如果是监视某个应⽤的上下⽂切换,就可以使⽤ pidstat命令监控指定进程的 Context Switch 上下⽂切换。
由于 Windows 没有像 vmstat 这样的⼯具,在 Windows 下,我们可以使⽤ Process Explorer,来查看程序执⾏时,线程间上
下⽂切换的次数。
⾄于系统开销具体发⽣在切换过程中的哪些具体环节,总结如下:
操作系统保存和恢复上下⽂;
调度器进⾏线程调度;
处理器⾼速缓存重新加载;
上下⽂切换也可能导致整个⾼速缓存区被冲刷,从⽽带来时间开销。
总结
上下⽂切换就是⼀个⼯作的线程被另外⼀个线程暂停,另外⼀个线程占⽤了处理器开始执⾏任务的过程。系统和 Java 程序⾃发性以及⾮⾃发性的调⽤操作,就会导致上下⽂切换,从⽽带来系统开销。
线程越多,系统的运⾏速度不⼀定越快。那么我们平时在并发量⽐较⼤的情况下,什么时候⽤单线程,什么时候⽤多线程呢?
⼀般在单个逻辑⽐较简单,⽽且速度相对来⾮常快的情况下,我们可以使⽤单线程。例如,我们前⾯讲到的 Redis,从内存中快速读取值,不⽤考虑 I/O 瓶颈带来的阻塞问题。⽽在逻辑相对来说很复杂的场景,等待时间相对较⻓⼜或者是需要⼤量计算的场景,我建议使⽤多线程来提⾼系统的整体性能。例如,NIO 时期的⽂件读写操作、图像处理以及⼤数据分析等。
思考题
以上我们主要讨论的是多线程的上下⽂切换,前⾯我讲分类的时候还曾提到了进程间的上下⽂切换。那么你知道在多线程中使
⽤Synchronized还会发⽣进程间的上下⽂切换吗?具体⼜会发⽣在哪些环节呢?
期待在留⾔区看到你的⻅解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他⼀起讨论。
精选留⾔ <br />![](https://cdn.nlark.com/yuque/0/2022/png/1852637/1646315918200-be176bee-b5c9-4b5d-b00c-f5291adc49e4.png#)李博<br />如果Synchronized块中包含io操作或者⼤量的内存分配时,可能会导致进程IO等待或者内存不⾜。进⼀步会导致操作系统进⾏进程切换,等待系统资源满⾜时在切换到当前进程。 不知道理解的对不对?<br />2019-06-22 17:25<br />作者回复<br />进程上下⽂切换,是指⽤户态和内核态的来回切换。我们知道,如果⼀旦Synchronized锁资源竞争激烈,线程将会被阻塞,阻塞的线程将会从⽤户态调⽤内核态,尝试获取mutex,这个过程就是进程上下⽂切换。<br />2019-06-23 09:30
晓杰
锁的竞争太激烈会导致锁升级为重量级锁,未抢到锁的线程会进⼊monitor,⽽monitor依赖于底层操作系统的mutex lock,获取锁时会发⽣⽤户态和内核态之间的切换,所以会发⽣进程间的上下⽂切换。
2019-06-25 16:04
作者回复
对的
2019-06-26 11:23
⽼杨同志
使⽤Synchronized获得锁失败,进⼊等待队列会发⽣上下⽂切换。如果竞争锁时锁是其他线程的偏向锁,需要降级,这是需要
stop the world也会发⽣上下⽂切换
2019-06-22 15:46
作者回复
理解正确~
2019-06-23 09:32
QQ怪
⽼师讲的上下⽂切换的确⼲货很多,思考题我觉得应该是使⽤synchronize导致单线程进⾏,且执⾏⽅法时间过⻓,当前进程时 间⽚执⾏时间结束,导致cpu不得不进⾏进程间上下⽂切换。
2019-06-22 16:55
作者回复
进程上下⽂切换,是指⽤户态和内核态的来回切换。当Synchronized锁资源竞争激烈,线程将会被阻塞,阻塞的线程将会从⽤
户态调⽤内核态,尝试获取mutex,这个过程是Synchronized锁产⽣的进程上下⽂切换。
2019-06-23 09:31
Jxin
⾸先,如何决定多线程。这点核⼼的依据我认为是提⾼计算机资源使⽤率。将cpu执⾏耗时较⻓⽐如io操作,跟耗时较短⽐如纯逻辑计算的业务操作分解开。根据时间⽐例对应分配操作线程池的线程数。进⽽保障资源最⼤化利⽤,⽐如耗时较短的业务线程不会空闲。理论上多核的现在,并⾏逻辑都要⽐串⾏逻辑快(并⾏交集时间,既剩下的时间⼤于上下⽂切换和资源合并的时间开销)。其实我觉得还得引⼊业务价值做考虑,核⼼业务加⼤优化⼒度,边缘业务性能保持在容忍线以上就好,为核⼼业务让渡资源。最后是思考题。才疏学浅,隐式锁个⼈认为是为线程价格执⾏体准备的,不会影响到进程间的切换。但是多进程间
⽤的也是同⼀台服务器的资源。所以必然也会有上下⽂切换,⽽这块都是⾮⾃发的。⽐如cpu时间分⽚呈现的进程间交替使⽤c
pu,或则进程各⾃持有的虚拟内存⻚对实际物理内存的使⽤。⾄于⽂件操作,java发现⽂件资源被其他进程占⽤好像是直接报错的,所以没有进程间竞争。但输出设备,打印机⾳箱这些,它们有多进程轮流共⽤的现象,感觉起来也有点分⽚执⾏,优先调度之类的样⼦,应该也有竞争。个⼈认知半猜回复,还望⽼师指正。搬砖引⽟。
2019-06-22 09:34
勿闻轩外⾹
⼲货满满
2019-06-24 10:58
周星星
使⽤Synchronized在锁获取的时候会发⽣CPU上下⽂切换,多线程本身也会有上下⽂切换,这样就会多⼀次切换,是这样吗?
2019-06-22 12:56
作者回复
Synchronized在轻量级锁之前,锁资源竞争产⽣的是线程上下⽂切换,⼀旦升级到重量级锁,就会产⽣进程上下⽂切换。
2019-06-23 09:38
sleep引起上下⽂切换是指系统调⽤吗?⽤户态到内核态的切换。但是这时候线程会从running变成block吗?感觉这个线程没有让出控制吧,跟wait不⼀样的吧
2019-06-22 09:34
作者回复
sleep和wait⼀样,都会进⼊阻塞状态,区别是sleep没有释放锁,⽽wait释放了锁。所以也是⼀次上下⽂切换。
2019-06-23 09:53
⽊⽊匠
思考题:多线程会导致线程间的上下⽂切换,⽽使⽤同步锁会导致多线程之间串⾏化,会增加程序执⾏时间,⽽过⻓的执⾏时间可能导致分配给本进程的时间⽚不够⽤,从⽽发⽣进程间的上下⽂切换。
2019-06-22 08:35
张德
⽼师讲⼀下 counter在多线程的情况下会不会超过⼀亿次呢?? 是如何保证线程安全的
2019-07-11 17:53
作者回复
synchronized (this)+if()判断保证了counter不会超过⼀个亿
2019-07-12 11:22
张德
// 创建⼀个实现 Runnable 的类
class MyRunnable implements Runnable { public void run() {
while (counter < 100000000) { synchronized (this) { if(counter < 100000000) { increaseCounter();
}
}
}
}
}
⽼师多线程这个类 共同引⽤了counter这个变量 他是线程安全的吗? increaseCounter();是线程安全的吗???
2019-07-11 17:40
作者回复
increaseCounter⽅法在这⾥是线程安全的
2019-07-12 11:11
任鹏斌
⽼师有个问题这⾥线程的状态是指操作系统线程吗?记得java线程没有running状态的,有点乱了
2019-07-09 19:25
作者回复
对的,这⾥描述的是系统线程状态。
2019-07-12 09:42
K先⽣
⽼师好,⽣产环境线程上下⽂切换次数⼀般都是28万次正常吗?
2019-07-01 10:18
作者回复
需要结合具体业务来看。⼀般单个服务持续28万次,有点⾼了。
2019-07-02 10:37
⼩美
转⼊到runnable状态时就加载上下⽂了?不应该是running状态时么
2019-06-29 11:41
作者回复
加载上下⽂⼀般是加载正在运⾏和将要运⾏的线程上下⽂。
2019-06-30 11:21
Lost In The
Echo。
java能⽀持协成吗?
2019-06-24 23:06
作者回复
可以引⼊第三⽅框架⽀持,⽬前原⽣java暂时没有⽀持协程。
2019-06-26 10:37
LW
看回复⽼师说锁升级到重量级锁,就会发⽣进程间切换,这个点能详细讲讲吗?
2019-06-24 14:33
作者回复
当升级到重量级锁后,线程竞争锁资源,将会进⼊等待队列中,并在等待队列中不断尝试获取锁资源。每次去获取锁资源,都需要通过系统调底层操作系统申请获取Mutex Lock,这个过程就是⼀次⽤户态和内核态的切换。
2019-06-26 10:15
夏天39度
Synchronized由⾃旋锁升级为重量级锁时会发⽣上下⽂切换,获取锁之后,cpu不会在释放锁之前切⾛,⽼师,我理解的对吗
?
2019-06-24 09:14
作者回复
Synchronized由⾃旋后升级为重量级锁,在存在多个线程竞争的情况下会发⽣上下⽂切换。
2019-06-26 09:47
-W.LI-
⽼师好!看了⼤⽜们的课后习题回答,⼤概意思就是偏斜锁,轻量级锁这种不涉及进程切换。然后并发严重膨胀为重量级锁了,发⽣blocked了或者调⽤wait(),join()⽅法释放锁资源,就会触发进程切换了。CAS这种乐观锁,不会触发进程上下⽂切换?LOC
K呢?在调⽤pack()的时候会导致进程切换么?lock()⽅法直接获取到锁,没有发⽣寻找安全点的时候是不是就不会触发进程上下
⽂切换了?
纯属瞎猜,希望⽼师解惑谢谢。
2019-06-23 12:40
作者回复
CAS乐观锁只是⼀个原⼦操作,为CPU指令实现,所以操作速度⾮常快,Java是调⽤C语⾔中的函数执⾏CPU指令操作,不需
要进⼊内核或者切换线程。
⽽lock竞争锁资源是基于⽤户态完成,所以竞争锁资源时不会发⽣进程上下⽂切换。
2019-06-24 10:34
nightmare
对于思考题,schronzid在激烈竞争的时候,有可能导致运⾏的进程⾥⾯的线程很快⽤完cpu时间⽚,⽽⾮⾃发的被切换,还有
⼀种情况就是stop the vvm虚拟机暂停,垃圾回收,那么只能其他进程更多的执⾏。关于⽂章的知识点最好带有案列,谢谢
2019-06-22 16:01
作者回复
之前讲到的锁优化,以及后⾯要讲的优化线程池,这些都是⼀些上下⽂切换的案例。
2019-06-23 09:39
汤⼩⾼
⽼师能否提供⼀份全⾯的如何定位性能⽅⾯问题的⼯具或者命令了,⽐如操作系统层⾯的,也就是⽂章中提到的,或者JAVA⼯具层⾯的。能出⼀篇这种通过相关命令或者⼯具定位排查问题的案例最好不过了。
2019-06-22 12:17
作者回复
好的,后⾯我总结⼀份命令排查⼯具的使⽤报告给⼤家。
2019-06-23 09:46