通过上一讲的介绍,相信你对上下文切换已经有了一定的认识。在单线程的情况下,线程一旦被 CPU 调用,通常不会被调度出去。然而,当可运行的线程数量远超过 CPU 数量时,操作系统会将正在运行的某个线程调度出来,让其他线程有机会使用 CPU,这就会引发上下文切换。
此外,在多线程环境中使用竞争锁时,如果线程因等待竞争锁而被阻塞,JVM 一般会将该锁挂起,并允许线程被交换出去。若频繁出现阻塞情况,对于 CPU 密集型程序而言,上下文切换的次数将会增多。
既然在某些场景下使用多线程是必不可少的,但多线程编程又确实会带来上下文切换,进而增加系统的性能开销,那么我们该如何优化多线程的上下文切换呢?这正是我今天要和你探讨的话题,接下来我将重点介绍几种常见的优化方法。
竞争锁优化
在多线程编程中,当遇到性能问题时,大多数人首先想到的往往是锁。
多线程对锁资源的竞争会引发上下文切换,而且因锁竞争导致的线程阻塞越多,上下文切换就越频繁,系统的性能开销也就越大。由此可见,在多线程编程里,锁本身并非性能开销的根源,竞争锁才是关键所在。
我们知道,锁优化的核心在于减少竞争。下面,我们再来总结一下锁优化的几种方式。
减少锁的持有时间
锁的持有时间越长,就意味着会有更多的线程在等待该竞争资源释放。如果使用的是 Synchronized 同步锁资源,不仅会引发线程间的上下文切换,甚至可能增加进程间的上下文切换。
在第 12 讲中,我分享过一些具体的方法。例如,可以把与锁无关的代码移出同步代码块,尤其是那些开销较大的操作以及可能会被阻塞的操作。
优化前
public synchronized void mySyncMethod(){
businesscode1();
mutextMethod();
businesscode2();
}
优化后
public void mySyncMethod(){
businesscode1();
synchronized(this) {
mutextMethod();
}
businesscode2();
}
降低锁的粒度
同步锁能够确保对象的原子性。我们可以考虑将锁的粒度进一步拆分,从而避免所有线程对同一锁资源进行过于激烈的竞争。具体方式有以下两种:
锁分离
与传统锁不同,读写锁实现了锁的分离,它由 “读锁” 和 “写锁” 两个锁构成,规则是可以共享读,但只能有一个写操作。
这样做的好处是,在多线程读的场景中,读读操作不互斥,读写操作互斥,写写操作也互斥。而传统的独占锁在未区分读写锁时,读写操作通常是:读读互斥、读写互斥、写写互斥。因此,在读操作远多于写操作的多线程场景下,锁分离能够避免高并发读时的资源竞争,进而避免上下文切换。
锁分段
在使用锁来保证集合或者大对象的原子性时,我们可以考虑将锁对象进一步分解。例如,在 Java 1.8 之前版本的 ConcurrentHashMap 就采用了锁分段技术。
非阻塞乐观锁替代竞争锁
volatile 关键字的作用是保证可见性和有序性,其读写操作不会导致上下文切换,因此开销相对较小。不过,volatile 无法保证操作变量的原子性,因为它没有锁的排他性。
而 CAS 是一种原子的 if - then - act 操作,它是一种无锁算法,能够确保对一个共享变量的读写操作保持一致。CAS 操作包含 3 个操作数:内存值 V、旧的预期值 A 和要修改的新值 B。只有当 A 和 V 相同时,才会将 V 修改为 B,否则不做任何操作,并且 CAS 算法不会导致上下文切换。Java 的 Atomic 包就运用了 CAS 算法来更新数据,无需额外加锁。
除了从编码层面优化竞争锁之外,JVM 内部也对 Synchronized 同步锁进行了优化。我在第 12 讲中对此有详细讲解,这里简单回顾一下。
在 JDK 1.6 中,JVM 将 Synchronized 同步锁划分为偏向锁、轻量级锁、偏向锁以及重量级锁,优化顺序也是按照这个顺序进行的。JIT 编译器在动态编译同步块时,还会通过锁消除、锁粗化的方式来优化同步锁。
wait/notify 优化
在 Java 中,我们可以通过调用 Object 对象的 wait () 方法和 notify () 方法或 notifyAll () 方法来实现线程间的通信。
在线程中调用 wait () 方法,线程会阻塞并等待其他线程的通知(即其他线程调用 notify () 方法或 notifyAll () 方法);而在线程中调用 notify () 方法或 notifyAll () 方法,则会通知其他线程从 wait () 方法处恢复执行。
下面,我们通过 wait () /notify () 来实现一个简单的生产者 - 消费者案例,代码如下:
public class WaitNotifyTest {
public static void main(String[] args) {
Vector<Integer> pool = new Vector<Integer>();
Producer producer = new Producer(pool, 10);
Consumer consumer = new Consumer(pool);
new Thread(producer).start();
new Thread(consumer).start();
}
}
/**
* 生产者
*/
class Producer implements Runnable {
private Vector<Integer> pool;
private Integer size;
public Producer(Vector<Integer> pool, Integer size) {
this.pool = pool;
this.size = size;
}
public void run() {
for (;;) {
try {
System.out.println(" 生产一个商品 ");
produce(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private void produce(int i) throws InterruptedException {
while (pool.size() == size) {
synchronized (pool) {
System.out.println(" 生产者等待消费者消费商品, 当前商品数量为 " + pool.size());
pool.wait(); // 等待消费者消费
}
}
synchronized (pool) {
pool.add(i);
pool.notifyAll(); // 生产成功,通知消费者消费
}
}
}
/**
* 消费者
*/
class Consumer implements Runnable {
private Vector<Integer> pool;
public Consumer(Vector<Integer> pool) {
this.pool = pool;
}
public void run() {
for (;;) {
try {
System.out.println(" 消费一个商品 ");
consume();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private void consume() throws InterruptedException {
while (pool.isEmpty()) {
synchronized (pool) {
System.out.println(" 消费者等待生产者生产商品, 当前商品数量为 " + pool.size());
pool.wait(); // 等待生产者生产商品
}
}
synchronized (pool) {
pool.remove(0);
pool.notifyAll(); // 通知生产者生产商品
}
}
}
wait/notify 的使用导致了较多的上下文切换
结合相关图片我们可以看到,在消费者第一次获取到锁之前,如果发现没有商品可供消费,就会执行 Object.wait () 方法,此时线程会挂起,进入阻塞状态,这就产生了一次上下文切换。
当生产者获取到锁并执行 notifyAll () 方法后,会唤醒处于阻塞状态的消费者线程,这里又会发生一次上下文切换。
被唤醒的等待线程在继续执行时,需要再次申请相应对象的内部锁,此时等待线程可能需要与其他新活跃的线程竞争内部锁,这也可能会导致上下文切换。
如果有多个消费者线程同时被阻塞,使用 notifyAll () 方法会唤醒所有阻塞的线程。但如果某些商品仍然没有库存,过早地唤醒这些没有库存商品的消费线程,可能会使线程再次进入阻塞状态,从而引发不必要的上下文切换。
优化 wait/notify 的使用,减少上下文切换
首先,在多个不同的消费场景中,我们可以使用 Object.notify () 方法替代 Object.notifyAll () 方法。因为 Object.notify () 方法只会唤醒指定的线程,不会过早地唤醒其他未满足需求的阻塞线程,从而可以减少相应的上下文切换。
其次,生产者在执行完 Object.notify () /notifyAll () 方法唤醒其他线程后,应尽快释放内部锁,以避免被唤醒的线程在恢复执行后长时间持有锁进行业务操作,从而避免被唤醒的线程再次申请相应内部锁时等待锁的释放。
最后,为了避免长时间等待,我们通常会使用 Object.wait (long) 方法设置等待超时时间。但线程无法区分返回结果是由于等待超时还是被通知线程唤醒,这会导致线程再次尝试获取锁操作,增加上下文切换。
在此,我建议使用 Lock 锁结合 Condition 接口来替代 Synchronized 内部锁中的 wait /notify 机制,实现等待 / 通知功能。这样做不仅可以解决 Object.wait (long) 方法无法区分返回原因的问题,还能解决线程被过早唤醒的问题。
Condition 接口定义的 await 方法、signal 方法和 signalAll 方法分别对应于 Object.wait ()、Object.notify () 和 Object.notifyAll () 方法。
合理地设置线程池大小,避免创建过多线程
线程池的线程数量不宜设置过大。一旦线程池的工作线程总数超过系统所拥有的处理器数量,就会导致过多的上下文切换。关于如何合理设置线程池数量的更多内容,我将在第 18 讲中详细讲解。
另外,在某些创建线程池的方法中,线程数量设置不会直接展示给我们。例如,使用 Executors.newCachedThreadPool () 方法创建的线程池,会复用其内部空闲的线程来处理新提交的任务,如果没有空闲线程,就会创建新的线程(不受 MAX_VALUE 限制)。在处理大量且耗时长的任务时,这样的线程池会创建大量的工作线程,从而导致频繁的上下文切换。因此,这类线程池只适合处理大量且耗时短的非阻塞任务。
使用协程实现非阻塞等待
很多人一提到协程(Coroutines),可能马上就会想到 Go 语言。对于大多数 Java 程序员来说,协程可能还比较陌生,但在 Go 语言中,协程的使用已经相当成熟。
协程是一种比线程更轻量级的机制。与由操作系统内核管理的进程和线程不同,协程完全由程序本身控制,即在用户态执行。协程避免了像线程切换那样产生的上下文切换,在性能方面有显著提升。关于协程在多线程业务中的应用,我会在第 18 讲中详细阐述。
减少 Java 虚拟机的垃圾回收
在上一讲讨论上下文切换的诱因时,我们提到过 “垃圾回收会导致上下文切换”。
许多 JVM 垃圾回收器(如 serial 收集器、ParNew 收集器)在回收旧对象时,会产生内存碎片,因此需要进行内存整理。在这个过程中,需要移动存活的对象,而移动内存对象意味着这些对象所在的内存地址会发生变化。所以,在移动对象前需要暂停线程,移动完成后再唤醒线程。因此,减少 JVM 垃圾回收的频率可以有效减少上下文切换。
总结
上下文切换是多线程编程中性能消耗的原因之一。竞争锁、线程间的通信以及过多地创建线程等多线程编程操作,都会给系统带来上下文切换。此外,I/O 阻塞以及 JVM 的垃圾回收也会增加上下文切换。
总体而言,过于频繁的上下文切换会影响系统的性能,我们应该尽量避免。同时,我们可以将上下文切换作为系统的性能参考指标,并将其纳入服务性能监控体系,做到防患于未然。