锁机制深度解析: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 | 竞争不激烈,代码简洁优先 | JDK 1.6后优化良好,低竞争下性能极佳 | 默认首选,除非需要高级特性 |
| ReentrantLock | 需要可中断、超时、公平锁、条件变量 | 高竞争下比synchronized略优 | 复杂同步场景使用,记得finally释放 |
| 读写锁 | 读多写少,读操作耗时 | 读并行,写独占,吞吐量提升明显 | 缓存类场景首选 |
| 自旋锁 | 锁持有时间极短(纳秒级) | 避免线程挂起,但浪费CPU | 配合CAS使用,不要用于IO操作 |
总结一下:没有银弹。选锁的关键是理解你的业务场景——竞争程度、读写比例、锁持有时间。我个人的习惯是:能用synchronized就用synchronized,代码最干净;需要精细控制时上ReentrantLock;读多写少上读写锁;极短临界区考虑自旋。
最后提醒一句:锁不是越多越好。有时候减少锁的粒度,甚至用无锁数据结构(比如ConcurrentHashMap、Atomic类),比纠结选哪把锁更有效。我在项目中见过太多人把简单问题复杂化了。
嗯,锁机制这块就聊到这儿。每种锁都有它的脾气,用对了是利器,用错了是累赘。多在实践中体会,慢慢就有感觉了。