并发面试的上篇围绕四条主线展开:线程与进程的本质区别、线程的创建与启动细节、六种状态的迁移规则,以及死锁从理论到排查的闭环。这组题决定了面试官对你并发功底的第一印象。

考察地图

  • 线程与进程:进程线程区别、Java 线程与操作系统线程的关系。考察对 JVM 内存区域划分与线程模型的理解。
  • 创建与启动:创建线程的方式辨析、start() 与 run() 的差别。考察「线程到底何时被创建」这种精确度。
  • 状态与协作:六种生命周期状态、上下文切换、sleep 与 wait。考察对 Thread.State 和 monitor 语义的掌握。
  • 死锁:定义、四个必要条件、检测工具、预防手段。考察从理论到线上定位的完整链路。
  • 建议自行查阅:并发与并行的概念区分(时间段 vs 时刻)、同步与异步、「程序计数器为什么私有」的推导、「为什么要用多线程」的宏观背诵题——这些多为铺垫性质,面试官很少单独深挖。

线程与进程

线程和进程有什么区别?

结论:进程是资源分配的最小单位,线程是 CPU 调度的最小单位;同一进程内的线程共享堆和方法区,但各自持有独立的程序计数器、虚拟机栈和本地方法栈。

原理:「共享与私有的分界线」是答题关键。程序计数器记录线程执行到哪条字节码,切换后靠它恢复现场,故必须私有;栈帧装着局部变量与操作数栈,一旦被共享,局部变量的线程封闭性就不存在了。堆放对象实例,方法区放类元数据与常量池,天然是公共资源。启动 main 本质就是启动一个 JVM 进程,main 线程只是其中之一。

延伸:共享进程资源让线程的创建与切换远轻于进程,故称「轻量级进程」;代价是线程间互相影响,一个线程把堆写崩(比如 OOM)整个进程遭殃。

追问:进程间通信与线程间通信各有哪些方式?提示:从「是否共享地址空间」推导——IPC 需管道、共享内存、socket,线程只需共享变量加同步。

Java 线程和操作系统线程是什么关系?

结论:JDK 21 之前,new Thread() 创建的平台线程与操作系统线程 1:1 映射,由 OS 全权调度;JDK 21 正式引入的虚拟线程由 JVM 在用户态调度,大量虚拟线程复用少量平台线程作为载体。

原理:HotSpot 平台线程走一对一模型,start() 最终经系统调用创建内核线程,时间片与优先级全交给操作系统。虚拟线程的栈帧放在堆上(Continuation),遇到阻塞 IO 时自动从载体线程卸载,单机可支撑百万级。早期 JDK 的「绿色线程」是多对一模型,无法利用多核,早已被抛弃。

延伸:虚拟线程不减少 CPU 计算时间,解决的是「线程数受 OS 限制」的吞吐瓶颈,适合 IO 密集场景;它与平台线程共用 Thread API,老代码迁移成本极低。

追问:虚拟线程在 synchronized 块中阻塞会怎样?提示:JDK 21 会钉住载体线程(pinning),JEP 491(JDK 24)已修复。

线程的创建与启动

创建线程有哪几种方式?

结论:严格意义上只有一种——new Thread().start();Runnable、Callable、线程池都只是任务包装层的方案,最终都落到 Thread 的创建上。

原理:继承 Thread 把「任务」和「执行机制」耦合,实现 Runnable 则二者解耦。Callable 能返回值并抛受检异常,但必须借助 FutureTask 适配成 Runnable 才能交给 Thread。线程池也不是新的创建方式,只是复用已创建的 Worker 线程跑任务:

// Callable 经由 FutureTask 适配成 Runnable
FutureTask<Integer> task = new FutureTask<>(() -> 42);
new Thread(task).start();
Integer result = task.get(); // 阻塞等待结果

延伸:点破「只有一种创建方式」能直接区分背题者与看过源码的人;工程上实现接口优先于继承,任务与线程解耦后才便于复用。

追问:CompletableFuture.supplyAsync 底层用什么执行?提示:默认走 ForkJoinPool.commonPool,可顺势追问工作窃取算法。

可以直接调用 Thread 的 run() 方法吗?

结论:可以调用,但不会开启新线程——run() 只是普通方法,在当前线程同步执行;必须走 start() 才会让 JVM 创建新线程去执行它。

