面试被问到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_create(Linux)或CreateThread(Windows)真正创建出一个系统线程。这个一对一模型也叫原生线程模型,是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_WAITING | notify/notifyAll唤醒 |
| LockSupport.park | 不看锁 | WAITING | unpark唤醒 |
| 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 | 约260ms |
| 16 | 约390ms |
| 64 | 约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密集型,否则很容易把新特性用在不该用的地方。