Java多线程高并发底层原理:从JMM到锁升级与线程池
Java多线程高并发这块很多朋友都卡在“会用”但“不懂原理”的阶段。背了一堆面试题synchronized怎么用、线程池几个参数分别什么意思都门儿清可一旦遇到线上性能瓶颈、诡异的并发Bug就完全无从下手。这篇文章我会直接从底层原理切入把线程的底层模型、Java内存模型、锁的实现机制、AQS和线程池的底层逻辑一条线串到底让你知其然更知其所以然。1. 线程的本质与高并发系统的底层基石1.1 先搞清一个问题我们说的“多线程”底层到底跑在什么上面很多人写多线程代码却从来没有停下来想过——new Thread()出来的这个线程在Java虚拟机层面到底是什么到了操作系统层面又是什么Java线程在JDK 1.2之后采用了一对一1:1的线程模型也就是一个Java线程对应一个操作系统原生线程。你在代码里创建一个线程JVM会通过底层的native方法调用向操作系统申请创建一个内核线程然后Java层面的线程栈、程序计数器、局部变量表这些运行时数据全部关联到这个内核线程上。所以当你写出几十个线程的时候真相是你的JVM进程向操作系统申请了几十条内核线程操作系统内核在这些线程之间做调度、切换。这也是为什么很多新手在Windows、Linux上跑多线程程序任务管理器里能看到对应数量的线程条目——这不是巧合就是1:1映射的直接体现。注意从JDK 21开始Java推出了虚拟线程Project Loom的成果它的底层是JVM自己调度的用户态线程不再完全依赖内核线程。但在生产环境主流版本Java 8/11/17里我们讨论的依然是传统的内核线程模型。1.2 线程的生命周期状态远不止你背的那六种Java线程有六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这个八股文大家都会背但有几个细节值得深挖。第一RUNNABLE 状态其实是“虚胖”的。Java层面处于RUNNABLE的线程底层可能是三种情况之一正在CPU上执行、在操作系统的就绪队列里排队等CPU、或者是被操作系统从CPU上抢下来等着下一次调度。也就是说Java层面的RUNNABLE并不代表线程此刻真的在“跑”它只是表示“没有主动阻塞在锁或等待队列上”。第二BLOCKED和WAITING的区别很多人理解错了。BLOCKED是线程在竞争synchronized监视器锁时没抢到锁被阻塞在临界区入口。而WAITING是线程已经持有了锁主动调用了wait()或者join()挂起自己。一个是“想进去进不去”一个是“进去了自己主动出来等着”。第三从调度的角度讲操作系统并不认识Java的六种状态它只知道就绪、运行、睡眠等原生状态。JVM在实现线程状态转换时需要把Java语义映射到操作系统语义上。比如Java的WAITING在某些实现下就是操作系统层面的睡眠等待。1.3 为什么说线程切换是高性能系统的头号敌人聊多线程高并发不得不提上下文切换的开销。所谓上下文切换就是操作系统保存当前线程的上下文寄存器、程序计数器、栈指针、内存映射等然后加载下一个线程的上下文让CPU去执行另外一个线程。这个开销有多大一次上下文切换在几微秒到几十微秒之间听起来很便宜但如果你一个线程只运行几百微秒就去抢锁、然后被阻塞、被唤醒、再切换那大部分时间其实都消耗在切换上了。很多高并发系统性能上不去不是代码逻辑不高效而是线程数开得太多、抢锁太频繁导致CPU大部分时间花在保存和恢复现场真正干活的时间反而很少。一个判断系统是否过度切换的实用方法用top -H -p PID看看单个进程内线程数或者用vmstat观察cscontext switch列。如果每秒上下文切换次数动辄几十万甚至上百万就要警惕是不是线程模型设计出了问题。2. Java内存模型高并发底层原理的“宪法”2.1 为什么需要一套内存模型CPU缓存不一致问题从何而来多线程并发有一个绕不开的底层背景CPU的运算速度和内存的访问速度差距太大。现代CPU为了解决这个问题引入了多级缓存L1、L2、L3。每个CPU核心都有自己的L1/L2缓存一个线程运行在某个核心上时它操作的数据会被复制到该核心的缓存里。问题就来了。假设线程A和线程B分别运行在两个核心上同时读写同一个共享变量A改了缓存里的值B还在用自己的缓存副本两边看到的数值不一致。如果没有协议去协调数据就乱了。硬件层面解决这个问题靠的是缓存一致性协议最经典的就是MESI协议。MESI用四种状态标记缓存行Modified、Exclusive、Shared、Invalid。一个核心修改数据时要先通知其他核心把相关缓存行标记为Invalid其他核心再读的时候发现缓存行失效需要重新从主内存去读。2.2 Java内存模型JMM到底规定了什么Java内存模型JMM是JVM规范层面的内存抽象。JMM定义了主内存和工作内存的概念所有变量都存储在主内存中每个线程拥有自己的工作内存线程读变量时先把主内存的值拷贝到工作内存写变量时先写工作内存再刷回主内存。这个模型和硬件层的CPU缓存结构是对应的主内存对应物理内存工作内存对应CPU缓存或者寄存器。JMM要解决的核心问题有三个原子性、可见性、有序性。原子性强调一个操作要么全部执行成功要么完全不执行不可被线程调度机制打断。可见性强调一个线程修改了共享变量后其他线程能否立刻看到这个修改。有序性强调编译器和CPU为了性能做的指令重排是否会破坏程序语义。2.3 happens-before规则判断并发正确性的唯一武器JMM通过happens-before规则来约束可见性和有序性。这个规则是面试必考内容也是排查并发Bug的重要思想工具。核心规则包括程序次序规则同一个线程内前面的操作happens-before后面的操作监视器锁规则解锁操作happens-before后续对同一个锁的加锁操作volatile变量规则对volatile字段的写操作happens-before后续对这个字段的读操作传递性规则A happens-before BB happens-before C则A happens-before C以及线程启动、线程终止、中断操作等规则。记住一个关键点happens-before关系并不意味着前一个操作必须“先执行完”后一个操作才开始它保证的是“前一个操作的结果对后一个操作可见”。这是很多并发Bug难排查的根源——代码执行顺序上看起来不对但在JMM的语义下却是合法的。底层原理上JMM的实现依赖两个手段内存屏障和锁机制。volatile通过插入内存屏障禁止指令重排、保证写刷回主存synchronized通过锁的获取和释放建立临界区的内存屏障语义。3. 从对象头到监视器锁synchronized 的底层实现深度拆解3.1 synchronized 锁的信息藏在对象头里很多人不理解synchronized“锁的是对象”这句话的底层含义。一个Java对象在内存中的布局包括三部分对象头、实例数据、对齐填充。锁的核心信息就存在对象头里。对象头里的Mark Word字段在64位JVM底层是64位的数据结构它的含义会随着对象状态变化而变化无锁状态存储对象的哈希码、GC分代年龄等偏向锁状态存储持有锁的线程ID轻量级锁状态指向栈中锁记录的指针重量级锁状态指向Monitor监视器对象的指针顺便说一句很多面试题问“为什么重写equals必须重写hashCode”这跟对象头其实是相关的——不重写hashCode会导致两个逻辑相等的对象有不同的哈希码而HashMap底层要根据hashCode确定桶位。3.2 锁升级偏向锁、轻量级锁、重量级锁的完整链路JDK 1.6之后synchronized做了大量优化锁不再是“一上来就是重量级”而是有一个锁升级的过程偏向锁当第一个线程进入同步块时JVM会把Mark Word设置为偏向锁模式记录下这个线程的ID。之后这个线程再次进入同步块只需要检查Mark Word里的线程ID是否是自己如果不是自己就用CAS尝试替换如果是就直接进入不需要任何原子操作和系统调用。轻量级锁当有第二个线程竞争偏向锁时偏向锁会撤销升级为轻量级锁。轻量级锁的核心是CAS自旋。线程在栈帧中创建锁记录空间尝试用CAS把Mark Word拷贝到锁记录并更新指针。如果竞争不激烈自旋一会儿就能拿到锁避免了线程阻塞和唤醒带来的用户态内核态切换。重量级锁当CAS自旋超过一定次数默认自适应自旋或者CPU核心数过多、自旋成本太高时锁膨胀为重量级锁。重量级锁依赖操作系统的互斥量线程会真正阻塞涉及用户态到内核态的切换开销大但绝对公平不会出现CPU空转。这里有一个高频面试点为什么要有偏向锁和轻量级锁直接都用重量级锁不行吗答案很简单——绝大多数场景下锁竞争并不激烈甚至大量场景是同一个线程反复进入同一个同步块。如果每次都走操作系统级别的锁开销是无法接受的。偏向锁就是为了解决“单线程反复加锁”这种场景轻量级锁是为了解决“两个线程短暂交替竞争”的场景只有真正竞争激烈的场景才让锁膨胀到重量级。3.3 Monitor机制与wait/notify的底层联系synchronized加锁的本质是让一个线程获取到对象关联的Monitor监视器锁。在HotSpot虚拟机里Monitor底层的核心数据结构包括Owner当前持有锁的线程、EntryList等待获取锁的线程队列、WaitSet调用wait方法后释放锁并等待被唤醒的线程队列。理解了这套结构就能理解wait()/notify()的底层逻辑wait()的前提是当前线程持有该对象的Monitor然后线程释放Monitor并进入WaitSet等待notify()从WaitSet中随机唤醒一个线程被唤醒的线程需要重新去EntryList竞争MonitornotifyAll()把WaitSet中所有线程移入EntryList用现实类比就是synchronized是拿到了餐厅包间的钥匙才能进门wait是你进了包间后主动把钥匙放桌上、然后出门排队notify是前台喊了一个排队的人来拿钥匙。这也解释了为什么wait必须在synchronized代码块里调用——如果没有持锁你连进包间的资格都没有更谈不上“把钥匙放桌上”。4. volatile、CAS与并发工具类无锁并发的底层逻辑4.1 volatile 的内存屏障语义不只是“可见性”volatile最常见的一句话解释是“保证共享变量的可见性”但只理解到这一步很难应对真正的并发问题。从底层看volatile实际上做了两件事第一禁止指令重排。JVM在volatile字段读写的前后插入内存屏障。读操作之后插入LoadLoad和LoadStore屏障写操作之前插入StoreStore屏障、之后插入StoreLoad屏障确保volatile写的值对其他线程可见时前面的普通写也全部刷回主存。经典的DCL双重检查锁单例模式就是典型的volatile应用场景。在没有volatile时对象实例化过程可能被重排为分配内存空间将内存地址赋值给引用执行初始化如果另一个线程在“赋值”之后“初始化”之前读取了引用拿到的就是一个半初始化的对象。加上volatile后通过禁止重排保证初始化完成之后引用才对外可见。第二确保读写都能直接命中主内存。volatile变量的读写不会被缓存到寄存器或CPU缓存中每次写入都强制刷回主内存。实际开发中使用volatile的场景主要有几个状态标志位一个线程写、其他线程读、双重检查锁、以及作为轻量级的“发布安全”保障。但是volatile不能保证复合操作的原子性比如count这种读改写操作用volatile修饰依然是线程不安全的需要Atomic类或者锁来兜底。4.2 CAS原子操作的无锁实现与ABA问题CASCompare And Swap是实现无锁并发的核心原语。它的语义是比较内存值是否为预期值如果是就用新值替换否则什么都不做。底层是由CPU提供的原子指令如x86的CMPXCHG完成的保证整个比较交换操作不可被中断。Java里的AtomicInteger、AtomicLong、ConcurrentHashMap的很多操作底层都是通过Unsafe类调用CAS实现的。CAS有几个关键问题值得深聊第一ABA问题。线程1把值从A改成B又改回A线程2用CAS比较时发现还是A就误以为没人动过。解决的思路是加版本号AtomicStampedReference就是为此设计的。第二自旋开销。CAS失败后通常要循环重试竞争激烈时CPU空转严重在高冲突场景性能会急剧下降。这也是为什么很多底层算法会在CAS重试一定次数后让出CPU。第三只能保证单个变量的原子性。如果要原子更新多个变量CAS无能为力需要锁或者把多个变量封装到一个对象里用AtomicReference。4.3 AQS撑起JUC半边天的并发框架基础聊高并发底层原理不能不提AQSAbstractQueuedSynchronizer。它是ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock这些JUC工具类共同的底层骨架。AQS的核心是一个state状态变量加一个CLH变体队列。state的含义由子类自定义ReentrantLock里表示持有锁的次数Semaphore里表示剩余许可数CountDownLatch里表示还需倒数的计数值。所有获取锁或资源失败的线程会被包装成Node节点挂到队列尾部自旋或者阻塞等待当前一个节点释放资源时会唤醒后继节点继续竞争。理解了AQS很多JUC工具类的“为什么这么设计”就迎刃而解了。比如为什么ReentrantLock支持公平锁和非公平锁本质上就是在入队之前是直接CAS抢一次状态非公平还是严格入队等待公平。为什么Semaphore的许可可以“并发获取多个”因为state可以一次性减N。在网上搜索java八股文类资料时AQS是必背内容但真正生产环境里我建议大家把AQS理解为“JUC并发工具的底层状态机”这样再看任何并发类的源码都有一种“原来是同一套骨架”的通透感。5. 线程池底层原理高并发系统的“线程管理中枢”5.1 线程池的核心参数之间到底是怎么配合的ThreadPoolExecutor是Java高并发编程中使用率最高的类之一。它的参数很多但在底层原理层面真正要理解的是这套状态机的运转逻辑。核心参数包括核心线程数corePoolSize、最大线程数maximumPoolSize、空闲线程存活时间keepAliveTime、工作队列workQueue、线程工厂threadFactory、拒绝策略handler。线程池的提交任务流程是这样的提交任务时如果当前线程数 corePoolSize创建新线程执行任务如果线程数 corePoolSize任务入队等待如果队列已满且线程数 maximumPoolSize创建新线程执行任务这些非核心线程在空闲keepAliveTime后会销毁如果队列已满且线程数 maximumPoolSize执行拒绝策略这里有个高频坑很多人以为“线程数先到最大线程数才入队”这是完全错的。真实的执行顺序是核心线程先干活干不过来了任务先排队队列满了才加临时线程。如果队列是LinkedBlockingQueue默认无界队列第三步永远不会触发maximumPoolSize形同虚设。5.2 线程池的状态机RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATEDThreadPoolExecutor用一个AtomicInteger同时记录线程池状态和线程数量高3位表示状态低29位表示线程数。这种“一个变量装两个信息”的设计不是为了炫技而是为了让“CAS修改线程池状态修改线程数”这个操作保持原子性因为这两个字段经常需要同步判断。RUNNING可接受新任务也可处理队列任务SHUTDOWN调用shutdown()后进入不再接受新任务但已入队的任务会继续执行STOP调用shutdownNow()后进入不再接受新任务不处理队列任务并中断正在执行的任务TIDYING所有任务结束后进入执行terminated()钩子TERMINATEDterminated()执行完后的最终状态线上问题排查时看到线程池“卡住不干活”但线程数不为0很多情况下是因为线程池进入了SHUTDOWN状态队列里还有任务但不再执行或者线程都阻塞在一个获取不到的任务上。看线程池状态时不要只看监控面板上“线程数”要看“队列积压量”和“任务完成数”。5.3 拒绝策略与任务队列的选择底层参数的实战考量四种拒绝策略的区别很多人背过但背后的设计考量值得展开AbortPolicy默认直接抛RejectedExecutionException让调用方感知任务被拒适合对丢任务零容忍且可降级的场景CallerRunsPolicy谁提交谁执行等于在当前线程里直接跑任务。好处是能自然限流——调用线程一边执行任务一边无法继续提交系统压力被反馈到上游DiscardPolicy静默丢弃适合允许丢数据的日志采集场景DiscardOldestPolicy丢弃队头任务再重新提交适合追求新任务优先执行的场景队列选择上LinkedBlockingQueue无界队列“永不拒绝”但任务会堆积内存可能被拖垮ArrayBlockingQueue有界队列配合合理池数和策略才能真正发挥线程池“削峰填谷”的作用。SynchronousQueue不存任务直接交给线程适合极端追求低延迟的场景。6. 高并发线上问题排查与经验沉淀6.1 死锁的定位jstack实战记录两线程互相持有对方需要的锁、且彼此不肯释放就形成死锁。排查死锁最经典的手段就是使用JVM自带的jstack工具。具体步骤是用jps找到JVM进程ID用jstack PID抓取线程快照在输出中查找“Found one Java-level deadlock”字样后面会列出死锁线程的详细信息我在实际运维中还遇到过一种“假死锁”——代码没死锁但线程全部阻塞在某个第三方SDK的锁上导致Tomcat的工作线程全部被占满服务表现为“假死”。这种情况下jstack里会看到大批线程卡在同一个类名的方法栈上顺着栈帧就能定位到是哪个SDK、哪个接口拖死了线程池。6.2 锁竞争激烈、上下文切换过高怎么定位高并发系统的很多性能问题最终都归结到锁竞争和上下文切换上。定位思路一般是这样先用top -H看CPU占用如果多核CPU利用率不均衡某些核打满、某些闲着大概率是锁竞争造成的因为抢不到锁的线程只能等待。再用jstack抓线程状态重点关注两个指标BLOCKED状态的线程数量和waiting to monitor关键字。只要看到大量线程阻塞在某个monitor对象上就说明该锁是当之无愧的“热点锁”。优化热点锁的思路有优先级先看能不能缩小同步块把耗时操作移出锁外再看能不能用并发容器替代锁如ConcurrentHashMap、CopyOnWriteArrayList最后才是换锁类型synchronized换成ReentrantLock、读写锁、甚至无锁CAS。特别提醒很多新手喜欢“一有并发问题就上ConcurrentHashMap”但并发度低、写多读少的场景ConcurrentHashMap的CAS链表扫描开销可能反而比普通HashMap加锁更高。先量化锁竞争的程度再谈优化手段这才是专业的做法。6.3 ThreadLocal泄漏一个容易被忽略的底层细节ThreadLocal在多线程场景下用来做线程上下文隔离非常方便但它有内存泄漏风险。这要从底层结构说起每个Thread内部有一个ThreadLocalMapkey是ThreadLocal对象本身value是你要存的数据。坑在于ThreadLocalMap的Entry继承WeakReferencekey是弱引用而value是强引用。当外部对ThreadLocal的强引用被置为null后key会被GC回收但value还在并且因为Thread对象可能存活很长时间例如线程池里的线程value就永远无法被回收。这就是为什么很多规范建议用完ThreadLocal后必须调用remove()。线程池场景下尤其如此因为线程是复用的上一个请求的数据可能被下一个请求读到造成难以追踪的数据串扰问题。6.4 底层原理对高并发系统设计的实际指导学习这些底层原理的最高境界是形成一套“并发设计直觉”。几句话总结我的体会线程不是越多越好线程数和CPU密集型/IO密集型任务强相关核心逻辑是让“线程都在干活而不是都在切换”。锁是并发冲突的放大器写代码时保证并发安全的边界越小、越快加锁越快释放系统吞吐量的提升就越明显。无锁不一定比加锁快但CAS自旋在低竞争场景的开销要远小于线程阻塞唤醒的系统调用反过来在高竞争场景无锁自旋又会浪费大量CPU这时候重量级锁让线程排队休眠反而是更优解。做高并发系统方案设计时从“共享可变状态”到“不可变对象”再到“线程封闭”ThreadLocal或栈上隔离优先级是从高到低的。能不改就不改、能不可变就不可变、实在要共享就用锁和并发工具管好访问。7. 面试题场景下的底层原理输出技巧7.1 “讲讲synchronized的锁升级”怎么答才不显得像背课文很多Java面试者能流利背出“偏向锁-轻量级锁-重量级锁”但面试官追问一句“为什么要有偏向锁”就卡住了。要答出区分度必须把“性能取舍”讲透。推荐的回答脉络是先讲对象头Mark Word存储锁信息的机制再讲锁升级是为了适配不同竞争强度的性能优化——单线程重复加锁用偏向锁避免原子操作低竞争用CAS自旋避免用户态内核态切换高竞争才用重量级锁保证不空转CPU。最后能补一句“锁升级是不可逆的一旦膨胀到重量级锁后续即使竞争消失了也回不到偏向锁”这就能体现出对底层实现的理解深度。7.2 “ConcurrentHashMap为什么效率高”要从哪几个层次答这个问题最能区分“背过八股”和“真懂并发”。一个完整的回答应该覆盖第一层锁粒度——JDK 1.7用分段锁Segment把整表锁拆成16段JDK 1.8直接放弃分段锁改用Node数组 CAS synchronized锁粒度精细到单个桶位。第二层无锁路径——初始化的部分用CAS保证线程安全空桶插入用CAS直接写入不需要加锁。第三层锁优化——链表转换成红黑树后缩小单个桶内遍历的复杂度桶内用synchronized锁住链表头节点避免对整个map操作。能把这几个层次串起来面试官会觉得你不只是会用ConcurrentHashMap是真的理解它底层的设计逻辑。7.3 高频面试问题速查表问题核心考点底层原理要点volatile能保证原子性吗可见性与原子性的区别volatile只能保证单次读写可见不能保证复合操作原子性wait和sleep的区别锁释放与状态切换wait释放Monitor并进入WaitSetsleep不释放锁为什么线程池不建议用Executors任务队列和OOM风险newFixedThreadPool默认无界队列LinkedBlockingQueue任务积压可能OOM如何实现分布式锁跨进程互斥与原子操作单机AQS无法跨进程需Redis SETNX或数据库唯一索引HashMap在多线程下会怎样线程安全性来源并发put可能触发扩容造成环形链表JDK 1.7就有这个问题ReentrantLock和synchronized区别锁的实现层次ReentrantLock基于AQS支持公平锁、可中断、可超时什么是伪共享缓存行与并发性能CPU缓存行无效化导致原本不冲突的变量互相拖累7.4 一个完整的按层次答题示例当面试官问“Java高并发是怎么保证数据一致性的”完整的回答应该从内存模型讲到Java提供的层层递进的工具第一层JMM定义主内存与工作内存的关系通过happens-before规则约束可见性这是所有并发一致性的基础。第二层语言级关键字volatile解决可见性和有序性synchronized解决原子性问题。第三层JUC类库Atomic类基于CAS无锁原子操作AQS是ReentrantLock、Semaphore等工具的统一骨架。第四层并发容器ConcurrentHashMap、CopyOnWriteArrayList针对特定读写场景做并发优化。第五层分布式场景下的最终一致性方案比如MQ异步削峰、本地消息表定时任务对账等。这套回答从单机到分布式层层递进既覆盖了底层原理又展现了系统架构能力是面试中比较加分的一个结构。8. 写在最后的一些实践心得我这些年排查线上并发问题的经验有一条最重要的体会底层原理不是用来背的是用来定位问题的坐标系。没有JMM的概念线上出现一个“偶发性的数据错乱”你根本不知道怎么分析没有线程状态和锁升级的知识jstack打出来的线程快照在你眼里就只是密密麻麻的乱码没有AQS的理解别说优化并发工具连自定义一个限流组件都无从下手。另一个体会是学习底层原理最好的方式是配合真实案例。起一个高并发压测脚本用jstack抓一次线程状态用top -H看一次CPU分布用jmap看一次堆内存那些概念就真正从纸上落到脑子里了。不要追求一次性看懂全部先拿synchronized和线程池开刀逐步扩大到JMM、AQS、并发容器这条路走通了你再看任何Java并发的源码都有一种“原来还是这套东西”的从容感。最后分享一个小技巧给线程命名。无论是ThreadPoolExecutor的threadFactory还是手动new Thread都给它起个有意义的名字。并发问题排查时jstack里一堆pool-3-thread-1只能告诉你这是个不知道哪里来的线程但一个叫order-service-timeout-checker的线程你一眼就能知道该去查哪一段代码。这个习惯在关键时刻能救你一次。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →