1. 为什么需要Lock锁:synchronized的局限性
在Java并发编程中,synchronized关键字是最基础的线程同步机制,但它在实际高并发场景下存在明显不足。我曾在电商秒杀系统开发中深刻体会到:当QPS突破5万时,synchronized会导致大量线程阻塞,系统吞吐量急剧下降。这就是我们需要Lock锁的根本原因。
synchronized的主要问题体现在三个方面:
- 不可中断性:线程一旦进入阻塞状态,会一直等待锁释放,无法响应中断。这在分布式锁场景下尤为致命——当持有锁的节点宕机时,其他线程只能无限期等待。
- 单一条件:每个对象只能有一个等待队列,不同等待条件(如生产者-消费者模型中的"非空"和"非满")无法区分管理。
- 非公平竞争:锁释放后,所有等待线程随机竞争,可能导致某些线程长期饥饿。
关键区别:Lock接口提供了tryLock()方法,支持带超时的锁获取。我在支付系统开发中,正是利用这个特性实现了"尝试锁定订单5秒,超时则自动取消"的业务逻辑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Lock锁的核心实现原理
2.1 AQS队列同步器
Java.util.concurrent.locks包下的ReentrantLock、ReentrantReadWriteLock等实现类,其核心都依赖于AbstractQueuedSynchronizer(AQS)。这个看似复杂的类,其实可以理解为"锁的骨架"。
AQS维护了一个双向链表结构的等待队列(CLH队列的变种),每个节点保存着线程引用和等待状态。当线程尝试获取锁失败时:
- 创建Node节点加入队列尾部
- 通过CAS操作保证线程安全入队
- 进入自旋或阻塞状态(通过LockSupport.park)
java复制// 典型的AQS获取锁流程(以非公平锁为例)
final void lock() {
if (compareAndSetState(0, 1)) // CAS尝试直接获取
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 进入队列等待
}
2.2 可重入性实现
可重入锁通过两个机制实现:
- ownerThread记录:保存当前持有锁的线程引用
- holdCount计数:记录重入次数。每次重入+1,释放时-1,归零才真正释放锁
java复制// ReentrantLock中的同步器实现
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) { // 重入判断
int nextc = c + acquires;
setState(nextc);
return true;
}
return false;
}
3. 不同Lock实现类的对比选型
3.1 ReentrantLock vs ReentrantReadWriteLock
| 特性 | ReentrantLock | ReentrantReadWriteLock |
|---|---|---|
| 锁类型 | 排他锁 | 读写分离 |
| 适用场景 | 写多读少 | 读多写少 |
| 吞吐量 | 较低 | 高(读读不互斥) |
| 锁降级 | 不支持 | 支持(写锁→读锁) |
| 内存消耗 | 较低 | 较高(维护两个同步器) |
实际项目中的经验法则:
- 当读写比超过10:1时,读写锁性能优势开始显现
- 配置中心这类读多写少的场景,使用读写锁可使QPS提升3-5倍
- 注意写锁的饥饿问题:连续读锁可能导致写线程长期等待
3.2 公平锁与非公平锁
ReentrantLock构造函数的fairness参数决定锁的公平性:
- 公平锁:严格按照FIFO顺序分配锁,避免饥饿但吞吐量低
- 非公平锁:允许插队,吞吐量高但可能造成饥饿
java复制// 创建公平锁
Lock fairLock = new ReentrantLock(true);
// 创建非公平锁(默认)
Lock nonFairLock = new ReentrantLock();
实测数据对比(4核8G环境):
- 公平锁:吞吐量约1.2万 ops/s
- 非公平锁:吞吐量约3.5万 ops/s
生产建议:除非有严格顺序要求,否则优先使用非公平锁。我在日志处理系统中将公平锁改为非公平锁后,处理速度提升了近200%。
4. Lock锁的最佳实践与避坑指南
4.1 正确释放锁的姿势
Lock必须放在finally块中释放,这是铁律!我曾排查过一个内存泄漏问题,就是因为异常路径未释放锁,导致所有后续线程永久阻塞:
java复制Lock lock = new ReentrantLock();
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock(); // 必须确保执行
}
4.2 避免死锁的四种策略
- 顺序加锁:所有线程按固定顺序获取锁(如按锁对象的hashCode排序)
- 超时机制:使用tryLock(timeout, unit)设置等待上限
- 死锁检测:定期扫描线程等待图,发现环路则报警
- 开放调用:不在持有锁的情况下调用外部方法
java复制// 超时锁示例
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lock2.tryLock(1, TimeUnit.SECONDS)) {
try {
// 成功获取双锁
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
4.3 性能优化技巧
- 减小锁粒度:将大锁拆分为多个小锁(如ConcurrentHashMap的分段锁)
- 锁粗化:对连续的小锁请求合并为大锁(JVM自动优化)
- 读写分离:读多写少场景使用ReadWriteLock
- 无锁编程:尝试用Atomic变量或CAS操作替代锁
实测案例:将用户积分系统的全局锁改为按用户ID哈希的分片锁后,TPS从800提升到6500。
5. Lock锁在虚拟线程中的特殊表现
Java 21引入的虚拟线程(Virtual Thread)对Lock的使用提出了新要求:
- 避免synchronized阻塞:虚拟线程在synchronized块内阻塞会连带载体线程(平台线程)一起阻塞
- Lock的优势显现:ReentrantLock不会固定绑定载体线程,更适合虚拟线程场景
- 新的死锁风险:虚拟线程数量可能极大(百万级),要特别注意锁竞争
java复制try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
Lock lock = new ReentrantLock();
lock.lock();
try {
Thread.sleep(Duration.ofMillis(100));
} finally {
lock.unlock();
}
});
});
}
虚拟线程环境下,建议将锁等待超时设置为普通线程的1/10,因为虚拟线程的创建成本极低,快速失败重试往往比长时间等待更高效。
