Java 锁机制:Synchronized 原理、锁升级与 Lock 对比

本文从锁策略的分类体系讲起,逐层深入到 synchronized 的底层实现,最后对比 synchronized 和 Lock。


一、先建坐标系:Java 中的锁策略分类

在深入源码之前,先理解「锁有哪些分类维度」——这些是锁的设计策略,不是具体实现。

1. 乐观锁 vs 悲观锁

                    如何看待「冲突」?

    悲观锁                             乐观锁
    "肯定有人跟我抢"                    "大概率没人抢"
    先加锁,再操作                     先操作,提交时检查

    实现:synchronized, Lock         实现:CAS, 版本号
    适合:写多读少                     适合:读多写少
// 悲观锁 —— 先加锁
synchronized (obj) {
    count++;  // 我锁住了,谁也进不来
}

// 乐观锁 —— 先改再看有没有冲突
atomicInteger.incrementAndGet();  // CAS 循环,冲突就重试

乐观锁的核心:CAS(Compare And Swap)

CAS(V, A, B):
    if (内存地址 V 的当前值 == 预期值 A)
        V = B;     // 更新成功
        return true;
    else
        return false;  // 被别人改了,重试

ABA 问题:线程 1 读 V=A → 线程 2 改 V=A→B→A → 线程 1 CAS 发现还是 A,以为没变。解决:加版本号AtomicStampedReference)。

2. 公平锁 vs 非公平锁

        ┌── 公平锁:先来后到,排队
 锁 ────┤
        └── 非公平锁:来了就抢,抢不到再排队
公平锁 非公平锁
获取顺序 按申请顺序(FIFO) 新来的可以先抢
性能 较低(频繁线程切换) 更高(减少上下文切换)
实现 new ReentrantLock(true) new ReentrantLock()(默认)
饥饿 不会 可能——有些线程永远抢不到

synchronized 天然是非公平锁。为什么默认非公平?因为公平锁每次都要从队列里唤醒一个线程,上下文切换成本远高于「刚释放的锁被正在运行的线程直接抢到」。

3. 可重入锁 vs 不可重入锁

可重入:同一个线程可以多次获取同一把锁。Java 中 synchronized 和 ReentrantLock 都是可重入的。

public synchronized void methodA() {
    methodB();  // methodB 也需要同一把锁——可重入的话不会死锁
}

public synchronized void methodB() {
    // 如果没有重入机制,这里就死锁了
}

原理:每个锁维护一个 持有者线程 ID + 重入计数器。同一线程每进一次计数器 +1,每退一次 -1,归零时真正释放锁。

4. 独占锁 vs 共享锁(读写锁)

独占锁(互斥锁) 共享锁
读-读 互斥 可并发
读-写 互斥 互斥
写-写 互斥 互斥
代表 synchronized, ReentrantLock ReentrantReadWriteLock 的读锁

其实还有读写互斥的一种细分:

  • 写锁(独占锁 / X 锁):别人不能读也不能写
  • 读锁(共享锁 / S 锁):别人能读但不能写

5. 自旋锁 vs 阻塞锁

自旋锁:抢不到锁 → while 循环一直等(不释放 CPU)
         适合:锁持有时间极短

阻塞锁:抢不到锁 → 挂起线程,让出 CPU
         适合:锁持有时间长
         代价:线程切换本身的开销(用户态 ↔ 内核态)

Java 的 synchronized 做了聪明的事:先自旋一会儿(自适应自旋),抢不到再阻塞


二、锁策略速查表

分类维度 选项 A 选项 B synchronized 属于
竞争态度 悲观锁(先锁) 乐观锁(CAS 重试) 悲观锁
排队策略 公平锁(FIFO) 非公平锁(可抢) 非公平锁
重入性 可重入 不可重入 可重入
共享性 独占锁 共享锁 独占锁
等待方式 自旋锁 阻塞锁 自适应(先自旋后阻塞)
粒度 对象锁 类锁 都支持

三、synchronized 底层原理:对象头与 Monitor

Java 对象的内存布局

任何一个 Java 对象,在堆内存中长这样:

