深入解析Java Synchronized锁机制:从对象头到锁升级全流程

📅 2026/8/7 3:16:15
深入解析Java Synchronized锁机制:从对象头到锁升级全流程
1. 项目概述为什么Synchronized值得深挖在Java并发编程的世界里Synchronized关键字就像空气一样无处不在却又常常被我们习以为常地忽略其复杂性。很多开发者尤其是刚入门的同行往往觉得它不就是个“锁”吗方法或者代码块前面加一下线程就安全了。但当你真正面对高并发场景下的性能瓶颈、死锁排查或者需要精细控制同步粒度时才会发现这个看似简单的关键字背后隐藏着一套从Java语法到JVM运行时再到操作系统内核的精密协作机制。我见过不少项目初期为了快速实现功能大范围地使用synchronized(this)或者直接锁住整个方法。在流量不大的时候相安无事一旦系统压力上来轻则接口响应时间飙升重则整个服务线程池被打满直接“拖死”。排查起来jstack日志里一堆BLOCKED状态的线程矛头直指某个粗粒度的锁。这时候再回头去优化往往伤筋动骨。所以理解Synchronized绝不仅仅是为了应付面试而是为了写出真正高效、健壮的并发程序。它不仅是“锁”更是一套由JVM管理的、完整的线程同步与协作方案。从偏向锁、轻量级锁到重量级锁的升级过程恰恰体现了JVM在并发性能与安全性之间所做的精妙权衡。这次我们就抛开那些笼统的概念深入到字节码、对象头、Monitor、锁升级的每一个细节把它彻底讲透。2. Synchronized的核心原理与锁升级全流程要彻底理解Synchronized就不能只停留在“它让代码串行执行”的层面。我们必须深入到JVM如何实现它而这一切的起点是Java对象在内存中的布局。2.1 对象头锁信息的存储基石在HotSpot虚拟机中一个对象在堆内存中的存储布局可以分为三块对象头Header、实例数据Instance Data和对齐填充Padding。其中对象头是理解Synchronized的关键。它主要包含两部分信息Mark Word标记字段这是对象头里最重要的一部分用于存储对象自身的运行时数据。它的长度在32位JVM中是32位在64位JVM中是64位。Mark Word就像一个多功能的数据区根据对象的状态它会复用相同的比特位来存储不同的信息。当对象未被锁定时它可能存储哈希码HashCode、对象分代年龄GC年龄等。而当对象作为锁时Mark Word就负责存储锁的标志位、指向锁记录或重量级锁Monitor的指针等信息。Klass Pointer类型指针指向对象所属类元数据Klass的指针JVM通过它来确定对象是哪个类的实例。锁的状态信息就巧妙地存储在Mark Word中。下图清晰地展示了32位JVM下Mark Word在不同状态时的结构锁状态25位4位1位偏向锁标志2位锁标志位无锁对象的哈希码对象分代年龄001偏向锁线程ID23位 Epoch2位对象分代年龄101轻量级锁指向栈中锁记录Lock Record的指针000重量级锁指向互斥量Monitor管程的指针010GC标记空不需要记录信息011注意64位JVM的Mark Word结构类似只是位宽更大。锁标志位lock和偏向锁标志biased_lock共同决定了当前对象的锁状态。这个设计非常精妙通过极小的空间复用来支持复杂的锁状态转换。2.2 锁升级的完整路径从偏向到重量级Synchronized的锁是“可升级”的也常被称为“自适应自旋锁”。这种设计是为了在几乎没有竞争和存在激烈竞争的不同场景下都能取得较好的性能。其升级路径是单向的无锁 - 偏向锁 - 轻量级锁 - 重量级锁。锁只能升级不能降级偏向锁可以被撤销回无锁但这不是降级而是重置。2.2.1 偏向锁单人独享的优化设计初衷在大多数情况下锁不仅不存在多线程竞争而且总是由同一个线程多次获得。为了让这个线程获得锁的代价更低引入了偏向锁。工作原理当一个线程第一次访问同步块时会检查对象头Mark Word中的锁标志位和偏向锁标志。如果处于可偏向状态即无锁状态01且允许偏向虚拟机将通过CAS操作将当前线程的ID记录到Mark Word中并将偏向锁标志置为1。此时对象就“偏向”了这个线程。以后该线程再次进入和退出这个同步块时不需要进行任何同步操作如CAS、加锁解锁。只需要简单地检查一下Mark Word里是否存储着自己的线程ID。如果是表明线程已经获得了锁直接进入。优点对于同一个线程重入的场景消除了同步开销性能接近无锁。撤销Revoke这是偏向锁的关键。当有另一个线程尝试竞争这个偏向锁时持有偏向锁的线程需要被挂起Safe PointJVM会遍历栈帧中的锁记录检查持有锁的线程是否存活或仍需要该锁。然后撤销偏向将锁升级为轻量级锁。这个撤销操作是STWStop-The-World的虽然时间很短但在高并发且锁竞争激烈的场景下频繁的偏向锁撤销会带来性能损耗。这就是为什么在JDK 15之后偏向锁被默认禁用并最终在后续版本中被标记为废弃。因为现代多核处理器上真正“无竞争”的场景变少而撤销成本变得不可忽视。2.2.2 轻量级锁短时间竞争的折中方案设计初衷当偏向锁被撤销或者一开始就不满足偏向条件如禁用偏向锁时如果锁竞争不激烈多个线程只是交替执行同步块并不存在锁的长期争用那么使用操作系统内核级的重量级锁互斥量进行线程阻塞和唤醒就太重量级了。轻量级锁通过CAS自旋来避免线程直接进入阻塞。加锁过程在代码即将进入同步块时如果对象未被锁定无锁状态JVM会在当前线程的栈帧中创建一个名为**锁记录Lock Record**的空间。将对象头的Mark Word复制到锁记录中官方称为Displaced Mark Word。然后JVM尝试通过CAS操作将对象头的Mark Word替换为指向锁记录的指针并将锁标志位变为00轻量级锁。如果这个CAS操作成功当前线程就获得了锁。如果失败说明至少存在两条线程在竞争同一个锁。这时轻量级锁并不会立即膨胀而是会进行自旋在JDK 6之后是自适应自旋尝试再次获取锁。解锁过程通过CAS操作尝试把Displaced Mark Word替换回对象头。如果替换成功说明同步期间没有发生竞争整个同步过程顺利完成。如果替换失败说明在持有锁期间有其他线程尝试竞争并导致了锁膨胀见下文此时需要在释放锁的同时唤醒被挂起的线程。2.2.3 重量级锁最终的安全屏障触发条件当轻量级锁竞争失败且自旋也未能成功获取到锁时比如自旋次数超过阈值或者竞争的线程数太多锁就会**膨胀Inflate**为重量级锁。核心机制重量级锁依赖于操作系统层面的**互斥量Mutex Lock来实现。在JVM中每个Java对象都可以关联一个Monitor管程**对象。重量级锁就是让竞争失败的线程进入这个Monitor的等待队列EntryList中并被操作系统挂起阻塞。Monitor模型可以把它想象成一个特殊的房间Object带有一个唯一的钥匙锁。房间有一个入口Entry Set一个所有者Owner和一个等待室Wait Set。Owner持有锁的线程。EntryList多个线程竞争锁没抢到的就在入口处排队等待处于BLOCKED状态。WaitSet已经获得锁的线程如果调用了Object.wait()方法会释放锁并进入等待室处于WAITING或TIMED_WAITING状态等待被notify唤醒。重量级锁的加锁、解锁过程会导致线程在用户态和内核态之间切换涉及操作系统的线程调度挂起和唤醒成本非常高。所以它是保证线程安全最后的、也是最重量级的手段。锁升级的意义这套升级机制的核心思想是“按需付费”。在无竞争或低竞争时使用开销极低的偏向锁或基于CAS的轻量级锁只有在真正发生激烈竞争时才付出操作系统级互斥锁的昂贵开销。这是一种典型的空间换时间Mark Word复用和延迟成本不到万不得已不膨胀的优化策略。3. Synchronized的三种应用方式与字节码本质理解了底层原理我们再回头看它的用法就会清晰很多。Synchronized有三种使用方式它们在字节码层面有细微差别但最终都通向同一个锁机制。3.1 同步实例方法锁住this对象public synchronized void instanceMethod() { // 同步代码 }锁对象当前实例对象this。字节码体现方法访问标志ACC_SYNCHRONIZED被置位。当线程执行该方法时JVM会去检查这个标志。如果设置了执行线程会先尝试获取this对象的Monitor成功后再执行方法体方法执行完毕后释放Monitor。适用场景保护对当前实例状态的并发访问。例如一个BankAccount对象的withdraw方法。实操心得锁住整个实例方法虽然简单但粒度较粗。如果这个方法内部只有一小部分代码需要同步或者方法执行时间较长会导致该实例的其他同步方法也被不必要的阻塞降低并发度。需要仔细评估。3.2 同步静态方法锁住Class对象public static synchronized void staticMethod() { // 同步代码 }锁对象当前类的Class对象例如MyClass.class。因为Class对象在JVM中每个类只有一个所以这会锁住这个类的所有静态同步方法。字节码体现同样是通过ACC_SYNCHRONIZED标志但获取的是对应Class对象的Monitor。适用场景保护静态变量类变量的并发访问。例如一个全局的计数器、缓存等。注意事项静态同步方法的锁粒度非常粗影响范围是全局的针对该类的所有实例。滥用它极易成为系统性能瓶颈需要格外谨慎。通常使用静态对象作为锁来保护静态数据是更好的选择因为你可以控制锁的粒度。3.3 同步代码块最灵活的锁粒度控制public void someMethod() { // 非同步代码... synchronized (lockObject) { // 同步代码块 } // 非同步代码... }锁对象显式指定的任意对象lockObject。这是最灵活的方式。字节码体现在编译后同步代码块的前后会被插入monitorenter和monitorexit两条指令。线程执行到monitorenter时尝试获取lockObject的Monitor执行到monitorexit时释放。适用场景需要精确控制同步范围时。你可以使用一个专门的私有对象private final Object lock new Object();作为锁而不是锁住this或Class对象这遵循了最小化锁范围和专锁专用的原则能有效减少死锁风险和提升并发性能。字节码视角我们通过一个简单例子来看。源代码public void syncBlock() { synchronized (this) { System.out.println(hello); } }使用javap -c反编译后关键部分如下public void syncBlock(); Code: 0: aload_0 // 将this引用压入操作数栈 1: dup // 复制栈顶值this引用 2: astore_1 // 将复制的引用存储到局部变量表槽1用于后续monitorexit 3: monitorenter // 以栈顶值this为锁进入同步块 4: getstatic #2 // 获取System.out静态字段 7: ldc #3 // 将字符串hello压栈 9: invokevirtual #4 // 调用PrintStream.println方法 12: aload_1 // 将局部变量1存储的this引用压栈 13: monitorexit // 退出同步块释放锁 14: goto 22 // 跳转到方法结束 17: astore_2 // 异常处理开始将异常对象存储到槽2 18: aload_1 // 再次加载锁对象this 19: monitorexit // 确保在异常路径上也释放锁 20: aload_2 21: athrow 22: return可以看到编译器不仅生成了正常的monitorenter/monitorexit对还额外添加了一个异常处理表确保即使在同步块内抛出异常锁也能被正确释放。这是Synchronized关键字提供的隐式安全性保障之一避免了因异常导致锁无法释放的死锁问题。4. 深入JVM源码与操作系统锁的最终归宿当锁膨胀为重量级锁后故事就从JVM层面转移到了操作系统层面。理解这一层能让我们明白“重量级”到底重在哪里。4.1 ObjectMonitor重量级锁的JVM实现在HotSpot源码中重量级锁的核心是ObjectMonitor类位于src/hotspot/share/runtime/objectMonitor.hpp。每个需要作为重量级锁的Java对象其Mark Word中指向的就是一个ObjectMonitor对象。ObjectMonitor的关键数据结构简化如下_owner指向持有锁的线程ObjectWaiter*。_WaitSet管理那些调用了wait()方法的线程队列。_EntryList管理那些在入口处等待锁的线程队列BLOCKED状态。_recursions锁的重入次数。_count一个计数器用于平衡enter和exit操作。当一个线程尝试获取重量级锁即调用ObjectMonitor::enter时通过CAS操作尝试将_owner设置为当前线程。如果成功获取锁_recursions递增支持重入。如果失败线程会尝试自旋一段时间自适应自旋。自旋失败后线程被包装成ObjectWaiter节点加入到_EntryList中然后通过操作系统调用如pthread_mutex_lock将自身挂起park进入阻塞状态。当持有锁的线程释放锁ObjectMonitor::exit时递减_recursions如果减到0表示锁完全释放。从_EntryList或_WaitSet中唤醒一个或多个等待线程。被唤醒的线程会重新尝试获取锁。线程状态的映射在jstack输出的线程堆栈中等待重量级锁的线程会显示为BLOCKED (on object monitor)并指明在等待哪个对象的Monitor。而调用了wait()的线程则处于WAITING (on object monitor)或TIMED_WAITING (on object monitor)状态。4.2 从JVM到操作系统Park与Unpark线程的挂起阻塞和唤醒最终是通过操作系统提供的线程调度机制完成的。在Linux系统下HotSpot虚拟机通常使用POSIX线程库pthread中的互斥锁mutex和条件变量condition variable或者更底层的光栅futex系统调用来实现。当ObjectMonitor决定挂起一个线程时最终会调用到os::PlatformEvent::park()这样的本地方法该方法内部可能会调用pthread_cond_wait或futex将线程从运行状态移出放入操作系统的等待队列。这个过程中会发生用户态到内核态的上下文切换这是一个成本很高的操作。同样唤醒线程os::PlatformEvent::unpark()也需要进行系统调用触发内核重新调度线程。正是这种频繁的内核态切换使得重量级锁的代价高昂。排查技巧在性能分析中如果发现大量的sysCPU时间内核态时间占用并且伴随着大量的线程上下文切换cs值很高可用vmstat或pidstat查看就需要警惕是否是重量级锁竞争过于激烈导致的。可以使用jstack工具抓取线程快照查看BLOCKED状态的线程和它们等待的锁结合jstat -gc查看GC情况因为STW的GC也会导致所有线程暂停可能被误判为锁竞争来定位问题根源。5. 实战Synchronized的常见问题与性能调优理论最终要服务于实践。下面我们来看几个Synchronized使用中的典型问题和优化思路。5.1 典型误区与避坑指南误区一锁粒度不当问题在方法级别滥用synchronized或者使用一个大对象如全局静态对象来保护所有细粒度操作。案例一个订单服务中createOrder方法被synchronized修饰但该方法内部包含了订单校验、库存检查、生成订单号、写数据库等多个步骤其中只有“生成订单号”需要保证唯一性和“扣减库存”需要同步。这导致创建订单的吞吐量极低。优化使用同步代码块只锁住必要的共享资源。例如用单独的锁对象保护订单号生成器用商品ID作为锁键来细粒度地锁库存需注意死锁风险。误区二锁对象选择错误问题锁住了可能被重新赋值的对象或者锁住了公用对象。案例synchronized(lock)但lock是一个非final的成员变量外部代码可能改变lock的引用导致不同线程实际上锁的是不同对象同步失效。优化锁对象应声明为private final。如果是静态锁也应声明为private static final。误区三在循环内加锁问题在循环体内频繁地加锁、解锁产生巨大的性能开销。案例遍历一个列表对每个元素进行需要同步的处理。// 低效做法 for (Item item : itemList) { synchronized(this) { process(item); } }优化如果允许尽量将同步范围扩大到整个循环体外。如果循环内操作必须独立同步考虑使用更高效的并发容器如ConcurrentHashMap或并行流需注意线程安全。误区四误以为synchronized锁的是代码问题这是概念性错误。Synchronized锁的是对象而不是代码块。两个线程锁不同的对象不会互斥。纠正时刻记住同步的关键在于所有竞争线程必须争夺同一个对象的Monitor。5.2 性能调优与监控手段偏向锁的取舍JDK 15默认已禁用-XX:-UseBiasedLocking。对于新项目通常无需再关心。旧版本JDK如果应用是“多线程写多”的类型如消息队列消费者可以在JVM启动参数中显式禁用偏向锁-XX:-UseBiasedLocking避免撤销开销。如果是“单线程写多”或“读多写少”开启偏向锁可能有益。自适应自旋优化JDK 6之后轻量级锁竞争失败后的自旋次数不再是固定的而是由JVM根据“上一次在同一个锁上的自旋是否成功”以及“持有锁的线程是否正在运行”来动态调整。这个策略通常工作得很好一般不需要手动干预。锁膨胀的监控使用jstack定期检查线程状态关注BLOCKED线程的数量和等待的锁。使用Java Mission Control (JMC) 或商业APM工具如Arthas的monitor命令可以监控特定方法的锁竞争情况等待时间、持有时间。通过-XX:PrintFlagsFinal查看JVM锁相关的参数如BiasedLockingStartupDelay偏向锁启动延迟、UseSpinning是否开启自旋等但非必要不建议修改。替代方案选型ReentrantLock相比synchronized它提供了更丰富的功能可中断的锁等待、公平锁、多个条件变量Condition、尝试非阻塞获取锁tryLock。在需要这些高级功能的场景下是更好的选择。但synchronized在JDK 6优化后在基础锁场景下性能已不逊色且语法简洁由JVM自动释放不易出错。StampedLock提供了乐观读模式在读多写少的场景下性能可能远超synchronized和ReentrantReadWriteLock。无锁编程对于计数器等场景优先考虑AtomicInteger、LongAdder等原子类它们基于CAS实现在高并发下通常性能更好。5.3 死锁的识别、预防与解决死锁是使用锁无法回避的问题。Synchronized导致的死锁通常是因为多个线程以不同的顺序请求多个锁。死锁产生的四个必要条件必须同时满足互斥条件资源是独占的。请求与保持条件线程持有至少一个资源同时请求另一个被其他线程持有的资源。不剥夺条件线程已获得的资源在未使用完之前不能被强制抢占。循环等待条件存在一个线程-资源的环形等待链。排查死锁jstack最直接的工具。运行jstack -l pid如果存在死锁JVM会在输出末尾清晰地报告Found one Java-level deadlock:并列出死锁线程的堆栈和等待的资源。JConsole/JVisualVM图形化工具线程标签页可以检测死锁。预防死锁的策略固定锁顺序强制所有线程以相同的全局顺序获取锁。这是最有效、最常用的方法。例如有两个账户A和B规定转账时总是先锁ID小的账户再锁ID大的账户。尝试锁Try-Lock使用ReentrantLock的tryLock()方法在获取锁失败时进行回退或重试避免无限期等待。Synchronized本身不支持此功能。锁超时同样使用ReentrantLock的tryLock(long timeout, TimeUnit unit)方法。减少锁粒度使用细粒度锁降低线程同时持有多个锁的概率。使用开放调用在调用外部方法时不持有锁。即缩小同步块的范围只在访问共享数据时加锁调用其他可能耗时或未知的方法前先释放锁。一个死锁案例与解决// 有死锁风险的代码 class Account { public void transfer(Account target, int amount) { synchronized(this) { // 先锁自己 synchronized(target) { // 再锁对方 // 转账操作... } } } } // 线程1: a.transfer(b, 100) // 线程2: b.transfer(a, 200) // 可能发生死锁线程1锁a等b线程2锁b等a。解决方案固定顺序class Account { private final Object tieLock new Object(); // 用于hash相同时的加锁 public void transfer(Account target, int amount) { Account first this; Account second target; if (System.identityHashCode(first) System.identityHashCode(second)) { first target; second this; } synchronized(first) { synchronized(second) { // 转账操作... } } } }通过比较两个对象的identityHashCode来确定一个全局的、一致的加锁顺序从而打破循环等待条件。