☰
Java锁底层原理:对象头、锁升级与内存屏障全解析
2026/10/1 17:39:51 网站建设 项目流程

我们平时写Java代码,说到“锁”基本就是synchronized和ReentrantLock,面试也喜欢问“锁升级过程”“偏向锁是什么”,但真到排查线上性能问题,或者自己去实现一个并发组件时,光背八股是不够的。Java锁的底层原理,核心其实就两条线:对象头里存锁状态,内存屏障保证可见性和顺序性。把这两条线捋清楚,很多以前觉得玄乎的概念——比如“轻量级锁自旋”“volatile禁止重排序”“AQS为什么用CAS”——都会瞬间串起来,看源码也不再糊涂。

这篇文章不打算按教科书目录念。我会从对象头的实际内存布局讲起,然后顺着锁升级的完整链路走到重量级锁,再单独把内存屏障这块硬骨头用生活化的方式拆开,最后对比synchronized和AQS的实现差异,顺带聊几个我实际踩过的坑和面试高频追问。内容偏底层,但没有C/C++基础也能看懂,只要你对synchronized怎么用有一定了解,就能跟上。

1. 先搞清楚锁到底在锁什么:Java对象头

1.1 对象在内存里长什么样

Java对象在堆内存中的布局分三块:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。对于锁来说,关键全部集中在对象头里。

在64位JVM中(且开启了压缩指针,这是默认配置),一个普通对象的对象头占用12字节,由两部分组成:

  • Mark Word:8字节,存对象的哈希码、GC分代年龄、锁状态标志位、指向锁记录的指针、指向Monitor的指针等。锁的核心秘密全在这里。
  • 类型指针(Klass Pointer):4字节,指向对象的类元数据,JVM通过它判断这个对象到底是哪个类的实例。

如果是数组对象,对象头里还会多一个4字节的数组长度。这多出来的长度是必要的,因为JVM必须知道数组到底有多少个元素,否则无法遍历和做边界检查。

Mark Word是锁机制的重中之重。它本质上是一块复用存储空间——同一个8字节区域,在不同状态下存放的数据含义完全不同。普通状态下,它存的是无锁信息;一旦进入锁竞争,它会改写为锁相关指针。这种“同一块内存,多种解释方式”的设计,就是为了在不额外增加对象内存开销的前提下支持锁功能。后面讲偏向锁、轻量级锁时你会发现,锁升级就是Mark Word里那几位标志位在变化。

1.2 Mark Word的位布局与锁标志位

64位JVM的Mark Word具体位布局如下(这是HotSpot源码markOop.hpp里的经典划分,不同JDK版本略有差异,但核心一致):

锁状态标志位(2bit)Mark Word存储内容
无锁01对象哈希码(31bit)、GC分代年龄(4bit)、是否偏向锁(1bit)
偏向锁01持有线程ID(54bit)、偏向时间戳(epoch)、GC分代年龄、是否偏向锁(1bit)
轻量级锁00指向线程栈中Lock Record的指针
重量级锁10指向Monitor(监视器锁)的指针
GC标记11标记为可回收状态,与锁无关

注意一个细节:无锁和偏向锁的标志位都是01,区分它们靠的是Mark Word里那个单独的biased_lock位(1bit)。biased_lock=1表示可偏向,=0表示不可偏向。这也是为什么偏向锁被撤销后,对象会进入“不可偏向的无锁状态”,而不是简单回到普通无锁。

理解这个布局后,很多问题就有答案了:

  • 为什么调用Object.hashCode()之后,偏向锁就失效了?因为无锁状态下Mark Word里存了31位的hashCode,而偏向锁要把这31位腾给线程ID。一旦对象计算过identity hash code,JVM就不允许它再进入偏向锁状态,因为没地方存储hashCode了。
  • 为什么偏向锁撤销代价高?因为撤销时要让拥有锁的线程到达安全点,然后修改Mark Word。多个线程频繁交替执行临界区时,偏向锁反而不如轻量级锁。