┌─────────────────────────────────────────────────┐
│                  对象头 (Header)                  │
│  ┌──────────────────────┬──────────────────────┐ │
│  │   Mark Word (64 位)   │   Klass Pointer (64 位) │
│  │  锁状态/GC 分代/hash  │    指向类元数据         │
│  └──────────────────────┴──────────────────────┘ │
├─────────────────────────────────────────────────┤
│               实例数据 (Instance Data)            │
│        成员变量:int, String, 引用...              │
├─────────────────────────────────────────────────┤
│               对齐填充 (Padding)                  │
│        保证对象大小是 8 字节的倍数(64 位 JVM)       │
└─────────────────────────────────────────────────┘

Mark Word 是核心——它存储了对象的 hash、GC 年龄、锁状态。同一块内存,不同锁状态下存储的内容完全不同:

Mark Word 在不同锁状态下的结构

无锁状态:
┌──────────────────────┬───────┐
│  unused (25bit)      │ hash  │ age │ 0 │ 01 │
│  未使用               │ 31bit │ 4bit│   │    │
└──────────────────────┴───────┘
                                     ↑ 锁标志位:001 = 无锁

偏向锁:
┌──────────────────────┬───────┐
│  线程 ID (54bit)      │ epoch │ age │ 1 │ 01 │
│  持有锁的线程          │  2bit │ 4bit│   │    │
└──────────────────────┴───────┘
                                     ↑ 锁标志位:101 = 偏向锁

轻量级锁:
┌────────────────────────────────┐
│  指向线程栈中 Lock Record 的指针  │ ... │ 00 │
│         (62bit)                 │     │    │
└────────────────────────────────┘
                                     ↑ 锁标志位:00 = 轻量级锁

重量级锁:
┌────────────────────────────────┐
│  指向 Monitor 对象的指针         │ ... │ 10 │
│         (62bit)                 │    │    │
└────────────────────────────────┘
                                     ↑ 锁标志位:10 = 重量级锁

锁标志位只有 2 位(最后两位),这 2 位决定了整把锁的状态机如何跳转

Monitor(管程)——重量级锁的核心

每个 Java 对象在 JVM 层面关联一个 Monitor 对象(C++ 的 ObjectMonitor):

┌──────────────────────────────┐
│         ObjectMonitor        │
│                              │
│  _owner      → 当前持锁线程    │
│  _EntryList  → 抢锁失败,阻塞在这 │
│  _WaitSet    → wait() 方法后进入 │
│  _recursions → 重入计数器        │
│  _count      → 锁计数器          │
└──────────────────────────────┘
// synchronized 代码块的字节码
public void method() {
    synchronized (this) {     // ← monitorenter 指令
        // 临界区
    }                         // ← monitorexit 指令
}
// 注意:编译器会自动加一个异常处理的 monitorexit,
// 保证即使抛异常也能释放锁

synchronized 方法:方法标志位中设置 ACC_SYNCHRONIZED,JVM 自动处理。


四、锁升级:synchronized 的性能优化之路

JDK 1.6 之前,synchronized 直接是重量级锁(每次加锁都要系统调用),性能极差。锁升级是 JDK 1.6 引入的关键优化——锁只会在竞争升级时才变重

                    无锁
                     │
                     ▼ (线程 A 第一次获取锁)
                  偏向锁  ← 记录线程 A 的 ID
                     │        A 再次获取:比较 ID,相同则直接进入
                     │
                     ▼ (线程 B 也来抢这把锁)
                  轻量级锁 ← CAS 自旋抢
                     │        抢到了 → 继续执行(无阻塞)
                     │        抢不到 → 自适应自旋
                     │
                     ▼ (自旋超时 / 竞争太激烈 / 调用 wait)
                  重量级锁 ← Monitor,阻塞等待,OS 内核调度
                    (不可逆——一旦升级到重量级,不会再降回来)

偏向锁(Biased Locking)

思想:「这把锁大概率一直只被一个线程用,那就干脆让它独占」。

线程 A 第一次获取锁:
  → Mark Word 记录线程 A 的 ID
  → 偏向锁标志位置为 1

