服务器与大带宽专家 · 持牌IDC/CDN/ISP服务商
简米科技官网JIANMI TECH
资讯 2026-10-31 简米科技 4,696 字 11 分钟阅读

对象锁在虚拟机里是如何实现的?,Java对象锁实现原理是什么

导读对象锁在虚拟机里并不是一把“锁”,而是一套从 Java 字节码一路下沉到 C++ 对象头的状态机:HotSpot 把锁状态记录在对象头的 Mark Word 中,按竞争激烈程度依次升级为偏向锁、轻量级锁、重量级锁,真正阻塞线程的那把锁由 ObjectMonitor 承担,对象头里的“状态栏”:锁到底存在哪很多人……

对象锁在虚拟机里并不是一把“锁”,而是一套从 Java 字节码一路下沉到 C++ 对象头的状态机:HotSpot 把锁状态记录在对象头的 Mark Word 中,按竞争激烈程度依次升级为偏向锁、轻量级锁、重量级锁,真正阻塞线程的那把锁由 ObjectMonitor 承担。

对象头里的“状态栏”:锁到底存在哪

很多人以为 synchronized 会给对象挂一个锁对象,其实虚拟机没有额外分配任何“锁结构”,锁的元信息就压在对象自己身上。

Mark Word:对象头里的那32位或64位

HotSpot 的对象在内存里由三部分组成:对象头、实例数据、对齐填充,对象头又拆成 Mark Word 和 Klass Pointer,Mark Word 是个“复用字段”,谁需要谁占:

  • 无锁状态时,它存对象的哈希码、分代年龄、锁标志位
  • 偏向锁时,它存偏向线程 ID、Epoch、分代年龄
  • 轻量级锁时,它存指向线程栈中 Lock Record 的指针
  • 重量级锁时,它存指向 ObjectMonitor 的指针

64 位虚拟机下 Mark Word 通常是 8 字节,最低两位是锁标志位:01 表示无锁或偏向锁,00 表示轻量级锁,10 表示重量级锁,11 表示 GC 标记,这就是虚拟机判断“这个对象现在被谁锁着”的全部依据,没有哈希表,也没有全局锁登记簿。

从字节码到 C++:monitorenter 之后发生了什么

写一段最普通的同步代码:

public void doWork() {
    synchronized (this) {
        count++;
    }
}

javap -v 看字节码,同步块前后会插上 monitorenter 和 monitorexit,而且编译器会额外生成一条异常路径上的 monitorexit,这就是同步块里抛异常也不会死锁的原因,如果锁的是方法,字节码里没有指令,靠方法访问标志 ACC_SYNCHRONIZED 标记,JVM 调用时自行判断。

字节码解释执行时,monitorenter 最终走到 HotSpot 的 InterpreterRuntime::monitorenter,接下来的判断顺序就是锁升级的主线:先看能否偏向,再看能否轻量级 CAS,最后才膨胀成重量级,业内专家指出,理解这条调用链,比死记“锁升级”三个字有用得多。

synchronized对象锁在JVM中怎么实现的:三级膨胀全过程

偏向锁:只有一个线程时的“认主”

绝大多数同步块一辈子只被一个线程进进出出,偏向锁的思路很朴素:既然是同一个人,就别每次都验身份。

第一次加锁时,虚拟机用 CAS 把当前线程 ID 写进 Mark Word,标志位设为偏向,之后这个线程再来,只比对一下 ID 相等就直接进去,连 CAS 都省了,只有另一个线程来抢,才需要撤销偏向。

实操层面有两个坑:

  • 偏向锁默认有约 4 秒的启动延迟,因为 JVM 启动阶段竞争激烈,批量撤销反而亏,调试时可以加 -XX:BiasedLockingStartupDelay=0 关掉延迟
  • 从 JDK 15 起偏向锁默认关闭(JEP 374),JDK 18 之后相关代码已经被移除,所以查资料时要注意版本,网上大量“偏向锁”文章对应的是 JDK 8 语境

确认当前 JDK 状态可以直接跑:

java -XX:+PrintFlagsFinal -version | grep -i biased

看到 UseBiasedLocking = false,就说明这把锁在你的环境里根本不存在。

轻量级锁:短兵相接时的 CAS

偏向失败、又还没到真正阻塞的程度,虚拟机会走轻量级锁,流程分三步:

  1. 在当前线程栈帧里划出一块 Lock Record,把对象原来的 Mark Word 复制进去,叫 Displaced Mark Word
  2. 用 CAS 尝试把对象头的 Mark Word 替换成指向这个 Lock Record 的指针
  3. CAS 成功就拿到锁;失败说明有竞争,先自旋重试一阵,还不行就膨胀

轻量级锁的设计前提是“锁持有时间短、几乎不冲突”,它不挂起线程,靠 CPU 空转换时间,如果你的同步块里有 IO、有远程调用、有长循环,自旋纯属浪费 CPU,这时候升级到重量级锁反而是好事。

重量级锁:ObjectMonitor 接管一切

