ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java锁底层原理:对象头、锁升级与内存屏障全解析

Java锁底层原理:对象头、锁升级与内存屏障全解析 我们平时写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 Word8字节存对象的哈希码、GC分代年龄、锁状态标志位、指向锁记录的指针、指向Monitor的指针等。锁的核心秘密全在这里。类型指针Klass Pointer4字节指向对象的类元数据JVM通过它判断这个对象到底是哪个类的实例。如果是数组对象对象头里还会多一个4字节的数组长度。这多出来的长度是必要的因为JVM必须知道数组到底有多少个元素否则无法遍历和做边界检查。Mark Word是锁机制的重中之重。它本质上是一块复用存储空间——同一个8字节区域在不同状态下存放的数据含义完全不同。普通状态下它存的是无锁信息一旦进入锁竞争它会改写为锁相关指针。这种“同一块内存多种解释方式”的设计就是为了在不额外增加对象内存开销的前提下支持锁功能。后面讲偏向锁、轻量级锁时你会发现锁升级就是Mark Word里那几位标志位在变化。1.2 Mark Word的位布局与锁标志位64位JVM的Mark Word具体位布局如下这是HotSpot源码markOop.hpp里的经典划分不同JDK版本略有差异但核心一致锁状态标志位2bitMark Word存储内容无锁01对象哈希码31bit、GC分代年龄4bit、是否偏向锁1bit偏向锁01持有线程ID54bit、偏向时间戳epoch、GC分代年龄、是否偏向锁1bit轻量级锁00指向线程栈中Lock Record的指针重量级锁10指向Monitor监视器锁的指针GC标记11标记为可回收状态与锁无关注意一个细节无锁和偏向锁的标志位都是01区分它们靠的是Mark Word里那个单独的biased_lock位1bit。biased_lock1表示可偏向0表示不可偏向。这也是为什么偏向锁被撤销后对象会进入“不可偏向的无锁状态”而不是简单回到普通无锁。理解这个布局后很多问题就有答案了为什么调用Object.hashCode()之后偏向锁就失效了因为无锁状态下Mark Word里存了31位的hashCode而偏向锁要把这31位腾给线程ID。一旦对象计算过identity hash codeJVM就不允许它再进入偏向锁状态因为没地方存储hashCode了。为什么偏向锁撤销代价高因为撤销时要让拥有锁的线程到达安全点然后修改Mark Word。多个线程频繁交替执行临界区时偏向锁反而不如轻量级锁。这里我补一句自己的实测经验早期JDK 8默认开启偏向锁-XX:UseBiasedLocking但很多高并发应用在启动初期会连续创建大量对象并短时间使用导致偏向锁撤销风暴反而拖慢性能。所以后来不少团队会直接关闭偏向锁或者升级到JDK 15JEP 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被改写为无锁状态然后采用轻量级锁重新竞争。轻量级锁的获取流程很有意思线程在自己的栈帧中分配一个Lock Record空间里面存着指向Mark Word的副本。使用CAS操作尝试把对象头Mark Word中的低位指针指向自己的Lock Record同时把标志位从01改为00。如果CAS成功轻量级锁获取成功。如果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修改stateCAS本身会带有全屏障语义释放锁时调用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到AQSReentrantLock的底层对比与选型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基于AbstractQueuedSynchronizerAQS。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 性能排查怎么定位是锁导致的慢线上接口突然变慢怎么判断是锁的问题我一般按这个顺序排查用jstack抓线程栈如果大量线程处于java.lang.Thread.State: BLOCKED或WAITING说明锁竞争激烈。查看线程栈中的monitor信息jstack会打印出锁对象的地址例如- locked 0x00000000abcd1234 (a java.lang.Object)可以定位到具体哪个对象是瓶颈。用jcmd Thread.print -l或者Arthas的thread -b找出阻塞其他线程的“锁王”。如果是ReentrantLock可以通过jstack看到“parking to wait for 0x...”配合AQS的队列信息分析。借助-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做了处理但别依赖这种事情。小心锁存放在ThreadLocalThreadLocal与线程绑定如果线程池复用线程ThreadLocal里的锁对象状态混乱会让你怀疑人生。这六年多写Java并发代码我最大的感受是锁不是什么魔法它就是在“正确性”和“性能”之间走钢丝。理解对象头能让你明白为什么锁有那么多状态理解内存屏障能让你明白为什么锁能保可见性而真正的工程能力是在无数个线上事故里磨出来的。下次再遇到锁相关的面试题别只背书试着从对象头里那几个位讲起再讲到CPU指令和JMM最后落到你们项目里的选型和事故这套组合拳比任何标准答案都更有说服力。
返回列表