Java中的常用锁

​ 在java中,我们主要用到的锁有两个:synchronized、ReentrantLock。一个是内置锁(语言级),一个是显示锁,一般如果我们不思考只图方便的化,会直接使用synchronized锁。其实ReentrantLock更灵活多变,适用跟多的场景。我们可以通过下面的表对比一下两者。

特性/维度 synchronized ReentrantLock
所属包 Java 语言层面(内置关键字) java.util.concurrent.locks
可重入性 ✅ 支持 ✅ 支持
是否必须手动解锁 ❌ 自动(方法或代码块退出时) ✅ 必须手动 unlock()
是否可中断获取锁 ❌ 不支持 ✅ 支持 lockInterruptibly()
是否支持尝试获取锁 ❌ 不支持 ✅ 支持 tryLock()(立即或超时)
是否支持公平锁 ❌ 不支持 ✅ 支持(new ReentrantLock(true)
是否支持条件队列 ❌ 不支持 ✅ 支持 Condition(类似 wait/notify
性能优化(偏向锁等) ✅ JVM 自动优化 ❌ 无(完全由开发者控制)
是否支持可见调试 ❌ 无法查看状态 ✅ 可查看是否被锁定、等待线程等
死锁处理 不易发现 可结合 tryLock() 等策略避免死锁

​ 其实在上述这些维度方面都没啥好说的,这些一般都知道。都知道synchronized是关键字而ReentrantLock是一个类,然后两者都能加锁,这锁怎么去加,有什么效果,都是知道的。

​ 那synchronized和ReentrantLock又分别是怎么加锁的呢?

#### synchronized
加锁原理(底层)
  • Java 对象头中包含了 Mark Word,在没有加锁时存储哈希值、GC信息等。
  • 当线程进入 synchronized 块时,会尝试修改对象头的 Mark Word,以标识当前线程持有锁。
  • JVM 使用了多种锁优化策略(偏向锁、轻量级锁、重量级锁):
    • 偏向锁:如果只有一个线程访问,尽量不加锁,提高性能。
    • 轻量级锁:使用 CAS 尝试获取锁,适用于低竞争。
    • 重量级锁(互斥锁):竞争激烈时,会挂起线程,使用 Monitor 来实现,依赖操作系统的互斥机制。
加锁过程简要流程图(逻辑)
1
2
3
4
5
6
7
线程进入synchronized:
|
|-- 检查对象头Mark Word(是否偏向、锁状态)
|
|-- 如果空闲,则尝试通过CAS加锁(轻量级)
|
|-- 如果失败,有竞争,则升级为重量级锁(挂起线程)
字节码层面

编译后使用 javap -v Test.class 查看,会看到如下结构:

1
2
3
4
5
6
7
8
9
java


CopyEdit
public void syncBlock() {
synchronized (this) {
System.out.println("Hello");
}
}

反编译字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
asm


CopyEdit
0: aload_0
1: dup
2: monitorenter // 进入同步块
3: getstatic java/lang/System.out : Ljava/io/PrintStream;
6: ldc "Hello"
8: invokevirtual java/io/PrintStream.println(Ljava/lang/String;)V
11: monitorexit // 退出同步块
12: goto 20
15: astore_1
16: monitorexit // 异常时也要退出同步块
17: aload_1
18: athrow

monitorentermonitorexit 是 JVM 指令,由 JVM 解释器直接处理,底层是调用 native 方法去操作对象的 Monitor。

JVM实现细节

HotSpot 中,对象的 Monitor 实际上是 ObjectMonitor

1
2
3
4
5
6
7
class ObjectMonitor {
...
ObjectWaiter * _EntryList; // 等待锁的线程队列
Thread * _Owner; // 当前持有锁的线程
int _recursions; // 重入次数
...
};

Monitor 与每个对象的 对象头(Mark Word) 关联,当竞争严重时锁会升级:

  • 偏向锁:只记录线程 ID,适合无竞争场景
  • 轻量级锁:线程通过 CAS 抢占锁
  • 重量级锁:膨胀为 ObjectMonitor,线程挂起和唤醒都依赖 OS 的 park/unpark

ReentrantLock

加锁原理(底层)
  • ReentrantLock 是基于 AQS(AbstractQueuedSynchronizer) 实现的。
  • 调用 lock() 会尝试通过 CAS 修改一个 state 状态变量,来标识是否持有锁。
  • 若获取失败,线程会被放入 AQS 的 双向等待队列中,等待唤醒。
加锁过程简要流程图(逻辑)
1
2
3
4
5
6
7
线程调用lock():
|
|-- 尝试通过CAS将state从0改为1(加锁)
|
|-- 如果成功,获得锁
|
|-- 如果失败,进入AQS等待队列
AQS的队列结构
1
2
3
head -> [Node1] -> [Node2] -> ...
^ ^
线程1 线程2(阻塞)

每个 Node 包含:

  • 等待线程 Thread thread
  • 等待状态 waitStatus
  • 前驱/后继节点 prev / next

底层通过 LockSupport.park()unpark() 来实现线程的阻塞与唤醒。

如何唤醒

​ 两者都是有阻塞队列,队列都是有序的,为什么会出现非公平唤醒呢?

​ **因为”排队” ≠ “唤醒一定按顺序”**。

Java 的锁(尤其是默认的 synchronized 和非公平 ReentrantLock)使用的阻塞队列只是阻塞等待顺序的组织方式,但唤醒后是否真正获得锁,还要竞争(CAS),所以就可能出现:

先排队的线程被唤醒了,但还没抢到锁;此时一个新线程进来,直接CAS成功拿到了锁。

1. synchronized 的非公平行为

synchronized 底层用的是 ObjectMonitor,当有多个线程竞争时,JVM 不保证 先唤醒的一定先拿到锁

1
2
3
4
5
CopyEdit
// ObjectMonitor.cpp
void ObjectMonitor::exit(...) {
// 调用 _EntryList 中的某个线程 unpark(但可能不是严格FIFO)
}

唤醒后还要重新尝试获取锁(CAS),不是直接转移锁的拥有权,所以:

  • 后来的线程进来,如果没有阻塞,仍然可能直接成功拿到锁。

  • 被唤醒的线程,反而还得和新线程竞争,可能继续失败。

2. ReentrantLock 的非公平行为

默认构造的 ReentrantLock()非公平锁,非公平策略体现在 lock() 方法中:

1
2
3
4
5
6
7
8
CopyEdit
final void lock() {
if (compareAndSetState(0, 1)) { // 直接抢锁,不管队列中有没有人
setExclusiveOwnerThread(Thread.currentThread());
} else {
acquire(1); // 抢不到才排队
}
}

也就是说:

  • 没有优先检查 AQS 队列中是否有等待的线程
  • 只要锁是空闲的,谁来谁抢,先到的不一定先拿到

即使队列中有等待线程,新线程仍然可以“插队”成功。