☰
Java线程核心考点与底层原理全解析:从状态流转到线程池
2026/10/13 11:24:00 网站建设 项目流程

最近整理了一轮常见的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里大量基础工具都建立在AQS(AbstractQueuedSynchronizer)之上,比如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减1,release则state加1。它适合做流量控制,比如限制某个资源的并发访问数。一个容易混淆的追问是"Semaphore允许多个线程同时访问吗?"答案是肯定允许,它本身就是多许可资源计数器,信号量模型跟锁模型不一样,锁是互斥的,信号量可以是多个许可并发访问。

5. ThreadLocal重灾区:原理和内存泄漏一个都不能漏

5.1 ThreadLocalMap的弱键设计

ThreadLocal的核心机制是:每个线程内部维护一个ThreadLocalMap,key是ThreadLocal对象本身,value是你要保存的线程私有副本。访问逻辑是ThreadLocal.get()时,拿到当前线程的ThreadLocalMap,以自身作为key去查找。因为key"藏在"线程内部,天然实现了线程隔离。

问题来了:ThreadLocalMap的Entry继承自WeakReference<ThreadLocal>,key的引用是弱引用。为什么要做成弱引用?因为如果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 任务提交后的执行轨迹

提交任务的执行流程是八股里的送分题,但很多人只会背"先核心线程、再队列、再非核心线程"这个顺序,甚至记反了。正确的执行流程是:

  1. 如果当前工作线程数 < corePoolSize,新建核心线程执行任务。
  2. 否则尝试把任务放入工作队列。
  3. 如果队列已满且工作线程数 < maximumPoolSize,新建非核心线程执行。
  4. 如果队列已满且线程数已达最大值,执行拒绝策略。

这里有一个反直觉的地方:核心线程数没满的时候,新任务直接创建线程,而不是复用已有的空闲线程。原因是线程池认为"任务很多,创建线程成本低于等待",等到任务少了,空闲线程自然靠keepAliveTime回收。队列满后再创建非核心线程,而不是先创建线程再排队,说明设计上优先让核心线程满负荷运转,尽量用队列缓冲平滑请求峰值。

拒绝策略有四种:AbortPolicy直接抛RejectedExecutionException;CallerRunsPolicy让提交任务的线程自己执行,起到反向背压的作用;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..B,Thread-2持有0x..B等待0x..A。这个输出非常直白,几乎不需要额外分析。生产环境如果无法现场dump,可以先做jstat看线程状态分布,或者通过监控告警发现线程数异常增长。

还有一个小经验:线程dump里看到大量线程处于BLOCKED状态且都在等同一个monitor,不一定是死锁,可能是锁竞争太激烈,即“伪死锁”。这时候要区分是死锁还是性能瓶颈,看等锁的monitor地址是否成环。成环是死锁,单点聚集是热点竞争。

7.3 阻塞队列和三方工具也可以辅助排查

排查并发问题,除了jstack,JMC(Java 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正确清理了吗?这三问能拦截绝大多数并发隐患,比刷十道面试题都实在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询