线程 A 再次获取:
  → 检查 Mark Word 中的线程 ID == 自己?→ 是,什么都不做,直接进入
  → 零开销!没有 CAS,没有系统调用

线程 B 也来获取:
  → 线程 ID 不匹配 → 撤销偏向锁(STW 安全点操作)
  → 升级为轻量级锁

JDK 15 默认关闭了偏向锁,JDK 21 直接废弃了。原因是:现代高并发场景下,一把锁被单个线程独占的情况越来越少,偏向锁的撤销成本反而拖累了性能。

轻量级锁(Lightweight Locking)

思想:「锁有竞争,但大家都在自旋,不会长期阻塞」。

流程:

1. 线程在当前栈帧中分配 Lock Record
2. 把对象的 Mark Word 拷贝到 Lock Record
3. CAS 尝试把对象的 Mark Word 替换为「指向 Lock Record 的指针」
   │
   ├── CAS 成功 → 轻量级锁获取成功(锁标志位 00)
   │
   └── CAS 失败 → 检查指针是否指向自己的 Lock Record
                  ├── 是 → 重入(再来一个 Lock Record)
                  └── 否 → 自旋等待
                           自旋超时 → 升级为重量级锁

自适应自旋:JVM 不固定自旋次数——根据「上次这个锁自旋了多久才获取到」动态调整。上次自旋成功了,这次多等一会儿;上次失败了,这次早点升级。

重量级锁(Heavyweight Locking)

思想:「抢不过了,交给 OS 吧」。

升级到重量级锁后:

  • 对象的 Mark Word 指向 Monitor
  • 没拿到锁的线程进入 Monitor 的 EntryList,被挂起(阻塞态)
  • 调用 wait() 的线程进入 WaitSet,等待 notify()
  • 锁释放后,OS 从 EntryList 中选一个线程唤醒

代价:用户态 ↔ 内核态切换,每次 ~ 几微秒。


五、synchronized vs Lock:一张表说清楚

维度 synchronized Lock(ReentrantLock)
关键字/类 Java 关键字,JVM 层面实现 java.util.concurrent.locks 的接口
加锁释放 自动——代码块结束/异常自动释放 手动——lock()try...finally → unlock()
可中断 ❌ 不可中断,等不到就一直等 lockInterruptibly(),可响应中断
超时获取 ❌ 不支持 tryLock(time, unit)
公平性 ❌ 非公平 ✅ 可选:new ReentrantLock(true)
条件变量 wait/notify/notifyAll,单一等待队列 Condition,可多个条件队列
性能 JDK 6+ 经过锁升级优化,不差 底层也是 AQS + CAS,与轻量级锁场景持平
读/写分离 ❌ 独占锁 ReentrantReadWriteLock
底层实现 Monitor(C++ ObjectMonitor) AQS(AbstractQueuedSynchronizer) + CAS
适用场景 简单同步,锁持有时间短 需要高级功能:超时、中断、条件变量、公平性

核心原则

能用 synchronized 就用 synchronized,需要高级功能再上 Lock。

原因:synchronized 自动释放、锁升级优化、JVM 甚至可以对它做「锁消除」「锁粗化」等编译优化——这些都是 Lock 享受不到的。


六、AQS:Lock 背后的魔法

ReentrantLock 不是自己实现的锁逻辑——所有 Java 并发工具(ReentrantLockCountDownLatchSemaphoreReentrantReadWriteLockCyclicBarrier…)背后都是同一个人:AQS(AbstractQueuedSynchronizer)

AQS 的设计

AQS 是一个模板类,内部维护:

┌── state (volatile int) ── 锁状态:0 = 未占用,>0 = 已占用
│
└── CLH 队列(双向链表)── 抢不到锁的线程排队等待
        │
    ┌───┴───┐   ┌───┐   ┌───┐
    │ Head  │ → │ T1│ → │ T2│ → ... (FIFO,可配置公平/非公平)
    └───────┘   └───┘   └───┘

ReentrantLock 加锁流程

// ReentrantLock.lock() 内部逻辑(简化)

