压测数据打脸:我把 synchronized 换成 ReentrantLock,反而慢了
8 月份做优惠券发放模块,有个扣库存的临界区。师傅说了句"ReentrantLock 比 synchronized 快",我就吭哧吭哧把代码全改了,还加了 finally 释放锁。结果自己压测一测,10 并发下 ReentrantLock 反而慢了 8%。
我把测试代码和结果贴出来找他,他看完笑了:"JDK 8 里 synchronized 早不是当年那个重量级锁了,你这是优化了个寂寞。"
先上我那份压测数据
测试场景:10 个线程,每个线程对同一个计数器累加 100 万次,JDK 8u171,macOS 10.13,i7-7700HQ。
public class LockBench {
private static final int THREADS = 10;
private static final int LOOPS = 1_000_000;
static int plainCount;
static final Object LOCK = new Object();
static void syncInc() {
synchronized (LOCK) {
plainCount++;
}
}
// ...
}
| 方式 | 10 线程 / 100 万次 | 单线程 / 1000 万次 |
|---|---|---|
| synchronized | 612 ms | 203 ms |
| ReentrantLock(非公平) | 663 ms | 268 ms |
| ReentrantLock(公平) | 4128 ms | 291 ms |
| AtomicInteger | 148 ms | 96 ms |
三个反直觉的地方:非公平 ReentrantLock 比 synchronized 略慢;公平锁慢了近 7 倍;单线程下公平锁也慢,因为它每次都要检查队列。
注意这结果只在 JDK 8 上成立。JDK 1.5 时代 synchronized 确实是重量级锁,性能差 ReentrantLock 一个数量级,很多老博客的结论就是从那时候传下来的,我信了。
synchronized 为什么变快了:锁升级
JDK 6 之后引入了偏向锁和轻量级锁,锁的状态会根据竞争情况单向升级,不能降级(这个"不能降级"在 JDK 8 里是对的):
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
对象头的 Mark Word 里存着锁状态,我用 JOL 打印过对象头验证:
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
四个阶段大致是这样:
- 偏向锁:假设这把锁一直由同一个线程持有。线程第一次拿到锁时,把线程 ID 用 CAS 写进 Mark Word,之后这个线程再进来,只需比对一下 ID,连 CAS 都不用做。适用于"锁基本没竞争"的场景,我们大部分业务代码都是这种。
- 轻量级锁:有第二个线程来抢了,偏向锁撤销,升级为轻量级锁。线程在栈帧里创建 Lock Record,用 CAS 把 Mark Word 换过来。抢不到就自旋,不阻塞。
- 重量级锁:自旋超过一定次数(JDK 8 默认是自适应自旋,不是固定 10 次)或者竞争太激烈,升级为重量级锁,抢不到的线程挂起,进入等待队列,涉及用户态到内核态的切换,这才是真正慢的地方。
所以"synchronized 慢"这个印象,说的是重量级锁阶段。在没有真实竞争的代码里,它可能一直停在偏向锁,开销几乎为零。
顺带说个参数:-XX:-UseBiasedLocking 可以关掉偏向锁。我们线上没关,但师傅提过,高并发且锁竞争激烈的服务可以考虑关,因为偏向锁的撤销本身有成本,需要等到 safepoint。
ReentrantLock 的 AQS 是怎么回事
ReentrantLock 快不快,得看它的实现。它内部有个 Sync 抽象类,继承 AbstractQueuedSynchronizer(AQS)。AQS 的核心就三样东西:
// AQS 里的关键字段(简化)
private volatile int state; // 同步状态
private transient volatile Node head; // 等待队列头
private transient volatile Node tail; // 等待队列尾
state:对 ReentrantLock 来说就是重入次数。0 表示没人持有,1 表示持有一次,重入一次加 1。- 双向 FIFO 队列(CLH 队列变体):抢锁失败的线程包装成 Node 排进队尾。
- CAS:用
compareAndSetState(0, 1)抢锁,只改一个 int,比内核调用便宜得多。
非公平锁的 lock() 大概就是:
final void lock() {
if (compareAndSetState(0, 1)) // 上来先插队试一次
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 失败再走 AQS 排队
}
公平锁去掉了那个"上来先插队",改成先判断队列里有没有人在等:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() && // 队列里没人排在我前面
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// ... 重入处理
}
这就是公平锁慢 7 倍的原因:每次都要遍历检查队列,而且不允许插队,导致线程频繁挂起唤醒。非公平锁允许插队,吞吐量高,但代价是可能有线程永远抢不到锁(饥饿)。JDK 默认用的是非公平的,实际业务里一般也够用。
什么时候非用 ReentrantLock 不可
性能不是选它的理由,这三个能力才是:
1. 可中断的锁获取
synchronized 抢不到锁就一直等,没法取消。ReentrantLock 有 lockInterruptibly():
try {
lock.lockInterruptibly();
try {
doSomething();
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断标志
log.warn("放弃抢锁");
}
我们那个优惠券发放有个批量任务,线程卡在锁上导致关闭线程池时 shutdownNow 都停不掉,后来就是靠这个解决的。
2. tryLock 超时
if (lock.tryLock(3, TimeUnit.SECONDS)) {
try {
deductStock();
} finally {
lock.unlock();
}
} else {
return Result.fail("系统繁忙,请稍后再试");
}
这个我用在秒杀接口上。与其让用户无限等待把线程池占满,不如 3 秒后快速失败。
3. 多个条件队列
synchronized 配 wait/notify,一个对象只有一个等待队列,notify 唤醒的是随机一个,容易唤醒错人。ReentrantLock 可以开多个 Condition:
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public void put(Object x) throws InterruptedException {
lock.lock();
try {
while (count == items.length) notFull.await(); // 只在 notFull 上等
items[putptr] = x;
notEmpty.signal(); // 只唤醒等 notEmpty 的
} finally {
lock.unlock();
}
}
这是 JDK 里 ArrayBlockingQueue 的实现思路。我自己写了个简单的限流队列时抄过这段。
现在我的选择标准
| 场景 | 用哪个 |
|---|---|
| 普通临界区,没什么竞争 | synchronized,代码短不易出错 |
| 需要 tryLock 超时 / 可中断 / 多条件 | ReentrantLock |
| 纯计数、状态标记 | AtomicInteger / volatile |
| 读多写少 | ReentrantReadWriteLock |
| 跨 JVM | Redis 分布式锁 |
最后说个必须记住的:ReentrantLock 的 unlock() 一定要放在 finally 里。synchronized 由 JVM 保证异常时释放,ReentrantLock 得自己保证。我刚改完那版就漏了一处,压测时抛异常导致锁永远不释放,整个接口卡死,比性能问题严重多了。