16 如何优化多线程上下文切换?

680 阅读4分钟

大家好,我是小水珠。

通过上一讲的讲解,相信你对上下文的切换已经有了一定的了解了。如果是单个线程,在CPU调用之后,那么它基本上是不会被调度出去的。如果可运行的线程数远大于CPU数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其它线程能够使用CPU,这就导致上下文切换。

还有,如果在多线程中如果使用了锁竞争,当线程由于等待锁竞争而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果频繁的发生阻塞,CPU密集型的程序就会发生更多的上下文切换。

那么问题来了,我们知道在某些场景下使用多线程是非常必要的,但多线程编程给系统带来了上下文切换,从而增加的性能开销也是实打实存在的。我们该如何优化多线程上下文切换呢?这就是我今天要和你分享的话题,我将重点介绍几种常见的优化方法。

一 锁竞争优化

1.减少锁的持有时间

  • 优化前

微信图片_20220813172616.jpg

  • 优化后

微信图片_202208131726161.jpg

2.降低锁的粒度

同步锁可以保证对象的原子性,我们可以考虑将锁的粒度分的更小一些,以此避免所有线程对一个锁资源的竞争过于激烈。具体方式有以下两种:

  • 锁分离

  • 锁分段

3.非阻塞乐观锁代替竞争锁

volitile关键字的作用是保障可见性及有序性,volitile的读写操作不会导致上下文切换,因此开销比较小。但是,volitile不能保证操作变量的原子性,因为没有锁的排他性。

而CAS是一个原子的if-then-act操作,CAS是一个无锁算法实现,保障了对一个共享变量读写操作的一致性。CAS操作中有3个操作数,内存值V,旧的预期值A和要修改的新值B,当且仅当A和V相同时,将V修改为B,否则什么都不做,CAS算法将不会导致上下文切换。Java的Atomic包就使用了CAS算法来更新数据,就不需要额外加锁。

二 wait/notify优化

下面我们通过wait()/notify()来实现一个简单的生产者和消费者的案例,代码如下:

微信图片_202208131726162.jpg 微信图片_202208131726163.jpg 微信图片_202208131726164.jpg

1.wait/notify的使用导致了较多的上下文切换

上下文切换.jpg

2.优化wait/notify的使用,减少上下文切换

这里我建议使用Lock锁结合Condition接口代替Sychronized内部锁中的wait/notify,实现等待/通知。这样做不仅可以解决Object.wait(long)无法区别是等待超时还是被通知线程唤醒的问题,还可以解决线程被过早唤醒的问题。

Condition接口定义的await方法,signal方法和signalAll方法分别相当于Object.wait(),Object.notify()和Object.notifyAll()。

三 合理地设置线程池大小,避免创建过多线程

在有些创建线程池的方法里,线程设置数量不会直接暴露给我们。比如,用Executors.newCachedThreadPool()创建的线程池,该线程池会复用其内部空闲的线程来处理新提交的任务,如果没有,在创建新的线程(不受MAX_VALUE限制),这样的线程池如果碰到大量且耗时长的任务场景,就会创建非常多的工作线程,从而导致频繁的上下文切换。因此,这类线程池就只适合处理大量且耗时短的非阻塞任务。

四 使用协程实现非阻塞等待

协程避免了像线程切换那样产生的上下文切换,在性能方面得到了很大的提升。协程在多线程业务的应用,之后的课程中我会详细讲述。

五 减少Java虚拟机垃圾回收

很多JVM垃圾回收器(serial收集器,ParNew收集器)再回收旧对象时,会产生内存碎片。从而需要整理内存,在移动对象前需要暂停线程,在移动完成后需要唤醒该线程。因此减少JVM垃圾回收的频率可以有效的减少上下文切换。

六 总结

上下文切换时多线程编程性能消耗的原因之一,而锁竞争,线程间的通信以及过多的创建线程等多线程操作,都会给系统带来上下文切换。除此之外,I/O阻塞以及JVM垃圾回收也会增加上下文切换。

总的来说,过于频繁的上下文切换会影响系统的性能,所以我们应该避免它。另外,我们还可以将上下文切换作为系统的性能参考指标,并将该指标纳入到服务性能监控,防患于未然。