这里我补一句自己的实测经验:早期JDK 8默认开启偏向锁(-XX:+UseBiasedLocking),但很多高并发应用在启动初期会连续创建大量对象并短时间使用,导致偏向锁撤销风暴,反而拖慢性能。所以后来不少团队会直接关闭偏向锁,或者升级到JDK 15+(JEP 374默认禁用了偏向锁,JDK 18彻底废弃)。处理老的面试题时,要知道这些历史背景。

2. 从偏向锁到重量级锁:锁升级的完整链路

2.1 偏向锁:为“单线程反复获取同一把锁”设计的懒人模式

偏向锁的核心思想很简单:如果这把锁自始至终只有一个线程获取,那就不需要真正加锁。第一次获取时,JVM在Mark Word里记录这个线程的ID,之后这个线程再来,只需要检查当前线程ID是否和Mark Word里记录的一致——一致就直接进入临界区,整个过程完全不需要任何原子操作,性能开销趋近于零。

这里的关键是“不需要原子操作”。因为偏向锁的获取不会修改共享变量,只是读取Mark Word并比较线程ID。只有在第一次获取和撤销时,才需要CAS操作修改Mark Word。所以它的优势在于:一个线程反复拿锁时,延迟极低。

什么场景适合偏向锁?典型的如Vector、StringBuffer、HashTable这类线程安全类中的Synchronized方法。在单线程访问时,它们不应该有任何同步开销。偏向锁正是为这类场景兜底。

2.2 轻量级锁:当第二个线程来竞争,偏向锁开始“退让”

一旦第二个线程需要获取这把偏向锁,偏向锁就无法继续“坐享其成”了。此时发生批量重偏向或批量撤销相关逻辑,或者直接进入偏向锁撤销流程。撤销偏向锁时,原持有线程到达安全点,Mark Word被改写为无锁状态,然后采用轻量级锁重新竞争。

轻量级锁的获取流程很有意思:

  1. 线程在自己的栈帧中分配一个Lock Record空间,里面存着指向Mark Word的副本。
  2. 使用CAS操作,尝试把对象头Mark Word中的低位指针指向自己的Lock Record,同时把标志位从01改为00。
  3. 如果CAS成功,轻量级锁获取成功。
  4. 如果CAS失败,说明存在竞争,此时线程会自旋——原地空转等待一段时间,而不是立刻阻塞挂起。

为什么要有自旋?因为线程挂起和恢复需要操作系统介入,涉及用户态/内核态切换,开销很大。而临界区代码通常很短,持有锁的线程马上就能释放,自旋一小段时间可能比挂起再唤醒要快得多。

自旋不是无限转,JDK 6之后引入了适应性自旋:JVM根据前一次自旋的等待时间和锁持有者的状态动态调整自旋次数,不再固定。

我用一个生活类比解释这层升级逻辑:偏向锁相当于写字楼里固定工位专属卡,你每次来直接坐,不用登记;第二个员工偶尔要用这个工位,这就不方便了,于是换成了临时访客卡,每次来都要在门口刷一下卡(CAS),如果门口正好有人,就在旁边等一会儿(自旋);如果天天有人抢工位,干脆请一个前台专门管理(重量级锁)。

2.3 重量级锁:进入Monitor,线程真正阻塞

如果自旋多次仍然竞争不到轻量级锁,说明锁竞争非常激烈,JVM会将其膨胀为重量级锁。重量级锁本质上依赖操作系统的互斥量(mutex)来实现阻塞和唤醒。Mark Word里保存的是指向ObjectMonitor的指针,ObjectMonitor内部包含一个等待队列、一个阻塞队列等数据结构。

线程竞争重量级锁时,如果没抢到,会被操作系统挂起,进入阻塞状态,直到持有锁的线程执行完synchronized块,调用monitorExit,系统才会唤醒阻塞队列中的线程。唤醒哪个线程?由系统调度决定,不一定遵循FIFO。这也是公平性话题的引入点:synchronized是非公平的,而ReentrantLock可以指定公平模式。

