ARTICLE DETAIL

资讯详情

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

Java线程优先级与时间分片:从OS调度到虚拟线程的底层真相

Java线程优先级与时间分片:从OS调度到虚拟线程的底层真相 面试被问到Java线程优先级十个人里八个会背出“范围是1到10默认是5”。但你再追问一句把优先级改成10在Linux上跑一个多线程压测CPU时间真的会往这个线程倾斜吗大多数人就开始含糊了。这个问题其实牵出了Java并发里最容易被忽略的一块底层知识线程调度与时间分片。我们平时写Thread.start()、synchronized、LockSupport.park()都以为线程的“跑与停”是JVM说了算实际上JVM多数时候只是个传话的。真正决定谁上CPU、跑多久、什么时候被换下来的是操作系统内核里的调度器。Java线程调度这个说法更严谨的表述应该是JVM如何把Java线程交给操作系统以及操作系统如何用时间分片来轮转这些线程。这篇文章我想把这件事讲透。内容包括HotSpot线程模型、时间分片机制、上下文切换的真实成本、setPriority/yield/sleep/wait这些API在调度器眼中的实际作用最后再看Java 21虚拟线程登场后这张调度底图发生了哪些变化。内容会偏底层但我会尽量用可操作、可复现的方式来讲适合正在啃Java并发八股文、或者线上遇到线程池性能问题想找根源的读者。1. Java线程和操作系统线程调度这件事本来就不归JVM管1.1 HotSpot的1:1线程模型现代HotSpot JVM里java.lang.Thread和操作系统线程是一一对应的。你new一个Thread底层对应一个内核级线程你把它start()JVM通过pthread_createLinux或CreateThreadWindows真正创建出一个系统线程。这个一对一模型也叫原生线程模型是JDK 1.3之后就稳定下来的选择。这意味着一个Java线程在操作系统里货真价实地占着一个内核线程的资源。栈空间、线程控制块TCB、内核堆栈都是有的。-Xss控制的就是这个原生线程的栈大小默认通常是1MB。所以为什么有的人创建几千个线程就报OutOfMemoryError: unable to create native thread不是Java层内存不够而是操作系统层的线程数量/虚拟内存受限了。有个细节值得注意new Thread()和start()是两步。只new不start线程还在NEW状态操作系统里根本没有这个线程。到了start()JVM才会去聊操作系统真正把线程“生出来”。但生出来也不等于立刻跑它只是进入了就绪队列什么时候拿到CPU取决于调度器的心情。1.2 JVM为什么不当这个调度员既然JVM号称托管运行时为什么内存能托管线程调度却托管不了不是不能是没必要而且上古JVM确实试过。早期Solaris上的Green Threads就是用户态线程由JVM自己调度但后来被放弃了原因很实际多核CPU普及后用户态线程想利用多核还是要映射到多个内核线程绕一圈回来复杂度却没省。操作系统调度器经过几十年打磨已经处理好了负载均衡、功耗感知、多核亲和性、优先级继承这些复杂问题。JVM自己再搞一套性能很难超越。现实世界里有大量JNI调用、系统调用、信号处理这些场景天然需要原生线程上下文纯用户态线程很难无缝兼容。所以HotSpot最终选择了1:1模型把“调度决策”整个外包给操作系统。Java线程的RUNNABLE状态对应的是操作系统里的“运行中”和“就绪”两种状态的合并。1.3 线程start之后发生了什么如果站在操作系统视角看一次线程启动流程大概是这样的JVM调用pthread_create内核分配线程描述符和内核栈。新线程进入就绪队列等待调度器选中。调度器选出这个线程给它设置一个时间片额度。操作系统做一次上下文切换把CPU控制权交给新线程。新线程开始执行Java代码。这个“等待调度器选中”的时间是不确定的。你在线程里写的代码不会因为start()调用完就立刻执行它得先排队。这也是为什么很多人做多线程测试时发现线程启动顺序和运行顺序完全对不上——start()只是把你放进队列不是喊你上台。2. 时间分片是什么一次CPU使用权的拆解与成本核算2.1 从“谁先跑”到“每人跑多久”调度器要解决两个问题先让谁跑以及让每个人跑多久。早期操作系统用过协作式调度线程自己觉得不跑了才让出CPU。这版方案有个致命弱点一个线程死循环整个系统就卡死了。所以后来主流系统都换成抢占式调度——每个线程最多只能占用CPU一段时间时间一到调度器强制把它换下去让别的线程上。这段被强制占用的最长CPU时间就是时间分片英文常叫time slice或quantum。你可以把时间分片理解成自助餐里的取餐时间每个线程上“餐桌”只能夹这么久时间到了必须下去重新排队。不管你的菜有没有夹完。2.2 Linux的CFS是怎么分时间的现代Linux默认调度器是CFS完全公平调度器。它和传统“固定时间片轮转”最大的区别是它不按绝对时间片来切而是按“虚拟运行时间”vruntime来保持公平。CFS里有一个sched_latency的概念你可以把它理解为“一轮完整调度所有可运行线程的期望周期”。默认值通常是6ms。如果当前有N个线程可运行理论上每个线程在每一轮里分到的时间大约是6ms / N。但为了防止线程数太多时时间片被切得过碎内核还设了一个底线min_granularity默认大约0.75ms。也就是说时间片最小也会到0.75ms左右不会无限细分下去。你可以用命令直接看本机的这两个参数sysctl kernel.sched_latency_ns sysctl kernel.sched_min_granularity_ns我这边输出分别是6000000和750000也就是6ms和0.75ms。这意味着如果一台4核机器上同时有32个线程在猛跑每个线程一次拿到的CPU时间大概也就0.75ms然后就不得不被换下去等下一轮。这个切换频率已经足够让性能问题从“计算开销”转移到“切换开销”上了。Windows那边风格不太一样传统上用一个“量子”quantum的概念按优先级队列做轮转。默认量子的量级通常也在几十毫秒以内和Linux的思路殊途同归不可能让一个线程无限占用CPU必须分片。2.3 上下文切换到底有多贵时间片用完调度器要做一次上下文切换context switch。这是线程调度里最容易被低估的开销。直接开销包括保存当前线程的寄存器现场通用寄存器、程序计数器、栈指针等。切换到内核态执行调度器代码。恢复下一个线程的现场切回用户态。间接开销更狠当前线程上次运行可能不在这个CPU核上L1/L2缓存、TLB、分支预测器全是凉的重新热身要花时间。如果切换涉及到进程切换还要处理地址空间切换TLB基本要失效一轮。我用一张表做个经验值梳理注意不同硬件差异会很大开销类型经验量级影响说明保存/恢复寄存器现场微秒级直接耗时CPU指令本身很快TLB/页表切换数微秒到数十微秒进程切换时尤其严重缓存失效热身数十微秒级性能影响线程被切到别的核缓存全部作废这个成本有多直观我之前在一台4核容器里做过纯CPU计算实验线程数和核数一致时任务平稳跑完。线程数翻到16倍后vmstat里的cs列从每秒几千跳到每秒数十万最终任务总耗时几乎翻了三倍。CPU时间没有变少变少的是真正用来算业务逻辑的那部分全都耗在切换现场上了。后面第五章我会给完整观测命令和示例代码。3. setPriority、yield、sleep、wait哄调度器的四种姿势3.1 Thread.setPriority的真相Java文档里写着线程优先级范围1到10默认5。很多面试者把这段背得滚瓜烂熟但问到“能不能真正确保高优先级线程先跑”就卡住了。真相是Java的优先级只是给操作系统的一个“提示”不是强制命令。HotSpot在Linux上会把Java优先级映射到原生优先级实际影响的是nice值从而影响CFS计算vruntime时的权重。但是普通用户启动的Java进程无法设置负nice值。也就是说你想把优先级调到10然后获得“超级优待”很多时候并不会生效。CFS本身是公平调度器它对权重的响应远没有实时调度器那么激进。Linux上真正能提供硬实时抢占的是SCHED_FIFO、SCHED_RR这类调度策略Java默认用的是SCHED_OTHER普通Java进程想切到实时策略需要root权限而且会让系统稳定性风险剧增。所以我的经验是不要用线程优先级来做业务上的重要级区分。线上环境里我见过为了“让核心线程优先处理”而把优先级全部调成MAX_PRIORITY的案例结果根本没达到预期反而因为其他线程被打到低优先级导致整体响应变差。要保证“重要任务先执行”正确的方向是队列优先级、线程池隔离或者优雅的任务队列而不是去调内核调度参数。3.2 Thread.yield是让位还是“我不太想走”Thread.yield()在Java层面表示“我愿意让出当前CPU让同等优先级的其他线程跑”。但你真的去压测就会发现这玩意儿在线程密集时往往帮不上忙在某些死循环场景下甚至会让线程霸占CPU更久。原因是Linux上yield()最终会触发sched_yield()当前线程会被放到运行队列的末尾。但CFS按vruntime排序不是简单的FIFO。如果这个线程的vruntime本来就比队列里其他线程小意味着它“欠跑”那它放到队尾之后调度器很快又把它挑回来了。结果就是让了个寂寞。Thread.sleep(0)和yield经常被拿来对比。sleep(0)也是让出CPU但它会走一次nanosleep系统调用然后把线程标记为需要重新调度。在很多Linux版本上sleep(0)的行为比yield更接近“真正的让位”。但注意这并不是说你应该用sleep(0)去搞业务编排它们都只是线索不是命令。如果遇到“某个线程死循环把CPU吃满想让它柔和一点”的问题我试过最稳妥的办法不是yield而是周期性sleep一个很短的时间比如1~5ms或者在业务算法层面做分片处理。真正的调度控制权程序员是抢不过内核的不如顺着内核来。3.3 sleep、wait、park三种“不再占用CPU”的手段Thread.sleep(millis)当前线程进入TIMED_WAITING这个状态里它不占用CPU时间片。睡够之后线程自动回到就绪队列等待调度器再次派发。这时要注意它醒来不代表立刻执行只是想跑还得排队。sleep不释放任何锁。如果你在synchronized代码块里调用sleep其他线程依然进不来这就是“抱着锁睡觉”。以前查过一个问题某个线程池任务执行时间极其不稳定抓jstack一看有个线程在持锁期间Thread.sleep(10)直接把锁的释放拖了10ms其他线程全堵在锁上。这个坑面试也常考回答时最好主动点出“sleep不释放锁”。Object.wait()必须持有monitor才能调用调用后线程进入WAITING或TIMED_WAITING并且释放monitor锁。等notify()/notifyAll()唤醒后线程回到就绪队列但重新去竞争锁。这里有个非常关键的点被唤醒不等于马上拿到锁它还得和其他线程一起抢。LockSupport.park()这是AQS底层的线程挂起原语。它不关心任何锁就是把当前线程挂起来等到unpark()再继续。它和synchronized不是一回事。ReentrantLock的等待队列就是基于park/unpark实现的。我在面试时会让对方用一张表把这些区分开真正能分清楚的人不多方式是否释放已持有的锁线程状态如何恢复Thread.sleep否TIMED_WAITING时间到自动恢复Object.wait是monitor锁WAITING/TIMED_WAITINGnotify/notifyAll唤醒LockSupport.park不看锁WAITINGunpark唤醒Thread.join不释放锁WAITING/TIMED_WAITING目标线程结束3.4 面试里怎么答这段才不像背八股单纯背“优先级1-10、yield是让位、wait释放锁”这些点在面试官眼里很容易被追问打穿。更稳的答法是分层表述第一层Java线程在HotSpot里是1:1绑定OS线程调度由OS负责。第二层时间分片是OS抢占式调度的基础机制Linux CFS按vruntime公平分片线程过多时时间片会被切得很碎。第三层Java的优先级、yield只是调度提示sleep/wait/park本质是让线程进入不同等待状态影响的是“是否占用CPU时间片”而不是“能不能被优先执行”。这个回答逻辑连贯还顺带展示了底层知识储备比单点背诵要抗追问得多。4. 线程状态机背后的调度秘密谁在排队谁被插队4.1 六种状态里的调度含义Java线程有六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。用调度视角看这六个状态其实是看一件事这个线程现在要不要CPU。NEW和TERMINATED还没进调度器或者已经离开调度器。RUNNABLE既包含正在CPU上跑的线程也包含随时等待调度的线程在JVM角度这两种情况被统一成一个状态了但在操作系统里一个是running一个是ready。BLOCKED想进synchronized临界区但没抢到monitor锁线程被挂起不消耗CPU。WAITING/TIMED_WAITING被wait、park、sleep、join等方式挂起也不消耗CPU。很多时候线上排查用jstack看状态最要警惕的是大量RUNNABLE线程。因为正常阻塞的线程状态非常清晰而RUNNABLE越多越可能是纯CPU密集任务挤在一起或者是线程数超过核数之后在疯狂抢时间片。4.2 锁竞争时调度器如何决定“谁先上”进入synchronized但没抢到锁线程会进入BLOCKED状态。这里的实现细节网上八股说得很多但很少有人解释清楚它和调度的关系。HotSpot对重量级锁使用ObjectMonitor结构里面维护了cxq、EntryList、WaitSet这些队列。当一个线程要进入synchronized块却拿不到锁时它会先自旋尝试如果还不行就进入内核态阻塞本质上是通过futex系统调用把自己挂起。锁被释放时HotSpot会从等待队列里挑一个线程唤醒。但那只是一场预选赛——被唤醒的线程重新进入就绪队列然后和新来的线程一起抢CPU和锁。这就是为什么synchronized是非公平的你等的线程不一定先上新来的线程反而可能插队。ReentrantLock则把这层逻辑暴露出来了。它可以配成公平锁内部按等待顺序FIFO唤醒也可以保持默认的非公平锁允许新来的线程插队。非公平锁的优势在于减少了“唤醒-抢占”来回切换的延迟所以吞吐通常更好公平锁虽然更“文明”但在高竞争场景下反而可能引发更多的上下文切换。从调度角度看锁竞争本质上是大量线程在“运行”和“阻塞”之间来回横跳的过程每一次跳变都可能伴随一次上下文切换。锁竞争越激烈调度器越忙系统吞吐越差。4.3 一次锁切换的完整复盘假设线程A已经持有锁线程B来抢锁。整个过程大致是B在Java层尝试进入同步块发现monitor已被A占用。JVM先让B自旋等一会儿JVM自己调节自旋策略不必开发者在代码里写。自旋失败B调用内核的futex阻塞从就绪队列消失进入BLOCKED状态。A释放锁JVM要唤醒B于是触发一次futex唤醒调用B回到就绪队列。B被调度器选中得到时间片恢复执行再次去抢monitor锁。这一段链条里至少有两次内核态调用和一次上下文切换而且还没算上缓存失效。所以“高并发锁竞争慢”不只是锁本身慢慢的是整个“挂起-唤醒-重新调度”过程。这也解释了为什么很多高性能框架喜欢用无锁或分段锁结构比如LongAdder、ConcurrentHashMap的锁分段思路——它们真正想减少的其实就是这种调度折腾。4.4 优先级反转和饥饿调度器的“不作为”高优先级线程等待低优先级线程释放锁导致高优先级线程迟迟跑不起来叫优先级反转。Java标准库没有内置完整的优先级继承机制遇到这种场景表现通常是高优先级线程在锁上干等。饥饿问题更常见非公平锁下如果锁竞争极端激烈个别线程可能一直抢不到锁导致长时间卡顿或者超时。虽然实际场景里很少真的“饿死”但线上排查时如果看到某个线程长期WAITING或BLOCKED需要把“锁设计是否公平”和“线程优先级是否合理”都列入怀疑清单。调度器本身无所谓偏向谁但错误的锁策略可以让一个线程被“晾着”很久。5. 实战观测如何量化一次线程切换的代价5.1 先用jstack快速得出状态分布拿到一个Java进程先看全局状态分布jstack pid stack.log grep java.lang.Thread.State stack.log | sort | uniq -c输出会告诉你当前线程里有多少RUNNABLE、多少BLOCKED、多少WAITING。如果阻塞数量高配合top -H -p pid看具体线程能定位到锁竞争热点。但jstack只是快照看不到切换频率。要量化时间分片带来的代价还得看操作系统的统计指标。5.2 用vmstat和/proc看切换次数先看全局vmstat 1关注cs列这就是每秒上下文切换次数。它包含所有进程的切换但如果这个数值飙到几十万机器基本已经在大量交“切换税”了。再看单线程级别的切换统计cat /proc/tid/status | grep ctxt里面的voluntary_ctxt_switches是自愿切换次数nonvoluntary_ctxt_switches是非自愿切换次数。两者的解读思路很有意思非自愿切换多通常是被调度器强制换下的次数太多要么是线程数远超CPU核数要么是时间片太短。自愿切换多通常是自己主动让出比如锁阻塞、IO等待、sleep锁竞争越激烈这个值长得越快。之前我排查过一个线上服务nonvoluntary_ctxt_switches每分钟狂涨top -H一看线程池里的线程数量是核数的32倍全是CPU密集任务。一查代码是某个同学把阻塞队列设得无限大同时又启了超大核心线程数导致任务在CPU上互相抢时间片。调小线程池后吞吐立刻回血这就是时间分片被切得太碎的真实案例。5.3 一个可复现的切换代价实验写一个小程序专门对比不同线程数下同一批CPU计算任务的耗时import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; public class SliceCost { static void compute() { long sum 0; for (int i 0; i 1_000_000; i) { sum i * 31L % 7; } } static void runWithThreads(int threadCount, int totalTasks) throws Exception { ThreadPoolExecutor pool new ThreadPoolExecutor( threadCount, threadCount, 0, TimeUnit.SECONDS, new LinkedBlockingQueue() ); long start System.nanoTime(); CountDownLatch done new CountDownLatch(totalTasks); for (int i 0; i totalTasks; i) { pool.submit(() - { compute(); done.countDown(); }); } done.await(); long costMs (System.nanoTime() - start) / 1_000_000; System.out.println(threads threadCount , cost costMs ms); pool.shutdown(); } public static void main(String[] args) throws Exception { int cores Runtime.getRuntime().availableProcessors(); int taskCount 64; runWithThreads(cores, taskCount); runWithThreads(cores * 4, taskCount); runWithThreads(cores * 16, taskCount); } }在4核容器里我实测到的大致结果是线程数64个任务总耗时4约260ms16约390ms64约760ms任务总量不变纯计算量不变但线程越多CPU时间被切得越碎等待和切换的时间占比大幅上升。这个实验特别适合当成面试中的“性能题”素材简单直观逻辑闭环。6. 虚拟线程登场时间分片会不会被杀死6.1 JVM终于自己管调度了Java 21正式引入了虚拟线程Virtual Thread。它和传统线程最大的区别是打破了1:1模型一个平台线程也就是操作系统线程上可以挂成百上千个虚拟线程。虚拟线程的创建、挂起、恢复由JVM自己管理只在最终真正需要CPU执行时才借助一个有界的ForkJoinPool调度器映射到少量平台线程上。这意味着JVM终于在调度这块开始“亲自下场”了。平台线程仍然由OS调度但虚拟线程之间的切换是JVM在用户态完成的。6.2 虚拟线程不靠“时间片”抢占平台线程遵循时间分片时间到了会被强制换下去。虚拟线程不是这样。它更像协作式调度当虚拟线程执行到阻塞点——比如等待锁、执行IO、调用LockSupport.park——JVM会把它从载体线程上摘下来让载体线程去执行另一个虚拟线程。如果没有阻塞点虚拟线程就会一直占着载体线程跑下去。所以虚拟线程根本没有传统意义的CPU时间片。它不会因为你写得久了被强切它只在自己主动让出时才会切换。这个特性带来了巨大优势创建大量线程跑IO密集任务时大部分时间都在等待IO虚拟线程可以被轻量地挂起再让载体线程去处理其他任务。之前用平台线程池需要上千个OS线程才能扛住的IO并发虚拟线程用几十个平台线程就能承载。6.3 什么时候用虚拟线程什么时候别用虚拟线程不是万能药最典型的反例就是CPU密集型任务。比如第六章那个纯计算实验你用1000个虚拟线程去抢4个CPU核对吞吐没有任何帮助反而会因为虚拟线程调度本身增加开销。原因很简单纯计算不产生阻塞虚拟线程不会主动让出JVM又不像OS那样用时间片强制抢占安排到载体线程上的那批虚拟线程会一直占着位置后到的虚拟线程只能排队。另一个经典坑是JDK 21~23里虚拟线程在synchronized代码块内阻塞时会“钉住”pinned载体线程导致其他虚拟线程没法让出载体线程从而失去并发优势。当时官方建议是尽量用ReentrantLock替代synchronized。到JDK 24之后synchronized在虚拟线程上的钉住问题已经被修复但如果线上还在用JDK 21/23这个坑值得记在心里。6.4 一个小实验虚拟线程怎么把阻塞变轻用虚拟线程执行大量“睡眠式”任务最能直观看到它的调度优势long start System.nanoTime(); try (var executor Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 10_000).forEach(i - executor.submit(() - { try { Thread.sleep(10); } catch (InterruptedException ignored) {} return null; })); } long costMs (System.nanoTime() - start) / 1_000_000; System.out.println(virtual threads cost costMs ms);同样用10个平台线程的固定线程池跑这10000个sleep(10)任务耗时基本是串行累加的百万秒量级而虚拟线程因为每个线程在sleep时都会让出载体线程总耗时基本和单批任务耗时差不多。这个实验不是让你真去“用sleep测试性能”而是帮你建立对虚拟线程调度模型的感觉它优化的是大量阻塞线程的切换成本而不是CPU密集任务的并行能力。理解了这一点再看Java并发编程的其他选型问题思路会清楚很多。我刚接触虚拟线程时还在担心它会不会颠覆我过去所有关于线程池的经验。实际用下来平台线程、线程池、锁竞争、时间分片这些老概念不但没有过时反而是理解虚拟线程的最佳垫脚石。调度从操作系统手里被JVM“抢回”了一部分但代价是你要更清楚地知道自己的任务是IO密集型还是CPU密集型否则很容易把新特性用在不该用的地方。
返回列表