1. 一条主线看懂Java锁的全貌
Java锁的底层原理,几乎是所有并发编程学习绕不过去的坎。你背过synchronized的用法,知道volatile能保证可见性,也听说过“锁升级”这个词,但真被问到“对象头里的Mark Word到底存了什么,偏向锁是怎么偏向的,内存屏障和锁有什么关系”时,很多人就露怯了。
这篇文章想做的事很简单:顺着一条主线——从对象头到内存屏障——把Java锁的底层机制串起来讲清楚。读完你会明白synchronized为什么会有无锁、偏向锁、轻量级锁、重量级锁这几种状态,也会知道volatile的读写在x86处理器上到底做了什么,还会理解为什么说“锁的本质是让并发有序化”。这篇文章适合刚入门并发编程、想深入理解JVM锁机制的开发者,也适合准备面试、想系统梳理底层原理的人——不管你属于哪种,跟着这条主线走一遍,基本能把零散的知识点焊成一张网。
坦白说,我最初学并发时也是东看一篇西看一篇,今天记一个Mark Word位图,明天背一段JMM定义,后天又搜一篇内存屏障短文,结果总觉得知识是碎的。直到有天我在某台生产服务器上排查一次锁竞争导致的高CPU问题,被迫把对象头、锁升级、Monitor、内存屏障这几个概念连起来读了一遍源码,才真正建立了完整的坐标系。所以这篇文章的定位就是给你搭这个坐标系。
2. HotSpot对象头:锁信息的“门牌号”
2.1 对象头到底长什么样
在HotSpot虚拟机中,任何一个Java对象,在内存里的布局可以分为三块:对象头、实例数据、对齐填充。对象头在最前面,它存的是对象运行时的基础数据,包括哈希码、GC分代年龄、锁状态标记、类元数据指针等信息。你可以把对象头理解成对象的大门门牌号,门牌上写清楚了这栋房子当前处于什么状态。
以64位虚拟机为例,对象头里的Mark Word共占8字节(也就是64位),这是整个锁机制的核心地带。Mark Word的设计非常巧妙——它是一块动态复用的内存区域,不同状态下这64位所表达的含义完全不一样。看下面这个表格会更直观:
| 锁状态 | 占用位(64位Mark Word结构简写) | 说明 |
|---|---|---|
| 偏向锁 | threadId(54位) + epoch(2位) + age(4位) + biased_lock(1位) + lock(2位) | 记录持有锁的线程ID |
| 轻量级锁 | 指向栈中Lock Record的指针(62位) + lock(2位) | 记录持有者栈帧中的锁记录地址 |
| 重量级锁 | 指向ObjectMonitor的指针(62位) + lock(2位) | 记录监视器对象地址 |
| 无锁/可偏向 | 哈希码(31位) + age(4位) + biased_lock(1位) + lock(2位) | 普通无锁对象 |
这里最关键的其实就两个点:
一是biased_lock和lock这3个标志位,它们共同决定了对象处于哪种锁状态;
二是剩下的位在不同状态下存的是完全不同的信息——偏向锁存的是线程ID,轻量级锁存的是Lock Record地址,重量级锁存的是Monitor地址,无锁时则用来存哈希码和GC年龄。
我第一次看到这个设计时觉得非常惊艳:一个对象的状态信息,通过位运算就能随时切换含义,不需要额外的内存开销。这也解释了为什么对象的hashCode和偏向锁会“互斥”——如果已经调用了hashCode()并缓存了哈希码,Mark Word里就没有位置再存偏向线程ID了,所以这类对象无法进入偏向锁状态。
2.2 锁状态流转:当门牌号变来变去
对象头的Mark Word是随着锁竞争的激烈程度不断变化状态的,这就是常说的“锁升级”。整个升级路径是单向的:
无锁或可偏向 → 偏向锁 → 轻量级锁 → 重量级锁
为什么设计成单向?因为锁升级意味着竞争越来越激烈,一旦膨胀到重量级锁,再降回去的成本极高,所以JVM不会让锁“降级”。
- 无锁状态下,多个线程都能尝试修改对象;
- 偏向锁状态下,Mark Word里记录了一个线程ID,只有该线程能进入同步块;
- 轻量级锁状态下,多线程通过CAS自旋获取锁;
- 重量级锁状态下,获取不到的线程进入操作系统级的等待队列,涉及用户态和内核态的切换。
这个过程并不是所有对象一上来就升级到重量级锁。JVM的初衷是用最轻量的方式解决最常见的场景:绝大多数情况锁只被一个线程访问,那就用偏向锁;偶尔有两个线程交替访问,就CAS自旋一下;只有多个线程同时争抢、自旋解决不了时,才上升到操作系统互斥量。
理解了对象头的结构和状态流转,再去网上背那些“偏向锁撤销”“自旋阈值”的名词,就会清晰很多。锁的状态并不是玄学,它就是对象头里那三个标志位在不同竞争情况下的具体表现。
3. 锁升级全流程:从偏向到重量级的蜕变
3.1 偏向锁:默认你是唯一
偏向锁解决的核心场景是:同一段同步代码,绝大多数时间只有一个线程在执行。比如一个只被单线程初始化的懒加载容器,里面加锁几乎是空转的。如果每次进入退出同步块都做CAS,纯属浪费CPU,于是JVM设计了偏向锁:把线程ID直接写进Mark Word,之后这个线程再进同步块时,只需要检查一下ThreadId对不对,连CAS都不用做。
偏向锁的获取流程可以拆解成三步:
- 当线程第一次进入synchronized块时,JVM检查对象头最后2位的锁标志位,如果是01且偏向标志位为1,说明当前处于可偏向状态;
- 此时用CAS把当前线程的ID写入Mark Word;
- CAS成功,表示获取偏向锁成功,Mark Word的偏向标志位置为1,后面这个线程进出同步块时只需比对线程ID是否一致,一致就直接放行。
这里有个很值得说的细节:偏向锁的持有者是不会主动释放锁的。它不像普通锁那样退出同步块就解锁,而是等其他线程来竞争时才释放。也就是说,只要没有别的线程来抢,这个锁就一直“偏向”第一个线程,哪怕它已经退出了同步代码块。
那什么时候偏向锁会撤销?当另一个线程尝试获取锁时,发现Mark Word里的ThreadId不是自己,就会发起偏向锁撤销。这个撤销操作并不是当前线程自己做的,而是由到达安全点后暂停持有偏向锁的线程,然后判断持有者是否还活着:
- 如果持有线程已经退出同步块或已经死亡,就把对象头恢复到无锁状态,重新偏向新线程;
- 如果持有线程还在同步块里执行,说明真的有竞争发生了,就把偏向锁升级为轻量级锁。
注意:偏向锁撤销需要等待全局安全点(Safe Point),这会触发短暂的STW。如果系统中有大量偏向锁被频繁撤销,性能不升反降,这就是为什么某些高并发场景下反而要禁用偏向锁的原因。
在实际项目中我遇到过一个真实场景:某服务的接口内部有一段很短的synchronized块,单机QPS不高时一切正常,但压测把一个实例的QPS提到几千后,CPU系统态占用暴涨。后来用JFR抓了锁相关事件才发现,大量线程在竞争同一把锁,导致偏向锁持续撤销、膨胀,每次撤销都在等安全点,消耗反而比直接上重量级锁还大。最后通过JVM参数禁用偏向锁,问题才缓解。这告诉我们:偏向锁不是“免费的午餐”,它是有适用场景的。
3.2 轻量级锁:CAS和自旋的配合
当偏向锁被撤销,或者一开始对象就处于不可偏向状态(比如调用了hashCode,或者JVM禁用了偏向锁),线程会尝试用轻量级锁来获取锁。
轻量级锁的核心思路是:在栈帧中创建一块Lock Record空间,然后用CAS尝试把对象头Mark Word替换为指向这块Lock Record的指针。如果CAS成功,说明锁获取成功,Lock Record里会保存原来的Mark Word值,这个值叫Displaced Mark Word;如果CAS失败,说明已经有其他线程持有了锁,当前线程进入自旋等待。
轻量级锁的获取步骤如下:
- 线程在自己的栈帧中创建Lock Record;
- 把对象头Mark Word复制到Lock Record中;
- 用CAS把对象头Mark Word的锁标志位改为00,同时将Mark Word指向Lock Record;
- CAS成功则持有锁,失败则进入自旋;
- 自旋超过一定次数或自旋期间发生竞争,锁升级为重量级锁。
自旋的本质就是忙等待:线程不放弃CPU,而是反复执行CAS指令尝试获取锁。这样做的好处是避免了线程阻塞和唤醒带来的上下文切换开销,坏处是白白占着CPU。JDK 6之后,JVM把自旋改成了自适应自旋——自旋次数不再固定,而是根据上一次同一个锁上自旋等待的成功率来动态调整。如果上次自旋成功,说明大概率很快就能拿到锁,就多自旋一会儿;如果上次自旋失败,说明竞争激烈,就少自旋甚至不自旋,直接阻塞。
我觉得理解轻量级锁最关键的视角是:它在用户态就解决掉大部分短临界区的竞争问题。多个线程交替争抢、每个线程持有锁的时间极短,CAS自旋完全扛得住,没必要惊动操作系统。只有自旋解决不了时,才需要重量级锁出场。
3.3 重量级锁:当竞争白热化时
当自旋失败或直接遇到激烈竞争时,锁会膨胀为重量级锁。此时对象头Mark Word指向一个叫做ObjectMonitor的底层对象,这才是真正意义上的“监视器锁”。
ObjectMonitor是HotSpot中实现synchronized底层语义的核心结构,它内部维护了几个关键字段:
- _owner:当前持有锁的线程;
- _EntryList:等待获取锁的线程队列;
- _WaitSet:调用了wait()方法的线程等待队列;
- _recursions:可重入锁的计数;
- _count:竞争计数。
重量级锁的工作机制可以理解为:当一个线程获取锁失败时,它会被封装成ObjectWaiter节点放入_EntryList,然后调用操作系统提供的互斥量或条件变量进入阻塞状态。这里涉及最重要的一个操作:线程从用户态切到内核态,由操作系统来挂起和唤醒线程。这个切换的开销非常大——一次上下文切换大概要消耗几微秒,而一个简单的CAS操作只要几纳秒,两者相差一千倍以上。
所以重量级锁的设计原则是:如果临界区代码确实很重(耗时几十毫秒甚至更久),那阻塞等待是合理的;但如果临界区只做一个简单的增删改查,重量级锁带来的线程调度开销会远大于业务逻辑本身的开销。这也是为什么并发编程中常常强调“缩小锁粒度”“用读写锁”“用CAS替代锁”的根本原因——避免让轻量级操作承受操作系统级的调度成本。
在JDK 6之后,synchronized引入了一系列锁优化(偏向锁、轻量级锁、自适应自旋、锁消除、锁粗化),让它在低竞争场景下已经非常接近无锁的性能,在高竞争场景下有锁竞争机制兜底。可以说,现代JVM的synchronized要比很多开发者想象中智能得多,只是它的“智能”全部建立在对象头Mark Word和锁升级这套机制上。
4. 内存屏障:锁和并发的“物理护栏”
4.1 指令重排序和内存可见性到底怎么来的
锁解决了“同一时刻只能有一个线程进入临界区”的互斥问题,但并发编程还有另一个同样要命的难题——可见性与重排序。这个问题光靠锁的状态流转是说不完的,必须引入内存屏障。
先说说为什么会有指令重排。现代CPU为了提升执行效率,会采用流水线、乱序执行等技术,编译器也会在保证单线程语义不变的前提下调整指令顺序。问题在于:在单线程里看不出来的顺序调整,在多线程环境里就可能让另一个线程看到“违背直觉”的执行结果。
举个最简单的例子:线程A执行 a=1; flag=true;,线程B执行 if(flag) print(a);。如果在没有同步措施的情况下,B有可能看到flag为true但a还是0。原因可能是编译器把两条赋值指令重排了,也可能是CPU把store操作放进了写缓冲还没刷新到主存,B读到的flag是新鲜值,读到的a却是旧值。
还有个容易忽略的点是CPU缓存架构。在多核处理器上,每个核心都有自己的L1/L2缓存,线程A修改了变量后数据可能还在核心私有的缓存里,没有同步到共享内存;线程B在自己的缓存里读到的是旧副本。这就是经典的内存一致性问题和缓存一致性问题。
要解决这些问题,JVM层面定义了一套规则——Java内存模型(JMM),它规定了哪些情况下一个线程的写操作对另一个线程是可见的,并约定了一系列操作之间的偏序关系(happens-before)。但JMM只是一种抽象规则,落到硬件层面,真正执行这些规则靠的是内存屏障指令。
4.2 JMM八大操作与内存屏障分类
JMM把内存交互抽象成了八个原子操作:read、load、use、assign、store、write、lock、unlock,这八个操作配合一组规则,保证了“如果A happens-before B,那么A的结果对B可见”。不过在硬件层面,我们更关心的是CPU提供的屏障指令。
内存屏障本质上是一条特殊的CPU指令,它强制读写操作按顺序执行,并保证屏障前后的内存操作结果对其他处理器可见。常见的屏障类型有四种:
| 屏障类型 | 作用 | 成本 |
|---|---|---|
| LoadLoad | 屏障前的读操作先于屏障后的读操作 | 较低 |
| StoreStore | 屏障前的写操作先于屏障后的写操作 | 较低 |
| LoadStore | 屏障前的读操作先于屏障后的写操作 | 较低 |
| StoreLoad | 屏障前的写操作先于屏障后的读操作,且写的结果对其他处理器可见 | 最高 |
在x86架构下,由于CPU采用了较强的一致性模型,普通读写不会被随意重排,实际只有StoreLoad屏障是必需的。这也解释了为什么很多讲volatile的文章会说“在x86上,volatile的读操作不需要额外屏障,因为load天然不会和后面的load重排”,真正昂贵的是volatile写操作时的StoreLoad屏障。
而JMM对锁和volatile的约束本质上是这样映射的:
- 锁的释放相当于执行了一个“写屏障”,把线程本地缓存的修改冲刷到主内存;
- 锁的获取相当于执行了一个“读屏障”,使本地缓存失效,强制从主内存重新加载数据。
所以你看到“synchronized保证可见性”这句话,在硬件层面其实就是通过屏障指令让缓存同步。
4.3 volatile、锁与lock前缀指令的关系
volatile的底层实现是理解内存屏障最好的切入点。当一个字段被声明为volatile时,JVM会在字节码层面插入屏障指令:
- 在volatile写之前插入StoreStore屏障,保证之前的普通写操作已经完成;
- 在volatile写之后插入StoreLoad屏障,防止后续的读操作被重排到写之前,同时强制写操作刷入主存;
- 在volatile读之后插入LoadLoad和LoadStore屏障,防止后续的读写操作被重排到读之前。
这就是volatile的两个核心语义的来源:可见性和禁止重排序。但要注意,volatile并不具备原子性,对于i++这种复合操作依然需要锁或CAS。
说回锁本身。synchronized块编译成字节码后,进入时是monitorenter指令,退出时是monitorexit指令。HotSpot在执行monitorenter时,除了修改对象头Mark Word之外,还会在底层加上具有全屏障效应的指令。x86上最常见的做法是“lock前缀指令”——比如lock cmpxchg、lock add等。lock前缀的作用有两个:
- 锁定总线或锁定缓存行,保证该指令期间的读改写操作是原子的,其他CPU无法访问这部分共享内存;
- 自带内存屏障效果,让lock前缀指令之前的写操作全部刷新到内存,之后的读操作全部从内存读取,不会被重排序跨越该指令。
所以你在理解“锁为什么能让多线程看到一致的数据”时,可以把它想成:每个线程进入锁之前,强制失效自己的缓存;退出锁时,强制把修改刷到主存。一进一出,缓存一致性由MESI协议和lock前缀指令共同保证,数据自然就“可见”了。
这时你会发现锁和内存屏障其实是两个层次的东西:锁解决的是“谁有资格进入临界区”的互斥问题,内存屏障解决的是“进入临界区后数据是否一致”的可见性问题。两者配合,才构成Java并发安全的完整底座。搞懂这条线,再回头看那些并发框架的源码,很多设计选择都能看得更明白。
5. 常见问题与排查技巧实录
5.1 偏向锁真的快吗?该不该禁用
我遇到过不少团队一上来就用-XX:-UseBiasedLocking禁用偏向锁,理由是“我们并发很高不需要偏向锁”。但这是个很容易拍脑袋做决定的问题。偏向锁适合的是低竞争、单线程访问为主的场景,它消除了绝大多数无谓的CAS操作;如果系统里锁竞争激烈、线程频繁交替争抢,偏向锁不仅没帮助,反而每次都要走到撤销和重新偏向的流程,白白增加开销。
这里有个判断方法:先跑一段模拟真实负载的压测,用JFR或async-profiler看锁相关的耗时分布。如果绝大多数锁的持有时间在几十微秒以下且竞争很少,保留偏向锁是划算的;如果压测曲线里锁竞争导致的阻塞占比很高,或者偏向锁撤销次数非常多,再考虑禁用。
另外有个容易忽略的参数是-XX:BiasedLockingStartupDelay=4000,即JVM启动后前4秒偏向锁是延迟开启的。这是为了避免应用启动阶段类加载、JIT编译等复杂场景下偏向锁频繁撤销拖慢启动。如果应用启动后马上要处理大量同步操作,可以把这个延迟调小,但一般不建议动。
5.2 调用hashCode后为什么锁行为变了
前面提过,hashCode和偏向锁在Mark Word里是共用位空间的。一个对象如果已经计算过identity hash code,Mark Word里要保存这个哈希值,就没有位置存偏向线程ID了,于是这个对象就只能走无锁或轻量级锁,无法进入偏向锁。
这个坑我在一个缓存方案里踩过:有个对象用它的默认hashCode做key,放到并发Map中作为锁对象来用,结果不管怎么压,性能上不去,用jcmd看锁状态时发现它从来不在偏向锁状态。后来排查清楚是hashCode调用导致偏向锁失效,代码本身同步逻辑没有问题,但白白失去了偏向锁的优化。
顺带一提,System.identityHashCode(obj)同样会触发这个效果,哪怕你没有显式调用obj.hashCode()。所以在设计“以对象为锁”的高频同步方案时,尽量避免使用会缓存哈希值的对象作为锁对象。
5.3 锁消除、锁粗化和逃逸分析是什么
锁消除是JIT编译器的优化:通过逃逸分析,如果发现一个对象只在当前线程内部使用,根本没有逃逸出方法,那么对这个对象加锁毫无意义,编译器会直接把这个锁去掉。很多人不知道,局部对象上的synchronized块,可能编译后压根不存在锁。
锁粗化则相反:JIT发现连续的同步块操作的是同一个锁对象,并且中间没有其他线程能插入,就会把这几个同步块合并成一个更大的同步块,减少反复加锁解锁的开销。比如循环里面用同一个锁对象做同步,JIT可能会把锁扩展到循环外。
这两个优化告诉我们一个道理:现代JVM下的性能问题不能靠直觉判断,要基于测量。有时候你“好心”在循环里缩小锁粒度,结果反而因为锁粗化把代码变成了另一种形态;有时候你觉得局部变量加锁无所谓,JIT可能早就帮你把锁去掉了。
5.4 锁排查的实战路径
拿到一个线上锁竞争导致的性能问题时,我的排查顺序通常是这样的:
- 先看CPU使用率和线程状态,如果大量线程处于BLOCKED或WAITING状态,大概率锁竞争严重;
- 用jstack或jcmd抓线程栈,找锁等待的调用链;
- 用jmap -histo:live看一下锁对象的分布情况;
- 用JFR录制一段时间,在Lock Instance事件里看具体的持锁时间和竞争线程数;
- 根据竞争对象加锁的位置,评估是缩小锁粒度、改用读写分离结构(如LongAdder思想的分段计数),还是用原子类CAS直接替代。
定位到代码层面后,再决定是调整JVM参数(禁用偏向锁、调整自旋)还是改代码结构。不要一上来就堆参数,先搞清楚锁竞争的来源和锁持有时间,否则很容易调了半天参数没效果。
6. 一些实操心得
最后聊几句个人经验吧。我最初学这些底层知识时,总想着把每个源码细节都抠死,但后来发现,真正有用的不是记住Mark Word某个位在第几号,而是建立一套分析锁性能的思路:先看对象的锁状态,再想JVM做了什么优化,再落到硬件层面的内存屏障和缓存一致性。有了这条路,遇到锁问题就不会慌。
我的建议是,你可以在本机跑个小程序,打印一下对象在无锁、哈希后、加偏向锁、加轻量级锁时的Mark Word字节,对比着看锁状态的变化,直观感受一下Mark Word复用。再配合Linux的perf工具看一下lock指令和上下文切换的次数,对“用户态和内核态切换到底有多贵”会变得特别敏感。这套底层认知,写业务代码时可能看不出来,一旦遇到线上高并发抖动,价值就体现出来了。
本文内容仅代表个人观点,如有偏颇之处,欢迎读者一同探讨,实际环境中的锁优化还需要结合真实的业务负载来判断。