重量级锁的完整流程中涉及一个关键动作:阻塞和唤醒都需要操作系统系统调用,每一次lock/unlock都可能触发系统调用,这就是为什么重量级锁效率低。也是从这一级开始,内存屏障的作用变得格外显眼。

2.4 锁升级不可逆与批量处理机制

锁升级是单向的:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,不能回退。也就是说,一旦重量级锁,之后即使竞争微弱,也一直是重量级锁。这显得有点“钢”,但这是为了简化JVM实现,避免复杂的锁状态回溯。

难点在于偏向锁的批量撤销(bulk rebias)和批量重偏向(bulk rebiasing)。如果大量对象都被同一个线程偏向锁持有,后来另一个线程也要访问它们,逐个撤销代价太大。JVM引入了类级别的epoch时间戳:

  • 每次发生批量撤销时,类的epoch值+1。
  • 如果对象的epoch和类的epoch不一致,说明偏向锁已经失效,可以安全地重新偏向新线程。
  • 当批量撤销达到一定阈值(默认20次),JVM会把当下所有偏向该线程的对象重新偏向到新竞争的线程;当撤销达到40次,则彻底禁用偏向锁,后续全部走轻量级锁。

这些阈值可以通过-XX:BiasedLockingBulkRebiasThreshold和-XX:BiasedLockingBulkRevokeThreshold调整。实际生产环境中,如果你观察日志发现“Revoke Bias”频繁出现,说明偏向锁不仅没提升性能,反而在拖后腿,直接用JVM参数关闭偏向锁可能更快。

3. 内存屏障:锁的可见性与顺序性基石

3.1 为什么要关心内存屏障

如果只把锁理解为“互斥”,就忽略了一半问题。Java并发模型(JMM)要求:一个线程在临界区内修改的变量,在另一个线程进入同一把锁保护的临界区后,必须能看到前一个线程的全部修改。

但现代CPU和编译器并不会严格按照代码顺序执行。CPU可能乱序执行,编译器可能重排代码,缓存也可能导致一个CPU核看不到另一个核刚刚写入的变量。如果没有额外的机制,锁的语义根本无法成立。

解决这个问题的底层机制就是内存屏障(Memory Barrier)。说直白点,它是一条指令,作用是禁止其左右两侧的操作跨过屏障顺序重排,同时配合缓存一致性协议,让屏障附近的内存操作对其他处理器可见。

用生活类比:内存屏障就像路口的那条停止线。你在线内等待,线上左边的车流和右边的车流不能随意越过线穿插。编译器看到屏障指令后,无法把屏障后的代码挪到屏障前,也无法把屏障前的代码挪到屏障后。

3.2 四种基础屏障与Java中的映射

从底层指令集角度,最常见的四类屏障是:

屏障类型作用
LoadLoad屏障前的读操作不会重排到屏障后的读操作之后
StoreStore屏障前的写操作不会重排到屏障后的写操作之后
LoadStore屏障前的读操作不会重排到屏障后的写操作之后
StoreLoad屏障前的写操作不会重排到屏障后的读操作之后,全屏障,开销最大

Java中的volatile读写、synchronized加锁解锁、CAS操作,在JIT编译后都会生成对应的屏障指令。Unsafe类里也有直接的屏障方法:loadFence()、storeFence()、fullFence()。

synchronized和volatile的关系很多人搞混。这里一句话点透:volatile是轻量的可见性保证,它不保证原子性(除了对long/double的写入),但它能保证单次读写的可见性和禁止重排序。synchronized是互斥+可见性双重保证,进入锁时读到的变量一定来自主内存,退出锁时变量一定写回主内存。这些保证正是通过屏障指令实现的。

3.3 JMM对锁的屏障规则

JMM规定:解锁(monitor exit)前必须执行StoreStore屏障,确保在临界区中对变量的写入操作在解锁前刷新到主内存,然后执行StoreLoad屏障确保后续的加锁操作不会读到旧值。加锁(monitor enter)后必须执行LoadLoad屏障,确保从主内存重新读取锁保护的变量。