原理:start() 内部调 native 的 start0(),由 JVM 向操作系统申请线程,新线程被调度执行后入口才是 run()。直接调 run() 的调用栈是 main → run(),与调普通方法毫无区别:

Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()));
t.run(); // 输出 main:当前线程内同步执行
t.start(); // 输出 Thread-0:新线程内执行

延伸:start() 只能调一次,重复调用抛 IllegalThreadStateException,因为 TERMINATED 不可回退。线程池的复用秘密正在于此:Worker 的 run() 里是 while 循环不断取任务执行,复用的是「执行体」而非反复 start。

追问:run() 执行完后,Java 线程对象与 OS 线程何时解绑?提示:解绑发生在 run() 返回后,Thread 对象本身只是个普通 Java 对象。

线程的状态与协作

说说线程的生命周期和状态?

结论:Thread.State 定义六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED,线程任意时刻只居其一,随代码执行在状态机中迁移。

原理:new 出来未 start 是 NEW;start 后进入 RUNNABLE——JVM 不区分「就绪」与「运行中」,能否分到 CPU 是 OS 调度器的事,JVM 看不到也无需暴露。抢 synchronized 锁失败进入 BLOCKED;调无参 wait()、join() 进入 WAITING,靠 notify 或中断回来;sleep(long)、wait(long) 进入 TIMED_WAITING,超时自动返回;run() 执行完进入 TERMINATED。BLOCKED 是「想进临界区进不去」的被动等待,WAITING 是「主动放弃锁等通知」,别混淆。

延伸:BLOCKED 状态的线程对 interrupt() 不抛异常,只记录中断标志位——这是 synchronized 与 lockInterruptibly() 的本质差别之一。jstack 输出里 BLOCKED (on object monitor) 与 waiting on condition 能直接区分两类等待。

追问:JVM 为什么不把 RUNNABLE 拆成 READY 和 RUNNING?提示:时间片调度对 JVM 透明,拆分只增加同步成本而无观测收益。

sleep() 和 wait() 有什么区别?为什么 wait() 定义在 Object 里?

结论:核心差异在锁——sleep() 抱着锁睡,wait() 释放 monitor 后进入等待队列;sleep 是 Thread 的静态方法,wait 是 Object 的实例方法,且必须在持有该对象 monitor 的同步块内调用。

原理:方法归属由「操作谁的资源」决定。sleep 只让当前线程让出 CPU、不碰任何锁,所以挂在 Thread 上且是静态的。wait 的语义是「释放当前对象的锁,把自己挂进该对象的等待集合,被通知后重新竞争锁」——锁是对象的属性,任何对象都可充当监视器,故 wait/notify 必须定义在 Object。这也解释了同步块外调 wait() 为何抛 IllegalMonitorStateException:没持有 monitor,谈何释放:

synchronized (lock) {
while (!condition) { // 必须用 while 防虚假唤醒
lock.wait(); // 释放 lock 的 monitor,进入 WAITING
}
}

延伸:等待侧用 while 而非 if 包住条件:被 notify 后只是重获竞争资格,抢到锁时条件可能已被改回去。sleep 是时间驱动,wait 是事件驱动。

追问:notify() 和 notifyAll() 怎么选?提示:等待条件单一时 notify 可用但有丢信号风险;不确定时一律 notifyAll 或改用 Condition 分组唤醒。

什么是线程上下文切换?

结论:线程让出 CPU 时,OS 保存其现场(程序计数器、寄存器、栈指针等),再加载下一个线程的现场,这个保存-恢复过程就是上下文切换,本身是纯开销。

原理:触发时机有四种:主动让出(sleep、wait)、时间片耗尽被抢占、阻塞型系统调用(如 IO)、线程终止。切换要经历用户态到内核态的转换,隐性成本还包括 CPU 缓存与 TLB 的局部性损失——切回来后缓存大概率已冷。线程数远超核数时,大量 CPU 时间被切换吃掉,这就是「线程不是越多越好」的直接原因。

延伸:减少切换的手段:线程池压到核数附近、CAS 无锁结构避免挂起、虚拟线程把切换搬到用户态(保存 Java 栈帧,比 OS 级便宜一个数量级)。

追问:自旋锁为什么能减少上下文切换,何时反而浪费 CPU?提示:省的是挂起加唤醒两次切换,代价是空转烧核;临界区长或竞争激烈时得不偿失。