void lock() {
    // 1. 先 CAS 抢一把(非公平锁的精髓)
    if (compareAndSetState(0, 1)) {
        setExclusiveOwnerThread(currentThread);  // 抢到了
        return;
    }
    // 2. 没抢到,进入 acquire
    acquire(1);
}

void acquire(int arg) {
    // 3. tryAcquire 再抢一次(可能重入)
    if (!tryAcquire(arg))
        // 4. 还是失败 → 入队,park 挂起
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg);
}

Condition:比 wait/notify 更强大

Lock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();
Condition notFull = lock.newCondition();

// 生产者
lock.lock();
try {
    while (queue.isFull()) {
        notFull.await();       // 队列满了,等消费者通知
    }
    queue.add(item);
    notEmpty.signal();         // 通知消费者「有货了」
} finally {
    lock.unlock();
}

// 消费者
lock.lock();
try {
    while (queue.isEmpty()) {
        notEmpty.await();      // 队列空了,等生产者通知
    }
    queue.take();
    notFull.signal();          // 通知生产者「有空位了」
} finally {
    lock.unlock();
}

synchronized 的 wait/notify 只能用一个等待队列,而 Lock 的 Condition 可以创建多个,实现精确唤醒——这正是 ArrayBlockingQueue 内部的设计。


七、JVM 对 synchronized 的「作弊」优化

除了锁升级,JVM 还会在编译期做这些优化:

锁消除(Lock Elimination)

// 你写的代码
public String concat() {
    StringBuffer sb = new StringBuffer();  // StringBuffer 是线程安全的
    sb.append("a").append("b").append("c");
    return sb.toString();
}
// sb 是局部变量,不可能被其他线程引用
// JIT 编译器直接删除 append 方法上的 synchronized

锁粗化(Lock Coarsening)

// 你写的代码
for (int i = 0; i < 100; i++) {
    synchronized (obj) {  // 每次循环加锁释锁,100 次!
        doSomething();
    }
}
// JIT 优化后(锁粗化)
synchronized (obj) {  // 一次锁住,执行 100 次
    for (int i = 0; i < 100; i++) {
        doSomething();
    }
}

八、实战选择清单

场景 推荐锁
简单同步,短临界区 synchronized —— 简单、安全、有优化
需要超时放弃 ReentrantLock.tryLock(time, unit)
需要可中断的锁等待 ReentrantLock.lockInterruptibly()
读多写少 ReentrantReadWriteLockStampedLock
需要公平排队 new ReentrantLock(true)
生产者-消费者 Lock + Condition(多条件队列)
需要尝试获取,失败不等待 ReentrantLock.tryLock()
大量线程竞争 两者都可以;超高竞争下 Lock 略优(条件变量优势)

九、一张脑图收尾

                          Java 锁体系
                              │
        ┌─────────────────────┼─────────────────────┐
        ▼                     ▼                     ▼
   锁策略(设计)           synchronized(实现)      Lock 框架(实现)
        │                     │                     │
  乐观 vs 悲观              关键字                  接口
  公平 vs 非公平             锁升级(1.6+)           ReentrantLock
  可重入 vs 不可重入           │                     ReentrantReadWriteLock
  独占 vs 共享                │                     StampedLock
  自旋 vs 阻塞                │                        │
        │                ┌───┼───┐                    │
        │                │   │   │                    ▼
        ▼                ▼   ▼   ▼                  AQS
   CAS / 版本号        无锁→偏向→轻量→重量          CLH 队列 + state
   (底层原子操作)       (不可逆)                  (模板方法模式)
                                               │
                                          ┌────┴────┐
                                          ▼         ▼
                                      公平锁    非公平锁

一句话总结

  • 锁策略:悲观/乐观、公平/非公平、可重入/不可重入——这是锁的「个性」维度
  • synchronized:JVM 内置,自动释放,从偏向锁→轻量级锁→重量级锁逐级升级,还有锁消除/锁粗化等 JIT 优化
  • Lock:API 层面,手动释放,但支持超时、中断、公平性、多条件队列——功能更强但责任也更大
  • 选锁原则:先考虑 synchronized,不够用再上 Lock。优化是 JVM 的事,正确性是自己的事

更多推荐