Administrator
发布于 2018-08-06 / 1490 阅读
14

synchronized 和 ReentrantLock 该怎么选

压测数据打脸:我把 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 万次
synchronized612 ms203 ms
ReentrantLock(非公平)663 ms268 ms
ReentrantLock(公平)4128 ms291 ms
AtomicInteger148 ms96 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
跨 JVMRedis 分布式锁

最后说个必须记住的:ReentrantLock 的 unlock() 一定要放在 finally 里。synchronized 由 JVM 保证异常时释放,ReentrantLock 得自己保证。我刚改完那版就漏了一处,压测时抛异常导致锁永远不释放,整个接口卡死,比性能问题严重多了。

参考