干Java这行,多多少少都被多线程折磨过几回。面试八股文里最绕不开的一块就是多线程与高并发,我这些年看过不少背题的候选者,synchronized和volatile能背得滚瓜烂熟,但问到“为什么加锁能保证可见性”“偏向锁在什么场景下才会退化”“AQS队列到底是怎么阻塞线程的”这类底层问题,很多人就卡住了。这篇博客我想用自己排查线上问题和读源码的实战视角,把 Java 多线程高并发这套底层原理彻底梳理一遍。目标读者是有一定 Java 基础、准备深入底层或正在备战面试的工程师,也适合遇到并发问题不知道怎么下手的同学。读完你至少能回答清楚这几个问题:线程的本质是什么、JMM在硬件层面到底约束了什么、锁升级的完整链路、AQS为什么能支撑那么多并发组件、以及线上并发故障怎么排查。
1. 线程模型先想清楚:Java线程到底是谁在执行
很多初学者以为 new Thread 就是创建了一个线程,这个说法其实只说对了一半。Java 里头的线程对象只是 JVM 层面的一个抽象壳子,真正干活的是操作系统内核线程。这里牵扯到线程模型的核心分类。
1.1 线程模型的前世今生:从用户线程到1:1映射
线程模型在操作系统教材里通常分三类:多对一、一对一、多对多。多对一模型意思是多个用户线程映射到同一个内核线程,好处是切换快、不依赖内核,但某个线程发起阻塞系统调用时整个进程都会卡住,所以这模型在现代系统中基本被抛弃。多对多模型是折中方案,用户线程与内核线程数量任意映射,灵活性高但实现极度复杂。Java 在早期(比如 Green Threads 时代)也试过用户态线程,后来主流 JVM 比如 HotSpot 全部改为一对一模型,也就是每一个 Java 线程都会对应一个操作系统原生线程,线程的创建、调度、销毁全部交给内核去管。
这个选择是“好用优先”的结果。虽然线程切换要陷入内核态,代价比用户态切换高不少,但胜在实现简单、能与操作系统的调度器无缝协作,并且能利用多核 CPU 真正并行。理解这一点非常重要,因为后续所有并发缺陷,比如上下文切换开销、线程数量与 CPU 核数的关系、为什么 IO 密集型的线程数配置和 CPU 密集型不一样,根源都在这里。
1.2 线程调度与上下文切换的代价
既然线程最终跑的载体是操作系统线程,那么调度权也在操作系统手里。操作系统用的是时间片轮转加优先级抢占,线程运行一段时间后会被强制挂起把 CPU 让给其他线程,这个过程叫上下文切换。切换要保存当前线程的寄存器、程序计数器、栈指针等一大堆状态,还要把新线程的状态恢复回去。
我实测过一次纯 CPU 密集的计算任务,开 200 个线程去跑 8 核机器上的计算,结果比开 8 个线程跑还要慢,原因就是上下文切换开销太大,CPU 都在忙着切来切去而不是真正算数。高并发场景中线程池之所以重要,就是因为它能复用线程、减少反复创建销毁的损耗,同时通过限制线程数量来控制上下文切换的频率。这里有个经验公式可以借鉴:CPU 密集型任务线程数设为 N+1,IO 密集型任务设为 2N,但现代机器上 IO 等待占比并不固定,所以更好的做法是动态调整,后面线程池部分再细说。
2. JMM与CPU缓存:并发问题的根源在硬件层
多线程编程出现各种诡异问题的本质,是多个线程共享变量时产生了数据竞争。要理解数据竞争,得先看看现代计算机的存储体系,Java 内存模型(JMM)其实是把底层硬件存储模型抽象成了规范。
2.1 从CPU三级缓存到主内存的写入过程
CPU 的运算速度远快于内存的读写速度,为了填平速度差,硬件在 CPU 和主内存之间加了好几层高速缓存,一般叫 L1、L2、L3。L1 又分成指令缓存和数据缓存,读写速度最快但容量极小,通常几十 KB 到几百 KB,L3 是所有核共享的,容量有几十 MB。
线程读取一个变量的真实过程是这样:先查 L1,没有就查 L2,再没有查 L3,最后才去主内存拉数据。写入的时候也不是直接写主内存,而是先写进缓存行,稍后再同步到主内存。这套机制在多线程场景下就引出问题了——一个线程改了缓存里的值,另一个线程可能读的还是旧值。这就是可见性问题的硬件根源。
为了不让缓存里的数据永远和其他核脱节,硬件层引入了缓存一致性协议,比较经典的是 MESI 协议,把缓存行标记成 Modified、Exclusive、Shared、Invalid 四种状态。当一个核写自己的缓存行时,会通过总线通知其他核把对应的缓存行置为 Invalid,其他核下次读取发现失效就会重新从主内存拉最新值。但这个协议跑起来也不是零成本,缓存同步会拖慢速度,而且编译器、CPU 还可能为了优化重排指令,这就是指令重排序。
2.2 JMM的抽象规则与happens-before
Java 内存模型做了一件事:把硬件上的缓存一致性、指令重排约束抽象成一套逻辑规则,让开发者不直接面对 CPU 原语也能写正确的并发代码。JMM 规定所有变量存储在主内存,每个线程有自己的工作内存,工作内存里保留变量的副本拷贝。线程对变量的操作必须先在工作内存中完成,然后写回主内存,这和 CPU 缓存模型对应的关系其实非常明显。
JMM 最核心的成果是 happens-before 规则。它的含义不是时间上谁先发生,而是“前一个操作的结果对后续操作可见”。程序顺序规则、监视器锁规则(解锁 happens-before 后一个加锁)、volatile 变量规则(写 happens-before 读)、传递性规则是高考八股里的常客,但真正理解后你会发现,排查并发问题本质就是在找哪两条操作之间缺失了 happens-before 关系。
我之前排查过一个线上库存超卖的问题,代码里用了 ConcurrentHashMap 存库存,读出来判断大于0再减一,看似线程安全实则整个“读-判断-写”没有原子性保护,而且 HashMap 内部的 val 数组直接暴露出来被多个线程改,可见性完全没保障。这类问题不能靠猜,得先在脑子里用 JMM 规则过一遍,再决定是加锁还是用 CAS。
3. synchronized底层到底做了什么
关键字synchronized是 Java 并发里最常用的老朋友。很多人从入门就在用它,但它的实现细节和优化过程值得仔细看一遍,因为这块承载了锁演化的完整历史。
3.1 对象头与Mark Word:锁信息藏在哪里
任何一个 Java 对象在内存里都有一个对象头,HotSpot 虚拟机里对象头包含两部分:Mark Word 和 Klass Pointer。Mark Word 里存储的是哈希码、GC 分代年龄、锁状态位、偏向线程 ID、偏向时间戳等信息,且这些信息会跟着锁状态变化而改变。
Mark Word 在不同锁状态下布局差异非常大。无锁状态下记录 hashcode 和分代年龄;偏向锁状态下记录偏向线程 ID 和 epoch;轻量级锁状态下记录指向栈中锁记录的指针;重量级锁状态下记录指向 monitor 的指针。这个设计非常精妙,用最小的内存代价存储了不同锁状态的核心数据,也是为什么对象头 8 个字节左右就能支撑整套锁机制的原因。
了解 Mark Word 后你会明白,为什么调用System.identityHashCode()之后再进入 synchronized 会导致偏向锁失效——因为偏向锁在 Mark Word 里存了线程 ID,没法再存 hashcode 了,两者在内存布局上是冲突的。
3.2 锁升级的完整链路:偏向锁到轻量级锁再到重量级锁
Java 5 之前 synchronized 是纯重量级锁,每次加锁都要向操作系统申请 mutex,性能很差。Java 6 做了大优化,引入了偏向锁、轻量级锁,以及锁升级机制。
偏向锁是什么意思?如果一个锁一直被同一个线程获取,那么它没必要反复加锁,直接在 Mark Word 里记下这个线程 ID 就行,下次该线程再来就直接认定锁是自己的。EPOCH 字段用来解决锁批量撤销的问题,这里不展开过多。偏向锁被其他线程竞争时,会先尝试偏向撤销,然后升级为轻量级锁。
轻量级锁的加锁逻辑是:线程在自己的栈帧里创建锁记录空间,复制 Mark Word,再通过 CAS 尝试把 Mark Word 替换成指向锁记录的指针。CAS 成功就获取锁成功;失败说明有其他线程在竞争,这时锁会膨胀为重量级锁,向操作系统申请 monitor。
整个过程从性能上说:偏向锁几乎零开销;轻量级锁只需要一次 CAS;重量级锁才需要陷入内核。这个优化最大的意义是让锁在竞争不激烈的时候非常轻量,符合现实中大多数场景只有极少数线程竞争同一个锁的情况。
3.3 monitor机制与锁的获取释放本质
重量级锁的实现依赖 monitor 对象,它内部关键字段是_owner、_EntryList和_WaitSet。_owner记录当前持有锁的线程;_EntryList存放处于 Blocked 状态的线程;_WaitSet存放调用 wait() 后进入等待状态的线程。
锁的获取和释放过程,本质就是改写_owner字段配合一个计数器。synchronized 的 wait/notify 机制正是依赖 monitor 的 WaitSet,这也是为什么 wait 和 notify 必须在 synchronized 块内调用。从底层来看,synchronized 的字节码层面是 monitorenter 和 monitorexit,JVM 根据锁状态走向不同实现路径。
一个值得注意的细节是锁可以重入。重入的实现是每个 monitor 维护一个递归计数_recursions,同一线程再次进入同一个锁会让计数加一,退出时减一,减到零才真正释放。这种设计避免了自己嵌套自己时发生死锁。
4. volatile、CAS与AQS:无锁并发的地基
如果说 synchronized 是有锁并发的代表,那 volatile 和无锁 CAS 就是另外一条并发路线。现代 Java 并发包基于这套无锁方案构建了大量高性能组件。
4.1 volatile的可见性和禁止重排序底层机制
volatile 修饰的变量有两个语义:保证可见性和禁止指令重排序。可见性的实现是直接让变量读写绕过工作内存的概念,在硬件层面变成对主内存的读写,并且会通过缓存一致性协议让其他核感知到变化。但要深度一点看,单靠 MESI 还不够,还需要内存屏障去约束编译器重排。
CPU 和编译器都可能为了流水线效率调整指令顺序,volatile 通过插入内存屏障来限制这种重排。JMM 定义了四种屏障组合:StoreStore、StoreLoad、LoadLoad、LoadStore。一个 volatile 读后面会加 LoadLoad 和 LoadStore 屏障,一个 volatile 写前面会加 StoreStore 前面、StoreLoad 后面。具体到 x86 平台上,StoreLoad 屏障就是lock前缀指令或者mfence,成本非常高。这就是为什么 volatile 能保证读永远看到最新写入的值,但依然不保证复合操作的原子性。
我经常举一个例子:用 volatile 修饰的计数器做count++,在多线程下依然会丢更新,因为“读-加一-写回”在底层是三步操作,期间变量完全可能被其他线程改掉。所以 volatile 适合状态标志位,不适合做累积统计。
4.2 CAS的底层指令与ABA问题
CAS(Compare And Swap)是并发包最底层的操作。Java 在Unsafe类里提供了compareAndSwapInt系列方法,这些方法在 JDK 内部通过cmpxchg指令完成,处理器会保证这个指令的原子性,在 x86 体系里通过锁总线或锁缓存实现。
CAS 的优势是无阻塞,线程失败时可以立即重试,不会像锁那样让线程挂起。AtomicInteger、AtomicLong、ConcurrentHashMap的很多操作都依赖 CAS。但 CAS 有个知名坑——ABA 问题:线程读到变量值是 A,准备改之前别的线程把它改成 B 又改回 A,那当前线程就以为没人动过,直接替换成功。为了解决这个问题,AtomicStampedReference 加入了版本号比对。
另外要注意 CAS 在竞争激烈时可能出现活锁和 CPU 空转,因为线程一直失败一直重试。解决方向有两个:一是使用 LongAdder,它把热点分散到一个 Cell 数组里,用分段累计再求和的方式降低单点竞争;二是把 CAS 和锁结合,比如 AQS 的做法。
4.3 AQS:一锁多用并发框架设计
AbstractQueuedSynchronizer,简称 AQS,是 java.util.concurrent 里几乎所有锁和同步器的基座。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 全部建立在一个共享的 state 变量和 CLH 变体队列上。
AQS 的 state 字段是 volatile int,表示当前同步状态。对于 ReentrantLock,state 为 0 表示没有线程持有锁,大于 0 表示持有且可能重入了几次;对于 Semaphore,state 直接代表剩余许可数量;对于 CountDownLatch,state 就是要等待的事件数。不管哪种同步器,最终都是通过对 state 做 CAS 或修改来体现业务语义。
排队机制是一种双向链表的变体队列。线程获取锁失败时包装成 Node 加入队列尾部,然后在循环里尝试获取锁、检查前驱节点状态,不满足条件就通过 LockSupport.park 把自己挂起。释放锁时会把下一个等待线程 unpark 唤醒。这一套设计非常优雅,把“锁”的语义抽象成状态 + 一个等待队列,避免了每个锁单独实现一套等待机制。
看源码你会发现一个细节:ReentrantLock 的公平锁与不公平锁差异只在入队前要不要做一次hasQueuedPredecessors()检查。公平锁看到队列有排队就乖乖去排队,非公平锁会先抢一次。非公平锁在有线程持有锁时更多是性能考量,减少一次线程挂起唤醒的损耗,但存在插队问题。
5. 高并发落地:线程池、并发工具与资源管控
底层原理最终还是要落到工程实践。高并发系统常见的资源管控手段无非是线程池、限流、隔离,这三个都是建立在前面那些底层机制之上的。
5.1 线程池参数选择与动态调整
ThreadPoolExecutor 是生产环境必须牢牢掌握的类。它的七个核心参数里,核心线程数、最大线程数、阻塞队列容量对并发吞吐影响最大。线程池的拒绝逻辑是:当前线程数小于核心线程数时直接新建线程;大于核心线程数且队列没满时塞进队列;队列满了且还没到最大线程数时继续开新线程;全满了才触发拒绝策略。
这里有一个大众误区:很多人以为核心线程数满了会先把队列塞满再扩容,用代码确认后你会发现并不是,任务超过核心线程数会先尝试入队,队列容量足够时线程数永远不涨,性能不佳的场景往往就是队列设置太大导致线程数被锁死在核心值。Executors.newFixedThreadPool 用无界队列,高并发请求下任务全部堆内存里,最终把内存打爆,所以《阿里巴巴Java开发手册》里明确禁止用 Executors 创建线程池,这是有真实血泪教训的。
线程数公式我已经提过,N+1 和 2N 只是粗糙估计。现代生产系统更好的方案是用动态线程池,根据任务积压情况实时调整核心线程数和队列容量。我在实践中会通过线程池暴露的getActiveCount()、getQueue().size()、任务耗时分位线等指标联动调整,标准的做法是引入配置中心,线程数改成可动态刷新的参数。
5.2 CompletableFuture与异步编排
高并发场景经常要求把多个 IO 请求并行发出,再汇总结果。CompletableFuture 提供了强大异步编排能力,thenApply、thenCompose、allOf、anyOf等方法让回调编排变得非常轻松。它底层是基于 fork/join 框架的线程池,也可以传入自定义 Executor 避免和业务线程池互相干扰。
我在一个对外接口的优化案例里,串行调用三个下游服务耗时 300ms,改成 CompletableFuture 并行后降到 120ms,效果立竿见影。但这个过程中要特别注意线程池隔离:如果所有接口共用同一个默认线程池,任何一个下游慢服务把线程池占满,整个应用都跟着瘫痪。正确做法是为不同调用方、不同优先级任务分配独立线程池。
5.3 ThreadLocal的内存泄漏与场景陷阱
ThreadLocal 是高并发场景里承载“线程上下文”的利器,但它有个隐蔽的坑。每个线程内部维护一个 ThreadLocalMap,key 是弱引用,value 是强引用。线程存活时间很长时,比如在线程池中的工作线程,如果 ThreadLocal 的 key 被 GC 回收但 value 还挂在 map 上,就造成了内存泄漏。
所以规范要求使用 ThreadLocal 必须手动 remove,尤其是线程池场景。我排查过一个缓慢内存增长的线上问题,最后定位就是业务代码在一个静态 ThreadLocal 里存了超大对象,线程池线程反复复用,value 一直释放不掉,堆内存持续上涨。解决后我看到好多人把 ThreadLocal 当参数传递工具用,其实这也行,但一定要想清楚生命周期。
5.4 高并发下的限流与隔离策略
并发不只是靠锁,限流也是高并发系统的核心手段。常见的限流算法有计数器、滑动窗口、漏桶、令牌桶。Guava RateLimiter 是令牌桶实现的典型,它允许一定程度的突发流量,又限制平均速率。自定义限流组件时,可以基于信号量 Semaphore 控制并发数,也可以基于 AQS 定制同步器实现更精细的配额逻辑。
隔离策略分为线程池隔离和信号量隔离。线程池隔离物理上把任务分到不同的线程池里,信号量隔离则只是限制并发数,往往用于不涉及等待场景。我在一个订单聚合接口中,对下游的三个基础服务分别建了独立线程池,每个线程池容量按那个下游服务的承载力单独设置,这样任何一个下游抖动都不会拖垮整个接口。这个设计虽然牺牲了一点点资源利用率,但换来了整个系统的稳定性,值得。
6. 常见问题与排查看板:实测中踩过的坑
写到最后这一章,分享一些我在实际排查高并发问题时反复遇到的场景和定位方法,这些经验都是文档里不会写的。
6.1 高频线上并发问题归类
死锁是最经典的并发故障。多个线程互相持有对方想要的锁,谁也不让。排查死锁用jstack抓线程栈,看到Found one Java-level deadlock字样后基本就定位了,剩下的是理清资源获取顺序。如果代码里多个锁的获取顺序不统一,就要通过统一加锁顺序或使用 tryLock 超时机制来防止死锁。
线程大量 Blocked 的状态往往意味着某个锁竞争过于激烈。有一次我线上看到几百个线程阻塞在同一个锁上,排查下来是某个缓存失效后所有请求同时回源数据库,又恰好所有回源动作经过同一个锁保护。解决思路是缓存预热加减少锁粒度。
另外一个经典现象是 CPU 飙高。用top -Hp PID可以定位到具体线程,再把线程 ID 转成十六进制去 jstack 里找对应的栈,十有八九是代码里某个热点方法死循环。我在一个生产事故里通过jstack找到了一个 HashMap 并发 resize 导致的 CPU 100% 问题,也初步怀疑过会不会是锁竞争导致的自旋,最后分析下来还是数据结构的并发修改触发了死循环。
6.2 JVM与Arthas辅助诊断技巧
jstack 抓线程栈是最基础的手段,但线上瞬时问题可能来不及抓取。Arthas 就比较强了,它可以直接在线查看某个方法的调用耗时、参数、返回值,甚至能反编译线上的 class 文件。比如排查线程阻塞时用thread -b可以直接定位阻塞其他线程的锁在哪里。
对于锁竞争问题,更系统的做法是给 JVM 开-XX:+PrintConcurrentLocks,让线程 dump 里直接包含锁的所有者。并发场景压测时也可以配合async-profiler做火焰图,定位到真正的热点代码路径。
6.3 多线程面试核心问题的考察意图
这块给面试者一个参考。面试官问 synchronized 和 ReentrantLock 的区别,不只是想听哪个性能好,而是想看你知不知道底层机制。最满分的回答框架应该同时覆盖:底层实现(monitor vs AQS)、是否支持可中断、是否支持公平锁、是否支持多个条件变量、精细化控制的区别。
问 volatile 能否替代 synchronized,其实是在考察你对原子性和可见性的理解边界。问 ConcurrentHashMap 为什么并发安全,关键点是 1.8 的实现从分段锁改成了 CAS + synchronized 锁链表头节点,这背后是锁粒度从多个段收敛到单个桶的高性能思路。
我个人的面试经验和带人经验都表明:底层原理不是背出来的,而是调试和阅读源码调出来的。如果你真的把 synchronized 锁升级、AQS 队列、线程池参数之间的关系在脑子里建立一张地图,面试官随机抽问任何一点你都能串起来回答,线上排查速度也会快一个量级。这套地图的核心就是本文反复强调的那条主线:线程是系统的调度单元,锁本质上是对共享资源的同步协议,高并发设计的本质是在性能、资源占用和一致性之间找到那个最合理的平衡点。