【JUC】多线程的上下文切换
上下文切换是指 CPU 从一个线程转到另一个线程时需要保存当前线程的上下文状态恢复另一个线程的上下文状态以便于下一次恢复执行该线程时能够正确地运行。在多线程编程中上下文切换是一种常见的操作上下文切换通常是指在一个CPU 上由于多个线程共享 CPU时间片当一个线程的时间片用完后需要切换到另一个线程运行。此时需要保存当前线程的状态信息包括程序计数器、寄存器、栈指针等以便下次继续执行该线程时能够恢复到正确的执行状态。同时需要将切换到的线程的状态信息恢复以便于该线程能够正确运行。在多线程中上下文切换的开销比直接用单线程大因为在多线程中需要保存和恢复更多的上下文信息。过多的上下文切换会降低系统的运行效率因此需要尽可能减少上下文切换的次数。一、什么时候会发生上下文切换1. 时间片耗尽操作系统不会让一个线程一直霸占 CPU。比如有三个线程线程 A 线程 B 线程 CCPU 可能这样执行A 执行 10ms - B 执行 10ms - C 执行 10ms - A 继续执行当线程 A 的时间片用完后即使 A 还没执行完操作系统也会暂停 A切换到其他线程。这就是上下文切换。可以理解为A我还没执行完 操作系统时间到了先让 B 执行2. 线程主动阻塞这个最常见。比如线程 A 执行到这里Thread.sleep(1000);它主动睡眠了CPU 不可能傻等它 1 秒所以操作系统会切换到其他线程执行。再比如synchronized(lock){// 临界区}如果线程 A 想进入 synchronized但是锁已经被线程 B 持有了那么线程 A 进不去可能会进入阻塞状态。这时候 CPU 就会去执行别的线程。常见主动阻塞包括sleep()wait()join()等待IO获取锁失败LockSupport.park()阻塞队列take()/put()本质都是当前线程暂时执行不了所以 CPU 切换给别人3. 高优先级线程抢占操作系统是抢占式调度。如果当前线程 A 正在运行突然一个更重要的线程 B 进入就绪状态操作系统可能会暂停 A优先执行 B。比如A 正在执行普通任务 B 是更高优先级任务突然就绪 CPU 切换到 B不过在 Java 里Thread.setPriority()只是给操作系统一个建议不是绝对保证。所以面试时可以说高优先级线程进入就绪状态时可能会触发线程抢占从而发生上下文切换但 Java 线程优先级不保证严格生效。4. Thread.yield()Thread.yield();这个方法的意思是当前线程提示操作系统我愿意让出 CPU但注意它只是“提示”不一定真的让出。比如线程 A 调用了Thread.yield();结果可能是A 让出 CPU - B 执行也可能是A 让出 CPU - 操作系统又选中了 A所以yield()不可靠一般实际开发中很少用。5. 中断处理这里的“中断”不是 Java 里的thread.interrupt();而是硬件层面的中断。比如网卡收到数据 磁盘 IO 完成 键盘鼠标输入 定时器中断CPU 正在执行线程 A突然硬件通知 CPU有重要事件要处理CPU 会暂停当前线程先去执行中断处理逻辑。处理完之后再决定继续执行原线程还是切换到其他线程。所以中断也可能导致上下文切换。6. 线程终止线程执行完了publicvoidrun(){System.out.println(执行完毕);}线程结束后CPU 肯定不能继续执行它了就会调度其他线程。比如线程 A 执行结束 CPU 切换到线程 B这也是一种上下文切换。二、上下文切换为什么有成本因为切换不是简单地“换个人执行”。比如 CPU 正在执行线程 Ainta10;intb20;intcab;执行到一半要切换走系统得保存线程 A 执行到哪了 寄存器里的值是什么 栈帧状态是什么 线程状态是什么然后切换到线程 B 时还要恢复 B 的执行现场。所以成本包括保存现场 恢复现场 CPU 缓存失效 线程调度开销 用户态/内核态切换如果线程特别多就会出现一种情况CPU 大量时间都在切线程而不是执行真正的业务代码这就是为什么线程不是越多越好。三、如何减少上下文切换1. 减少线程数假设 CPU 只有 4 核你创建了 1000 个线程。这 1000 个线程不可能同时运行大部分线程都在抢 CPU。结果就是线程 A 执行一下 切到线程 B 线程 B 执行一下 切到线程 C ...频繁切换性能反而下降。所以要用线程池控制线程数量。例如ExecutorServicepoolExecutors.newFixedThreadPool(8);合理线程数不是固定的要看任务类型CPU 密集型线程数接近 CPU 核心数 IO 密集型线程数可以比 CPU 核心数多一些2. 使用无锁并发编程阻塞锁容易导致线程挂起和唤醒。比如synchronized(lock){count;}如果竞争激烈很多线程抢不到锁就会被阻塞。阻塞之后又要唤醒唤醒之后又要参与调度这些都可能带来上下文切换。无锁编程的思想是尽量不要让线程阻塞比如使用AtomicIntegerConcurrentHashMapLongAdder示例AtomicIntegercountnewAtomicInteger(0);count.incrementAndGet();它底层通常使用 CAS不需要进入重量级阻塞。3. 使用 CASCAS 的意思是Compare And Swap 比较并交换比如现在变量count 10。线程 A 想把它改成 11它会判断我之前看到的值是不是 10 如果还是 10就改成 11 如果不是 10说明被别人改过了我再重试伪代码while(true){intoldValuecount;intnewValueoldValue1;if(compareAndSwap(oldValue,newValue)){break;}}CAS 的好处是失败了不一定阻塞而是重试所以它可以减少线程阻塞和唤醒从而减少上下文切换。但是要注意CAS 不是永远更好。如果竞争特别激烈很多线程一直 CAS 失败就会不断自旋浪费 CPU。所以可以总结为CAS 适合竞争不太激烈、操作很短的场景竞争特别激烈时CAS 自旋也可能带来 CPU 浪费。4. 使用虚拟线程 / 协程传统 Java 线程是平台线程通常对应操作系统线程。也就是说Java Thread - OS Thread如果线程阻塞可能会涉及操作系统线程的挂起和恢复。虚拟线程是 JDK 21 正式引入的轻量级线程。它的特点是虚拟线程很多时候由 JVM 调度 阻塞时不一定占用操作系统线程 切换成本更低可以理解为传统线程操作系统帮你切 虚拟线程很多情况下 JVM 自己管理切换所以虚拟线程可以降低大量阻塞场景下的线程切换成本特别适合大量 IO 阻塞任务 大量网络请求 大量数据库调用但它不是让所有场景都变快。如果是 CPU 密集型任务比如大量计算while(true){// 大量计算}虚拟线程也不能突破 CPU 核心数限制。5. 合理使用锁锁的范围越大其他线程等待的时间越长。比如这样不好synchronized(this){queryDatabase();updateCache();sendMessage();}如果queryDatabase()很慢其他线程会一直等锁。更好的做法是缩小锁范围DatadataqueryDatabase();synchronized(this){updateSharedData(data);}sendMessage();也就是说只锁真正访问共享资源的代码 不要把耗时操作放进锁里这样可以减少线程等待锁的时间减少阻塞也就减少上下文切换。四、你可以这样记上下文切换通常发生在1. 时间片用完了 2. 当前线程阻塞了 3. 更高优先级线程来了 4. 当前线程主动让出 CPU 5. 硬件中断来了 6. 当前线程执行结束了减少上下文切换的思路是1. 别创建太多线程 2. 别让线程频繁阻塞 3. 减少锁竞争 4. 缩小锁范围 5. 用 CAS、无锁结构、并发容器 6. IO 密集型场景可以考虑虚拟线程面试可以总结成一句话上下文切换是 CPU 从一个线程切换到另一个线程时保存和恢复执行现场的过程。它通常由时间片耗尽、线程阻塞、锁竞争、线程让出 CPU、中断或线程结束触发。上下文切换会带来保存现场、恢复现场、调度和缓存失效等成本所以可以通过控制线程数量、减少锁竞争、使用无锁并发、CAS、缩小同步范围以及虚拟线程等方式来降低上下文切换开销。