这也是为什么synchronized代码块里,即使变量不是volatile,线程A修改后线程B也能看到。因为加锁和解锁动作强制刷主内存、重新读主内存。当然,主内存是个逻辑概念,物理上依赖CPU的缓存一致性协议(如MESI),但JMM层面我们不需要关心底层协议细节。

再看ReentrantLock。它是基于AQS实现的,AQS内部有个volatile int state字段。加锁时用CAS修改state,CAS本身会带有全屏障语义;释放锁时调用state的volatile写,配合Unsafe的putOrderedInt之类的方法。所以ReentrantLock的可见性保证完全覆盖synchronized。这也是面试中常问“ReentrantLock和synchronized都能保证可见性,底层有什么区别”的切入点。

3.4 实践中的内存屏障陷阱

我在实际并发编程中踩过几个坑,都跟屏障有关:

第一件事,是双重检查锁(DCL)中instance字段必须声明为volatile。如果不用volatile,在JDK 1.5之前,指令重排会导致线程返回一个未构造完成的对象。JDK 1.5后,volatile加强了语义,DCL才能安全使用。这个知识点面试必问,也有不少人嘴上会背,实际写代码时依然会忘记加volatile。

第二件事,是伪共享(False Sharing)。虽然伪共享本质是缓存行的问题,但和内存屏障也有关系。当多个线程修改同一个缓存行中的不同变量时,缓存一致性协议会让彼此的操作互相干扰,表现为严重的性能下降。常见的解决方式是用@Contended注解或手动填充缓存行(64字节)。我在写一个高并发计数器时遇到过吞吐量从千万级掉到百万级,排查了很久才发现是伪共享,加缓存行填充后立刻恢复。

第三件事,是不要迷信自旋。有些同学一看到CAS就说“高效”,但CAS在高冲突场景下会导致大量无意义的循环重试,CPU空转,反而比阻塞更糟。如果你的临界区代码比较长,重量级锁的阻塞可能更合适;如果你的临界区代码极短且冲突概率低,自旋CAS更合适。这个判断能力是并发编程水平的分水岭。

4. 从synchronized到AQS:ReentrantLock的底层对比与选型

4.1 synchronized的监视器锁实现

重量级锁对应的ObjectMonitor本质是一个C++对象,内部关键字段包括:

  • _owner:当前持有锁的线程。
  • _recursions:记录重入次数。同一个线程同一个Monitor可以重入多次,每次monitorentry都递增。
  • _cxq、_EntryList、_WaitSet:两个阻塞队列和一个等待队列(wait/notify相关)。

ObjectMonitor的竞争流程大致是:线程尝试CAS将_owner置为自己,失败则进入_cxq队列,竞争释放时有“后进先出”趋势,这就是synchronized非公平的根源。JDK 6之后对ObjectMonitor做了大量优化,比如收缩锁、扁平化锁等,但大框架不变。

重入的底层解释:monitorenter指令可以多次执行,ObjectMonitor的_recursions递增,monitorexit递减。所以synchronized的可重入是“天然”的,不需要像ReentrantLock那样用state字段手动管理。

4.2 AQS的模板方法与state字段

ReentrantLock基于AbstractQueuedSynchronizer(AQS)。AQS内部维护一个volatile int state和双向队列,队列节点代表等待获取锁的线程。子类只需要实现tryAcquire和tryRelease两个方法,定义如何修改state。

  • state = 0时,锁空闲。
  • state = 1时,锁被某个线程持有一次。
  • state = n时,表示同一个线程重入n次。

ReentrantLock默认非公平,NonfairSync.tryAcquire中第一步就是CAS抢占,完全不管队列里有没有人在等。FairSync.tryAcquire则会先检查队列中是否有前驱节点,如果有,则老老实实排队。这就是公平与非公平的底层区别。

进入acquire流程后,如果tryAcquire失败,线程会被包装成Node加入队列尾部,并借助LockSupport.park阻塞自己。因为state是volatile的,所以tryAcquire中对state的读能看到前一个线程释放锁后对state的写。这就是可见性保证的具体链路。

