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来实现,依赖操作系统的互斥机制。
加锁过程简要流程图(逻辑)
flowchart TD
A[线程进入 synchronized] --> B[检查对象头 Mark Word
是否偏向、锁状态]
B --> C{对象是否空闲?}
C -->|是| D[尝试通过 CAS 加锁 轻量级锁]
C -->|否| E{CAS 加锁是否成功?}
E -->|成功| F[获得锁 轻量级]
E -->|失败| G[有竞争 → 升级为重量级锁
线程挂起等待]
字节码层面
编译后使用 javap -v Test.class 查看,会看到如下结构:
1 | public void syncBlock() { |
反编译字节码:
1 | asm |
monitorenter 和 monitorexit 是 JVM 指令,由 JVM 解释器直接处理,底层是调用 native 方法去操作对象的 Monitor。
JVM实现细节
HotSpot 中,对象的 Monitor 实际上是 ObjectMonitor:
1 | class ObjectMonitor { |
Monitor 与每个对象的 对象头(Mark Word) 关联,当竞争严重时锁会升级:
- 偏向锁:只记录线程 ID,适合无竞争场景
- 轻量级锁:线程通过 CAS 抢占锁
- 重量级锁:膨胀为
ObjectMonitor,线程挂起和唤醒都依赖 OS 的park/unpark
ReentrantLock
加锁原理(底层)
ReentrantLock是基于 AQS(AbstractQueuedSynchronizer) 实现的。- 调用
lock()会尝试通过 CAS 修改一个 state 状态变量,来标识是否持有锁。 - 若获取失败,线程会被放入 AQS 的 双向等待队列中,等待唤醒。
加锁过程简要流程图(逻辑)
flowchart TD
A[线程调用lock] --> B{CAS 尝试将 state 从 0 改为 1}
B -->|成功| C[获得锁]
B -->|失败| D[进入 AQS 等待队列]
AQS的队列结构
1 | head -> [Node1] -> [Node2] -> ... |
每个 Node 包含:
- 等待线程
Thread thread - 等待状态
waitStatus - 前驱/后继节点
prev/next
底层通过 LockSupport.park() 和 unpark() 来实现线程的阻塞与唤醒。
如何唤醒
两者都是有阻塞队列,队列都是有序的,为什么会出现非公平唤醒呢?
**因为”排队” ≠ “唤醒一定按顺序”**。
Java 的锁(尤其是默认的 synchronized 和非公平 ReentrantLock)使用的阻塞队列只是阻塞等待顺序的组织方式,但唤醒后是否真正获得锁,还要竞争(CAS),所以就可能出现:
先排队的线程被唤醒了,但还没抢到锁;此时一个新线程进来,直接CAS成功拿到了锁。
1. synchronized 的非公平行为
synchronized 底层用的是 ObjectMonitor,当有多个线程竞争时,JVM 不保证 先唤醒的一定先拿到锁。
1 | // ObjectMonitor.cpp |
唤醒后还要重新尝试获取锁(CAS),不是直接转移锁的拥有权,所以:
后来的线程进来,如果没有阻塞,仍然可能直接成功拿到锁。
被唤醒的线程,反而还得和新线程竞争,可能继续失败。
2. ReentrantLock 的非公平行为
默认构造的 ReentrantLock() 是非公平锁,非公平策略体现在 lock() 方法中:
1 | final void lock() { |
也就是说:
- 没有优先检查 AQS 队列中是否有等待的线程
- 只要锁是空闲的,谁来谁抢,先到的不一定先拿到
即使队列中有等待线程,新线程仍然可以“插队”成功。
flowchart TD
A[执行 SQL] --> B[EXPLAIN / EXPLAIN ANALYZE]
B --> C{是否走索引?}
C -- 否 --> D[检查 WHERE 条件字段 建立合适索引]
C -- 是 --> E{扫描行数是否过多?}
E -- 是 --> F[考虑复合索引 / 覆盖索引
优化表结构]
E -- 否 --> G{是否出现 filesort/temporary?}
G -- 是 --> H[优化 ORDER BY / GROUP BY
避免临时表]
G -- 否 --> I{SQL 是否过于复杂?}
I -- 是 --> J[拆分大 SQL
用中间表/子查询优化]
I -- 否 --> K{分页或大 OFFSET?}
K -- 是 --> L[使用索引+子查询优化分页
避免 LIMIT 大偏移]
K -- 否 --> M[执行效率已较优]
D --> B
F --> B
H --> B
J --> B
L --> B