单核 CPU 上多线程一定会更快吗?

结论:不一定。IO 密集任务在单核上开多线程仍有收益——线程阻塞等 IO 时 CPU 可让给别人;CPU 密集任务在单核上多线程只会因频繁切换而更慢。

原理:单核任何时刻只有一个线程执行,收益只来自「时间复用」。请求一半时间计算、一半等 IO 时,单线程 CPU 利用率约 50%;两线程交替可逼近 100%。纯计算没有等待空窗,多线程只剩切换开销。多核时代逻辑反转:CPU 密集开约等于核数个线程即可真并行,IO 密集的最优线程数可用 核数 × (1 + 等待时间 / 计算时间) 估算。

延伸:换成非阻塞 IO 后「等 IO」不再占用线程,这正是 Netty 用少量线程撑住海量连接的原因。先判断任务类型再定线程数,是这题最想听到的工程结论。

追问:线程池线程数如何定量估算?提示:经验公式只是起点,真实的计算/等待比例要靠压测拿到再反推。

死锁

什么是死锁?产生的四个必要条件是什么?

结论:死锁是多个线程互相持有对方需要的资源并无限期等待、谁都无法推进的状态。四个必要条件:互斥、请求与保持、不剥夺、循环等待。

原理:互斥——资源同一时刻只被一个线程持有,synchronized 天然互斥;请求与保持——拿着 A 锁不放同时申请 B 锁;不剥夺——已持有的锁不能被外力抢走,只能持有者主动释放;循环等待——等待链首尾相接成环。四者缺一死锁即不成立。经典复现是两线程以相反顺序嵌套申请两把锁,sleep 放大时间窗:

new Thread(() -> {
synchronized (lockA) {
try { Thread.sleep(200); } catch (InterruptedException ignored) {}
synchronized (lockB) { /* 业务逻辑 */ }
}
}).start();
// 另一个线程按 lockB -> lockA 的相反顺序申请

延伸:互斥与不剥夺是锁的固有属性,很难破坏;工程上通常从「请求与保持」(一次申请全部资源)和「循环等待」(固定加锁顺序)下手。

追问:活锁、饥饿与死锁有什么区别?提示:活锁的线程在动但互相谦让无进展,饥饿是低优先级长期抢不到;两者的等待链都不成环。

如何检测死锁?

结论:JDK 自带工具即可定位:jstack <pid> 或 jcmd <pid> Thread.print 会直接报告 Java 级死锁及涉及的锁;图形化可用 JConsole、VisualVM 的「检测死锁」。

原理:jstack 抓的是线程快照:每个线程的状态、调用栈、持有哪些 monitor、等待哪些 monitor。等待关系成环时,输出末尾打出 Found one Java-level deadlock 并列出环上线程与锁地址,顺栈信息可定位到代码行。AQS 锁形成的死锁表现为 parking to wait for ...,需 Thread.print -l 加锁信息才能看全。注意 jmap 是堆工具,与死锁排查无关——常见混淆点。

延伸:生产环境可程序化巡检:定期调 ThreadMXBean.findDeadlockedThreads(),非空即告警。抓证据建议连抓 3 次、间隔几秒——真死锁三次快照完全一致,短暂的锁竞争高峰则会变化。

追问:线程显示 BLOCKED 但没有 deadlock 字样说明什么?提示:等待链未成环,可能只是锁竞争激烈或持锁线程执行慢,结合多次快照判断。

如何预防和避免死锁?

结论:预防是破坏四个必要条件之一,最常用「固定加锁顺序」破坏循环等待;避免是分配前评估是否进入安全状态(银行家算法);工程实践中更常用超时机制兜底。

原理:三条预防路径对应三个可破坏的条件:一次申请所有资源破请求与保持;申请不到新资源时主动释放已持有的(tryLock 天然支持退让)破不剥夺;给锁编号、任何线程按升序加锁破循环等待——环无法形成。超时兜底用 tryLock(timeout):超时返回 false,线程可放弃、重试或走降级,把「无限等待」变「有限等待」。

延伸:落地习惯:加锁顺序全局约定并写进文档;能用一把锁就别嵌套两把;警惕「开放调用」——持锁期间调外部回调是隐蔽死锁的温床。

追问:锁对象运行时才确定时怎么保证全局顺序?提示:按 System.identityHashCode 排序,哈希冲突时再加一把「裁判锁」串行化。