
1. 时间片是怎么来的多个线程抢一个CPU时发生了什么先讲一个我真实遇到过的场景。有段时间我在给一个并发任务做压测机器是8核业务逻辑也简单就是一个纯计算任务按道理应该把8个核吃满。结果压测发现CPU利用率只有130%左右程序跑得反而比单线程还慢。当时第一反应是代码里是不是有锁竞争、有IO等待排查了半天没发现问题。最后usingtop看到进程下有30多个线程在抢那8个核问题才慢慢浮现出来——线程之间频繁地切换、抢占、让出时间都浪费在了调度本身上。这就是理解Java线程调度最关键的一点操作系统里的CPU核数是有限的但并发线程数可以远超核数。一台8核机器上开50个线程同一时刻真正在执行指令的只能有8个剩下的42个要么在等待CPU要么在等待锁和IO。那么问题来了这8个核到底分配给哪8个线程每个线程能跑多久谁来确保没有线程被饿死答案就是时间片Time Slice。时间片是操作系统分配给每个线程的一段连续执行时间线程在这个时间里独占CPU时间用完了调度器就会把它换下去让另一个线程上来。这个“换人”的动作就是上下文切换Context Switch。我用一个生活化的例子解释。想象10个人排队用一台打印机每个人打印得差不多了后面的人就得等着。时间片就是“每个人最多打印5分钟”5分钟一到不管有没有打完都让下一个人来。如果时间片太长比如允许一个人打印一天那排在后面的人就会等崩溃如果太短比如允许每人打印1秒那大家大部分时间都在起身让座、重新摆弄文档上真正打印的时间反而少了。在操作系统层面这个“让座”可不是站起来就走那么简单。上下文切换要保存当前线程的寄存器状态、程序计数器、内存栈指针还要把下一个线程的这些数据恢复回来。这个过程本身要消耗CPU时间而且切换之后新线程刚加载进来的数据在CPU缓存里可能是“冷”的访问速度会变慢。一次上下文切换看起来只有几微秒但服务器上每秒可能发生几千次甚至几万次切换积少成多就是一个不小的开销。理解了这个Java开发者应该意识到两件事第一线程不是开得越多越好线程多了调度开销会侵蚀掉并行带来的收益第二Java本身不直接控制时间片的大小它完全交给操作系统。所以要想真正搞懂Java线程调度第一步是搞懂底层的时间片机制而不是先在Thread类上找答案。顺带说一句如果你在面试里被问到“Java线程调度原理”一上来就背synchronized和锁的八股文并不是重点。面试官真正想听的往往是你对时间片、上下文切换、操作系统调度器这些底层概念的理解以及你知不知道Java线程最终是映射到操作系统线程上的。2. 时间片的长短不是拍脑袋CFS和反馈队列的取舍逻辑既然时间片这么重要那它到底是怎么定的很多人以为时间片是一个固定值比如Linux每10毫秒切一次。其实早期的Linux确实有这个倾向内核大约每10毫秒产生一次时钟中断处理完中断就可能触发调度。但随着系统越来越复杂固定的时间片已经不够用了原因很容易理解一个纯计算的线程和一个正在等用户输入的线程它们的调度需求完全是相反的。计算型线程希望一次拿到足够长的时间片赶紧把活儿干完中途不要被打断交互型线程恰恰相反它通常是“等一会儿事件→处理一下→又去等”它希望自己一进入可运行状态就立刻被调度到而不是排队等上几十毫秒。如果固定时间片太长交互型应用会感觉到明显的卡顿固定太短计算型任务会频繁被切换白白损失性能。现代Linux采用的CFSCompletely Fair Scheduler完全公平调度器换了一个思路不搞固定时间片而是追求“虚拟运行时间”的公平。每个可运行的线程都有一个vruntime记录它累计运行的虚拟时间。调度器每次都选vruntime最小的那个线程来运行也就是说谁运行得少谁优先上CPU。这样天然公平谁也别想饿着。那vruntime和实际运行时间的区别在哪这就涉及到优先级了。CFS给每个线程分配一个权重权重高的线程vruntime增长得慢它的“虚拟时间走得慢”于是实际获得的CPU时间反而更多。这个权重和nice值挂钩nice值每差一档权重大约相差25%左右。nice值越小权重越高在CFS眼里越“受照顾”。举个例子两个线程A和BA的nice值比B低5档权重就差不多是B的三倍多。它们在同样的真实运行时间里A的vruntime增长速度远慢于B。调度器看到B的vruntime比A大就会更倾向于选A。结果是A获得大约75%以上的CPU时间。所以你看在Linux上“优先级”不是直接决定谁先跑的硬规则而是通过影响vruntime增长速度来分配的软规则。CFS还引入了目标延迟sched_latency和最小粒度min_granularity两个参数。目标延迟是调度器的“周期目标”比如默认6毫秒最小粒度是线程每次最少能获得的时间比如0.75毫秒。当可运行线程数少的时候时间片可以宽裕一些当线程数多了就按目标延迟除以线程数来计算单个线程的时间片但不得小于最小粒度。这样设计保证了线程少时切换少、吞吐高线程多时响应快、延迟可控。除了CFS经典的调度算法里还有一个概念叫多级反馈队列MLFQ现在很多系统也会借鉴它的思想。它的核心逻辑是把就绪队列分成多个优先级层次新线程先进入最高优先级队列给一个较短的时间片如果这个线程用完了整个时间片还没完成说明它是计算型的就把它降到下一级队列下一级的时间片更长一些如果线程在执行中间就主动让出CPU比如在等待IO说明它是交互型的就把它留在当前优先级的队列头部下次优先调度。这样交互型任务响应快计算型任务虽然优先级降下来了但时间片更长也不会饿死。讲了这么多操作系统层面的调度算法你可能会问那Java里能不能调时间片答案是不能也不建议。JVM本身是跨平台的Windows、Linux、macOS的调度策略各不相同JVM不可能让你用一套代码去精确控制每个平台的时间片。Java能做的极限就是通过线程优先级和yield、sleep之类的方法向调度器传递“建议”至于操作系统听不听那要看它自己的算法脸色。这里有一个关键点需要记住在现代操作系统上调度器往往比程序员更聪明它会根据线程的实际行为不断调整调度决策。你手动干预得越多反而可能越帮倒忙。3. Java线程调度模型从Thread.start()到内核线程的真实映射现在回到Java本身。Java的线程机制和操作系统线程到底是什么关系答案很明确在目前的绝大多数JVM实现上Java线程和操作系统内核线程是一一对应的这种模型叫1:1模型。当你new了一个Thread对象并在代码里调用start()的时候JVM会通过native方法创建一个真正意义上的操作系统线程。在Linux上这个创建过程会走到pthread_create底层是clone系统调用。从这一刻起你的线程就不再是“Java层面的对象”了它已经变成一个被内核调度器管理的实体自己的栈、寄存器状态、调度优先级都真实存在于操作系统里。既然是一一映射Java线程的各种状态就能直接映射到操作系统的状态。很多人学Java线程状态时单独背觉得很简单但真正排查问题的时候反而容易糊涂原因就是没建立这个映射关系。我用一个表来说明。Java线程状态对应操作系统/内核状态说明RUNNABLERunning或Ready就绪线程正在执行或者等待被调度器选中执行BLOCKEDSleeping在锁上等待线程想进入synchronized块但锁被其他线程持有进入阻塞队列WAITINGSleeping条件等待调用了wait/join/park无限期等待被唤醒TIMED_WAITINGSleeping限时等待sleep、带超时的wait/park时间到了自动醒来NEW / TERMINATED不存在对应OS线程或线程已销毁对象已创建但未start或run方法已返回这个表里最容易被误解的就是RUNNABLE。Java的RUNNABLE其实包含了两种情况一种是正在CPU上运行另一种是已经就绪、但正在等待调度器给它分配CPU时间片。也就是说jstack里看到大量RUNNABLE线程不代表它们都在干活也可能有一堆在排队等CPU。这一点在后面的排查章节我再展开。聊到调度就必须说yield()和sleep(0)。很多初学者把它们当成“让出CPU给别的线程”的救命稻草实际效果却常常令人失望。Thread.yield()的语义是当前线程愿意放弃CPU让自己回到就绪队列的尾部让同优先级或更高优先级的线程有机会运行。但这个语义在现在的JVM上非常弱。原因有三第一现代操作系统根本不怎么按“优先级队列”来调度CFS只认vruntime你让出来的CPU分给谁并不受你控制第二多核机器上你让出的CPU核可能空着其他线程在别的核上跑得好好的第三HotSpot在某些平台上把yield直接映射成一个空操作或者一个简单的hint根本没有实际让出的效果。sleep(0)其实是比yield更“实在”一点的做法。它可以触发一次线程重调度让当前线程至少从运行态让出来哪怕只有一瞬间。我在写高并发监控脚本时偶尔会用sleep(0)来平衡各个采集线程的CPU占用但它的精度和效果在不同系统上差异也很大不能当作精确调度工具来用。还有一个容易忽略的点每个Java线程的默认栈大小通常是1MB这1MB是虚拟内存不一定会都被物理内存占用但线程数量多了以后内存开销依然不容忽视。我曾经在生产环境见过一个应用创建了5000多个线程光是线程栈就占了几GB的虚拟内存再加上切换开销整个进程卡到连jstack都快敲不出命令了。后来把线程池压到几百性能反而恢复正常。这里延伸一个面试高频问题既然Java线程是1:1映射内核线程那创建线程为什么那么贵除了内核需要分配TCB线程控制块之外还要分配独立的内核栈和用户栈再加上创建过程中的系统调用开销比起对象创建来说确实重很多倍。这也是为什么实际生产代码里几乎不用new Thread而是用线程池来复用线程的原因。4. 线程优先级和优先级反转Java里最容易踩的隐性坑Java的Thread类提供了优先级设置范围是1到10默认是5看起来挺好用设置个MAX_PRIORITY就能让关键线程多分点CPU。但真实情况远没那么美好而且这里面有坑。先搞清楚映射关系。Java的10个优先级在操作系统层面并不会一一对应到调度优先级。Windows有自己的优先级类机制Linux是用nice值范围-20到19两者的映射都不可能做到精确的1:1。更重要的是在不少Linux版本上HotSpot JVM根本没有把Java线程的优先级映射成不同的nice值也就是说你设置的1到10在Linux上可能统统都变成了同一个nice值。线程优先级表现为“设置了但好像没用”。为什么会这样一方面是跨平台实现成本高另一方面是CFS调度器本身就不信任静态优先级它更愿意根据线程的实际运行行为做动态调整。一个线程就算你给它设置了MAX_PRIORITY如果它是个疯狂的计算任务运行时间长了vruntime照样会涨上去它在系统里的“实际地位”并不会比普通线程高多少。反过来一个IO密集的小线程虽然nice值是默认的但因为经常主动让出CPUCFS会把它排在更前面。那Java优先级这件事是不是完全没用也不是。在Windows平台上JVM确实会把Java优先级映射到不同线程优先级类效果会明显一些。但Linux服务器上我的建议是别把它当成功能来用当成“提示”就好更不要在业务逻辑里依赖它来保证执行顺序。比优先级更值得警惕的是优先级反转问题。这个问题在计算机系统里非常经典一个高优先级的线程T3需要访问一个锁锁被低优先级线程T1持有与此同时T1被一个中优先级线程T2抢占T2又不需要那个锁只知道埋头吃CPU。结果就是T3优先级最高但拿不到锁T1优先级低但持有锁却被T2抢占T2优先级居中但疯狂占用CPU。最终的运行顺序变成了T2 → T1 → T3高优先级线程反而被低优先级线程拖住了。优先级反转在操作系统内核里是可以通过“优先级继承”来解决的也就是当高优先级线程在等待低优先级线程持有的锁时临时把低优先级线程的优先级提上来让它可以尽快释放锁。但在Java应用层JVM并没有提供这种自动机制所以如果你在业务中混用不同优先级的线程去抢同一把锁就很可能遇到这种奇怪的卡顿明明关键线程优先级最高却感觉被什么东西拖住了等它跑起来黄花菜都凉了。给实际工作的建议第一条多线程协作完成任务时用CountDownLatch、Semaphore、CyclicBarrier这类显式同步工具来控制执行顺序不要用优先级来隐式表达“这个线程应该先跑”。第二条锁的持有时间越短越好尤其是不要让低优先级的线程持锁做耗时操作这会放大优先级反转的伤害。第三条如果真的需要让某些线程“更被照顾”与其调优先级不如从线程数量上做文章——给关键任务单独开一个专用线程池保证它无论如何都有独立的CPU机会这比在优先级上较劲靠谱得多。面试里问到线程优先级的时候我还比较推荐这套回答思路先说明Java优先级是一个提示性参数再说JVM和操作系统之间的映射是有损耗的最后补一句“依赖优先级来保证执行顺序是一种反模式应该用显式同步工具”。这样答深度和实用性都有了。5. 锁、阻塞与自旋调度器面前的三个暗礁如果说时间片和优先级是调度的底层规则那锁和阻塞就是Java线程在业务代码里真正让调度“翻车”的地方。我调过无数个并发性能问题发现90%的诡异卡顿根源都是线程在锁和阻塞上消耗了远超预期的调度资源。先看最简单的synchronized。当一个线程进入synchronized方法或代码块时如果锁已经被别的线程持有这个线程就会进入BLOCKED状态在操作系统的“锁等待队列”里挂起。这个挂起和唤醒过程不是Java层面能做到的它要借助操作系统的线程阻塞和唤醒原语也就是要从用户态切到内核态。一次也就罢了但如果锁竞争非常激烈大量线程频繁地在“运行—阻塞—唤醒—运行”之间切换上下文切换的次数会飙升CPU大量时间花在调度而不是业务逻辑上。Java对这个问题的应对是锁优化。JDK从早期开始就给synchronized设计了偏向锁、轻量级锁、重量级锁的升级路径。无竞争时用偏向锁只有一个线程反复进入时几乎零开销出现轻微竞争就CAS抢轻量级锁竞争激烈了才会膨胀成重量级锁让线程真的去阻塞。这套机制很有效但它有一个隐含的前提真正走到阻塞这一步时代价已经非常高了。所以你在写代码时如果发现某个锁竞争特别激烈第一步不是继续加锁而是想办法减少锁的粒度比如分段锁、读写锁、或者用ConcurrentHashMap兜底。除了锁自旋是另一个容易被误解的点。有些场景下线程拿不到锁并不会立刻阻塞而是选择“原地转圈再试几次”这就是自旋锁。自旋的优点是避免了上下文切换缺点是会白白占用CPU。到底该自旋还是该阻塞经验法则是看临界区有多长。如果临界区非常短比如就几个CAS操作那自旋哪怕浪费一点CPU也远比切换一次划算如果临界区较长比如要做复杂的计算或者IO那就该果断阻塞让出CPU。JVM里其实有自适应自旋它会根据上次自旋的结果动态调整自旋时间和次数这个机制平时不必手动干预但了解它有助于解释“为什么我的代码看起来在自旋CPU却烧得很高”。再看一个我在业务代码里经常见到的坏习惯用Thread.sleep()来等待异步结果。有个项目里一个线程调用了远程接口然后直接sleep(1)也就是睡1秒再查结果美其名曰“给接口一点时间”。看上去没什么但这个线程在sleep期间还持有锁其他线程全部卡在锁外面等白白浪费了过去。正确的做法是用线程间通信机制比如wait/notify、CountDownLatch、或者直接用CompletableFuture和Future的回调让线程真正“挂起等待被唤醒”而不是“假装睡觉但占着锁”。排除了sleep轮询之后锁的等待时间大幅下降调度器的切换压力也自然缓解了。再深挖一层无锁技术在调度友好度上比任何锁都强。CAS比较并交换是CPU指令级支持的原子操作多线程共享一个变量时用AtomicInteger或者LongAdder就能避免锁竞争和线程阻塞。ThreadLocal则是另一种思路——每个线程一份独立数据压根不需要共享。不可变对象更是彻底绕开了并发问题不需要加锁不会阻塞调度器自然乐得轻松。这些都是高并发场景下比锁更优雅的方案。线程池的线程数设置也和调度开销强相关。之前说过线程多了切换成本就上来了。生产上一个常用的经验公式是CPU密集型线程数 CPU核数 1IO密集型线程数 CPU核数 ×1 平均等待时间 / 平均计算时间。注意这只是起步参考值真实环境必须压测验证。我遇到过不少团队机器只有8核线程池配了200个以为能大幅提升吞吐结果响应时间反而变高了因为大部分时间都在切换线程。把线程池调小之后吞吐量不降反升CPU使用率反而更稳定。这个反直觉的结果本质上就是线程调度开销在起作用。6. 用工具看清调度真相CPU高但吞吐低的问题排查链路最后分享一套我在生产环境反复用过的排查思路。当你发现一个Java进程CPU使用率很高但业务吞吐量却很低先别急着改业务代码大概率问题出在调度和资源竞争上。按下面的链路查基本能定位。第一步用top命令看整体负载和上下文切换。top显示的是一整台机器的情况配合vmstat可以看cs这一列表示每秒上下文切换次数。如果cs值长期上万甚至十万以上说明系统里线程切换极其频繁这时候就算CPU使用率不高吞吐也会被调度拖垮。多核机器上尽量的上下文切换次数不应持续处于高位。第二步定位到具体的进程和线程。用 top -H -p 可以按线程维度查看CPU占用找到CPU占用最高的几个线程。记录下它们的线程ID然后用 printf %x\n 转成十六进制再到jstack导出的线程栈里去对以 nid0x... 的方式找到对应的线程。第三步重点看jstack输出里的线程状态分布。如果你的线程大量处于BLOCKED状态说明锁竞争激烈大量处于WAITING或TIMED_WAITING说明线程要么在等条件变量、要么在sleep基本没在干活但还占着线程资源大量处于RUNNABLE但并不在计算比如在自旋说明锁的临界区设计有问题线程在原地空转。我实际处理过的一个案例是服务有200个线程跑定时任务每到整点就出现CPU飙高、接口响应变慢。用上面这套方法查下来jstack里面几百行全都是同一个synchronized方法的等待栈vmstat的cs值直接翻了五倍。进一步定位发现所有线程都在抢同一个全局锁来刷新缓存40多个线程等于在一个锁门前排队。后来我把缓存刷新改成了单线程预热加无锁读取锁取消之后CPU占用立刻降了一半响应时间也稳定了。更多时候业务方跟我抱怨“CPU已经爆了”实际上是没有区分“CPU高”和“忙”。用perf top看一下CPU时间的分布如果大量时间花在锁相关的指令、或者spinning/spinlock上那就说明CPU不是被业务逻辑消耗的而是被并发控制消耗的。进阶的工具可以上async-profiler出火焰图之后能很直观地看到哪个栈占CPU最多是锁竞争、是GC、还是真在计算。第四步不要忽视JVM自带的工具。Arthas的thread -n 1可以直接列出CPU占用最高的线程省去了手动转换的步骤。Java Flight RecorderJFR则能记录线程阻塞、锁竞争、上下文切换等更细的分析事件适合做压测之后的离线分析。这些工具各有侧重但对于“Java线程调度与时间片”主题来说核心就是回答三个问题哪些线程在消耗CPU哪些线程在排队等待切换是否过于频繁排完这些问题之后你往往会在代码层面找到这样几个方向的优化空间要么锁竞争过于激烈要么线程数量超过了硬件承载能力要么是sleep轮询类代码把线程变成了“僵尸”却还占着资源。以我个人的经验多线程调优做得越多越会感慨真正让吞吐提升的往往不是把CPU利用率从80%干到99%而是把无谓的切换和等待从系统里清出去。调度器是朋友不是敌人它已经很努力地在每个时间片里做到公平了。我们要做的是别用糟糕的并发设计去考验它的耐心。如果你下次再遇到“线程开了很多但速度反而慢”的怪事先别骂Java拿vmstat看一眼cs值拿jstack看一眼线程状态答案往往就藏在这里。