目录
- Synchronized 与 Lock 的实现上的区别?
- Lock 的实现原理(API 层面)
- 什么情况下只能使用 Lock
- 效率上的区别
- 重量级锁可以降级吗
- 轻量级锁触发条件及 inflate(膨胀)过程
Synchronized 与 Lock 的实现上的区别?
synchronized的底层是完全基于 JVM 虚拟机的实现,开发者无法看见具体的 Java 实现源码。从字节码上如果是同步块的情况下,依赖于monitorenter和monitorexit指令。
- 同步代码块:当执行到
monitorenter时,线程试图获取对象的锁(即 Monitor);执行完或发生异常时,执行monitorexit释放锁。 - 同步方法:并没有这两个指令,而是在方法的全局标志(flags)中设置了
ACC_SYNCHRONIZED标志。JVM 激活方法时会检查该标志,从而隐式地调用 Monitor。
Lock 的实现原理(API 层面)
Lock(如ReentrantLock)完全是由 Java 代码编写的,它的核心是依托于 AQS(AbstractQueuedSynchronizer,抽象队列同步器)框架。
AQS 核心机制
AQS 的底层实现主要依靠三个核心要素:
- volatile 状态位(state):内部有一个
volatile int state变量,用来表示锁的状态(如 0 表示未锁,1 表示已锁,大于 1 表示重入次数)。 - CAS(Compare And Swap)操作:线程尝试获取锁时,通过底层
Unsafe类的 CAS 操作(原子性操作)去修改state的值。如果修改成功,说明获取锁成功。 - CLH 队列(双向链表):如果 CAS 修改失败,说明锁被其他线程占用。当前线程会被封装成一个
Node节点,加入到 AQS 的双向同步队列中,并通过LockSupport.park()挂起自己,等待被唤醒。
什么情况下只能使用 Lock
- 需要“超时放弃”或“非阻塞尝试”获取锁时
- 需要线程在等待锁时能够“响应中断”
- 需要实现“公平锁”
- 需要多个等待条件(精准唤醒)
效率上的区别
在传统的操作系统线程模型下,两者的效率取决于竞争的激烈程度:
🟢 低竞争/无竞争场景:synchronized 略胜一筹或持平
- 原理:JVM 对
synchronized做了大量的底层优化。如果一个锁没有竞争,JVM 会使用轻量级锁(通过底层 CAS 修改对象头),甚至通过锁消除(Lock Elimination)优化掉不必要的锁。 - 结果:此时
synchronized的性能开销几乎可以忽略不计,甚至优于Lock的显式加锁。
🟡 中等竞争场景:两者性能基本持平
- 原理:当出现一定程度的竞争时,
synchronized会升级为轻量级锁并进行自适应自旋(Adaptive Spinning);而Lock也会通过 AQS 进行 CAS 自旋尝试获取锁。 - 结果:两者的底层都是基于 CPU 的 CAS 原子指令,性能差异微乎其微。
🔴 高竞争场景:Lock 表现更稳定、上限更高
- 原理:当大量线程疯狂争抢同一个锁时,
synchronized会彻底升级为重量级锁,引起的内核态/用户态切换和线程上下文切换开销会变大。而Lock(如ReentrantLock)由于其 AQS 内部拥有更精细的等待队列控制、非公平锁的抢占机制,以及支持tryLock()这种“打得过就打,打不过就跑”的策略,能够有效避免线程大面积阻塞带来的系统雪崩。 - 结果:在极端高并发下,
Lock的吞吐量通常比synchronized更平稳。
重量级锁可以降级吗
- 在线程运行期(加锁过程中):不能降级。一旦锁升级为重量级锁,当前争抢锁的线程绝不会在执行期间将其降级回轻量级锁或偏向锁。
- 在锁闲置期(无线程竞争时):可以降级。JVM 底层有一套完善的回收机制,当一个重量级锁长时间没有线程竞争、处于闲置状态时,JVM 会收回其关联的 Monitor 对象,并把锁对象恢复为无锁状态。
轻量级锁触发条件及 inflate(膨胀)过程
1. 触发膨胀的场景
场景 A:高并发下的多线程“正面撞车”(CAS 失败)
- 过程:线程 A 当前持有某个对象的轻量级锁(对象头指向线程 A 的栈帧)。此时,线程 B 也企图获取这个锁,它会尝试通过 CAS 操作将对象头指向自己的 Lock Record。
- 结果:线程 B 的 CAS 必定失败。因为此时对象头已经被线程 A 改变了。一旦 CAS 失败,就意味着存在锁竞争,轻量级锁就会开始向重量级锁膨胀。
场景 B:自适应自旋(Adaptive Spinning)达到极限
在早期的 JVM 中,CAS 失败后线程会固定自旋(比如 10 次),如果还没拿到锁就膨胀。现代 JVM 引入了自适应自旋:
- 原理:JVM 会根据“上一次在同一个锁上自旋是否成功”以及“锁拥有者的状态”来决定自旋的次数。
- 膨胀时机:如果线程 B 在自旋期间,线程 A 释放了锁,那么线程 B 就能顺利升级为轻量级锁所有者。但如果线程 B 自旋了足够多的次数(JVM 认为再旋下去就是纯纯浪费 CPU 算力了),线程 A 依然没有释放锁,线程 B 就会停止自旋,开始触发锁膨胀。
场景 C:持有轻量级锁的线程调用了Object.wait()
- 原理:
wait()、notify()和notifyAll()机制在底层是完全依赖ObjectMonitor(管程)中的等待队列(_WaitSet)来实现的。 - 膨胀时机:如果线程 A 已经拿到了轻量级锁,但在同步块里调用了
lockObj.wait(),此时轻量级锁必须立刻膨胀为重量级锁。因为轻量级锁根本没有地方去存放被挂起的等待线程队列,必须借助ObjectMonitor的结构。
2. 锁膨胀的底层落地过程(Inflate)
当 JVM 决定将锁膨胀为重量级锁时,底层(C++ 层面)会经历一段非常严谨的“变身”流转:
- 发起膨胀(通常是竞争失败的线程 B 触发):线程 B 发现自旋失败,调用 JVM 底层的
ObjectSynchronizer::inflate方法。 - 创建重量级锁实体:线程 B 在 Native Memory(堆外内存)中申请分配一个
ObjectMonitor对象。 - 设置 Monitor 内部状态:将
ObjectMonitor的_owner指向当前正持有轻量级锁的线程 A。将原轻量级锁保存在线程 A 栈帧中的Displaced Mark Word备份,完整复制到ObjectMonitor的_header属性中(确保对象的 HashCode 和分代年龄不丢失)。 - 修改对象头(关键的原子操作):线程 B 通过 CAS 操作,将 Java 对象的 Mark Word 改为:
指向该 ObjectMonitor 的指针 | 锁标志位 10(重量级锁标志)。 - 阻塞自己:线程 B 将自己封装成一个节点,放入
ObjectMonitor的_cxq或_EntryList阻塞队列中,然后调用操作系统的底层指令(如 Linux 的pthread_mutex_lock或park)将自己挂起(变为BLOCKED状态),静静等待被唤醒。
醒。