多线程代码跑多了,总会撞上一种很邪门的现象:一个Java服务日志量不大、请求量也不高,CPU却烧到了100%。拿jstack抓线程栈一看,好家伙,好几个线程全卡在同一个while循环里死转,有的在自旋等待一个标志位,有的在循环里反复读一个volatile变量。问题的根子往往不在算法复杂度,而是线程不会"主动让出CPU"。
线程让出CPU这件事,在Java里有很多种写法:sleep、yield、wait、LockSupport.park,甚至Thread.onSpinWait。每种方式看起来都像是"让当前线程歇一下",但语义、代价、适用场景差了十万八千里。用对了,CPU占用率能断崖式下跌;用错了,要么让不出效果,要么引入饥饿、死锁、伪唤醒。这篇就专门掰开揉碎讲清楚这件事,适合正在写并发代码、做性能排查、或者准备面试时被问到"线程如何主动让出CPU"的人参考。
1. 为什么要主动让出CPU,让操作系统自己调度不行吗
1.1 先搞清楚Java线程和CPU调度之间的关系
很多初写并发的人有个误解:Java里new一个Thread,线程仿佛就自动被某个"管理器"妥善安排。真实情况是,Java线程本质上是操作系统线程的包装,在Linux上就是一个进程内的原生线程(1:1映射),由内核的调度器负责分配CPU时间片。每到一定的时间片(通常几毫秒到几十毫秒,具体看内核配置),内核会被硬件中断触发,执行调度逻辑,决定下一个该运行哪个线程。
这个模型叫"抢占式调度",简单理解就是:CPU的时间片不由线程自己掌控,而是系统说了算。理论上你可以在Java层面什么都不做,线程轮流跑就行。那为什么还需要"主动让出"?因为抢占式调度只保证"每个线程都有机会跑",不保证"你觉得该等的时候它真的愿意等"。如果某个线程在while循环里空转等待条件满足,调度器会照常分配时间片给它,这个线程就会把完整的CPU时间片消耗在没有任何产出的空转上。
用生活类比来说,抢占式调度就像公司里的会议抢麦,大家都按规则轮流发言,但有个员工什么事都没有,还是每次都把麦克风占满,硬说"我还在等资料"。你当然可以指望主持人反应过来再切走,但更优雅的做法是这个员工主动说一句"我先给下一位",把话筒交出去。
1.2 哪些场景下线程必须"主动让"而不是等被动切走
被动切换不是万能的,最常见的三类场景,基本都要靠主动让出来救场。
第一个是忙等待(busy-wait)。比如一个线程在等另一个线程把某个状态置好,代码写成while (!ready) { },每次循环都读内存、判空、再循环。这个线程虽然没有实际工作,但它一直处于Runnable状态,只要调度器给时间片,就会被切换进去空转。多线程都这么写,CPU立刻变成电暖器。
第二个是锁竞争之前的自旋。JDK里的很多并发工具(比如LongAdder、ConcurrentHashMap的某些路径)为了减少线程切换开销,会先自旋一小会儿再阻塞。如果在自旋期间不加任何策略地死等,既占CPU又没有明确退出时机,非常尴尬。此时需要短促地让出CPU,给持有锁的线程一点执行机会。
第三个是任务流转的间隙。比如生产者线程往队列里塞了任务,消费者线程暂时没有任务可处理,如果让消费者一直轮询队列,和忙等待没有区别。更合理的做法是把消费者挂起,生产者塞完任务后再把它唤醒。
这三类场景的共性都是:线程本身没有正事可干,但处于"可运行"状态,占据了调度器的注意力。主动让出的本质,就是把自己从"立刻可运行"变成"暂时不可运行"或"降低运行优先级",好让真正有工作的线程先跑。
1.3 "主动让出"和"被动阻塞"的边界要分清
说到这得提一个容易混淆的点:主动让出不意味着一定要把线程永久挂起。线程让出CPU有三种程度的退让。
最低程度是让出本次调度机会,线程还留在可运行队列里,比如yield和onSpinWait,它们只是告诉调度器"这次先给别人",下次轮到我照样跑。这个程度适合自旋锁里临时退让,不适合长时间等待。
中等程度是让出后续一段时间,比如sleep(milliseconds),线程进入TIMED_WAITING状态,沉睡指定的时间后再回到可运行队列。适合需要固定间隔重试的轮询场景。
最高程度是让出并等待信号,比如wait和park,线程直接进入WAITING/TIMED_WAITING状态,不再参与调度,直到被别人唤醒或超时。这是最高效的让出方式,适合真正不确定什么时候条件满足的等待。
把这个边界想清楚,你就不会在应该用park的地方用sleep,也不会在只是想让一下的地方把整个线程block掉。接下来就逐个拆解这些工具。
2. 主力让出手段逐个拆解:sleep、yield、wait、park
2.1 sleep:最朴素的让出,但别拿它当万金油
Thread.sleep是大家最早接触的让出方式。它的语义非常朴素:让当前线程暂时停止执行millis毫秒,进入TIMED_WAITING状态,到点后再进入可运行状态,等待调度器分配时间片。
sleep的问题在于,它只告诉系统"我什么时候恢复",没有告诉系统"我等待的条件是什么"。所以用sleep实现等待逻辑,通常需要配合一个循环不断检查条件,比如:
while (!isReady()) { Thread.sleep(10); // 每10ms检查一次 }这段代码在功能上可行,但有两个明显问题。第一,即便条件在1ms后就满足了,线程还是要睡满10ms才能继续检查,白白增加延迟。第二,如果条件一直不满足,这个循环会持续以每10ms一次的执行频率空跑,尽管CPU占用不高,但效率依然是浪费的。
sleep更合适的场景是"定时任务"或者"明确知道必须等待固定时长"。比如限速器里限制请求频率,每隔100ms允许一次操作,用sleep就顺理成章。而用它来泛化实现"等一个异步结果"的语义,基本是下下策。
前两年我开始重构一个旧的异步回调代码时,原实现就是用sleep轮询数据库任务状态,平均每个任务要轮询几百次。后来改成CountDownLatch做一次性等待,程序立刻从"隔几秒钟跳一下的抽搐式运行"变成了"结果就绪立刻返回"的流畅状态。如果任务完成时间不确定,不要用sleep,要用心跳式等待的机制。
2.2 yield:名义上让出,实际效果最缥缈
Thread.yield是面试高频题,但也是实际开发中最容易给人误导的工具。它的语义是:向调度器表示当前线程愿意让出当前的CPU执行权,让同优先级或更高优先级的其他可运行线程先执行。当前线程则会从Running状态回到Runnable状态,继续留在可运行队列里。
听上去很合理,实际上有两点让人头疼。
第一,yield不保证"一定让出去"。如果可运行队列里没有其他同优先级线程,当前线程会被立刻调度回去继续执行。也就是说,yield更像是一个"请求",调度器完全可以无视它。在上面提到的忙等循环里加yield,如果就一个线程在跑,加了等于没加。
第二,yield在不同平台上的实现差异很大。Linux上的HotSpot VM对yield的底层实现是调用sched_yield(),但即使调用成功,内核也可能会立刻把该线程再次调度上来。尤其在多核CPU上,由于每个核心都在运行线程,yield一次往往不会带来肉眼可见的改善。我在实际压测中观察到,如果一个四核机器上有四个线程同时忙等,各自加yield仍然有很高的CPU占用,因为线程会被立刻重新调度回核心上执行。
那yield到底还有什么用?有两个场景勉强算适用。一是锁的自旋退让中,短暂让出一轮时间片,给持锁线程以喘息机会;二是为了让低优先级的线程有机会执行(不过Java的线程优先级本身映射到操作系统就不可靠,这个用途也存疑)。
具体到编码层面,更为推荐的方式是在自旋中使用Thread.onSpinWait()(JDK 9+)代替yield。onSpinWait的语义是向CPU暗示当前正在自旋,CPU可以根据指令集的PAUSE/RSEB等原语调整流水线,降低功耗并提高超线程同伴线程的执行效率。它不是真正让出CPU,但比yield更适合"短暂等候"的自旋场景。
2.3 wait/notify:经典协作式让出,但要握紧锁
与sleep和yield不同,Object.wait是彻底把线程挂起,直到另一个线程调用notify或notifyAll才会被唤醒。这是多线程协作中最经典的"让出CPU并等待信号"的方式。
wait的使用有一个硬性前提:当前线程必须持有被wait调用对象的监视器锁(也就是synchronized或方法上的锁)。用伪代码表示就是:
synchronized (lock) { while (!condition) { lock.wait(); // 让出CPU,同时释放lock锁 } // 条件满足后的处理 }这里有个特别容易混淆的关键点:wait在让出CPU的同时会释放监控器锁。这意味着别的线程进入synchronized块操作共享状态。但yield和sleep让出CPU时都不会释放锁。很多线程拿不到锁的bug,其实都是"在持有锁的情况下调sleep,然后幻想锁能被别人抢走"。
wait的配套动作是notify和notifyAll。notify只随机唤醒一个等待线程,如果这个线程不满足条件,它还得继续wait;notifyAll唤醒所有等待线程,让它们重新竞争。标准做法是在wait外面套while条件而不是if条件,防止"伪唤醒"(spurious wakeup)。伪唤醒就是说线程可能在没有任何通知的情况下被唤醒,这是JVM为了某些硬件行为留下的后门。基于这个原因,写成while循环才是安全写法。
wait的适用范围是对象锁级别的一对多通知。如果你的代码里只有一把锁和少量条件,wait/notify完全够用。一旦条件多了,多个线程等不同条件,朴素wait/notify会把所有等待者全部唤醒再各自检查,性能会退化成惊群效应。这时候建议换显式锁的Condition或更高层的并发工具。
2.4 LockSupport.park:解锁的让出之王
要说Java里"主动让出CPU"最实用的API,我首推LockSupport.park。它能让你在没有监视器锁的情况下挂起线程,也不需要持有任何对象锁,配对使用LockSupport.unpark(Thread)来唤醒目标线程。
park的底层原理是配合Unsafe的park/unpark原语,通过许可证(permit)机制实现。每个线程都有一个和二进制的permit关联,初始为0。unpark会把这个permit置为1,park如果发现permit是1,就直接消费掉并返回;如果是0,就阻塞当前线程。无法确定是等待的线程。
这种licence机制带来一个极为重要的特性:unpark可以在park之前调用。假设线程A先调unpark给B发了permit,B再去调park,发现许可证已到达,立即继续,不会阻塞。这个特性完美解决了"唤醒发生在等待之前"的尴尬,比wait/notify的"先notify后wait会漏信号"要安全得多。
park让出CPU的程度是彻底的,线程进入WAITING状态,几乎不参与调度,对CPU占用影响为零。等到upark,立即精确唤醒目标线程。因此,park适合用来实现自定义的阻塞队列、自旋锁的升级路径、线程池的worker任务等待等场景。
看一个最常见的用法:阻塞队列的空闲消费者。
// 消费者线程执行循环 for (;;) { Runnable task = queue.poll(); if (task != null) { task.run(); } else { LockSupport.park(); // 队列空,让出CPU等待任务 } } // 生产者线程往里塞任务时 queue.offer(task); LockSupport.unpark(consumerThread);这段代码干净地表达了一个高效生产消费模型:队列为空就挂起,不空转不浪费CPU;有任务时立刻唤醒消费。比起sleep轮询,这种方式延迟低、CPU占用几乎为零。首次写此类逻辑的人会发现CPU占用率从满载荷变成跳动式的小尖刺,舒服太多。
2.5 四种方式对比速查表
把手头的四种工具摆在一起,语义差异一目了然。
| 方法 | 是否进入阻塞 | 是否释放锁 | 恢复方式 | 适用场景 |
|---|---|---|---|---|
| Thread.sleep(ms) | 是(定时) | 否 | 等待超时自动恢复 | 固定间隔轮询、限速 |
| Thread.yield | 否(仍可运行) | 否 | 调度器重新分配 | 自旋短暂退让(推荐onSpinWait替代) |
| Object.wait | 是(直到通知) | 是 | notify/notifyAll | 一个锁一个条件的经典协作 |
| LockSupport.park | 是(直到unpark) | 否 | unpark(thread) | 自定义阻塞、线程池任务等待 |
看到这张表,建议在每次写等待逻辑前默念一遍:能精确唤醒就不要轮询,能阻塞挂起就不要让出又回来,能让出一轮就不要占满时间片。这十二个字基本覆盖了选择依据。
3. 实战:从自旋锁的CPU灾难到优雅让出的完整改造
3.1 一个能看出问题的场景设计
先构造一个典型的"需要让出CPU"的场景。假设要在Java里实现一个极简限流器:最多允许N个线程同时进入关键区,超过的线程在门口自旋等待,直到有人出来。第一个版本我用自旋实现,类似这样:
public class SpinLock { private final AtomicBoolean locked = new AtomicBoolean(false); public void lock() { while (!locked.compareAndSet(false, true)) { // 空转等待 } } public void unlock() { locked.set(false); } }这个锁在低竞争下确实很轻量。但一旦有多个线程同时争抢,失败的线程就会死死占着CPU,不断CAS循环。如果关键区执行时间稍长,外部表现就是CPU飙高、程序吞吐量骤降。这正是"自旋锁没处理好让出"的典型症状。
3.2 第一版改进:加入Thread.yield
自然的想法是在空转循环里加yield,把CPU让给别人。改成:
public void lock() { while (!locked.compareAndSet(false, true)) { Thread.yield(); } }我实测过这个版本。在仅有2个线程竞争时,确实能明显降低一个核的占用率,因为失败的线程会频繁让出时间片给持锁线程。但当竞争线程数量达到8个以上时,yield的无力感就出来了:大量线程同时调用yield,调度器不得不反复在这些可运行线程之间切换,上下文切换开销反而变高,CPU出现尖峰。
关键问题是,yield后的线程依然在可运行队列里,调度器还是会频繁调度它,并没有真正从底层去掉轮询开销。
3.3 更优改良:Thread.onSpinWait配合退让上限
针对"短促等待"的自旋场景,更合理的做法是自旋前几十次用onSpinWait暗示CPU,如果还没等到就换手段。伪代码如下:
public void lock() { int spins = 0; while (!locked.compareAndSet(false, true)) { if (spins < 100) { Thread.onSpinWait(); // 前100次自旋,让CPU管道不那么紧绷 } else { // 随后让出本轮CPU Thread.yield(); spins = 0; } spins++; } }这个版本的思路是通过阈值控制自旋的"激进程度"。前100次自旋,利用onSpinWait降低CPU流水线压力;超过100次说明持锁线程可能真的执行了较长时间,此时主动yield,给持锁线程留出调度机会。实测下来比纯yield效果好一些,但依然没能根治"等待线程频繁被调度"的问题。
3.4 最终方案:用LockSupport彻底挂起,唤醒靠unpark
如果关键区最长执行时间通常在微秒级,自旋无可厚非;如果可能出现毫秒级等待,则应该直接上阻塞。在SpLock类里,把等待机制换成LockSupport:
public class ParkSpinLock { private final AtomicBoolean locked = new AtomicBoolean(false); private final ThreadLocal<Thread> holder = new ThreadLocal<>(); private ConcurrentLinkedQueue<Thread> waiters = new ConcurrentLinkedQueue<>(); public void lock() { if (locked.compareAndSet(false, true)) { holder.set(Thread.currentThread()); return; } waiters.add(Thread.currentThread()); for (;;) { if (locked.compareAndSet(false, true)) { holder.set(Thread.currentThread()); waiters.remove(Thread.currentThread()); return; } LockSupport.park(); // 彻底让出CPU } } public void unlock() { holder.remove(); locked.set(false); Thread waiter = waiters.poll(); if (waiter != null) { LockSupport.unpark(waiter); } } }注意这里的关键点:失败线程不再反复自旋,而是进入park挂起;解锁线程不是简单置标志位,而是从等待队列中取出一个线程,精确unpark唤醒它。这样在竞争强度高的情况下,几乎不产生无谓的空转CPU消耗。虽然加入了一条队列和内存分配,换来的是整个锁的可扩展性。
从压测数据看,多线程争抢执行短任务时,纯自旋锁的CPU占用最高,yield版本次之,park版本最稳。park版本的吞吐曲线几乎不再受线程数增加的影响,原因是等待线程已经完全不参与调度竞争。
3.5 用工具观察让出的实际效果
改造完代码后,怎么确认线程真的让出CPU了?我习惯用两个工具组合检视。
先看进程级CPU占用:top -H -p ,观察JVM进程内线程的CPU使用率。如果看到大量线程的CPU时间是0.0%,说明这些线程已经进入阻塞等待,让出生效。再看jstack抓线程状态,处于WAITING状态且栈顶停在LockSupport.park的叫唤说明挂起成功;如果看到大量RUNNABLE且都在同一个while循环里,那说明还没有让出或者让出策略无效。
这个检视方法判断"让出是否真的发生",比我之前靠感觉调参要靠谱得多。每次改动等待策略,我都会抓一份jstack做前后对比,线程状态分布的变化是评估效果的第一手证据。
4. 让出CPU的常见坑与排查经验
4.1 坑一:yield在Linux上经常看起来"没效果"
不少人在多核Linux机器上调yield,发现加了等于没加。这不是错觉。多核环境下每个内核在线地跑一个线程,yield只是把这个线程从当前核上退下来,但它马上又会被调度到另一个空核上继续跑。尤其当系统的可运行线程数小于核心数时,yield往往立刻被切回,几乎没有停顿。
所以,yield最不适合的场景恰恰是"核多但等待线程少"的情况。遇到这种环境,放弃yield,直接考虑sleep(1)或park才是正解。单纯yield的另一个风险是它没法保证"让给哪个线程",在调度器看来所有线程一视同仁,没法做到"让给正在持锁的某个线程"这种细粒度控制。
我的经验法则是:yield只做"礼貌性退让",不做可靠性等待。如果等待时间预期超过1毫秒,就不要再依赖yield和自旋,直接切换成挂起阻塞。
4.2 坑二:sleep(0)和sleep(1)被混用
有些老代码里会把Thread.sleep(0)当作yield来用,而sleep(1)当作"稍微歇一下"。严格来说,sleep(0)在很多平台上确实会让出一个时间片,但语义和yield一样不保证;sleep(1)则至少沉睡1毫秒,加上调度间隔,实际恢复可能长达几毫秒甚至十几毫秒。
曾接手过一个定期刷缓存的任务,原实现用了sleep(100)轮询一个外部配置变更,导致配置变更后最迟要100毫秒才生效。后来把方案改成:变更时用unpark精确唤醒等待线程,平时线程挂起在park里。那次改造把配置生效延迟从平均50毫秒降到了1毫秒以内,CPU占用还降了一截。
这里想提醒的一点:sleep的粒度不适合做"高频条件等待"。凡是等待的目标是某个条件变量而不是纯时间流逝,优先考虑park/wait,而不是sleep循环。
4.3 坑三:park丢失唤醒问题
LockSupport有"unpark先于park不丢信号"的特性,但这不代表用park就不会踩丢失唤醒的坑。在实际使用中,危险的写法是:先判断条件,再park,但如果条件的设置和唤醒被分配在同一个线程里,就可能出现"条件已满足但已经错过了unpark"的假象。
经典的正确写法是循环包裹:
while (!isConditionSatisfied()) { LockSupport.park(); }park醒来后再次检查条件。如果条件不满足就继续park。这个循环和wait里的while循环是一个道理,都是防止在条件未达成时错误地往下走。另一个连带问题是要避免在处于临界区时持有锁,但又调用park,导致其它线程无法unpark——因为unpark只需要线程对象,不涉及锁,所以这个坑相对小。
4.4 排查顺序:看状态、看热点、看上下文切换
线程让出相关的问题,我建议按三步排查。
第一步做线程状态分布统计。用jstack把线程栈导出来,统计RUNNABLE、WAITING、TIMED_WAITING、BLOCKED各有多少。如果大量线程处于RUNNABLE而又没有真正在工作,那就是没有让出;如果线程集中在WAITING,说明让出策略已经生效,问题应该转移到"恢复条件"上。
第二步用perf或者async-profiler看热点。如果热点集中在某个CAS方法或锁的内部循环里,那通常就是自旋空转问题;如果热点在LockSupport.park之类,则要检查是不是大量线程park被反复唤醒,说明唤醒时机或者队列策略有问题。
第三步看上下文切换次数。过高的cs(context switch)说明线程在频繁切换状态。一种典型的坏味道是一段代码在同一过程中不停地park/unpark,导致线程在可运行和阻塞之间反复横跳。这种情况往往要用"条件未满足才park,条件满足立即返回"的循环语义去约束,而不是在每次操作前后都无脑park。
4.5 经验速查:选型一图流
把前面的经验简化成几个问题,就能快速选出合适的让出姿势。
- 等待时间预计在纳秒到微秒级?用Thread.onSpinWait或短暂自旋。
- 等待时间预计在毫秒级且有明确条件?用LockSupport.park等待,unpark唤醒。
- 需要定时重试或限速?用Thread.sleep。
- 已持有对象锁且希望等待后释放锁?用Object.wait/notify。
- 只是想礼貌性让一轮且不在乎效果?用Thread.yield。
这个问题清单是我写并发等待代码前必过一通的。多数并发性能事故,追根溯源都是"选型时少问了一句:这个等待到底要等多久、靠什么恢复"。
一线实战中的个人体会
聊这些工具的语义和坑,最终落点还是代码质量和运行表现。我自己的经验是,每写一段"线程要停下等某个东西"的逻辑,都会强制自己回答三个问题:等多久、等什么信号、谁负责唤醒。三个问题答不上来,宁可先用最保守的poll加sleep方案,也不要随手甩一个自旋锁糊弄过去。
另一个很深的心得是:让出CPU和线程调度是平台的活,但设计等待策略是应用层的活。别高估yield的作用,也别低估park的威力。绝大多数业务里的"轮询等待",都值得改成"信号通知",Java并发包提供的前缀工具已经完全够用,不需要去翻底层API。
如果你手头正好有一段"CPU跑满但业务空转"的代码,别急着加缓存或改算法,先用jstack看看线程在等什么、怎么等的。很多时候,一行LockSupport.park就能救回一颗滚烫的CPU。