16讲多线程调优(下):如何优化多线程上下文切换 - 图116讲多线程调优(下):如何优化多线程上下⽂切换

你好,我是刘超。

16讲多线程调优(下):如何优化多线程上下文切换 - 图2通过上⼀讲的讲解,相信你对上下⽂切换已经有了⼀定的了解了。如果是单个线程,在 CPU 调⽤之后,那么它基本上是不会被调度出去的。如果可运⾏的线程数远⼤于 CPU 数量,那么操作系统最终会将某个正在运⾏的线程调度出来,从⽽使其它线程能够使⽤ CPU ,这就会导致上下⽂切换。

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

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

竞争锁优化

⼤多数⼈在多线程编程中碰到性能问题,第⼀反应多是想到了锁。

多线程对锁资源的竞争会引起上下⽂切换,还有锁竞争导致的线程阻塞越多,上下⽂切换就越频繁,系统的性能开销也就越
⼤。由此可⻅,在多线程编程中,锁其实不是性能开销的根源,竞争锁才是。

第11~13讲中我曾集中讲过锁优化,我们知道锁的优化归根结底就是减少竞争。这讲中我们就再来总结下锁优化的⼀些⽅式。

1.减少锁的持有时间

我们知道,锁的持有时间越⻓,就意味着有越多的线程在等待该竞争资源释放。如果是Synchronized同步锁资源,就不仅是带来线程间的上下⽂切换,还有可能会增加进程间的上下⽂切换。

在第12讲中,我曾分享过⼀些更具体的⽅法,例如,可以将⼀些与锁⽆关的代码移出同步代码块,尤其是那些开销较⼤的操
作以及可能被阻塞的操作。

优化前

public synchronized void mySyncMethod(){ businesscode1(); mutextMethod(); businesscode2();
}

优化后

public void mySyncMethod(){ businesscode1(); synchronized(this)
{
mutextMethod();
}
businesscode2();
}

2.降低锁的粒度

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

锁分离

与传统锁不同的是,读写锁实现了锁分离,也就是说读写锁是由“读锁”和“写锁”两个锁实现的,其规则是可以共享读,但只有
⼀个写。

这样做的好处是,在多线程读的时候,读读是不互斥的,读写是互斥的,写写是互斥的。⽽传统的独占锁在没有区分读写锁的时候,读写操作⼀般是:读读互斥、读写互斥、写写互斥。所以在读远⼤于写的多线程场景中,锁分离避免了在⾼并发读情况下的资源竞争,从⽽避免了上下⽂切换。

锁分段

我们在使⽤锁来保证集合或者⼤对象原⼦性时,可以考虑将锁对象进⼀步分解。例如,我之前讲过的 Java1.8 之前版本的
ConcurrentHashMap 就使⽤了锁分段。

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

volatile关键字的作⽤是保障可⻅性及有序性,volatile的读写操作不会导致上下⽂切换,因此开销⽐较⼩。 但是,volatile不能保证操作变量的原⼦性,因为没有锁的排他性。

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

上⾯我们了解了如何从编码层⾯去优化竞争锁,那么除此之外,JVM内部其实也对Synchronized同步锁做了优化,我在12讲
中有详细地讲解过,这⾥简单回顾⼀下。

