Java 锁完全指南:从 synchronized 到 StampedLock
如果你问一个初级开发者:“Java 中怎么保证线程安全?”
他大概率会回答你:用 synchronized 关键字。
这没错——synchronized 简单、安全,在很多基础场景下确实够用。
但当你成长为高级工程师,开始设计高吞吐、低延迟的系统时,你会发现:
“简单”往往意味着“性能瓶颈”。
在高并发架构的世界里,没有万能的锁。
今天,我们就深入 java.util.concurrent.locks 包,一起认识 Java 锁家族的三大主力:
ReentrantLockReentrantReadWriteLock- 以及性能怪兽
StampedLock
1. ReentrantLock:手动挡的“精准控制”
你可以把 synchronized 想象成一辆自动挡汽车:
它自动帮你加减档(自动加锁/解锁),但你完全没法干预过程。
而 ReentrantLock 就像手动挡——
你拥有完全控制权,但一旦忘了换挡(忘记解锁),车子就会熄火(死锁)。
为什么需要它?
核心原因是:灵活性。
相比 synchronized,ReentrantLock 提供了几个“超能力”:
- 可中断:等待锁的线程可以被中断(
lock.lockInterruptibly()) - 带超时:尝试获取锁,2 秒拿不到就放弃(
lock.tryLock(timeout)) - 公平性:可以强制按“先来后到”顺序分配锁(FIFO,避免线程饿死)
“可重入”是什么意思?
“Reentrant”(可重入)的意思是:
如果一个线程已经持有这把锁,它可以再次获取同一把锁而不被阻塞。
这对递归调用或类内部方法互相调用非常关键。
架构师的最佳实践
永远用 try-finally 块! 这在代码评审中是铁律。
Lock lock = new ReentrantLock();
lock.lock();
try {
// 临界区
processData();
} finally {
// 如果这里不 unlock,服务器迟早会卡死!
lock.unlock();
}
2. ReentrantReadWriteLock:为“读多写少”而生
想象一个商品目录 API:
每分钟有 1 万名用户在查价格,但管理员每天只更新一次。
如果用 ReentrantLock,那每次读操作都会互相阻塞——
线程 A 在读,线程 B 就得等。
但读操作不会破坏数据,这种阻塞完全是浪费资源!
这时候,ReentrantReadWriteLock 就派上用场了。
它是怎么工作的?
它把锁拆成两种:
- 读锁(Read Lock):共享的,多个线程可以同时持有
- 写锁(Write Lock):独占的,只有一个人能写,且写的时候不能有人读
白板类比(很好理解!)
想象教室里的白板:
- 读锁:老师走开了,30 个学生可以同时看白板
- 写锁:老师站在白板前写字,没人能看(读),也没人能插手写(写)
什么时候用?
当你的读写比例严重失衡(比如 90% 读,10% 写)时,果断上它!
ReadWriteLock rwLock = new ReentrantReadWriteLock();
// 多个线程可以同时进入!
public String getPrice() {
rwLock.readLock().lock();
try {
return this.price;
} finally {
rwLock.readLock().unlock();
}
}
// 只有一个线程能进入
public void setPrice(String newPrice) {
rwLock.writeLock().lock();
try {
this.price = newPrice;
} finally {
rwLock.writeLock().unlock();
}
}
3. StampedLock:F1 赛车级别的性能怪兽
StampedLock 是 Java 8 引入的“性能猛兽”,专为极致吞吐设计。
ReadWriteLock 的痛点
即使是 ReentrantReadWriteLock,也有开销:
每次读者获取锁,都要更新内存中的“读者计数器”。
如果成百上千个线程同时争抢这个计数器,就会产生内存竞争(memory contention),反而拖慢性能。
StampedLock 的绝招:乐观读(Optimistic Reading)
它说:“我不急着加锁。我先直接读数据,读完再检查一个‘时间戳’(stamp),看看期间有没有人改过数据。”
- 如果没人改?太好了!你刚刚完成了一次零开销的读操作。
- 如果有人改了?没关系,退一步,老老实实用读锁重读一遍。
⚠️ 重要警告(务必注意!)
StampedLock 不是可重入的!
如果你在一个方法里拿了 StampedLock,又调用了另一个也试图拿同一把锁的方法,会自己把自己锁死(死锁)。
这一点和前面两种锁完全不同!
代码示例(虽然啰嗦,但值得)
StampedLock sl = new StampedLock();
double x, y;
public double optimisticRead() {
// 1. 获取一个“乐观读”的 stamp(实际上没加锁!)
long stamp = sl.tryOptimisticRead();
// 2. 把状态拷贝到局部变量
double currentX = x;
double currentY = y;
// 3. 验证 stamp:期间有没有人写过?
if (!sl.validate(stamp)) {
// 4. 如果验证失败(数据被改了),那就老老实实用读锁
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
总结:到底该选哪种锁?
作为架构师,我给团队的“速查表”如下:
| 场景 | 推荐锁 |
|---|---|
| 简单同步,低并发 | synchronized |
| 需要中断、超时、公平性 | ReentrantLock |
| 读远多于写(如缓存、配置) | ReentrantReadWriteLock |
| 极致性能,读极频繁,能接受复杂逻辑 | StampedLock |
💡 小贴士:
StampedLock虽快,但代码难读、易出错。永远从最简单的方案开始。
先用性能分析工具(profiler)确认锁确实是瓶颈,再考虑升级到复杂锁。
最后的话
并发是一把双刃剑。StampedLock 虽快,但用不好反而会引入 bug 或死锁。
不要为了炫技而用高级锁,要为真实性能问题而用。
- 点赞
- 收藏
- 关注作者
评论(0)