对象锁在虚拟机里本质上是一个存储在对象头(Mark Word)中的状态标记,HotSpot虚拟机通过Monitor机制配合锁升级流程来管理它。 这不是把锁钉在对象身上,而是每次线程进入同步代码块时,去修改对象头里的几个二进制位,以此宣告“这块地盘归我了”。
对象锁在虚拟机里是如何实现的?先看懂对象头
Java中每个对象在内存里都有一块“门牌区”,叫对象头,HotSpot虚拟机的对象头分为两部分:第一部分是Mark Word,存储对象的哈希码、GC分代年龄和锁状态;第二部分是Klass Pointer,指向对象的类元数据,对象锁的操作,几乎全部发生在Mark Word上。
Mark Word的位宽跟虚拟机位数相关,32位虚拟机里占32比特,64位虚拟机里占64比特,锁状态不同,Mark Word里每个bit的含义就重新洗牌一次,以一个常见布局为例:
| 锁状态 | 锁标志位 | |
|---|---|---|
| 无锁 | 对象哈希码、分代年龄 | 01 |
| 偏向锁 | 偏向线程ID、Epoch、分代年龄 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 |
| 重量级锁 | 指向互斥量(Monitor)的指针 | 10 |
| GC标记 | 空 | 11 |
看到“锁标志位”一栏,你就能理解什么叫“锁是状态”,同一个对象,在不同时刻,对象头里的数据可能从哈希码变成线程ID,再变成指针。虚拟机上锁的过程,本质上就是CAS原子地修改这串bit位的过程。
对象锁和类锁的区别:作用范围完全不同
很多人问“对象锁和类锁的区别”,其实两者锁的不是同一个东西。
对象锁,对应synchronized修饰实例方法或代码块时,锁定的是当前实例对象。
class Account {
private int balance;
public synchronized void deposit(int amount) {
balance += amount;
}
}
两个线程同时调用同一个Account实例的deposit,只有一个能进去,但如果换成两个不同的Account实例,锁就各管各的,互不干扰。
类锁,对应synchronized修饰静态方法,或者用类名.class作为锁对象,它锁的是Class对象,作用域是整个类,行业共识认为,类锁是为了保护静态资源的原子性,一个经典面试场景是:同一个类里,一个静态同步方法和一个实例同步方法同时跑,它们之间互不阻塞因为一个锁在Class对象上,另一个锁在实例对象上,入口不同,自然不打架。
在“上海Java面试”的常考清单里,这个知识点经常和“synchronized底层原理”放在一起问,你光回答“一个是实例一个是类”还不够,最好能补一句:两者在虚拟机的Mark Word里,表现方式完全相同,锁的载体都是对象头,只是临界区的访问规则不同。
锁升级过程:从无锁到重量级锁的四个“岗位”
JDK 1.6之前,synchronized一上来就是重量级锁,凡是有竞争就找操作系统申请mutex,性能难看,后来HotSpot团队做了优化,设计了锁升级过程,让锁根据竞争激烈程度自己“换岗”。
无锁状态
没有任何线程进入同步块,对象头就是普通状态,哈希码按需写入,谁都可以读,没有竞争。
偏向锁
第一个线程进入同步块时,虚拟机把Mark Word改成偏向锁模式,记录这个线程的ID,之后该线程再次进入同一同步块,检测到线程ID匹配,直接放行,连CAS都省了,业内专家指出,这种设计是为了应对“绝大多数情况下锁只被一个线程访问”的典型场景。
轻量级锁
当另一个线程来抢锁,偏向锁失效,两个线程轮流竞争,虚拟机在各自栈帧中创建锁记录(Lock Record),通过CAS把Mark Word改成指向自己锁记录的指针,抢不到的一方会自旋等待,窗口期很短,不会立刻挂起,自旋不是死循环,会随着等待次数自动调整,甚至自适应。
重量级锁
如果自旋超过阈值,或者竞争者太多,锁就升级为重量级锁,Mark Word指向一个Monitor对象,这个Monitor底层调用操作系统的互斥量,拿不到锁的线程会进入阻塞队列,由操作系统调度,线程状态从RUNNABLE变成BLOCKED,上下文切换成本很高。
四种锁的适用场景对比:
| 锁类型 | 适合场景 | 开销 | 缺点 |
|---|---|---|---|
| 偏向锁 | 单线程访问 | 最小 | 有竞争就撤销 |
| 轻量级锁 | 线程交替执行 | 中 | 自旋消耗CPU |
| 重量级锁 | 多线程长期竞争 | 最大 | 阻塞和唤醒慢 |
锁升级是单向的,只能从偏向到轻量再到重量,不能逆向降级,这很好理解,锁一旦“吵”到重量级,说明竞争已经白热化,再降回去反而会引起新一轮摩擦。
synchronized底层原理在Java面试中怎么讲?
面试官问“对象锁在虚拟机里是如何实现的”,实际上是在考察你对synchronized的理解深度,讲解时最好按三条线走:
-
字节码层面:synchronized对应
monitorenter和monitorexit两条指令,进入同步块时执行monitorenter,正常退出和异常退出都会执行monitorexit,异常退出通常由编译器自动生成对应的指令,保证锁被释放。 -
运行时层面:锁状态全部编码在Mark Word里,通过CAS完成状态切换,偏向锁撤销、轻量级锁膨胀成重量级锁,都有一系列原子操作作为支撑。
-
操作系统层面:重量级锁会调用mutex,涉及用户态和内核态的切换,这也是为什么synchronized在JDK 1.6之前被诟病“重”的原因。
建议你用javap -c反汇编一段同步代码,观察monitorenter和monitorexit的出现位置,你会在flag属性里看到ACC_SYNCHRONIZED,这其实是方法级别的同步标志,底层同样走Monitor。
想看到Mark Word的变化,可以借助JOL(Java Object Layout)工具,在pom.xml里加上依赖后,写一行代码:
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
分别在有锁和无锁状态下打印,锁标志位的变化一目了然。
如何在实际项目中降低锁竞争?
锁机制是虚拟机兜底的方案,但你不能完全指望它,日常编码中有几个常见手段:
- 减小锁粒度:能锁一个局部变量,就别锁整个方法;能用读写锁,就别用排他锁。
- 使用乐观锁:CAS本身是无锁操作,比如
AtomicInteger,适合竞争不激烈的计数场景。 - 锁随机化:如果必须锁多个对象,可以考虑分段锁,ConcurrentHashMap在1.8之前就这么干。
- 注意锁的对象:不要用字符串常量、Integer缓存等可能被多个无关代码共享的对象作为锁,容易造成意外阻塞。
常见问题:对象锁在虚拟机里是如何实现的?
偏向锁一定比轻量级锁快吗?
要看场景,偏向锁的优势在于同一线程反复进入同步块时,不需要CAS操作,只比对线程ID,但一旦存在竞争,偏向锁的撤销成本比轻量级锁更高,如果两个线程频繁交替持有锁,轻量级锁的CAS自旋反而更省力,JDK 15之后默认禁用了偏向锁,也说明这种优化在多数现代应用里不再有绝对优势。
锁升级过程一定是从无锁走到重量级吗?
不必然,如果同步块只被一个线程访问,一直停留在偏向锁阶段,当第二个线程出现时,偏向锁可能直接膨胀为重量级锁,也可能先变成轻量级锁,取决于竞争发生的时机,轻量级锁在自旋失败后才会膨胀,升级”是条路径,但每个阶段都是可跨越的。
为什么说对象锁是对对象头的操作,而不是“锁住”对象?
你可以把对象头理解成一个公告板,线程进入同步块时,在公告板上写字“我占着这个位置”,走的时候擦掉,其他线程看到公告板有字,就知道必须等,整个过程中,对象本身的数据(实例字段、方法表)没有被动过,只是状态位发生了变化,这正是虚拟机层面对象锁的实现精髓:用最少的内存位成本,完成多线程的互斥协调。