6.5并发上下文切换原理
目录介绍
- 01.了解什么是上下文
- 1.1 线程切换的概念
- 1.2 什么是上下文
- 1.3 上下文包含什么
- 1.5 上下文切换开销
- 02.上下文切换原理
- 2.1 上下文切换原因
- 2.2 自发性上下文切换
- 2.3 非自发性上下文切换
- 2.4 上下文切换开销
- 2.5 监控上下文切换频率
- 2.6 山下文开销原理
- 03.实践中的思考
- 3.1 单线程VS多线程
- 3.2 Synchronized上下文切换
- 3.3 volatile有上下文吗
- 3.8 上下文切换指标
- 04.优化上下文切换
- 4.1 减少锁持有时间
- 4.2 减少锁持有粒度
- 4.3 乐观锁替代竞争锁
- 4.4 wait/notify优化
- 4.5 减少虚拟机垃圾回收
01.了解什么是上下文
1.1 线程切换的概念
- 在并发程序中,并不是启动更多的线程就能让程序最大限度地并发执行。
- 线程数量设置太小,会导致程序不能充分地利用系统资源;线程数量设置太大,又可能带来资源的过度竞争,导致上下文切换带来额外的系统开销。
- 在多线程环境下,操作系统在不同线程之间切换执行的过程中,就会涉及到上下文切换。
- 每当操作系统决定将CPU时间片从一个线程切换到另一个线程时,就会发生上下文切换。
- 上下文切换涉及保存和恢复线程的执行状态,包括寄存器、程序计数器和堆栈等。这个过程需要操作系统的干预,因此会带来一定的开销。
1.2 什么是上下文
- 在单个处理器的时期,操作系统就能处理多线程并发任务。
- 处理器给每个线程分配 CPU 时间片(Time Slice),线程在分配获得的时间片内执行任务。CPU 时间片是 CPU 分配给每个线程执行的时间段,一般为几十毫秒。
- 在这么短的时间内线程互相切换,我们根本感觉不到,所以看上去就好像是同时进行的一样。时间片决定了一个线程可以连续占用处理器运行的时长。
- 当一个线程的时间片用完了,或者因自身原因被迫暂停运行了,这个时候,另外一个线程(可以是同一个线程或者其它进程的线程)就会被操作系统选中,来占用处理器。
- 这种一个线程被暂停剥夺使用权,另外一个线程被选中开始或者继续运行的过程就叫做上下文切换(Context Switch)。
- 一句话概括上下文概念
- 一个线程被剥夺处理器的使用权而被暂停运行,就是“切出”;一个线程被选中占用处理器开始或者继续运行,就是“切入”。
- 在这种切出切入的过程中,操作系统需要保存和恢复相应的进度信息,这个进度信息就是“上下文”。
1.3 上下文包含什么
- 那上下文都包括哪些内容呢?
- 具体来说,它包括了寄存器的存储内容以及程序计数器存储的指令内容。CPU 寄存器负责存储已经、正在和将要执行的任务,程序计数器负责存储 CPU 正在执行的指令位置以及即将执行的下一条指令的位置。
- 在当前 CPU 数量远远不止一个的情况下,操作系统将 CPU 轮流分配给线程任务,此时的上下文切换就变得更加频繁了,并且存在跨 CPU 上下文切换,比起单核上下文切换,跨核切换更加昂贵。
1.5 上下文切换开销
- 上下文切换的开销主要包括以下几个方面:
- 寄存器保存和恢复:当一个线程被切换出去时,它的寄存器状态需要保存到内存中;当它再次被切换回来时,需要从内存中恢复寄存器状态。这个过程涉及到寄存器值的读写操作,会带来一定的开销。
- 内存刷新:在上下文切换时,操作系统可能需要刷新缓存,以确保线程切换后的代码和数据的一致性。这个过程可能导致缓存失效,从而增加了内存访问的开销。
- 调度开销:操作系统需要决定下一个要执行的线程,并进行线程切换。这个过程涉及到调度算法的执行和上下文切换的决策,会带来一定的开销。
- 上下文切换的开销是相对较高的,因此在设计多线程应用程序时需要注意减少上下文切换的次数。
- 一些常见的减少上下文切换的方法包括使用线程池、减少线程的数量、避免线程间频繁的竞争和同步等。
02.上下文切换原理
2.1 上下文切换原因
- 在操作系统中,上下文切换的类型还可以分为进程间的上下文切换和线程间的上下文切换。
- 在多线程编程中,我们主要面对的就是线程间的上下文切换导致的性能问题,下面我们就重点看看究竟是什么原因导致了多线程的上下文切换。
- 开始之前,先看下系统线程的生命周期状态。
image - 结合图示可知,线程主要有“新建”(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,这又是什么诱发的呢?
- 分两种情况来分析,一种是程序本身触发的切换,这种我们称为自发性上下文切换,另一种是由系统或者虚拟机诱发的非自发性上下文切换。
2.2 自发性上下文切换
- 自发性上下文切换指线程由 Java 程序调用导致切出,在多线程编程中,执行调用以下方法或关键字,常常就会引发自发性上下文切换。
- sleep(),wait(),yield(),join(),park()
- synchronized
- lock
2.3 非自发性上下文切换
- 非自发性上下文切换指线程由于调度器的原因被迫切出。常见的有:
- 线程被分配的时间片用完,虚拟机垃圾回收导致或者执行优先级的问题导致。
- 虚拟机垃圾回收为什么会导致上下文切换
- 在 Java 虚拟机中,对象的内存都是由虚拟机中的堆分配的,在程序运行过程中,新的对象将不断被创建,如果旧的对象使用后不进行回收,堆内存将很快被耗尽。
- Java 虚拟机提供了一种回收机制,对创建后不再使用的对象进行回收,从而保证堆内存的可持续性分配。而这种垃圾回收机制的使用有可能会导致 stop-the-world 事件的发生,这其实就是一种线程暂停行为。
2.4 上下文切换开销
- 上下文切换会带来系统开销,那它带来的性能问题是不是真有这么糟糕呢?我们又该怎么去监测到上下文切换?上下文切换到底开销在哪些环节?
- 接下来我将给出一段代码,来对比串联执行和并发执行的速度,然后一一解答这些问题。
public class ThreadContext { 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) { 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(); } }
- 执行之后,看一下两者的时间测试结果:
multi thread exce time: 4153s counter: 100000000 serial exec time: 700s counter: 100000000
- 通过数据对比我们可以看到:串联的执行速度比并发的执行速度要快。
- 这就是因为线程的上下文切换导致了额外的开销,使用 Synchronized 锁关键字,导致了资源竞争,从而引起了上下文切换。
- 但即使不使用 Synchronized 锁关键字,并发的执行速度也无法超越串联的执行速度,这是因为多线程同样存在着上下文切换。
2.5 监控上下文切换频率
2.6 山下文开销原理
- 多线程对锁资源的竞争会引起上下文切换
- 锁竞争导致的线程阻塞越多,上下文切换就越频繁,系统的性能开销也就越大。由此可见,在多线程编程中,锁其实不是性能开销的根源,竞争锁才是。
03.实践中的思考
3.1 单线程VS多线程
- 线程越多,系统的运行速度不一定越快。那么我们平时在并发量比较大的情况下,什么时候用单线程,什么时候用多线程呢?
- 一般在单个逻辑比较简单,而且速度相对来非常快的情况下,我们可以使用单线程。例如,前面讲到的 Redis,从内存中快速读取值,不用考虑 I/O 瓶颈带来的阻塞问题。
- 在逻辑相对来说很复杂的场景,等待时间相对较长又或者是需要大量计算的场景,建议使用多线程来提高系统的整体性能。
- 使用单线程的情况:
- 任务是顺序执行的:如果任务之间没有依赖关系,且需要按照特定的顺序执行,那么使用单线程可能更简单和高效。
- 任务是计算密集型的:如果任务主要是CPU密集型的,而不涉及I/O操作,使用多线程可能不会带来明显的性能提升,甚至可能因为上下文切换而导致性能下降。
- 使用多线程的情况:
- 任务是I/O密集型的:如果任务涉及大量的I/O操作(如网络请求、文件读写等),使用多线程可以充分利用CPU的空闲时间,提高系统的吞吐量。
- 并行处理:如果任务可以被分解为独立的子任务,并且这些子任务可以并行执行,使用多线程可以加快整体处理速度。
- 响应性要求高:如果应用程序需要快速响应用户的请求,使用多线程可以使某些任务在后台异步执行,从而提高用户体验。
3.2 Synchronized上下文切换
- 在多线程中使用 Synchronized 还会发生进程间的上下文切换吗?具体又会发生在哪些环节呢?
- Synchronized在轻量级锁之前,锁资源竞争产生的是线程上下文切换,一旦升级到重量级锁,就会产生进程上下文切换。
- 如果Synchronized块中包含io操作或者大量的内存分配时,可能会导致进程IO等待或者内存不足。进一步会导致操作系统进行进程切换,等待系统资源满足时在切换到当前进程。
- 进程上下文切换,是指用户态和内核态的来回切换。我们知道,如果一旦Synchronized锁资源竞争激烈,线程将会被阻塞,阻塞的线程将会从用户态调用内核态,尝试获取mutex,这个过程就是进程上下文切换。
- 用户态到内核态为什么也属于进程上下文切换?
- 在linux操作系统中,进程的运行空间一般分为用户态和内核态,用户态空间一般是进程应用运行空间,而内核态空间一般是指应用需要调用系统资源,应用不能在用户态空间直接调用系统资源,需要通过内核态来系统系统资源。
- 所以进程在用户态和内核态两个直接相互切换,就称之为进程上下文切换。
3.3 volatile有上下文吗
- volatile的读写不会导致上下文切换,操作系统层面怎么理解呢
- volatile主要是用来保证共享变量额可见性,以及防止指令重排序,保证执行的有序性。
- 通过生成.class文件之后,反编译文件我们可以看到通过volatile修饰的共享变量,在写入操作的时候会多一个Lock前缀这样的指令,当操作系统执行时会由于这个指令,将当前处理器缓存的数据写回系统内存中,并通知其他处理器中的缓存失效。
- 所以volatile不会带来线程的挂起操作,不会导致上下文切换。
04.优化上下文切换
4.1 减少锁持有时间
- 锁的持有时间越长,就意味着有越多的线程在等待该竞争资源释放。
- 如果是 Synchronized 同步锁资源,就不仅是带来线程间的上下文切换,还有可能会增加进程间的上下文切换。
- 例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作以及可能被阻塞的操作。
public synchronized void mySyncMethod(){ test1(); mutextMethod(); test2(); } public void mySyncMethod(){ test1(); synchronized(this) { mutextMethod(); } test2(); }
4.2 减少锁持有粒度
- 同步锁可以保证对象的原子性,我们可以考虑将锁粒度拆分得更小一些,以此避免所有线程对一个锁资源的竞争过于激烈。
- 具体方式有以下两种:锁分离 和 锁分段
- 锁分离
- 与传统锁不同的是,读写锁实现了锁分离,也就是说读写锁是由“读锁”和“写锁”两个锁实现的,其规则是可以共享读,但只有一个写。
- 这样做的好处是,在多线程读的时候,读读是不互斥的,读写是互斥的,写写是互斥的。
- 而传统的独占锁在没有区分读写锁的时候,读写操作一般是:读读互斥、读写互斥、写写互斥。所以在读远大于写的多线程场景中,锁分离避免了在高并发读情况下的资源竞争,从而避免了上下文切换。
- 锁分段
- 在使用锁来保证集合或者大对象原子性时,可以考虑将锁对象进一步分解。例如,Java1.8 之前版本的 ConcurrentHashMap 就使用了锁分段。
4.3 乐观锁替代竞争锁
- volatile 关键字的作用是保障可见性及有序性
- volatile 的读写操作不会导致上下文切换,因此开销比较小。 但是,volatile 不能保证操作变量的原子性,因为没有锁的排他性。
- CAS 是一个原子的 if-then-act 操作,CAS 是一个无锁算法实现,保障了对一个共享变量读写操作的一致性。
- CAS 操作中有 3 个操作数,内存值 V、旧的预期值 A 和要修改的新值 B,当且仅当 A 和 V 相同时,将 V 修改为 B,否则什么都不做,CAS 算法将不会导致上下文切换。
- Java 的 Atomic 包就使用了 CAS 算法来更新数据,就不需要额外加锁。
- 在 JDK1.6 中,JVM 将 Synchronized 同步锁分为了偏向锁、轻量级锁、自旋锁以及重量级锁,优化路径也是按照以上顺序进行。
- JIT 编译器在动态编译同步块的时候,也会通过锁消除、锁粗化的方式来优化该同步锁。
4.4 wait/notify优化
- 调用 Object 对象的 wait() 方法和 notify() 方法或 notifyAll() 方法来实现线程间的通信。
- 在线程中调用 wait() 方法,将阻塞等待其它线程的通知(其它线程调用 notify() 方法或 notifyAll() 方法),在线程中调用 notify() 方法或 notifyAll() 方法,将通知其它线程从 wait() 方法处返回。
- 通过 wait() / notify() 来实现一个简单的生产者和消费者的案例,代码如下:
public class WaitAndNotify { 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(); } /** * 生产者 * * @author admin */ static 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();//生产成功,通知消费者消费 } } } /** * 消费者 * * @author admin */ static 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 { synchronized (pool) { while (pool.isEmpty()) { System.out.println("消费者等待生产者生产商品,当前商品数量为" + pool.size()); pool.wait();//等待生产者生产商品 } } synchronized (pool) { pool.remove(0); pool.notifyAll();//通知生产者生产商品 } } } }
- wait/notify 的使用导致了较多的上下文切换
- 1.在消费者第一次申请到锁之前,发现没有商品消费,此时会执行 Object.wait() 方法,这里会导致线程挂起,进入阻塞状态,这里为一次上下文切换。
- 2.当生产者获取到锁并执行 notifyAll() 之后,会唤醒处于阻塞状态的消费者线程,此时这里又发生了一次上下文切换。
- 3.被唤醒的等待线程在继续运行时,需要再次申请相应对象的内部锁,此时等待线程可能需要和其它新来的活跃线程争用内部锁,这也可能会导致上下文切换。
- 4.如果有多个消费者线程同时被阻塞,用 notifyAll() 方法,将会唤醒所有阻塞的线程。而某些商品依然没有库存,过早地唤醒这些没有库存的商品的消费线程,可能会导致线程再次进入阻塞状态,从而引起不必要的上下文切换。
image
- 优化 wait/notify 的使用,减少上下文切换
- 1.在多个不同消费场景中,可以使用 Object.notify() 替代 Object.notifyAll()。 因为 Object.notify() 只会唤醒指定线程,不会过早地唤醒其它未满足需求的阻塞线程,所以可以减少相应的上下文切换。
- 2.在生产者执行完 Object.notify() / notifyAll() 唤醒其它线程之后,应该尽快地释放内部锁,以避免其它线程在唤醒之后长时间地持有锁处理业务操作,这样可以避免被唤醒的线程再次申请相应内部锁的时候等待锁的释放。
- 3.为了避免长时间等待,我们常会使用 Object.wait (long)设置等待超时时间,但线程无法区分其返回是由于等待超时还是被通知线程唤醒,从而导致线程再次尝试获取锁操作,增加了上下文切换。
- 4.建议使用 Lock 锁结合 Condition 接口替代 Synchronized 内部锁中的 wait / notify,实现等待/通知。这样做不仅可以解决上述的 Object.wait(long) 无法区分的问题,还可以解决线程被过早唤醒的问题。
- Condition 接口定义的 await 方法 、signal 方法和 signalAll 方法分别相当于 Object.wait()、 Object.notify() 和 Object.notifyAll()。
4.5 减少虚拟机垃圾回收
- 减少 JVM 垃圾回收的频率可以有效地减少上下文切换。
- 很多 JVM 垃圾回收器(serial 收集器、ParNew 收集器)在回收旧对象时,会产生内存碎片,从而需要进行内存整理,在这个过程中就需要移动存活的对象。
- 而移动内存对象就意味着这些对象所在的内存地址会发生变化,因此在移动对象前需要暂停线程,在移动完成后需要再次唤醒该线程。