synchronized类锁与对象锁:从字节码到JVM对象头的全面解析

发布时间:2026/9/16 12:41:32
synchronized类锁与对象锁:从字节码到JVM对象头的全面解析 先说一个很多人踩过的坑。你写了一个库存扣减服务同事在 static 方法上加了 synchronized你觉得没必要直接在实例方法上也加了 synchronized结果并发压测的时候超卖了。排查到最后才发现两个线程进的根本不是同一把锁。static synchronized 锁的是类锁synchronized 实例方法锁的是对象锁两者互不干涉。但问题来了——类锁到底锁的是什么它和对象锁在 JVM 内存里有什么区别为什么 static 方法就必须用类锁这篇文章我会从字节码、对象头、JVM 的 klass 模型三个层面把这个面试常客彻底讲透。1. 反编译之后synchronized 最容易被忽视的真面目很多讲 synchronized 的文章一上来就讲锁升级、Mark Word但我觉得最直观的切入点是先看看 synchronized 编译之后长什么样。你在 IDE 里写一段代码javac 编译再用 javap 反编译synchronized 的底层形态立刻暴露出来。这一步搞清楚了后面什么对象锁、类锁的问题都迎刃而解。1.1 同步代码块与同步方法的字节码差异先看一段最简单的代码public class SyncDemo { public void blockMethod() { synchronized (this) { System.out.println(sync block); } } public synchronized void syncMethod() { System.out.println(sync method); } public static synchronized void staticSyncMethod() { System.out.println(static sync method); } }编译之后执行javap -verbose SyncDemo前面一大段无关信息先忽略直接看方法字节码。blockMethod 里会出现两条关键指令monitorenter和monitorexit。翻译过来就是进入同步代码块时尝试获取某个对象的监视器锁Monitor退出时释放锁。这里有个细节值得注意monitorexit不止一条。正常执行路径会有一条异常路径还有一条隐藏的monitorexit。JVM 之所以这么做是为了保证同步代码块中抛出异常时锁也能被释放否则一旦异常导致线程中断锁就永远卡在那里直接造成死锁。这一设计让我对 synchronized 的工程严谨性有了很深的体会它不像很多人想象的那样是个粗糙的语法糖异常安全在字节码层面就已经被考虑进去了。再对比 syncMethod 和 staticSyncMethod你会发现字节码里根本没有monitorenter/monitorexit指令取而代之的是方法访问标志ACC_SYNCHRONIZED。public synchronized void syncMethod(); descriptor: ()V flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack2, locals1, args_size1 0: getstatic #7 3: ldc #13 5: invokevirtual #14 8: return public static synchronized void staticSyncMethod(); descriptor: ()V flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED Code: stack2, locals0, args_size0 0: getstatic #7 3: ldc #13 5: invokevirtual #14 8: return这个差异说明了什么说明 synchronized 修饰方法时JVM 不依赖字节码指令而是通过方法的访问标志来隐式获取锁。方法调用时调用指令会检查ACC_SYNCHRONIZED标志如果设置了执行线程必须先成功持有监视器锁然后才能执行方法体方法执行完包括异常返回锁自动释放。1.2 一个关键问题static 方法没有 this锁从哪里来还记得吗实例方法有一个隐式的this参数在局部变量表里args_size1。synchronized 修饰实例方法时JVM 很自然地用这个this作为锁对象也就是当前实例。但static方法在局部变量表里args_size0根本没有 this 可用。那ACC_SYNCHRONIZED标志生效时JVM 拿什么对象来充当监视器锁答案就是Class对象。静态方法属于类而不属于某个具体实例它没有 this但它在 JVM 的方法区元空间里有对应的类元数据同时堆里还有一个代表该类的java.lang.Class实例。static synchronized锁住的正是这个Class实例——也就是我们口头说的类锁。这里顺便澄清一个常见的概念误区网上很多说法认为类锁是 JVM 层面的特殊锁机制其实并不是。类锁没有任何特殊的锁实现它和对象锁用的是同一套 Monitor 机制只不过锁的载体从实例对象换成了Class 对象。你甚至可以自己验证把static synchronized方法体改写成synchronized (Xxx.class) { ... }行为完全等价。2. 锁到底落在哪里对象头里的惊天内幕知道了 synchronized 的两种字节码形态接下来必须回答JVM 拿什么来记录这个对象已经被某个线程锁住了这就绕不开 Java 对象在内存中的布局以及对象头里的 Mark Word。很多 JVM 面试题问到锁升级、偏向锁、轻量级锁本质上问的都是 Mark Word 的状态流转。2.1 Java 对象在堆内存里的三块区域一个 Java 对象在堆内存中由三部分组成对象头Object Header、实例数据Instance Data、对齐填充Padding。对齐填充纯粹是为了让对象大小对齐到 8 字节的整数倍方便内存分配和访问不存放任何有效数据。对象头才是关键。在 64 位 JVM 上对象头通常包含两部分Mark Word默认存储对象的 hashCode、GC 分代年龄、锁状态标志、偏向线程 ID、偏向时间戳等信息。这部分是理解锁的核心。类型指针Klass Pointer指向方法区的类元数据JVM 通过它来确定这个对象是哪个类的实例。如果开启了压缩指针这部分通常占 4 字节。数组对象还有额外的数组长度字段。不过对于理解锁来说Mark Word 才是最值得研究的。2.2 Mark Word 的锁状态流转从无锁到重量级Mark Word 是动态的数据结构它会根据对象的锁状态复用存储空间。在 64 位 JVM 上它可以表示以下几种状态锁状态存储内容无锁对象的 hashCode、分代年龄、是否偏向标志0偏向锁持有偏向锁的线程 ID、epoch、分代年龄、是否偏向标志1轻量级锁指向栈中锁记录的指针重量级锁指向 Monitor管程的指针GC 标记空不存信息synchronized 被很多老程序员吐槽性能差其实 HotSpot 早就做了大量优化。锁的状态并不是一上来就变成重量级而是有一个升级过程无锁 → 偏向锁 → 轻量级锁 → 重量级锁。只有竞争激烈的时候才会升级到重量级锁也就是真正用到操作系统互斥量的阶段。偏向锁的逻辑是同一个线程多次进入同步块时不需要每次都做 CAS 原子操作只要检查 Mark Word 里的偏向线程 ID 是不是自己是就直接进入。这个设计解决的场景是同一个线程反复进入同一个同步块。如果锁被其他线程竞争偏向锁就会撤销升级为轻量级锁。轻量级锁通过 CAS 尝试把 Mark Word 替换为指向线程栈帧中锁记录的指针如果成功就持有锁失败则说明有竞争继续膨胀为重量级锁。有一点必须强调锁升级是单向的只能从低到高不能降级。这意味着一旦锁竞争激烈升级到重量级锁即使后续竞争消失也不会自动回到轻量级锁状态。这也是为什么高并发场景下要控制锁粒度、缩小临界区的原因之一。2.3 Monitor重量级锁背后的管程模型当锁升级到重量级时Mark Word 里存放的就是指向 Monitor 的指针。Monitor 是操作系统层面的同步原语在 JVM 中对应的是 ObjectMonitor 对象。它内部维护了几个关键字段Owner当前持有锁的线程、EntryList等待获取锁的线程队列、WaitSet调用了 wait() 方法的线程集合。这里我用自己的话给你串一遍重量级锁的完整逻辑线程 A 执行到 synchronized发现锁已升级为重量级锁尝试获取 Monitor。获取成功Owner 设置为线程 A。线程 B 也来抢占发现 Owner 已经有人了进入 EntryList 阻塞等待。线程 A 执行完同步块释放 MonitorOwner 置空。线程 B 被唤醒竞争锁竞争成功的线程成为新的 Owner。调用wait()/notify()的本质也在这里wait()会让当前线程释放 Monitor并进入 WaitSetnotify()则从 WaitSet 里唤醒一个线程让它重新竞争锁。这也是为什么wait()/notify()必须在 synchronized 代码块里调用——因为它们的操作对象就是 Monitor 本身没有持有 Monitor 就调用这些方法直接抛 IllegalMonitorStateException。3. 类锁的根JVM 的 Class 对象与 klass 模型前面提到了类锁锁的是 Class 对象但这句话背后的原理很多人并没有真正理解。为什么每个类在 JVM 里会有一个Class 对象为什么锁这个对象就能让所有静态同步方法互斥这要从 JVM 的内部对象模型说起。3.1 每个类在 JVM 里都有两份类数据HotSpot 虚拟机内部用 C 结构体来描述 Java 类这套结构统称为 klass 模型。对于一个普通的 Java 类JVM 会在元空间里创建instanceKlass存放这个类在 JVM 内部的元数据常量池、字段信息、方法信息、接口信息等这是 JVM 运行时用来解析类的内部档案。同时JVM 还会在堆中创建一个java.lang.Class实例这个实例就是我们在 Java 代码里通过Xxx.class拿到的东西官方叫法是一个类的镜像类mirror class。也就是说一个 Java 类在 JVM 里其实有两份对应的数据一份是元空间的底层元数据instanceKlass一份是堆上的 Class 对象mirror。为什么要这样设计因为 Java 是面向对象的语言反射机制需要把类本身也当作一个对象来操作。Class对象就是类在堆中的对外门面getClass()、getName()、getMethods()这些反射操作本质上都是通过这个 mirror 对象转发到 instanceKlass 的元数据上去的。3.2 static synchronized 锁的到底是哪个对象现在问题变得非常清晰了。static方法没有实例对象JVM 必须选一个对象来承担锁的职责。它不可能用 instanceKlass——那是 C 层面的内部结构Java 代码拿不到也不安全。它用的是镜像类也就是堆里的java.lang.Class实例。这个对象对所有通过该类访问静态资源的线程来说都是唯一的、全局可见的天然适合作为静态方法同步的锁载体。举一个例子。UserService.class在堆中只有一个 Class 实例所有线程、所有代码位置拿到的都是同一个对象。因此两个不同的 static synchronized 方法哪怕是不同方法在 JVM 看来锁的都是同一个 Class 对象的 Monitor。所以它们之间是互斥的——线程 A 进入static synchronized methodA()时持有了UserService.class的锁线程 B 想执行static synchronized methodB()就必须等到 A 释放。这里有个关键点需要特别说明通过同一个类加载器加载的类Class 对象唯一不同类加载器加载的同一个类Class 对象不唯一。如果你写了个自定义类加载器同一个类被加载了两份那么这两份对应的 Class 对象不是同一个static synchronized 也就不再互斥。这是一个极其隐蔽的问题典型的场景出现在一些热部署框架、OSGi 容器、埋点增强等需要自定义类加载器的中间件中。遇到并发问题排查半天发现类被加载了两遍那种挫败感我太熟悉了。3.3 用代码证明类锁其实就是 Class 对象的锁理论讲完了上实操验证。下面这段代码可以证明static synchronized等价于synchronized (Xxx.class)public class ClassLockDemo { public static synchronized void methodA() { System.out.println(Thread.currentThread().getName() enter methodA); try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(Thread.currentThread().getName() exit methodA); } public static void methodB() { synchronized (ClassLockDemo.class) { System.out.println(Thread.currentThread().getName() enter methodB); try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(Thread.currentThread().getName() exit methodB); } } public static void main(String[] args) { new Thread(ClassLockDemo::methodA, Thread-A).start(); new Thread(ClassLockDemo::methodB, Thread-B).start(); } }运行结果会让你看到 Thread-A 和 Thread-B 的 enter 和 exit 是交错出现的A 先进入B 必须等 A 完全退出后才能进入。两个方法一个用的 synchronized 关键字、一个用的同步代码块但锁的对象完全一致所以互斥成立。这就是类锁最直接的证据。反过来如果把 methodB 改成synchronized (new ClassLockDemo())你会发现 A 和 B 可以同时进入因为它们锁住的分别是 Class 对象和普通的实例对象两个 Monitor 互不相干。4. 类锁与对象锁的实战边界混用场景才是事故高发区原理清楚了接下来聊点实战中真正要命的东西。我在线上排查过的并发事故里超过一半都不是因为锁本身用错了而是因为同一份资源上有人用类锁、有人用对象锁导致互斥失效。这个坑几乎每个团队都会踩一遍。4.1 经典事故静态变量被实例方法修改最典型的一种事故长这样public class StockService { private static int stock 100; public static synchronized void deductByStatic() { if (stock 0) { stock--; } } public synchronized void deductByInstance() { if (stock 0) { stock--; } } }deductByStatic锁的是StockService.classdeductByInstance锁的是this当前实例。如果线程 A 调用deductByStatic、线程 B 调用deductByInstance两个方法同时操作静态变量 stock锁完全没有交集stock 的并发安全瞬间被击穿。这类问题的根源在于静态变量属于类操作它的方法的同步锁必须是类锁实例变量属于对象操作它才适合用对象锁。很多团队在做代码评审时根本没有强调这个规则等出了问题才开始追溯这个变量到底是类级别的还是实例级别的。我个人的建议是凡是静态变量涉及读写就必须用类锁而且要统一写法。要么所有相关方法都写static synchronized要么都用synchronized (StockService.class)。最忌讳的就是一半用一个写法、一半用另一个美观度是提升了安全性就没了。4.2 类锁与对象锁互相嵌套引发的死锁还有一种更隐蔽的问题类锁和对象锁嵌套使用不同线程持有各自的锁却在等待对方的锁直接死锁。看这段代码public class DeadLockDemo { public synchronized void instanceMethod() { System.out.println(Instance method got object lock); // 试图获取类锁 synchronized (DeadLockDemo.class) { System.out.println(Instance method got class lock); } } public static synchronized void staticMethod() { System.out.println(Static method got class lock); // 试图获取对象锁 synchronized (new DeadLockDemo()) { System.out.println(Static method got object lock); } } }线程 A 调用instanceMethod先持有实例锁再尝试获取类锁线程 B 调用staticMethod先持有类锁再尝试获取实例锁。如果 A 持有实例锁等待类锁、B 持有类锁等待实例锁两边谁都不让就死锁了。这种问题在 jstack 线程转储里会看到经典的DeadLockDemo线程互相持有对方需要的锁排查起来非常直观。所以我的建议是尽量不要在持有一把锁的情况下去获取另一把锁。如果业务确实需要同时使用类锁和对象锁务必约定好加锁顺序所有线程都按同样的顺序拿锁才能从根本上避免死锁。4.3 几个冷门但实用的细节最后分享几个我在实际项目中总结出来的细节这些细节平时容易忽略关键时刻能救命。反射调用静态同步方法是否生效通过反射调用static synchronized方法时锁依然有效因为锁的信息在ACC_SYNCHRONIZED标志里JVM 在解释执行时仍然会检查并尝试获取 Class 对象的 Monitor。getClass() 与 .class 的不同在实例方法里写synchronized (getClass())和synchronized (Xxx.class)并不完全等价。如果这个类被继承getClass()返回的是运行时类的 Class 对象而Xxx.class是编译期指定的类。一旦子类继承了这个实例方法两个对象可能不再是同一个锁的语义会发生微妙变化。类锁对性能的影响范围更大对象锁只锁当前实例类锁锁的是所有通过该 Class 访问的静态同步代码。如果一个类里有多个不相关的静态同步方法它们也会互相阻塞因为大家持有的是同一把类锁。这是典型的锁粒度问题在高并发下会导致吞吐量急剧下降。如果确实需要细粒度控制建议用不同的内部静态对象作为锁比如public class FineGrainedLock { private static final Object LOCK_A new Object(); private static final Object LOCK_B new Object(); public static void opA() { synchronized (LOCK_A) { // 操作资源 A } } public static void opB() { synchronized (LOCK_B) { // 操作资源 B } } }类锁与类加载器的关系前面已经说过自定义类加载器可能导致同一个类出现多份 Class 对象。这里补充一点很多 Java 高级工程师面试时会用这个问题来考察候选人对 JVM 类加载机制的理解深度。如果你的应用部署在某个支持热加载的框架上遇到 static synchronized 不互斥的问题第一反应应该是检查类加载器的唯一性而不是怀疑 JVM 的锁实现出了 bug。偏置锁对类锁同样适用偏向锁的优化并不仅限于实例对象Class 对象的 Mark Word 同样可以处于偏向锁状态。如果一个静态同步方法长时间被同一个线程调用JVM 会把这个 Class 对象的 Mark Word 偏向该线程后续进入时连 CAS 都省了。这也是为什么 synchronized 在单线程反复访问的场景下性能极佳的原因。5. 最后再分享一点排障经验写到这里关于 static synchronized 为什么是类锁这个问题我觉得可以给自己一个交代了。从字节码到对象布局再到 JVM 的 klass 模型本质上就是一句话static 方法没有 thisJVM 天然选择用一个全局唯一且和类生命周期绑定的对象作为锁载体这个对象就是 Class 的镜像实例。但理论归理论真正让我对这些知识记得这么牢的还是线上事故的教训。我印象最深的一次业务方反馈说并发下总有数据对不上代码里明明加了 synchronized怎么还能出问题。我拿到线程转储一看一个方法写的是static synchronized另一个方法写的是synchronized (this)两者根本不是一把锁。从那次之后我在团队里定了一条规矩涉及到静态资源并发访问的代码评审时必须明确标注锁对象是谁、锁粒度多大不能只说加了锁。如果你在排查类似问题时卡住了几句实用的话送给你第一先别急着看业务逻辑先反编译确认 synchronized 的字节码形态第二用 jstack 抓线程转储看线程究竟阻塞在哪个对象的 Monitor 上第三当你怀疑明明加了锁为什么不互斥时优先检查锁对象的类加载器是否唯一、代码里是否混用了类锁和对象锁。这三板斧下来绝大多数锁相关的问题都能水落石出。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询