[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代码块时:
- JVM会执行
monitorenter指令尝试获取锁 - 获取成功时,锁计数器+1;获取失败时,线程进入阻塞状态
- 退出时执行
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仍然保持优势,特别是使用读写锁时
表格总结两者的关键差异:
| 特性 | synchronized | Lock |
|---|---|---|
| 锁获取方式 | 隐式(JVM自动) | 显式(手动调用) |
| 可中断性 | 不支持 | 支持 |
| 超时机制 | 不支持 | 支持 |
| 公平锁 | 非公平(不可配置) | 可配置公平/非公平 |
| 条件变量 | 只能有一个等待队列 | 支持多个Condition |
| 锁状态查询 | 不支持 | 支持 |
| 异常处理 | 自动释放锁 | 需在finally中手动释放 |
六、实战选型指南:如何做出明智选择
优先选择synchronized的场景:
- 简单的同步需求(如计数器、简单的共享资源保护)
- 代码简洁性优先的场景
- 竞争不激烈的情况
// 简单计数器 - synchronized是最佳选择
public class SimpleCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
优先选择Lock的场景:
- 需要可中断、超时功能(避免死锁)
- 需要公平锁(按顺序获取锁)
- 读写分离场景(使用ReadWriteLock)
- 复杂线程协作(需要多个条件变量)
// 读写锁场景 - 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();
八、总结与展望
synchronized和Lock不是替代关系,而是互补关系。正如传统餐厅和智能餐厅可以共存一样,它们各自有适用的场景。
现代开发建议:
- 优先尝试synchronized:在满足需求的情况下,它的简洁性是巨大优势
- 需要高级功能时选择Lock:当需要可中断、超时、公平锁等特性时,Lock是必然选择
- 读写分离场景用ReadWriteLock:读多写少的场景下,性能提升明显
随着虚拟线程在Java 21中的引入,锁的使用模式可能会发生变化,但基本的同步原理不会改变。掌握这两种核心锁机制,将为你打下坚实的并发编程基础。
希望这篇文章能帮助你理解synchronized和Lock的区别与应用场景。在实际开发中,没有最好的锁,只有最合适的锁。明智的选择来自于对需求准确的理解和对工具深入的认知。
思考题:在你的项目中,哪些场景适合用synchronized?哪些场景又需要Lock的强大功能呢?欢迎在评论区分享你的实践经验!
更多技术干货欢迎关注微信公众号科威舟的AI笔记~
【转载须知】:转载请注明原文出处及作者信息