锁的进化论:从synchronized到Lock,谁才是并发编程的终极选择?

46 阅读8分钟

[toc]

大家好,我是你们的船长:科威舟,今天给大家分享一下synchronized到Lock的区别和使用场景。

如果你也曾为多线程并发问题头疼过,那么这次对话将为你彻底解开锁的心结

在并发编程的世界里,有两个"门神"把守着线程安全的大门:一个是元老级的synchronized,一个是新生代的Lock。它们看似做着相同的工作,却有着完全不同的行事风格。今天,就让我们一起揭开它们的神秘面纱!

一、从一场餐厅比喻开始

想象一下,你去一家餐厅吃饭:

synchronized就像是一家传统餐厅——只有一个入口,门口站着一位经理。每位顾客(线程)都要排队,经理会按照顺序让顾客进入。但问题是:如果前面的人堵在门口不动,后面所有人都得干等着,连上厕所都不行!

Lock则像是一家现代化智能餐厅——你可以通过App查看排队情况,可以设置等待时间("如果超过30分钟还没位子,我就去别家"),甚至可以在等待时去做其他事情(响应中断)。

这个简单的比喻已经道出了两者的核心差异。下面我们来深入技术细节。

二、身世大不同:内置关键字 vs API接口

synchronized是Java的亲儿子,从JDK 1.0就存在的内置关键字,由JVM原生支持。它的使用简单到令人发指:

// 同步方法
public synchronized void doSomething() {
    // 临界区代码
}

// 同步代码块
public void doSomething() {
    synchronized(this) {
        // 临界区代码
    }
}

Lock是Java 5之后引入的**"外来女婿"**,是一个接口,需要显式地创建和使用:

private Lock lock = new ReentrantLock();

public void doSomething() {
    lock.lock();  // 手动上锁
    try {
        // 临界区代码
    } finally {
        lock.unlock();  // 必须在finally中手动释放
    }
}

看到这里的try-finally套路了吗?这是Lock的一大特点——手动挡操作,相比synchronized自动挡,需要更多代码但提供了更精细的控制。

三、特性大比拼:为什么Lock被称为"增强版"

1. 可中断性:能否"半途而废"

synchronized:一旦开始等待锁,就会变成"望妻石"—死等到底,不能被中断。

// 线程B会一直阻塞,无法被中断
synchronized(lockObj) {
    // 如果这个锁一直被占用,线程会无限期等待
}

Lock:提供了lockInterruptibly()方法,允许线程在等待锁的过程中响应中断。

try {
    lock.lockInterruptibly();  // 可被中断的锁获取
    try {
        // 临界区代码
    } finally {
        lock.unlock();
    }
} catch (InterruptedException e) {
    // 收到中断信号,放弃等待,做其他事情
    Thread.currentThread().interrupt();
}

2. 超时机制:给等待加个"保质期"

synchronized:等待没有时间限制,可能永远阻塞

Lock:可以设置最大等待时间,避免无限期等待。

if (lock.tryLock(3, TimeUnit.SECONDS)) {  // 最多等待3秒
    try {
        // 获取锁成功,执行临界区代码
    } finally {
        lock.unlock();
    }
} else {
    // 等待超时,执行备选方案
    System.out.println("等待超时,先去处理其他任务");
}

这个特性在避免死锁方面特别有用,比如在分布式锁场景中,我们不会因为一个节点的故障导致整个系统挂起。

3. 公平性:能否"插队"

synchronized非公平锁(默认且无法修改),允许插队,吞吐量高但可能导致某些线程"饿死"。

Lock:可通过构造参数选择公平性。

// 公平锁 - 先来后到
Lock fairLock = new ReentrantLock(true);

// 非公平锁 - 允许插队,吞吐量更高
Lock unfairLock = new ReentrantLock(false);

公平锁保证了顺序性但性能较低,非公平锁性能更高但可能不公平——这就是典型的效率与公平的权衡

4. 条件变量:精细化的线程通信

synchronized:只能使用Object.wait()notify()notifyAll(),是**"广播式"通信**,不够精确。

Lock:可以通过Condition实现**"精准通知"**。

class MessageBuffer {
    private Lock lock = new ReentrantLock();
    private Condition notFull = lock.newCondition();   // 条件:缓冲区未满
    private Condition notEmpty = lock.newCondition();  // 条件:缓冲区非空
    
    public void produce(String message) throws InterruptedException {
        lock.lock();
        try {
            while (buffer.isFull()) {
                notFull.await();  // 等待"缓冲区未满"条件
            }
            buffer.add(message);
            notEmpty.signal();    // 精准唤醒消费者线程
        } finally {
            lock.unlock();
        }
    }
}

这种精细化的控制使得Lock在复杂线程协作场景中表现优异。

四、底层原理:Monitor vs AQS

synchronized的Monitor机制

