资讯详情

资讯详情

Java多线程高并发底层原理:从JMM到AQS的锁与线程池实战

干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 密集型任务线程数设为 N1IO 密集型任务设为 2N但现代机器上 IO 等待占比并不固定所以更好的做法是动态调整后面线程池部分再细说。2. JMM与CPU缓存并发问题的根源在硬件层多线程编程出现各种诡异问题的本质是多个线程共享变量时产生了数据竞争。要理解数据竞争得先看看现代计算机的存储体系Java 内存模型JMM其实是把底层硬件存储模型抽象成了规范。2.1 从CPU三级缓存到主内存的写入过程CPU 的运算速度远快于内存的读写速度为了填平速度差硬件在 CPU 和主内存之间加了好几层高速缓存一般叫 L1、L2、L3。L1 又分成指令缓存和数据缓存读写速度最快但容量极小通常几十 KB 到几百 KBL3 是所有核共享的容量有几十 MB。线程读取一个变量的真实过程是这样先查 L1没有就查 L2再没有查 L3最后才去主内存拉数据。写入的时候也不是直接写主内存而是先写进缓存行稍后再同步到主内存。这套机制在多线程场景下就引出问题了——一个线程改了缓存里的值另一个线程可能读的还是旧值。这就是可见性问题的硬件根源。为了不让缓存里的数据永远和其他核脱节硬件层引入了缓存一致性协议比较经典的是 MESI 协议把缓存行标记成 Modified、Exclusive、Shared、Invalid 四种状态。当一个核写自己的缓存行时会通过总线通知其他核把对应的缓存行置为 Invalid其他核下次读取发现失效就会重新从主内存拉最新值。但这个协议跑起来也不是零成本缓存同步会拖慢速度而且编译器、CPU 还可能为了优化重排指令这就是指令重排序。2.2 JMM的抽象规则与happens-beforeJava 内存模型做了一件事把硬件上的缓存一致性、指令重排约束抽象成一套逻辑规则让开发者不直接面对 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 和 monitorexitJVM 根据锁状态走向不同实现路径。一个值得注意的细节是锁可以重入。重入的实现是每个 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问题CASCompare 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表示当前同步状态。对于 ReentrantLockstate 为 0 表示没有线程持有锁大于 0 表示持有且可能重入了几次对于 Semaphorestate 直接代表剩余许可数量对于 CountDownLatchstate 就是要等待的事件数。不管哪种同步器最终都是通过对 state 做 CAS 或修改来体现业务语义。排队机制是一种双向链表的变体队列。线程获取锁失败时包装成 Node 加入队列尾部然后在循环里尝试获取锁、检查前驱节点状态不满足条件就通过 LockSupport.park 把自己挂起。释放锁时会把下一个等待线程 unpark 唤醒。这一套设计非常优雅把“锁”的语义抽象成状态 一个等待队列避免了每个锁单独实现一套等待机制。看源码你会发现一个细节ReentrantLock 的公平锁与不公平锁差异只在入队前要不要做一次hasQueuedPredecessors()检查。公平锁看到队列有排队就乖乖去排队非公平锁会先抢一次。非公平锁在有线程持有锁时更多是性能考量减少一次线程挂起唤醒的损耗但存在插队问题。5. 高并发落地线程池、并发工具与资源管控底层原理最终还是要落到工程实践。高并发系统常见的资源管控手段无非是线程池、限流、隔离这三个都是建立在前面那些底层机制之上的。5.1 线程池参数选择与动态调整ThreadPoolExecutor 是生产环境必须牢牢掌握的类。它的七个核心参数里核心线程数、最大线程数、阻塞队列容量对并发吞吐影响最大。线程池的拒绝逻辑是当前线程数小于核心线程数时直接新建线程大于核心线程数且队列没满时塞进队列队列满了且还没到最大线程数时继续开新线程全满了才触发拒绝策略。这里有一个大众误区很多人以为核心线程数满了会先把队列塞满再扩容用代码确认后你会发现并不是任务超过核心线程数会先尝试入队队列容量足够时线程数永远不涨性能不佳的场景往往就是队列设置太大导致线程数被锁死在核心值。Executors.newFixedThreadPool 用无界队列高并发请求下任务全部堆内存里最终把内存打爆所以《阿里巴巴Java开发手册》里明确禁止用 Executors 创建线程池这是有真实血泪教训的。线程数公式我已经提过N1 和 2N 只是粗糙估计。现代生产系统更好的方案是用动态线程池根据任务积压情况实时调整核心线程数和队列容量。我在实践中会通过线程池暴露的getActiveCount()、getQueue().size()、任务耗时分位线等指标联动调整标准的做法是引入配置中心线程数改成可动态刷新的参数。5.2 CompletableFuture与异步编排高并发场景经常要求把多个 IO 请求并行发出再汇总结果。CompletableFuture 提供了强大异步编排能力thenApply、thenCompose、allOf、anyOf等方法让回调编排变得非常轻松。它底层是基于 fork/join 框架的线程池也可以传入自定义 Executor 避免和业务线程池互相干扰。我在一个对外接口的优化案例里串行调用三个下游服务耗时 300ms改成 CompletableFuture 并行后降到 120ms效果立竿见影。但这个过程中要特别注意线程池隔离如果所有接口共用同一个默认线程池任何一个下游慢服务把线程池占满整个应用都跟着瘫痪。正确做法是为不同调用方、不同优先级任务分配独立线程池。5.3 ThreadLocal的内存泄漏与场景陷阱ThreadLocal 是高并发场景里承载“线程上下文”的利器但它有个隐蔽的坑。每个线程内部维护一个 ThreadLocalMapkey 是弱引用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 队列、线程池参数之间的关系在脑子里建立一张地图面试官随机抽问任何一点你都能串起来回答线上排查速度也会快一个量级。这套地图的核心就是本文反复强调的那条主线线程是系统的调度单元锁本质上是对共享资源的同步协议高并发设计的本质是在性能、资源占用和一致性之间找到那个最合理的平衡点。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →