锁机制深度解析:synchronized关键字、ReentrantLock、读写锁、自旋锁的原理与选择

说到多线程,锁是绕不开的话题。

我刚开始做并发编程时,总觉得锁就是锁,加个synchronized就完事了。后来线上出了几次性能问题,才意识到——选错锁,代价真不小。你想想看,一个系统的吞吐量,可能就毁在一把不合适的锁上。

今天咱们就把Java里几种主流锁掰开揉碎讲清楚。我会结合自己踩过的坑,帮你理清什么时候该用哪把锁。

synchronized关键字:最朴素的锁

synchronized是Java内置的锁,用法简单。说白了,它就是JVM层面提供的互斥机制。

用法就两种:

  • 修饰方法:锁住当前对象实例或类
  • 修饰代码块:锁住指定对象
// 修饰实例方法
public synchronized void doSomething() {
    // 业务逻辑
}

// 修饰代码块
public void doSomething() {
    synchronized (this) {
        // 业务逻辑
    }
}

我在项目中遇到过一个问题:早期用synchronized保护一个高频调用的方法,结果压测时发现CPU飙升。后来才知道,JDK 1.6之前synchronized是重量级锁,每次都要挂起线程。好在JDK 1.6之后引入了偏向锁、轻量级锁的优化,大部分场景下性能已经不输ReentrantLock了。

核心要点:synchronized适合锁竞争不激烈的场景。如果锁几乎没竞争,偏向锁能让性能接近无锁。

个人习惯:我一般优先用synchronized,除非明确需要ReentrantLock的高级特性。代码更简洁,也不容易忘释放锁。

ReentrantLock:更灵活的替代品

ReentrantLock是java.util.concurrent包下的显式锁。它比synchronized多了几个关键能力:

  • 可中断:lockInterruptibly()支持响应中断
  • 可超时:tryLock(timeout, unit)避免死等
  • 公平性选择:构造参数传true可实现公平锁
  • 条件变量:通过newCondition()实现精确唤醒
ReentrantLock lock = new ReentrantLock(true); // 公平锁
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();

lock.lock();
try {
    while (队列满) {
        notFull.await(); // 生产者等待
    }
    // 入队操作
    notEmpty.signal(); // 唤醒消费者
} finally {
    lock.unlock();
}

我曾经在一个消息中间件项目里用过ReentrantLock。当时需要实现一个阻塞队列,生产者满了要等,消费者空了也要等。用synchronized加wait/notify也能做,但唤醒粒度太粗——你想想看,生产者唤醒时可能把所有消费者都叫醒了,但实际只需要一个。用Condition就能精确控制,性能提升很明显。

避坑指南:ReentrantLock一定要在finally里释放锁!我曾经见过线上代码忘了unlock,结果线程卡死,整个服务雪崩。血的教训。

读写锁:读多写少的利器

读写锁(ReentrantReadWriteLock)把锁分成了读锁和写锁。读锁可以多个线程同时持有,写锁是独占的。

说白了,就是读读不互斥,读写互斥,写写互斥。

ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();

// 读操作
readLock.lock();
try {
    // 读取共享数据
} finally {
    readLock.unlock();
}

// 写操作
writeLock.lock();
try {
    // 修改共享数据
} finally {
    writeLock.unlock();
}

我记得有个缓存系统的项目,数据读的频率是写的几百倍。一开始用synchronized,读操作全串行化了,QPS上不去。换成读写锁后,读操作可以并行,吞吐量直接翻了3倍多。

适用场景:读多写少,且读操作耗时较长。如果读写比例接近1:1,读写锁的优势就不明显了,甚至可能因为锁维护开销反而更慢。

自旋锁:轻量级的等待

自旋锁和前面几种锁的思路完全不同。它不挂起线程,而是让线程原地循环等待(自旋)。

为什么要这么做?因为线程挂起和恢复的开销很大。如果锁很快就能释放,自旋比挂起更划算。

public class SpinLock {
    private AtomicReference<Thread> owner = new AtomicReference<>();

    public void lock() {
        Thread current = Thread.currentThread();
        // 自旋等待,直到成功获取锁
        while (!owner.compareAndSet(null, current)) {
            // 空转,或者执行Thread.yield()
        }
    }

    public void unlock() {
        Thread current = Thread.currentThread();
        owner.compareAndSet(current, null);
    }
}

自旋锁在Java里其实无处不在。synchronized的轻量级锁底层就用到了自旋。JUC里的Atomic类也是基于CAS自旋实现的。

我的经验:自旋锁适合锁持有时间极短的场景,比如对一个计数器做原子递增。如果锁持有时间超过几十个CPU指令周期,自旋就是浪费CPU。我曾经见过一个同事把自旋锁用在数据库操作上,结果CPU直接打满——数据库操作动不动几十毫秒,自旋等于空转。

如何选择?一张图说清楚

下面这张图是我自己总结的锁选择决策流程,帮你快速定位该用哪把锁:

锁选择决策流程图 开始选择锁 锁竞争是否激烈? 否 synchronized 是 是否读多写少? 是 ReentrantReadWriteLock 否 需要可中断/超时/条件变量? 是 ReentrantLock 否 自旋锁 / CAS 注:自旋锁适合锁持有时间极短的场景,否则浪费CPU

性能对比一览

锁类型 适用场景 性能特点 我的建议
synchronized 竞争不激烈,代码简洁优先 JDK 1.6后优化良好,低竞争下性能极佳 默认首选,除非需要高级特性
ReentrantLock 需要可中断、超时、公平锁、条件变量 高竞争下比synchronized略优 复杂同步场景使用,记得finally释放
读写锁 读多写少,读操作耗时 读并行,写独占,吞吐量提升明显 缓存类场景首选
自旋锁 锁持有时间极短(纳秒级) 避免线程挂起,但浪费CPU 配合CAS使用,不要用于IO操作

总结一下:没有银弹。选锁的关键是理解你的业务场景——竞争程度、读写比例、锁持有时间。我个人的习惯是:能用synchronized就用synchronized,代码最干净;需要精细控制时上ReentrantLock;读多写少上读写锁;极短临界区考虑自旋。

最后提醒一句:锁不是越多越好。有时候减少锁的粒度,甚至用无锁数据结构(比如ConcurrentHashMap、Atomic类),比纠结选哪把锁更有效。我在项目中见过太多人把简单问题复杂化了。

嗯,锁机制这块就聊到这儿。每种锁都有它的脾气,用对了是利器,用错了是累赘。多在实践中体会,慢慢就有感觉了。