4.3 为什么推荐ReentrantLock的场景,以及为什么不总是用它

从功能上看,ReentrantLock比synchronized多出的核心能力:

  • 可中断获取锁:lockInterruptibly(),在等待锁的过程中可以响应中断。
  • 超时获取锁:tryLock(timeout, unit),避免无限期阻塞。
  • 多个条件变量:Condition支持多个等待队列,更精细地管理唤醒。
  • 公平性选择:公平锁适合多线程长任务排队场景,非公平锁适合短临界区高吞吐场景。

那为什么不直接用ReentrantLock替换所有synchronized?因为synchronized经过JVM几十年的优化,已经非常高效,且代码简洁、自动释放锁,不会出现忘记unlock导致的死锁。JIT还会对synchronized做锁消除和锁粗化优化。ReentrantLock需要手动释放,原子性、可见性、顺序性保障由AQS和Unsafe完成,性能上在JDK 8之后差距并不大。

我自己的选型建议是:

场景推荐
单个方法加锁,代码简单synchronized
需要超时、中断、多条件变量ReentrantLock
追求非公平高吞吐,且临界区极短两者皆可,固定后压测
需要公平排队ReentrantLock(true)
分布式环境下的多个服务互斥Redis分布式锁或数据库锁(等会聊)

4.4 分布式锁的底层呼应

虽然标题是Java锁底层,但热词里不断出现分布式锁、Redis分布式锁,这里简单串联一下:分布式锁解决的是多进程或多机器间的互斥,和JVM内存锁不在一个层面。Redis分布式锁最常见的实现是SET NX EX命令,在单Redis实例下可以保证互斥。但要注意,分布式锁的安全边界要复杂得多——主从切换可能丢锁,Redisson的看门狗续期机制能缓解这种问题,但要彻底解决需要Redlock算法(也存在争议)。

我当年做订单分布式锁时,踩过一个典型坑:用SETNX加锁后,业务代码抛异常导致锁没释放,后面所有请求全部卡死。后来改成在finally里释放,并加上锁的持有者标识,只允许持有者释放锁。这提醒我,分布式锁的健壮性不只是底层原理,工程实践同样重要。在面试中讲到这里,能体现出对临界资源和一致性问题的完整理解。

5. 常见问题排查与面试高频坑点

5.1 性能排查:怎么定位是锁导致的慢

线上接口突然变慢,怎么判断是锁的问题?我一般按这个顺序排查:

  1. 用jstack抓线程栈,如果大量线程处于java.lang.Thread.State: BLOCKED或WAITING,说明锁竞争激烈。
  2. 查看线程栈中的monitor信息,jstack会打印出锁对象的地址,例如- locked <0x00000000abcd1234> (a java.lang.Object),可以定位到具体哪个对象是瓶颈。
  3. 用jcmd Thread.print -l或者Arthas的thread -b,找出阻塞其他线程的“锁王”。
  4. 如果是ReentrantLock,可以通过jstack看到“parking to wait for <0x...>”,配合AQS的队列信息分析。
  5. 借助-XX:+PrintSafepointStatistics和-XX:+PrintGCDetails排除GC导致的停顿时,锁问题往往和GC问题相伴。

有个细节要提醒:锁竞争不一定表现为BLOCKED。自旋的轻量级锁在jstack里看到的线程状态是RUNNABLE,但它实际上在空转CPU,占用率飙升。这时候要看CPU使用率,再用top -Hp看线程级CPU占用,掂量一下是否自旋过度。

5.2 死锁、活锁与饥饿:三个容易混淆的状态

  • 死锁:线程A持有锁1等待锁2,线程B持有锁2等待锁1,二者互相等待。jstack能检测出死锁,打印“Found one Java-level deadlock”。
  • 活锁:线程没有阻塞,但一直在重复尝试动作,互相谦让,导致无法推进。例如两个线程同时检测到冲突,都主动释放资源,然后再获取,反复循环。
  • 饥饿:线程长时间得不到锁,因为非公平锁让新来的线程不断抢占,老线程一直排队。ReentrantLock(false)下可能出现饥饿;公平锁能缓解饥饿。