在JDK1.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 pool=new Vector(); Producer producer=new Producer(pool, 10); Consumer consumer=new Consumer(pool);
new Thread(producer).start(); new Thread(consumer).start();
}
}
/**

  • ⽣产者
  • @author admin


/
class Producer implements Runnable{ private Vector pool; private Integer size;

public Producer(Vector pool, Integer size) { this.pool = pool;
this.size = size;
}

public void run() { for(;;){
try {
System.out.println(“⽣产⼀个商品 “); produce(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block 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();//⽣产成功,通知消费者消费
}
}
}

/**

  • 消费者
  • @author admin


/
class Consumer implements Runnable{ private Vector pool;
public Consumer(Vector pool) { this.pool = pool;
}

public void run() { for(;;){
try {
System.out.println(“消费⼀个商品”); consume();
} catch (InterruptedException e) {
// TODO Auto-generated catch block e.printStackTrace();
}
}
}

private void consume() throws InterruptedException{ synchronized (pool) {
while(pool.isEmpty()) {

System.out.println(“消费者等待⽣产者⽣产商品,当前商品数量为”+pool.size());
pool.wait();//等待⽣产者⽣产商品
}
}
synchronized (pool) { pool.remove(0);
pool.notifyAll();//通知⽣产者⽣产商品

}
}

}

wait/notify的使⽤导致了较多的上下⽂切换

结合以下图⽚,我们可以看到,在消费者第⼀次申请到锁之前,发现没有商品消费,此时会执⾏ Object.wait() ⽅法,这⾥会导致线程挂起,进⼊阻塞状态,这⾥为⼀次上下⽂切换。

当⽣产者获取到锁并执⾏notifyAll()之后,会唤醒处于阻塞状态的消费者线程,此时这⾥⼜发⽣了⼀次上下⽂切换。

被唤醒的等待线程在继续运⾏时,需要再次申请相应对象的内部锁,此时等待线程可能需要和其它新来的活跃线程争⽤内部锁,这也可能会导致上下⽂切换。

如果有多个消费者线程同时被阻塞,⽤notifyAll()⽅法,将会唤醒所有阻塞的线程。⽽某些商品依然没有库存,过早地唤醒这些没有库存的商品的消费线程,可能会导致线程再次进⼊阻塞状态,从⽽引起不必要的上下⽂切换。

16讲多线程调优(下):如何优化多线程上下文切换 - 图3

优化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的垃圾回收也会增加上下⽂切换。

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

思考题

除了我总结中提到的线程间上下⽂切换的⼀些诱因,你还知道其它诱因吗?对应的优化⽅法⼜是什么?

期待在留⾔区看到你的分享。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他⼀起讨论。

16讲多线程调优(下):如何优化多线程上下文切换 - 图4

  1. 精选留⾔

16讲多线程调优(下):如何优化多线程上下文切换 - 图5Geek_1f1a07
Zed说的不对,⾸先,所有的锁,⽆论synchronize还是lock,如果发⽣竞争条件,都可能造成上下⽂切换,优化锁的⽬的是为了尽量降低发⽣锁竞争的概率,synchronize做的优化都是把竞争的可能消灭在前期的偏向锁,轻量级锁,把会造成上下⽂切换 的“脏活”留在最后。lock的乐观锁⼤体思路也是⼀样的,不到万不得已,不会轻易调⽤park⽅法。但是本质上java⽬前都是利⽤内核线程,所以都会有上下⽂切换。除⾮使⽤协程的技术,这个以前有green thread,后来不⽤了,期待⽼师后⾯对协程的讲解。
2019-06-25 10:09
作者回复
回答很好,赞⼀个。
2019-06-26 11:16

16讲多线程调优(下):如何优化多线程上下文切换 - 图6QQ怪
我觉得有些⼈建议使⽤notifyall的原因是使⽤notify需要有⼗⾜的把握去确认哪条线程需要唤醒,因为⼀不留神就容易搞错,为了优化⽽优化最后事倍功半,所以⼤家才会使⽤notifyall⼀劳永逸,我其实挺认同⽼师的观点,⽼师,全部唤醒会导致更多的上下⽂切换,是否要优化这点,我觉得还是得看个⼈了吧
2019-06-26 00:21
作者回复
notify()可以结合wait(long)⽅法使⽤,解决某些没有通知的线程被通知不到的问题
2019-06-26 09:25

16讲多线程调优(下):如何优化多线程上下文切换 - 图7WL
⽼师请问⼀下在⼀段程序中除了⼯作线程之外还有很多守护线程, 这些线程加起来的数量必然⽐cup的数量会多很多, 那么为什么创建线程池的时候要参考CPU的数量呢, 为什么不把守护线程也考虑进去呢?
2019-06-25 19:41

16讲多线程调优(下):如何优化多线程上下文切换 - 图8Zed
回答趙衍同学

如你所说,synchronized主要是因为有⽤户态和内核态的交互所以能到进程级别。
⽽Lock是通过AQS的state状态来判断是否持有锁,整个过程都是在⽤户态或者说纯java实现。

最后lock.await()也是把当前线程放到当前条件变量的等待队列中并让出cpu。顺便提下,lock⽀持多条件变量。
2019-06-25 09:06
作者回复
回答很好。线程进⼊阻塞,两者都会发⽣进程上下⽂切换。Synchronized中阻塞线程⽆论何时去获取锁,都需要进⼊到内核态
,⽽AQS中,阻塞线程再次获取锁时,是通过state以及CAS操作判断,只有没有竞争成功时,才会再次被挂起,这样可以尽量减少上下⽂切换。
2019-06-26 11:01

16讲多线程调优(下):如何优化多线程上下文切换 - 图9⽪⽪
⽼师您好,⼀直有个疑问想请教,就是JDK1.5引⼊的lock锁底层实现也是调⽤了lockhelper的park和unpark⽅法,这个是否也 涉及到系统的上下⽂切换,⽤户态和内核态的切换?
2019-06-25 20:52
作者回复
是的
2019-06-26 11:08

16讲多线程调优(下):如何优化多线程上下文切换 - 图10 多个软件共同运⾏也有可能导致上下⽂切换,有些软件考虑使⽤绑定固定cpu核⽅式运⾏
2019-06-25 07:11
16讲多线程调优(下):如何优化多线程上下文切换 - 图11趙衍
⽼师好!在synchronized中,“挂起”这个动作是由JVM来实现的,获取不到锁的线程会被迫让出CPU,由于synchronized是基于操作系统的mutex机制,所以会产⽣进程的上下⽂切换。我想请问⽼师,在JDK的Lock中,或者AQS中,线程“挂起”这个动作⼜是怎么实现的呢?为什么不会产⽣进程级别的上下⽂切换呢?
2019-06-25 01:35
作者回复
AQS挂起是通过LockSupport中的park进⼊阻塞状态,这个过程也是存在进程上下⽂切换的。但被阻塞的线程再次获取锁时, 不会产⽣进程上下⽂切换,⽽synchronized阻塞的线程每次获取锁资源都要通过系统调⽤内核来完成,这样就⽐AQS阻塞的线程更消耗系统资源了。
2019-06-26 11:00

16讲多线程调优(下):如何优化多线程上下文切换 - 图12DemonLee

  1. ‘’在 JDK1.6 中,JVM 将 Synchronized 同步锁分为了偏向锁、轻量级锁、偏向锁以及重量级锁,优化路径也是按照以上顺序进⾏。‘’

这句话⾥⾯有两个偏向锁,后⼀个是不是“⾃旋锁”呀

  1. ⽼师只说了减少垃圾回收频率可以减少上下⽂切换,没说如何减少回收频率。感觉不是个好问题,算了,我还是先去Google 下找答案吧。

2019-07-14 00:13
作者回复
你好DemonLee同学,第⼀句是偏向锁、轻量级锁、⾃旋锁以及重量级锁; 第22讲中详细讲解了优化垃圾回收的内容,可以跳过去先了解下。
2019-07-14 16:18

16讲多线程调优(下):如何优化多线程上下文切换 - 图13旭东
在 JDK1.6 中,JVM 将 Synchronized 同步锁分为了偏向锁、轻量级锁、偏向锁以及重量级锁,优化路径也是按照以上顺序进
⾏。JIT 编译器在动态编译同步块的时候,也会通过锁消除、锁粗化的⽅式来优化该同步锁。

⽼师,这个只是JDK1.6的优化,还是1.6以后都是这么优化的?
2019-07-04 07:51
作者回复
包括了JDK1.6
2019-07-04 10:47

奇奇
不同时执⾏remive 但是也会进去执⾏remove(0)也是不符合语义的
2019-07-03 09:19
作者回复
是的,这个循环应该放到锁⾥⾯。已修正,谢谢提醒。
2019-07-04 10:56

奇奇
代码写错了
while(pool.isEmpty())不能放在同步代码块的外⾯
假设此时pool不为空容量为1,此时10个线程的pool.isEmpty都为false,此时全部跳出循环。全部执⾏pool.remove(0) 错误
2019-07-02 13:53
作者回复
同学你好!后⾯有个锁,不会同时进去remove。如有疑问,可继续留⾔。
2019-07-03 09:18

16讲多线程调优(下):如何优化多线程上下文切换 - 图14WL
⽼师请问⼀下, JVM在操作系统层⾯是⼀个进程还是多个进程, 如果是⼀个进程的话, 那synchronize和park()⽅法发⽣的是进程级别的状态切换的话是指操作系统不运⾏JVM了吗?
2019-06-27 19:50
作者回复
⼀个JVM在操作系统中只有⼀个进程,这⾥指的是进程中的某个运⾏的线程停⽌使⽤CPU,切换到内核获取CPU运⾏,⽽不是 说停⽌JVM,然后运⾏内核。这⾥的切换是⽤户态使⽤CPU切换到了内核态使⽤CPU。
2019-06-28 11:08

16讲多线程调优(下):如何优化多线程上下文切换 - 图15梁中华
原⽂:“⽽移动内存对象就意味着这些对象所在的内存地址会发⽣变化,因此在移动对象前需要暂停线程,在移动完成后需要再次唤醒该线程”。 这句话是不是不太严密?每次ygc都会导致年轻代内存地址变化,这也会导致暂停线程吗?如果是的话,那线程切换也太频繁了,似乎和事实不符啊。
2019-06-26 17:49
作者回复
年轻代是部分对象复制过程,是不会存在stop the world的发⽣。如果存在对象移动,使⽤对象的线程是会被挂起的,这个过程存在上下⽂切换。
2019-06-28 11:57

16讲多线程调优(下):如何优化多线程上下文切换 - 图16undifined
⽼师,在并发编程那个专栏第 6 讲中⽼师提到:notify() 是会随机地通知等待队列中的⼀个线程,⽽ notifyAll() 会通知等待队列中的所有线程;即使使⽤ notifyAll(),也只有⼀个线程能够进⼊临界区;但是 notify() 的⻛险在于可能导致某些线程永远不会被通知到;所以除⾮有特殊考虑,否则尽量使⽤notifyAll()

如果现在⼜考虑到锁,应该怎么做选择
2019-06-26 08:26
作者回复
notify()可以结合wait(long)⽅法使⽤,解决某些没有通知的线程被通知不到的问题
2019-06-26 09:22

16讲多线程调优(下):如何优化多线程上下文切换 - 图17JackJin
使⽤notify会带来线程饥饿,该怎样避免?
2019-06-25 15:52
16讲多线程调优(下):如何优化多线程上下文切换 - 图18周星星
sync在使⽤重量级锁的时候会有上下⽂切换,lock由于内部是Java实现,锁的等待是基于park来的,所以在lock中只会有线程切换带来的CPU上下⽂切换,没有锁竞争的上下⽂切换,⽐sync少⼀次CPU上下⽂切换
2019-06-25 13:26

16讲多线程调优(下):如何优化多线程上下文切换 - 图19CRann
⽼师,请问⼀下如果有多个线程wait()的时候notify()怎么唤醒指定线程?
2019-06-25 10:13
作者回复
可以根据条件来唤醒,例如,当有合适的库存时,依次唤醒其他线程。
2019-06-26 11:55
16讲多线程调优(下):如何优化多线程上下文切换 - 图20Jxin
g1并⾏垃圾回收。不⼀定会上下⽂切换吧。⾄于上下⽂切换这个,java还有信号量的实现。
2019-06-25 09:32
作者回复
g1只是减少,不能避免哦。
2019-06-26 11:16

16讲多线程调优(下):如何优化多线程上下文切换 - 图21cricket1981
怎样监控上下⽂切换?为什么有的并发书建议⽤notifyAll⽽不是notify?
2019-06-25 08:48
作者回复
因为notify结合wait使⽤,可能会导致某些线程没有被唤醒,⽽处于⼀直等待状态。这个可以根据⾃⼰的具体业务来衡量使⽤哪
⼀个。

在15讲中,有提到使⽤vmstat监测进程上下⽂切换,以及pidstat监测线程的上下⽂切换。

2019-06-26 10:41