
最近整理了一轮常见的Java线程八股问题发现这些题目翻来覆去也就那么几类线程状态、锁、内存模型、线程池、并发工具。但很多朋友背题背得滚瓜烂熟一被追问底层就露怯。这篇文章按面试追问的深度把线程相关的核心考点重新梳理一遍每个点尽量讲清“为什么”而不是只给结论适合准备面试的开发者对照查漏也适合平时写并发代码时回头看看有没有理解偏差。1. 线程基础从状态流转开始理解线程的一辈子1.1 六种线程状态别背错关键是流转条件线程状态是Java并发问题的地基也是面试里最常见的开场题。JavaThread.State枚举定义了六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。很多人会对着操作系统教材把RUNNABLE拆成“就绪”和“运行”两种但在Java里它们是合并的因为JVM不区分这两个状态线程只要能被调度器调度就处于RUNNABLE。真正面试爱问的是状态之间的流转路径。NEW表示线程对象创建了但还没调用start()调用start()后线程进入RUNNABLE这时候线程可能在跑也可能在等CPU时间片。从RUNNABLE到BLOCKED只有一种途径线程在synchronized同步块或同步方法上竞争锁失败被挂起进入阻塞队列。RUNNABLE - WAITING则有三种常见操作Object.wait()不带超时、Thread.join()不带超时、LockSupport.park()不带许可。RUNNABLE - TIMED_WAITING对应带超时的这几个方法的变体还有Thread.sleep()。有个容易混淆的点Thread.sleep()和Object.wait()最大的区别在于sleep不会释放任何锁wait会释放当前持有的monitor锁。我见过很多人在while循环里用sleep(100)模拟等待效果实际上这种轮询方式对CPU不友好正确的姿势是用wait/notify或LockSupport让出CPU由内核主动唤醒。这道题的价值在于考察你是否理解阻塞与让出CPU的区别以及锁和线程调度的关系。1.2 wait和notify的正确打开方式wait必须在synchronized块或方法里调用否则直接抛IllegalMonitorStateException。原因是wait的语义要求当前线程持有对象的monitor才能把线程加入该对象的等待集。同理notify/notifyAll也必须持有同一把锁。经典的wait使用范式是synchronized (lock) { while (!condition) { lock.wait(); } // 执行业务逻辑 }这里有个很关键的细节为什么判断条件要用while而不是if因为wait被唤醒后并不代表条件一定满足了。存在两种情况一是多个生产者/消费者线程被notifyAll同时唤醒其中某个线程已经把条件资源消费掉了二是线程在等待期间被中断异常恢复后条件可能仍然不满足。所以必须用while重新检查条件这叫做“循环等待”惯例。如果你用if被唤醒后直接往下走就会读到错误的状态数据甚至空指针。面试官问“wait为什么放在while里”就是在考察你有没有真正处理过生产者消费者模型。notify和notifyAll的选择也有讲究。notify只唤醒等待集中的任意一个线程如果被唤醒的那个线程不满足条件它还会继续wait可能导致“信号丢失”——剩下的线程永远不被唤醒而陷入饥饿。所以当多个线程等待不同条件时notifyAll更安全代价是唤醒所有线程造成一定的竞争开销。2. synchronized的底层原理这题基本必考要能讲透锁升级2.1 Monitor模型与对象头synchronized在JVM里的实现经历了多次演进现在讲到锁机制必须谈锁升级和Monitor。一个synchronized块经过编译会生成monitorenter和monitorexit两条字节码指令monitorexit可能有两条一条在正常路径结束一条在异常路径兜底保证锁一定能释放。这两条指令操作的对象是一个称为Monitor的管程结构在HotSpot里对应ObjectMonitor对象。面试中常问“对象头里存了什么”。普通Java对象在内存中分为对象头、实例数据、对齐填充对象头又包含Mark Word和Klass Pointer。Mark Word里存放hashCode、GC分代年龄、锁状态标记位和偏向线程ID等信息。锁状态不同Mark Word内部布局也不一样无锁时存hashCode和分代年龄偏向锁时存偏向线程ID和epoch轻量级锁时存指向栈中锁记录的指针重量级锁时存指向ObjectMonitor的指针。这就是为什么“锁住一个对象”本质上是修改对象头的Mark Word。2.2 从偏向锁到重量级锁的膨胀过程锁升级的完整路径是无锁 - 偏向锁 - 轻量级锁 - 重量级锁。为什么要搞这么复杂核心原因是避免无意义的同步开销大多数锁在现实中只被同一个线程重复获取或者只有很短暂的竞争直接上重量级锁涉及操作系统内核态的用户态切换成本太高。偏向锁的指导思想是如果一个锁被同一个线程反复获取那就不需要每次都做CAS同步直接在Mark Word里记录这个线程的ID。下一次这个线程再进入同步块时只需检查Mark Word里的偏向线程ID是不是自己是就直接执行免去任何原子操作。偏向锁只会在一个线程重复获取的场景下生效。一旦出现另一个线程尝试获取这把锁偏向模式撤销如果原持有者还活着暂停它并判断是否还持有锁如果没有则偏向锁撤销并升级为轻量级锁如果持有则膨胀为重量级锁。这里有个面试容易翻车的点偏向锁的撤销需要等待安全点JVM会暂停原持有线程这个代价不低所以高并发场景下可以显式关闭偏向锁。轻量级锁则适用于两个线程交替执行、几乎没有竞争的场合。线程进入同步块时在栈帧中分配一个锁记录空间用CAS尝试把Mark Word替换为指向锁记录的指针。CAS成功则获取轻量级锁失败说明有竞争锁会膨胀为重量级锁。重量级锁就是依赖对象Monitor实现的线程竞争失败会进入阻塞队列涉及操作系统级别的线程挂起和唤醒。2.3 为什么说轻量级锁是自旋优化的产物实际上轻量级锁的获取过程伴随自旋。线程CAS失败后不会立刻挂起而是空转几个CPU周期再尝试期望持有锁的线程很快释放这就是“自旋锁”的思想。JDK 1.6之后自旋锁默认开启且是自适应的JVM根据前一次同一锁的自旋时间动态调整。自旋的代价是消耗CPU因此如果锁持有时间长、竞争线程多自旋反而是浪费这时膨胀为重量级锁更合理。面试真题里有一个梗“为什么synchronized在低竞争场景下快速高竞争场景下也还行”答案就在于这种自适应自旋加上锁升级的阶梯设计。讲到这块时可以顺手提一下锁消除和锁粗化锁消除是JIT经过逃逸分析发现对象不会被其他线程访问所以把锁直接去掉锁粗化则是把相邻的多个同步块合并成一个大的同步块减少频繁加锁解锁的开销。这两个属于编译器优化层面的理解能补上会加分很多。3. volatile和JMM可见性与有序性背后的硬件真相3.1 JMM的主内存和工作内存模型Java内存模型规定每个线程有自己的工作内存里面保存了主存变量的副本线程对变量的所有操作都必须在工作内存中进行不能直接读写主存不同线程之间无法直接访问对方的工作内存只能通过主存完成传值。这句话翻译成人话就是线程A改了变量线程B不一定看得到因为B读到的可能是自己工作内存里的旧副本。这种模型对应到硬件上是CPU缓存和内存的多层架构每个CPU核心有L1/L2缓存跨核心之间通过缓存一致性协议如MESI保证共享数据的一致。但JMM比硬件模型多了一道屏障管理因为Java要运行在所有平台上不能假设每个CPU都有一致性协议能把写操作立刻同步给其他核心。线程八股里最常引用的原则就是happens-before规则程序顺序规则、监视器锁规则、volatile变量规则、线程启动规则、线程终止规则、传递性。其中volatile变量规则说的是对一个volatile变量的写操作happens-before后续对这个变量的读操作。这保证了写完后读线程能看到最新的值。3.2 volatile到底保证了什么volatile有两个保证和一个不保证。保证的是可见性和有序性不保证原子性。这段几乎必考。可见性靠的是插入内存屏障和缓存失效。当线程对volatile变量执行写操作时JVM会在写后插入StoreLoad屏障强制把当前线程工作内存中修改的值刷新到主存读操作前插入LoadLoad屏障强制从主存重新读取变量值到工作内存。这样任何线程读到的都是最新版本。有序性更好理解volatile写和读操作前后插入了屏障禁止CPU和编译器对这些操作进行指令重排序。经典的单例双重检查锁定需要考虑的就是这个instance字段不加volatile时对象引用赋值可能先于构造函数执行导致另一个线程拿到一个构造了一半的对象。加了volatile就能保证new、init、store三步骤的重排序被禁止。不保证原子性是什么意思count这类复合操作即使count声明为volatile两个线程同时最终结果可能少1。因为本质上是读-加-写三步volatile只保证每次读都读到最新值、每次写都立刻刷新但读和写之间的窗口期另一个线程也可能读到了同一个旧值然后都写回相同的新值就丢了一次累加。解决方式是用AtomicInteger或synchronized保护这个操作。3.3 内存屏障的四类操作JMM定义了四类内存屏障LoadLoad、LoadStore、StoreStore、StoreLoad。volatile读前插入LoadLoad和LoadStore写后插入StoreStore和StoreLoad。StoreLoad是最贵的屏障兼具其他三种屏障的效果也因此成为volatile写后必须的一个全栅栏。日常编程不需要手写这些屏障那是JVM和JDK并发工具内部的活但理解屏障的作用能帮你分析Unsafe、VarHandle、AtomicReference这些底层类的实现思路。面试追问到这里可以举例说明指令重排序的危害。一个被问烂的范例是int x 0, y 0; int a 0, b 0; // 线程1 a 1; x b; // 线程2 b 1; y a;如果没有正确同步两个线程最终可能得到x 0 y 0的结果原因就是两个线程各自的语句被重排成了先读后写。volatile或锁能消除这种重排序风险。这个例子能够非常直观地解释为什么顺序写代码不等于CPU按照代码顺序执行。4. AQS看懂并发工具类的一把钥匙4.1 AQS的核心三件套java.util.concurrent里大量基础工具都建立在AQSAbstractQueuedSynchronizer之上比如ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock、ThreadPoolExecutor里的Worker也用到了AQS。面试题一旦深入并发包AQS基本绕不开。AQS的核心结构是一个volatile int state和一个CLH变体队列。state表示同步状态具体语义由子类定义ReentrantLock用它记录重入次数Semaphore用它表示剩余许可数CountDownLatch用它表示未完成的count值。CLH队列保存等待获取锁的线程节点每个节点封装线程及其等待状态。AQS的框架方法是acquire和release它们采用了模板方法模式tryAcquire(arg)子类实现尝试获取同步状态返回boolean。tryRelease(arg)子类实现尝试释放同步状态。acquire()内部先调tryAcquire失败后把当前线程包装成Node加入等待队列然后通过LockSupport.park挂起自己前驱节点释放锁后会unpark唤醒后继节点。这个编程模型把哪些线程能拿到锁交给子类决定把线程排队、挂起、唤醒这套通用机制收敛在AQS里。所以面试官问AQS提供了什么时不要只说CLH队列要说它是并发同步器设计的模板骨架将资源管理和线程调度解耦。4.2 ReentrantLock的公平与非公平ReentrantLock基于AQS实现了可重入的互斥锁内部有FairSync和NonfairSync两个子类。非公平锁的tryAcquire实现是先直接CAS抢一次state抢不到再进入排队逻辑公平锁则先检查等待队列里有没有前驱节点有就直接返回false排队去。为什么要分成公平和非公平非公平锁的性能通常更好因为刚释放锁的线程再次获取锁的缓存热点还在减少了一次线程上下文切换但可能出现线程饥饿。公平锁能保证先到先得代价是吞吐量下降因为每一次获取都要检查队列状态。实际开发中大多数场景用非公平锁即可需要避免线程饿死的场景才选择公平锁。面试还爱问synchronized和ReentrantLock的区别。归纳一下有这几点synchronized是JVM层面实现ReentrantLock是JDK层面实现ReentrantLock可以尝试非阻塞获取锁tryLock、支持可中断获取lockInterruptibly、支持公平锁、支持多个条件变量newCondition。但在Java 6之后synchronized经过锁升级优化在很多场景下性能已经不输ReentrantLock所以选谁更多是功能取舍不是性能差异。4.3 CountDownLatch、Semaphore和CyclicBarrier的适用场景CountDownLatch用共享模式实现初始化时state N每次countDown()把state减1减到0时唤醒所有等待的线程。常用于主线程等待多个worker都完成后再统一处理结果。CyclicBarrier则相反线程之间互相等待到达屏障点后一起释放且可以重置循环使用。区别很直观CountDownLatch是一次性的CyclicBarrier可以复用CountDownLatch是一个线程等N个线程CyclicBarrier是N个线程互相等。Semaphore也是AQS共享模式初始化state 许可证数acquire成功则state减1release则state加1。它适合做流量控制比如限制某个资源的并发访问数。一个容易混淆的追问是Semaphore允许多个线程同时访问吗答案是肯定允许它本身就是多许可资源计数器信号量模型跟锁模型不一样锁是互斥的信号量可以是多个许可并发访问。5. ThreadLocal重灾区原理和内存泄漏一个都不能漏5.1 ThreadLocalMap的弱键设计ThreadLocal的核心机制是每个线程内部维护一个ThreadLocalMapkey是ThreadLocal对象本身value是你要保存的线程私有副本。访问逻辑是ThreadLocal.get()时拿到当前线程的ThreadLocalMap以自身作为key去查找。因为key藏在线程内部天然实现了线程隔离。问题来了ThreadLocalMap的Entry继承自WeakReferenceThreadLocalkey的引用是弱引用。为什么要做成弱引用因为如果key是强引用那ThreadLocal对象即使被业务代码设置为null只要线程还活着ThreadLocal实例就永远无法被GC回收造成类加载器级别的泄漏比如在Web容器里ThreadLocal持有类加载器最终可能导致整个应用无法热部署。弱引用解决了key侧的泄漏风险ThreadLocal对象没有强引用后GC时key会被回收Entry变成null key。5.2 value的内存泄漏和remove的必要性坑在value这一侧Entry里的value是强引用。key被回收了value却还留在ThreadLocalMap里如果线程一直存活典型就是线程池里的线程这块value就永远无法被访问和回收只能靠get()或set()时顺带探测清除null key的Entry来擦屁股。这个清理不是实时的如果业务代码没有后续get/set内存泄漏就会累积。所以ThreadLocal使用有一个铁律每次使用完调用remove()尤其是在线程池场景。这个问题面试官几乎必追问你知道ThreadLocal内存泄漏吗为什么要remove如果只答弱引用不到一半分数要答出value是强引用、线程复用导致永久残留这两层才算完整。还有一种更隐蔽的场景使用ThreadLocal做上下文传递比如链路追踪ID、用户身份信息。如果忘记remove下一个小任务复用同一个线程时会读到上一次遗留的数据这就是串号。我在实际项目里见过由于线程池复用ThreadLocal导致A用户看到B用户的身份信息这是事故级的bug。所以不光要remove还要在finally块里remove。5.3 InheritableThreadLocal与可传递性的边界InheritableThreadLocal允许创建子线程时继承父线程的ThreadLocal值原理是创建Thread时在构造函数里把父线程的inheritableThreadLocals复制一份给子线程。但它只对父线程创建子线程那一刻的快照值生效之后父线程再set的值子线程不会看到子线程自己set的值也不影响父线程。更关键的是线程池场景下InheritableThreadLocal基本没用提交任务给线程池池里线程是提前创建好的它们不会继承提交任务线程的值。要做跨线程传递业界常见方案是使用阿里开源的TransmittableThreadLocal它在提交任务时捕获当前上下文并快照保存执行任务时再做回放。面试时能把这一层讲清楚说明你是真的踩过跨线程传递上下文的坑。6. 线程池核心参数、执行流程和拒绝策略的送分与送命6.1 七个参数缺一不可ThreadPoolExecutor构造函数的七个参数corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。面试里常问corePoolSize和maximumPoolSize的区别是什么。简单回答核心线程数以内的线程常驻即使空闲也不会被回收超过核心数的线程是临时线程空闲时间超过keepAliveTime后会被回收线程总数不能超过maximumPoolSize。问到你项目里线程池怎么配置的如果回答一个固定的数字面试官基本都会追问计算依据。常见的估算公式是线程数 CPU核数 * (1 平均等待时间 / 平均计算时间)这个公式来自布赖恩·戈茨的书《Java并发编程实战》。公式背后的逻辑IO密集型任务的等待时间占比高CPU在等IO闲着就可以多开线程提高CPU利用率CPU密集型任务计算时间占比高线程数逼近CPU核数即可多了只会增加上下文切换成本。实际调优还得压测先根据这个公式估算初值用压力测试和监控指标线程池活跃度、队列积压量动态调整。6.2 任务提交后的执行轨迹提交任务的执行流程是八股里的送分题但很多人只会背先核心线程、再队列、再非核心线程这个顺序甚至记反了。正确的执行流程是如果当前工作线程数 corePoolSize新建核心线程执行任务。否则尝试把任务放入工作队列。如果队列已满且工作线程数 maximumPoolSize新建非核心线程执行。如果队列已满且线程数已达最大值执行拒绝策略。这里有一个反直觉的地方核心线程数没满的时候新任务直接创建线程而不是复用已有的空闲线程。原因是线程池认为任务很多创建线程成本低于等待等到任务少了空闲线程自然靠keepAliveTime回收。队列满后再创建非核心线程而不是先创建线程再排队说明设计上优先让核心线程满负荷运转尽量用队列缓冲平滑请求峰值。拒绝策略有四种AbortPolicy直接抛RejectedExecutionExceptionCallerRunsPolicy让提交任务的线程自己执行起到反向背压的作用DiscardPolicy静默丢弃DiscardOldestPolicy丢弃队列里最老的未处理任务。实际业务里CallerRunsPolicy比较常用因为它至少让任务不会丢而且提交线程亲自执行会变慢间接让上游降低提交速度。6.3 Executors的坑和Queue的选择Executors.newFixedThreadPool、newSingleThreadExecutor底层用的是无界LinkedBlockingQueue队列容量默认是Integer.MAX_VALUE。这意味着maximumPoolSize参数永远没有触发时机线程最多只会创建corePoolSize个如果任务堆积速度超过处理速度队列就会无限膨胀最终撑爆内存造成OOM。Executors.newCachedThreadPool用的是SynchronousQueue这个队列不存储元素提交的任务必须立刻被一个线程消费线程池会不断创建新线程如果任务阻塞时间过长线程数会无限制膨胀同样危险。所以企业规范里都会建议开发者不要用Executors的快捷方法而是直接new ThreadPoolExecutor显式指定有界队列和拒绝策略。我个人的习惯是new ThreadPoolExecutor(8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy());队列大小怎么定要看业务容忍的积压量。队列上限决定了背压的滞后性队列太小高峰流量直接触发拒绝策略队列太大请求积压过多导致下游处理完时数据已经过时。对于实时性要求高的服务宁可拒绝也不要堆积对于异步批处理任务可以给一个比较大的队列做削峰填谷。7. 死锁、活锁和并发排查的实战手记7.1 死锁的四个必要条件死锁需要同时满足互斥、持有并等待、不可剥夺、循环等待四个条件。互斥在Java锁场景里天然满足持有并等待是一个线程拿着锁A又去要锁B不可剥夺是synchronized和ReentrantLock默认都不允许抢占循环等待就是多个线程互相持有对方想要的锁形成闭环。解决思路也一样破坏其中任意一个条件即可。最常见的手段是破坏循环等待——用全局顺序加锁保证每个线程都按同一个顺序获取锁。举个例子账户转账场景里两个账户互相向对方转账就可能死锁。解决方式是对两个账户的id做排序先锁id小的再锁id大的这样无论转哪边获取锁的顺序都一致环路就断了。活锁比死锁更隐蔽线程并没有阻塞而是不断互相“谦让”以及重试导致任务一直无法推进。典型是多个线程检测到冲突时都主动释放资源然后重试结果每次都在同一个点再次冲突。解决思路是引入随机退避或优先级。7.2 jstack排查死锁的完整步骤排查死锁先用jps找到进程号再jstack pid导出线程快照。jstack会在死锁检测处打印Found one Java-level deadlock:下面列出循环等待链Thread-1持有0x..A等待0x..BThread-2持有0x..B等待0x..A。这个输出非常直白几乎不需要额外分析。生产环境如果无法现场dump可以先做jstat看线程状态分布或者通过监控告警发现线程数异常增长。还有一个小经验线程dump里看到大量线程处于BLOCKED状态且都在等同一个monitor不一定是死锁可能是锁竞争太激烈即“伪死锁”。这时候要区分是死锁还是性能瓶颈看等锁的monitor地址是否成环。成环是死锁单点聚集是热点竞争。7.3 阻塞队列和三方工具也可以辅助排查排查并发问题除了jstackJMCJava Mission Control、Arthas也值得掌握。Arthas的thread命令可以直接查看线程状态、CPU占用、堆栈还能thread --state BLOCKED筛选阻塞线程非常方便。生产环境不能随便重启时Arthas动态诊断往往比jstack更快速。另外日常写代码时多用有界队列、让线程池任务打印开始结束日志也可以让生产问题的复现路径快速浮出水面。8. 把八股知识沉淀成项目经验线程八股看起来背起来无穷无尽但它们的底层逻辑是相通的多线程编程的三大问题是原子性、可见性、有序性锁解决原子性volatile解决可见性和有序性AQS提供统一的同步框架。把这套逻辑抽出来你已经不是在背题而是建立了一张关于Java并发的地图。项目侧可以这样升级写一个小的高并发接口用压测工具分别观察单线程、synchronized、ReentrantLock、AtomicLong在不同并发数下的吞吐和RT再写一个线程池提交任务并配置不同队列大小、拒绝策略观察任务成功率。这些实验会让你对八股结论有更体感的认知。比如你亲手测得AtomicLong在高竞争下性能下降、LongAdder在线程数增加时延迟更稳定记忆会远比背考点深刻得多。面试中如果被问到一个模糊的并发题先把三个问题摆出来“这里存在原子性问题还是可见性问题”再把对应的机制说出来基本能覆盖八成考点。线程状态流转、锁升级、AQS、ThreadLocal、线程池这五类题目即使场景千变万化核心考察点永远是这套底层逻辑。最后分享一个我自己的检查习惯每写一段涉及共享变量的代码先问自己三个问题——共享变量用volatile了吗复合操作加锁了吗线程生命周期内ThreadLocal正确清理了吗这三问能拦截绝大多数并发隐患比刷十道面试题都实在。