膨胀之后,Mark Word 指向一个 C++ 结构体 ObjectMonitor,源码在 HotSpot 的 objectMonitor.hpp 里,几个关键字段值得记住:

  • _owner:当前持有锁的线程
  • _recursions:重入次数,synchronized 是可重入的
  • _cxq:竞争队列,新来的线程先在这里 CAS 排队
  • _EntryList:等待获取锁的线程集合
  • _WaitSet:调用 wait() 后被挂起的线程

线程拿不到锁就进入内核态挂起,唤醒要走操作系统调度,一次上下文切换的开销,比自旋几十次都大,这就是“重量级”这个叫法的由来,它的好处是竞争再激烈也不会空耗 CPU。

锁状态 Mark Word 存什么 竞争应对方式 适用场景
偏向锁 偏向线程 ID 直接比对,无需 CAS 单线程反复进入
轻量级锁 栈上 Lock Record 指针 CAS 加自旋 交替执行、冲突少
重量级锁 ObjectMonitor 指针 挂起与唤醒 竞争激烈、持锁久

Java对象锁和类锁的区别是什么:别把两把锁当一把用

这是面试和线上事故里的高频混淆点,两者的底层机制一样,但锁的“载体”完全不同。

  • 对象锁锁的是实例。synchronized 修饰实例方法,或者 synchronized(this),锁的是当前对象实例,不同实例之间互不干扰
  • 类锁锁的是 Class 对象。synchronized 修饰静态方法,或者 synchronized(Xxx.class),全虚拟机里这个类的 Class 对象只有一个,所以是全局互斥

来看一个典型翻车场景:

public class Counter {
    private static int total;
    public synchronized void add() { total++; }   // 锁的是实例
}

new 出三个 Counter 并发调 add(),三个线程各持各的锁,total++ 照样出问题,改成 public static synchronized void add() 或者锁 Counter.class 才有效。

行业共识认为,静态变量配实例锁是最容易埋雷的组合,因为表面上方法加了 synchronized,读代码的人很容易默认它是线程安全的,更隐蔽的是 Integer 常量池、字符串常量池做锁对象,两个毫不相干的模块可能锁到同一个对象上。

高并发场景下对象锁性能怎么优化

先测量,再优化:用 JOL 看对象头

别急着改代码,加一行 JOL 依赖,就能把 Mark Word 打出来:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>
</dependency>
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o) {
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

前后两段输出对比,锁标志位的变化一目了然,这比看十篇文章都直观。

锁消除与锁粗化:虚拟机替你做的两件事

  • 锁消除:JIT 通过逃逸分析发现锁对象没有逃出当前线程,直接删掉同步,典型例子是 StringBuffer 的 append,局部变量场景下那些同步开销会被优化掉
  • 锁粗化:一连串对同一个对象的加锁解锁,虚拟机会合并成一次大锁,避免反复进出

这两项是 JIT 自动完成的,但前提是你的代码别让对象随便逃逸。

减少临界区本身,比调锁参数更有效

  • 把不影响共享状态的逻辑挪出同步块,临界区越短越好
  • 能用 java.util.concurrent 里的 LongAdder、AtomicInteger 就别用锁,CAS 无阻塞
  • 读多写少优先 ReentrantReadWriteLock 或 StampedLock
  • 确实要长时间持锁,考虑拆锁:按 key 分段,用 ConcurrentHashMap 做锁对象池,这就是分段锁的思路

近年来不少团队把长事务里的大同步块拆成多个小锁,实测吞吐提升明显,不过这依赖具体业务,没有通用数字。

面试与排查场景:怎么验证锁真的生效了

排查死锁不用瞎猜,两个命令足够:

jstack <pid> > stack.txt        # 找 "Found one Java-level deadlock"
jconsole                        # 图形化看线程状态和锁持有

想看锁竞争激烈程度,可以开 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 观察同步相关的内联情况,或者用 JFR 录制 jdk.JavaMonitorEnter 事件,直接统计每次等锁耗时,北京不少 Java 后端团队在面试里会追问“你怎么证明这段代码真的没锁竞争”,答案就是这些可复现的命令和事件,而不是背概念。

关于对象锁在虚拟机中实现的Q&A

Q:对象锁在虚拟机里是如何实现的,为什么不直接用操作系统互斥量?

因为纯内核互斥量每次加锁都要系统调用,单线程场景下开销太大,HotSpot 用 Mark Word 状态位加三级膨胀做“按需付费”:没竞争就不进内核,有竞争才挂起,这是性能与正确性的折中。

Q:偏向锁被移除了,是不是锁优化的知识就不用学了?

不是,JEP 374 移除的是偏向锁这一级,轻量级锁、重量级锁、自旋、锁消除、锁粗化都还在,JDK 17、21 上跑 synchronized,走的仍然是 Mark Word 加 ObjectMonitor 这套机制,只是少了“认主”环节,首次加锁直接从轻量级开始。

Q:为什么 synchronized 是可重入的?

因为重量级锁对应的 ObjectMonitor 里有 _recursions 计数,同一线程再次进入时该字段加一,退出时减一,归零才真正释放,轻量级锁对应的 Lock Record 也是按栈帧压入多个,天然支持重入。

分享本文
本文为 简米科技官网 原创,已由运维技术专家审核。转载请注明来源:原文链接
售前咨询 服务热线 售后 邮箱