什么是上下文切换
其实在单个处理器的时期,操作系统就能处理多线程并发任务。处理器会给每个线程分配 CPU 时间片(Time Slice),CPU 时间片是 CPU 分配给每个线程执行的时间段,一般为几十毫秒,线程在分配获得的时间片内执行任务。在这么短的时间内线程互相切换,我们根本感觉不到,所以看上去就好像是同时进行的一样。单处理器上的多线程其实就是通过这种时间片分配的方式实现的。
时间片决定了一个线程可以连续占用处理器运行的时长。当一个线程由于其时间片用完或自身原因被迫或主动暂停运行时,另一个线程(可以是同一个线程或者其它进程的线程)可以被操作系统选中,来占用处理器。这种一个线程被暂停剥夺使用权,另一个线程被选中开始或继续运行的过程就叫做上下文切换(Context Switch)。
具体来说,一个线程被剥夺处理器的使用权而被暂停运行,就是“切出”;一个线程被选中占用处理器开始或继续运行,就是“切入”。在这种切出切入的过程中,操作系统需要保存和恢复相应的进度信息,即切入和切出的那一刻相应线程所执行的任务进行到什么程度了。这个进度信息就是上下文(Context)了。上下文一般包括了寄存器的存储内容以及程序计数器存储的指令内容。CPU 寄存器负责存储已经、正在和将要执行的任务,程序计数器负责存储 CPU 正在执行的指令位置以及即将执行的下一条指令的位置。
在当前 CPU 数量远远不止一个的情况下,操作系统将 CPU 轮流分配给线程任务,此时的上下文切换就变得更加频繁了,并且存在跨 CPU 上下文切换,比起单核上下文切换,跨核切换更加昂贵。
上下文切换的诱因
在操作系统中,上下文切换的类型还可以分为进程间的上下文切换和线程间的上下文切换。在多线程编程中,我们主要面对的就是线程间的上下文切换导致的性能问题,下面我们就重点看看究竟是什么原因导致了多线程的上下文切换。开始之前,先看下系统线程的生命周期状态。
结合图示可知,线程主要有新建(NEW)、就绪(RUNNABLE)、运行(RUNNING)、阻塞(BLOCKED)、死亡(DEAD)这五种状态。到了 Java 层面它们都被映射为了 NEW、RUNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINADTED 这六种状态。
在该过程中,线程由 RUNNABLE 转为非 RUNNABLE 的过程就是线程上下文切换。当一个线程从 RUNNING 状态转为 BLOCKED 状态时,我们称为一个线程的暂停,线程暂停被切出之后,操作系统会保存相应的上下文以便这个线程稍后再次进入 RUNNABLE 状态时能够在之前执行进度的基础上继续执行。当一个线程从 BLOCKED 状态进入到 RUNNABLE 状态时,我们称为一个线程的唤醒,此时线程将获取上次保存的上下文继续执行。
通过线程的运行状态及状态间的相互切换可以知道,多线程的上下文切换实际上就是由多线程两个运行状态的互相切换导致的。那在线程运行时,线程状态由 RUNNING 转为 BLOCKED 或者由 BLOCKED 转为 RUNNABLE 又是什么诱发的呢?
我们可以分两种情况来分析,一种是程序本身触发的切换,这种我们称为自发性上下文切换,另一种是由系统或者虚拟机诱发的非自发性上下文切换。自发性上下文切换指线程由 Java 程序调用导致切出,在多线程编程中调用以下方法或关键字,常常就会引发自发性上下文切换:
- sleep()
- wait()
- yield()
- join()
- park()
- synchronized lock
- 阻塞式 IO
非自发性上下文切换指线程由于调度器的原因被迫切出。常见的有:线程被分配的时间片用完,虚拟机的垃圾回收动作导致或者有线程优先级更高的线程需要被运行等。因为 Java 虚拟机在执行垃圾回收的过程中可能需要暂停所有应用线程(stop-the-world)才能完成其工作,这其实就是一种线程暂停行为。
上下文切换的开销
一方面,上下文切换是必要的。即使是在多核处理器系统中上下文切换也是必要的,这是因为一个系统上需要运行的线程的数量相对于这个系统所拥有的处理器数量总是要大得多。另一方面,上下文切换又有其不容小觑的开销。从定性的角度来说,上下文切换的开销包括直接开销和间接开销。
其中,直接开销包括:
- 操作系统保存和恢复上下文所需的开销,这主要是处理器时间开销。
- 线程调度器进行线程调度的开销。比如,按照一定的规则决定哪个线程会占用处理器来运行。
间接开销包括:
- 处理器高速缓存重新加载的开销。一个被切出的线程可能稍后在另外一个处理器上被切入继续运行。由于这个处理器之前可能未运行过该线程,那么这个线程在其继续运行过程中需访问的变量仍然需要被该处理器重新从主内存或者通过缓存一致性协议从其他处理器加载到高速缓存之中。
- 上下文切换也可能导致整个一级高速缓存中的内容被冲刷(Flush),即一级高速缓存中的内容会被写入下一级高速缓存(如二级高速缓存)或主内存(RAM)中。
从定量的角度来说,一次上下文切换的时间消耗是微秒级的。当线程的数量越多,它们可能导致的上下文切换的开销也就可能越大。因此,多线程编程中使用的线程数量越多,程序的计算效率可能反而越低!因此,在设计多线程程序时,减少上下文切换也是一个重要的考量因素。
测量上下文切换次数
在 Linux 系统下,可以使用 Linux 内核提供的 vmstat 命令,来监视 Java 程序运行过程中系统的上下文切换频率,如下图所示 cs 一列展示了上下文切换的次数:
如果是监视具体某个应用的上下文切换,可以使用 pidstat 命令监控指定进程的上下文切换。
pidstat -w -p {进程号} 1 10
由于 Windows 没有像 vmstat 这样的工具,在 Windows 下,我们可以使用 Process Explorer 来查看程序执行时的线程间上下文切换的次数。
如何优化上下文切换
1)竞争锁优化
多线程对锁资源的竞争会引起上下文切换,还有锁竞争导致的线程阻塞越多,上下文切换就越频繁,系统的性能开销也就越大。由此可见,在多线程编程中,锁其实不是性能开销的根源,竞争锁才是。下面我们总结下锁优化的一些方式。
减少锁的持有时间。比如将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作以及可能被阻塞的操作。
降低锁的粒度。通过将锁粒度拆分得更小一些,以避免所有线程对一个锁资源的竞争过于激烈。具体实现方式包括:锁分离(读写锁)、锁分段(JDK 8 之前版本的 ConcurrentHashMap)。
非阻塞乐观锁替代竞争锁,通过 CAS 操作代替锁。
2)合理设置线程池大小
一旦线程池的工作线程总数超过系统所拥有的处理器数量,就会导致过多的上下文切换。
3)使用协程实现非阻塞等待
协程是一种比线程更加轻量级的东西,相比于由操作系统内核来管理的进程和线程,协程则完全由程序本身所控制,也就是在用户态执行。协程避免了像线程切换那样产生的上下文切换,在性能方面得到了很大的提升。
4)减少 Java 虚拟机的垃圾回收
垃圾回收会导致上下文切换,很多 JVM 垃圾回收器(Serial 收集器、ParNew 收集器)在回收旧对象时,会产生内存碎片,从而需要进行内存整理,在这个过程中就需要移动存活的对象。而移动内存对象就意味着这些对象所在的内存地址会发生变化,因此在移动对象前需要暂停线程,在移动完成后需要再次唤醒该线程。因此减少 JVM 垃圾回收的频率可以有效地减少上下文切换。