我在实际项目中遇到过活锁,场景是重试机制设计不当:A和B同时操作数据库行,发生冲突后各自等待随机时间再重试,结果等待时间太短,仍然同时重试,一晚上都在空转。解决办法是把重试退避策略改成指数退避加随机抖动,才稳定下来。

5.3 锁相关经典面试问题:不要只背结论

列几个我在面试中常问的,也是读者最可能被追问的:

1. 为什么synchronized是非公平的?对象Monitor的入口队列是_cxq,新来的线程直接CAS抢占owner,成功就进去了,排队的线程只能等。这就是非公平。

2. volatile的可见性是怎么实现的?JIT在volatile读后插入LoadLoad和LoadStore屏障,在volatile写前插入StoreStore,写后插入StoreLoad屏障。底层配合CPU缓存一致性协议,使写操作能刷新到主内存,读操作能拿到最新值。

3. CAS失败真的会自旋吗?Java层面AtomicInteger.getAndIncrement()在自增时若CAS失败会进入循环重试。底层Unsafe.compareAndSwapInt是JNI调用,依赖于CPU原子指令(如cmpxchg)。Lock前缀能锁住内存总线或缓存行,保证读改写原子性。循环重试就是自旋。

4. 偏向锁撤销为什么消耗性能?撤销偏向锁需要等待持有锁的线程到达安全点,安全点通常涉及到线程状态的冻结、执行栈的遍历等操作,过频繁的安全点STW会让所有线程暂停一小段,高并发场景下累积效应很明显。

5. 为什么锁都要支持可重入?如果不可重入,同一线程调用一个持有锁的方法再去调用另一个加同一把锁的方法就会死锁。可重入性让锁的粒度控制更灵活,是设计同步模块时的基本要求。

6. 分布式锁和本地锁的本质区别?本地锁由JVM保证,分布式锁需要外部存储配合。本地锁只需要一个原子变量,分布式锁需要网络通信、过期时间、时钟漂移等一大堆问题,复杂度不是一个量级。

5.4 一份避坑清单

最后整理一份我的实战避坑清单:

  • 不要在锁内做耗时操作:比如持有锁访问远程服务、写日志、查数据库。锁内代码越短越好。我见过有同事在synchronized块里调外部API,结果把整个系统拖挂了。
  • 锁对象不要用字符串字面量:比如synchronized("lock"),字符串常量池会复用对象,可能让完全不相关的地方互相锁住。用专门的对象或private final Object lock = new Object()。
  • ReentrantLock一定要在finally里unlock:别抱着“反正代码不会抛异常”的想法。业务代码一旦异常,锁永远不会释放,轻则线程阻塞,重则系统崩溃。
  • 不要随意调整JVM锁参数:-XX:+UseBiasedLocking、-XX:BiasedLockingStartupDelay这些参数跟JDK版本相关,需要压测验证。默认参数在绝大多数场景已经调优过,乱调容易踩雷。
  • 锁对象不能被GC回收:在持有锁期间,锁对象引用要能可达。如果一个对象没被强引用,在synchronized块中可能被GC,虽然HotSpot做了处理,但别依赖这种事情。
  • 小心锁存放在ThreadLocal:ThreadLocal与线程绑定,如果线程池复用线程,ThreadLocal里的锁对象状态混乱会让你怀疑人生。

这六年多写Java并发代码,我最大的感受是:锁不是什么魔法,它就是在“正确性”和“性能”之间走钢丝。理解对象头能让你明白为什么锁有那么多状态,理解内存屏障能让你明白为什么锁能保可见性,而真正的工程能力,是在无数个线上事故里磨出来的。下次再遇到锁相关的面试题,别只背书,试着从对象头里那几个位讲起,再讲到CPU指令和JMM,最后落到你们项目里的选型和事故,这套组合拳比任何标准答案都更有说服力。

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

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

立即咨询