ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Thread.yield()深度解析:从底层原理到真实场景,别再乱用让出CPU

Thread.yield()深度解析:从底层原理到真实场景,别再乱用让出CPU 最近有个朋友拿一段线上代码来问我服务里开了几十个线程每个任务都在一个while (true)循环里轮询某个状态结果 CPU 被打到 90% 以上业务却没有明显进展。他一着急在每个循环里都加了Thread.yield()跑了一阵发现 CPU 没降下来任务反而更慢了。这个场景其实把“线程主动让出 CPU”这个话题推到了台面上。很多人面试时会背出Thread.yield()这个答案但真正能把它讲清楚、知道它底层做了什么、并且能在项目里用得对的其实不多。今天这篇就是从一次真实问题出发把yield从 Java 层到操作系统层拆开再结合几个可复现的测试聊聊这个东西到底什么时候有用、什么时候就是帮倒忙。1. 先从一次线上卡顿说起CPU 时间片都去哪了1.1 一个让 CPU 空转的真实场景那个项目本身不复杂核心逻辑是一批任务要并发处理每个任务会持续检查某个条件是否满足条件不满足就继续等满足了再往下走。代码里最初写的是这样一类结构ExecutorService pool Executors.newFixedThreadPool(32); while ((task queue.poll()) ! null) { pool.submit(() - { while (!ready.get()) { // 空转等待 } handle(task); }); }ready是一个volatile AtomicBoolean之类的共享标记。由于业务本身是异步触发的任务线程在拿到标记前会一直空转。32 个线程同时跑这种循环每个线程都在抢 CPU 时间片核心很快就满了。这种情况非常典型线程并不是“没有让出 CPU”而是这段空转代码根本没给系统让路的机会。说白了线程在不停地检查同一个变量操作系统给它的每个时间片都被拿来执行这个循环了。时间片耗尽后确实会被调度走但几十个线程轮流跑每个线程一拿到 CPU 又开始检查整体表现就是“又忙又没产出”。1.2 抢占式调度下线程什么时候会被“请出 CPU”要理解yield到底在干什么得先搞清楚现代操作系统调度线程的基本逻辑。现在的桌面和服务器系统基本都是抢占式调度。每个线程会被分配一个时间片大约几毫秒到几十毫秒这个时间片用完调度器就会强制把它换下来让其他处于就绪状态的线程上去跑。线程自己并不需要主动说“我让一下”系统会替它安排。线程通常在几种情况下会让出 CPU时间片耗尽被调度器抢占主动发起阻塞操作比如读网络、等锁、sleep主动调用类似yield的接口表示“我可以让”。所以这里有一个认知上的分界系统本来就会抢时间片你不需要也不应该频繁干预yield只是在某些特殊编码场景下告诉调度器“我现在可以退让”。它更像排队办事时你暂时不处理了主动走到队伍后面让后面的人先上来。但窗口叫不叫你最终系统说了算而且你走回队尾后可能马上又被叫到。回到那个线上问题真正的解法不是加yield而是把空转循环改成等待通知、parkNanos或者带超时的阻塞队列。后面我会专门说这些替代方案这里先卖个关子。2. Thread.yield() 的底层真相从 JVM 到操作系统的调度器2.1 Java 层的 native 方法到底做了什么Thread.yield()的定义非常短在Thread.java里是一个native方法public static native void yield();既然是native那具体动作就交给 JVM 和操作系统去实现。HotSpot 里面Thread.yield()最终会调用到os::naked_yield()而os::naked_yield()在不同平台上分别是这样实现的Linux 上调用sched_yield()Windows 上调用SwitchToThread()Linux的sched_yield()语义是把当前线程从运行队列的头部拿下来放到队列尾部然后调度器选下一个可运行线程执行。但这有一个很关键的地方如果只有当前线程处于就绪状态或者就绪队列里已经没什么其他线程可跑调度器会立刻又选中它等于yield白做了。Windows的SwitchToThread()也是一样它只让出当前 CPU 上的执行机会如果没有其他线程等待运行调用会直接返回当前线程继续执行。所以从底层看yield就是一个“软让出”它没有任何“必须等待多长时间”的语义。这一点和sleep有本质区别。2.2 JVM 自身也会在内部使用类似机制很多人在 JDK 源码里见过的LockSupport.parkNanos()、同步器里的自旋等待它们在实现时其实会用到类似“短暂让出 CPU”的手段。HotSpot 在做偏向锁撤销、锁膨胀等操作时也会在自旋一段时间后调用os::naked_yield()来避免某个核心一直被占住。这说明一个事实让出 CPU 这个动作在高性能并发库的底层是有价值的但它是作为“退避策略”配合自旋使用的而不是拿来随随便便放在业务循环里的。2.3 yield、sleep、wait、join、park 到底有什么不一样面试里经常被连环追问yield和sleep有什么区别yield会释放锁吗wait和yield又有什么区别我把最常用的几个方法整理成一张表方法线程状态变化是否释放已持有的锁是否会响应中断典型用途Thread.yield()running - runnable否否主动放弃当前时间片让调度器重新选择Thread.sleep(n)running - timed_waiting否是指定时间不参与调度Object.wait()running - waiting是是等待被notify配合锁使用Thread.join()running - waiting内部通过 wait 实现会释放 Thread 对象 monitor是等待另一个线程结束LockSupport.park()running - waiting否是更灵活的阻塞/唤醒控制这里最容易被搞错的是yield不释放锁。如果你在synchronized块里调用Thread.yield()锁依然握在自己手上其他线程进不来你只是白白浪费了一个时间片而且还加重了锁竞争。而Object.wait()会释放监视器锁这正是它能在生产者消费者模型里发挥作用的原因。sleep也不会释放锁它只是让你自己睡一会儿但别人还是拿不到你手里的锁。2.4 关键认知yield 不保证任何调度结果JDK 文档里其实写得很直白Thread.yield()只是给调度器一个提示表示当前线程愿意让出当前使用的处理器。调度器完全可以忽略这个提示。结合前面的底层实现可以得出几个更具体的结论yield不保证让出的时间长度可能立即继续运行yield不保证其他线程能抢到 CPUyield的语义和效果依赖操作系统和 JVM 版本在单核机器上yield通常能让其他线程更快获得 CPU在多核机器上当前线程让出一个核心后调度器可能把它安排到另一个核心继续跑其他线程依然在别的核上并行执行最终是否“让出去”要看整体负载。所以不要把它理解成“调用后其他线程一定先跑”。它只是“我愿意让”不是“别人必须跑”。3. 什么场景下 yield 真的有用两个能复现的实验3.1 自旋等待中加 yieldCPU 占用从 100% 降下来第一个实验很直观。写两个线程一个等待一个volatile标志被设置为true另一个在几毫秒后修改标志。等待线程里用一个空循环自旋。先看不加yield的版本public class SpinDemo { private static volatile boolean flag false; public static void main(String[] args) throws Exception { Thread waiter new Thread(() - { long count 0; while (!flag) { count; } System.out.println(waiter exit, loop count count); }); waiter.start(); Thread.sleep(2); flag true; waiter.join(); } }运行这段代码waiter在 2 毫秒内可能循环了几十万次占用一个逻辑核心的几乎所有性能。如果打开任务管理器或者top能看到一个核心被拉满。然后我们把等待循环改成这样while (!flag) { Thread.yield(); }改完再跑循环次数会大大降低CPU 占用也会明显降下来。因为每次yield会让出当前核心调度器可能先让主线程执行flag被设置的速度会更快并且等待线程不会死占着核心不放。这个实验说明了一个典型用法当你确实需要一个自旋等待结构又不想把 CPU 完全烧掉时yield可以作为最简单的退避手段。但请注意这只是“最简单”不是“最优”。后面会讲更可控的LockSupport.parkNanos。3.2 用 yield 做简单的线程调度协作用户态轮转的粗糙实现第二个实验有点意思我们可以用yield模拟一个非常粗糙的协作式调度。假设 4 个线程轮流获得执行机会。每个线程只有在自己编号对应的轮次里才干活否则就让出 CPU。代码可以这样写import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; public class CooperativeDemo { private static final int THREAD_COUNT 4; private static final int TOTAL_STEPS 200_000; private static final AtomicInteger turn new AtomicInteger(0); private static final AtomicLong counter new AtomicLong(0); public static void main(String[] args) throws Exception { Thread[] threads new Thread[THREAD_COUNT]; for (int i 0; i THREAD_COUNT; i) { final int id i; threads[i] new Thread(() - { while (counter.get() TOTAL_STEPS) { if (turn.get() ! id) { Thread.yield(); continue; } counter.incrementAndGet(); turn.compareAndSet(id, (id 1) % THREAD_COUNT); } }); threads[i].start(); } for (Thread thread : threads) { thread.join(); } System.out.println(counter counter.get()); } }在这个程序里当前轮次不属于自己时线程会调用Thread.yield()让其他线程有机会运行。这样整体行为会像一个“人人让一步”的协作调度器每个线程都会按照0 - 1 - 2 - 3 - 0的顺序推进。如果去掉yield程序也能跑完但 4 个线程会疯狂空转检查turnCPU 消耗会大很多。这个例子更多是教学演示用来展示yield在用户态调度中的语义。实际项目里不要这么做正确做法是用队列、条件变量或者完整的调度框架。3.3 实验带来的结论收益和代价要分开看两轮实验跑下来能得出一个很清晰的体验yield能让自旋等待时的 CPU 占有率下降yield不会神奇地提升业务吞吐量在条件很快满足的场景yield可能让整体延迟略增因为它增加了线程切换的开销在多核机器上如果等待线程让出 CPU 后被调度到另一个核心CPU 占用可能依然不低只是分散到了多个核。所以正确心态是yield是降低空转成本的手段不是提升性能的手段。如果你的程序本来就在忙正常业务加yield只会增加无意义的上下文切换。3.4 一个必须澄清的误区yield 不是实现公平性的工具很多人以为调用yield后其他优先级相同的线程就能轮流获得 CPU于是想用它实现公平调度。这其实想得太美了。操作系统的调度器有自己的优先级、时间片和负载均衡策略。yield只是把当前线程放到队尾但如果队里没有合适的线程当前线程会被立刻选回来。所以它既不能保证“按顺序轮流”也不能保证“低优先级线程有机会跑”。真正需要公平调度时应该依赖操作系统本身的设计或者在应用层用队列、信号量、条件变量来控制执行顺序。4. 现代 Java 还需要手动“让位”吗从线程池到虚拟线程4.1 线程池里的线程其实靠阻塞让出 CPU而不是 yield热搜词里有个很常见的词叫“threadpoolexecutor 内置线程池”。很多人会想线程池里那么多线程是不是也要靠yield来让出 CPU完全不是。ThreadPoolExecutor的核心设计是工作线程在没有任务时会阻塞在workQueue.take()上或者用poll(timeout)等待新任务。这两种操作都会让线程进入TIMED_WAITING或WAITING状态线程不会占着 CPU。也就是说线程池让出 CPU 靠的是阻塞队列不是主动让出。我们平时写代码时真正应该让出 CPU 的场景是少数而阻塞等待是常态。比如// 任务从队列取没有任务就阻塞等待 Runnable task workQueue.take();这一句就把线程挂起了操作系统不会再为它调度 CPU 时间片直到有新任务被放入队列。这和yield完全不是一个量级的行为yield只是放弃当前时间片状态还是 runnable系统随时可能再调度它阻塞则是彻底让出直到某个条件满足。所以如果你的业务线程用到了线程池并且在任务之间有空闲它本来就会让出 CPU。真正造成 CPU 空转的往往是任务内部的while循环而不是线程池本身。4.2 yield 和虚拟线程的“让出”根本不是一回事最近“虚拟线程原理”也经常出现在 Java 进阶的讨论里。虚拟线程之所以能支撑几十万上百万并发靠的是把线程的栈和执行状态保存到堆里遇到阻塞 I/O 或park时可以快速挂起载体线程再切换去执行其他虚拟线程。虚拟线程底层的让出机制核心并不是Thread.yield()而是jdk.internal.misc.Unsafe里的park/unpark机制和 continuation 切换。Thread.yield()更多是“让出当前调度时间片”而虚拟线程的挂起是“让出整个执行权并且恢复时接着从挂起点跑”。两者面向的问题和开销都不一样。在实际编码中虚拟线程很容易让人误以为可以随便写while (true) { Thread.yield(); }。但虚拟线程同样不喜欢空转正确写法是遇到条件不满足时调用LockSupport.parkNanos或者干脆用阻塞队列、CompletableFuture这类高层抽象。4.3 别再靠 yield 救 CPU先检查线程数和任务设计我见过不少项目一遇到 CPU 飙高就往代码里塞yield但真正的问题要么是线程数太多要么是任务设计不合理。一个比较实用的经验是如果是 CPU 密集型任务线程数设置在 CPU 核数附近一般取核数 1如果是 I/O 密集型任务可以适当增加线程数按NCPU * (1 W/C)来估算W是等待时间C是计算时间如果大量线程在空转等待条件说明应该用通知机制而不是轮询如果锁竞争激烈优先优化锁粒度而不是让线程让出 CPU。先做这几件事CPU 通常会回到正常水平。yield只能是在代码结构本身合理的前提下对极少数自旋场景做微调。5. 实际项目里被 yield 坑过的三种写法5.1 在持有锁的循环里调用 yield这是最经典的错误。synchronized (lock) { while (!ready) { Thread.yield(); } }表面上看在循环里调用yield是“让其他线程跑一跑”但实际上你仍然持有lock其他线程根本进不了这个同步块也就无法改变ready的状态。这个yield只是把你的 CPU 时间片白白浪费掉同时却把锁握在手里不放导致别人干着急。正确做法是把等待条件放到锁外面或者用wait/notifysynchronized (lock) { while (!ready) { lock.wait(); } }这样不仅让出 CPU还释放锁其他线程才能进入同步块修改条件。5.2 用 yield 实现“稍后再试”导致业务超时还有一种常见写法是轮询某个外部状态时用yield充当“等一下再看”的逻辑。while (!orderFinished()) { Thread.yield(); }yield只是放弃当前时间片立刻又可能被调度回来轮询间隔极短基本没有退避效果。如果外部状态更新需要几十毫秒甚至几百毫秒这个循环会把一个核心打到 100%而且由于阻塞时间不可控还可能让服务整体响应变慢。正确做法是加入固定的休眠时间或者超时机制while (!orderFinished()) { Thread.sleep(50); }或者用LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(50))至少你还能控制让出时长不至于空转到让系统雪崩。5.3 网上流传的 yield 与 sleep(0) 对比别太当真网上经常有人争论“yield和sleep(0)到底谁更强”。这两个操作在底层调用的系统接口确实不一样实际行为也受操作系统和 JVM 版本影响。但我的看法是从业务代码角度这两个都不该频繁出现在核心路径里。如果你需要“让出一小段时间”直接明确地给出时间长度会更可预测如果你需要“检查别的线程有没有机会跑”那说明你的设计可能出了问题应该去找能真正挂起线程的方案而不是在调度语义上较劲。6. 经验性结论到底什么时候才该写 Thread.yield()6.1 我认为可以接受使用 yield 的少数场景虽然前面说了很多负面的点但yield并不是完全没用。我实际会接受它出现在以下这些地方自旋锁或自旋等待的高性能实现里作为退避策略的一部分无锁队列、ring buffer 的填充等待逻辑里短暂让出核心在学习、测试、演示调度行为时用来观察协作式调度的效果在某个非常短的临界等待场景中来不及设计完整的条件变量且自旋时间极短时。判断要不要用yield可以问自己几个问题我是不是必须让当前线程保持 runnable 状态我能不能改成阻塞等待、等待通知或者固定睡眠我是否清楚这个让出会有可能没有任何效果我是否已经排除了线程数过多、锁竞争、死循环这些问题如果前面几个问题里有一个答案偏向“否”就应该换方案。6.2 更好的替代方案LockSupport.parkNanos(long nanos)自旋退避的首选可以让出指定时间还能响应中断Object.wait/notify适合有明确条件变化通知的场景BlockingQueue适合生产者消费者场景线程会自动阻塞CompletableFuture适合异步编排不用手动控制线程让出虚拟线程适合大量阻塞等待的场景配合park可以获得很高的吞吐。举个例子如果你只是想自旋等待一个原子变量从 0 变成 1与其写while (atomic.get() 0) { Thread.yield(); }不如写while (atomic.get() 0) { LockSupport.parkNanos(1_000); // 1 微秒 }这样每个等待线程最多每 1 微秒醒来检查一次CPU 占用更可控也不会因为持续空转导致缓存一致性流量过大。6.3 我个人的选择做了这么多年 Java 并发相关内容我的个人体会是Thread.yield()更适合作为理解线程调度机制的入口而不是日常业务的工具。它让我明白了“主动让出 CPU”和“被动阻塞”之间的本质区别也让我在看 JVM 源码和操作系统调度时有了一个清晰的锚点。如果你现在正在做 Java 进阶我建议先把调度模型、锁状态、阻塞队列这些基础吃透再回头来看yield会发现它其实是一个非常底层的“小动作”并不神秘也没有那么多奇技淫巧。真正重要的是弄清楚你的线程到底该不该一直占着 CPU以及它等待的资源什么时候会到来。把这两件事想明白了比记住yield的八种用法有用得多。
返回列表