每个Java对象都有一个内置的Monitor(监视器)。当线程进入synchronized代码块时:

  1. JVM会执行monitorenter指令尝试获取锁
  2. 获取成功时,锁计数器+1;获取失败时,线程进入阻塞状态
  3. 退出时执行monitorexit指令,计数器-1,计数器为0时释放锁

在JDK 1.6之后,synchronized经历了重大优化,引入了锁升级机制

无锁 → 偏向锁 → 轻量级锁 → 重量级锁

这个优化让synchronized在竞争不激烈时性能大幅提升。

Lock的AQS魔法

Lock的核心是AQS(AbstractQueuedSynchronizer),这是一个用于构建锁的框架。

AQS内部维护了一个FIFO队列来管理等待锁的线程,通过CAS(Compare-And-Swap)操作实现无锁化同步。CAS是一种乐观锁机制,它假设冲突很少发生,发生时再重试。

// CAS的伪代码实现
public boolean compareAndSet(int expect, int update) {
    // 如果当前值等于期望值,则原子性地更新为新值
    // 这是一个CPU原子指令,不会被打断
}

这种机制避免了线程切换的开销,在高并发场景下性能更好。

五、性能对比:不再是绝对的优劣

历史上有一个常见的误解:"Lock性能永远比synchronized好"。事实要更加复杂:

  • JDK 1.5时期:Lock性能明显优于synchronized
  • JDK 1.6+:synchronized经过优化(偏向锁、轻量级锁等),在低竞争场景下性能与Lock相当
  • 高竞争场景:Lock仍然保持优势,特别是使用读写锁时

表格总结两者的关键差异:

特性synchronizedLock
锁获取方式隐式(JVM自动)显式(手动调用)
可中断性不支持支持
超时机制不支持支持
公平锁非公平(不可配置)可配置公平/非公平
条件变量只能有一个等待队列支持多个Condition
锁状态查询不支持支持
异常处理自动释放锁需在finally中手动释放

六、实战选型指南:如何做出明智选择

优先选择synchronized的场景:

  1. 简单的同步需求(如计数器、简单的共享资源保护)
  2. 代码简洁性优先的场景
  3. 竞争不激烈的情况
// 简单计数器 - synchronized是最佳选择
public class SimpleCounter {
    private int count = 0;
    
    public synchronized void increment() {
        count++;
    }
}

优先选择Lock的场景:

  1. 需要可中断、超时功能(避免死锁)
  2. 需要公平锁(按顺序获取锁)
  3. 读写分离场景(使用ReadWriteLock)
  4. 复杂线程协作(需要多个条件变量)
// 读写锁场景 - Lock有明显优势
public class DataRepository {
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();
    
    // 多个线程可以同时读
    public String readData() {
        readLock.lock();
        try {
            // 读取操作
            return data;
        } finally {
            readLock.unlock();
        }
    }
    
    // 写操作独占
    public void writeData(String newData) {
        writeLock.lock();
        try {
            // 写入操作
            data = newData;
        } finally {
            writeLock.unlock();
        }
    }
}

七、避免常见陷阱

Lock的经典死锁陷阱:

// 错误示例:忘记在finally中释放锁
public void riskyMethod() {
    lock.lock();
    // 如果此处抛出异常,锁永远不会释放!
    doSomething();
    lock.unlock();
}

// 正确做法
public void safeMethod() {
    lock.lock();
    try {
        doSomething();
    } finally {
        lock.unlock();  // 确保锁一定被释放
    }
}

synchronized的不可中断陷阱:

// 线程B会无限期等待,无法被中断
Thread threadB = new Thread(() -> {
    synchronized(lock) {
        // 如果线程A一直不释放锁,这里永远等不到
    }
});
threadB.start();

// 尝试中断threadB是无效的!
threadB.interrupt();

八、总结与展望

synchronizedLock不是替代关系,而是互补关系。正如传统餐厅和智能餐厅可以共存一样,它们各自有适用的场景。

现代开发建议

  1. 优先尝试synchronized:在满足需求的情况下,它的简洁性是巨大优势
  2. 需要高级功能时选择Lock:当需要可中断、超时、公平锁等特性时,Lock是必然选择
  3. 读写分离场景用ReadWriteLock:读多写少的场景下,性能提升明显

随着虚拟线程在Java 21中的引入,锁的使用模式可能会发生变化,但基本的同步原理不会改变。掌握这两种核心锁机制,将为你打下坚实的并发编程基础。

希望这篇文章能帮助你理解synchronizedLock的区别与应用场景。在实际开发中,没有最好的锁,只有最合适的锁。明智的选择来自于对需求准确的理解和对工具深入的认知。

思考题:在你的项目中,哪些场景适合用synchronized?哪些场景又需要Lock的强大功能呢?欢迎在评论区分享你的实践经验!


更多技术干货欢迎关注微信公众号科威舟的AI笔记~

【转载须知】:转载请注明原文出处及作者信息