1 锁机制概述:为何需要锁
在现代多线程编程环境中,当多个线程需要同时访问和修改同一个共享资源时,如果缺乏有效的协调机制,就可能导致数据不一致等线程安全问题。锁(Lock)就是用于控制多个线程对共享资源进行访问的工具,它帮助我们确保线程安全,避免数据竞争和死锁问题。
Java提供了丰富的锁机制来解决并发访问问题,每种锁都有其特定的应用场景和优势。理解不同锁的特性和适用场景,是编写高效并发程序的关键。
2 Java锁的分类体系
Java中的锁可以根据不同的特性进行多维度分类,下面这个表格概括了核心锁的类型和特点:
| 锁分类维度 | 锁类型 | 核心思想/特点 | 典型实现/场景 |
|---|---|---|---|
| 并发策略 | 悲观锁 | 假定并发冲突高,访问资源前先加锁。 | synchronized, ReentrantLock |
| 乐观锁 | 假定并发冲突低,先操作,提交时检测冲突。 | AtomicInteger(基于CAS) |
|
| 调度公平性 | 公平锁 | 按线程申请锁的顺序(FIFO)分配,防止饥饿,吞吐量可能较低。 | ReentrantLock(true) |
| 非公平锁 | 允许"插队",吞吐量通常更高,但可能造成线程饥饿。 | synchronized, ReentrantLock()(默认) |
|
| 重入性 | 可重入锁 | 同一线程可多次获取同一把锁,避免死锁。 | synchronized, ReentrantLock |
| 资源共享 | 独占锁(写锁) | 排他性,同一时间只允许一个线程访问。 | synchronized, ReentrantLock, ReentrantReadWriteLock.WriteLock |
| 共享锁(读锁) | 允许多个线程同时读取共享资源。 | ReentrantReadWriteLock.ReadLock |
🔄 核心锁机制深度剖析
🔐 悲观锁 vs 乐观锁
这两种锁代表了两种最基本的并发控制哲学。
-
工作原理与实现:
- 悲观锁 的基本思路是"先取锁,再访问 "。在Java中,最典型的实现是
synchronized关键字和ReentrantLock。它们通过在访问共享资源前加锁,确保操作的原子性。在数据库层面,类似的思路是使用SELECT ... FOR UPDATE这样的语句。 - 乐观锁 并不真正锁定数据,而是采用一种冲突检测机制 ,通常基于 版本号 或 CAS (Compare-And-Swap) 实现 。例如,Java中的
AtomicInteger等原子类底层就是通过CPU的CAS指令来保证原子性更新 。其基本流程是:读取当前值和版本号 -> 进行计算 -> 提交更新(比较当前值/版本号是否发生变化,是则更新成功,否则重试或失败)。
- 悲观锁 的基本思路是"先取锁,再访问 "。在Java中,最典型的实现是
-
性能与开销:悲观锁的加锁、释放锁以及线程的挂起、唤醒会带来显著的性能开销。乐观锁在无冲突或低冲突时性能很高,因为其操作通常不需要阻塞线程。但在高冲突场景下,频繁的更新失败和重试(如CAS的循环操作)反而会消耗大量CPU资源。
-
典型应用场景:
- 悲观锁适用 :数据一致性要求极高、写操作非常频繁且冲突概率大的场景,如银行核心系统的账户余额扣减、库存管理中的出库入库操作 。
- 乐观锁适用 :读多写少的场景,如多数互联网应用的信息缓存更新、商品详情查询 。也常用于秒杀系统,允许部分请求在最后更新时因冲突而失败,以保护系统。
⚖️ 公平锁 vs 非公平锁
这组概念关注的是等待锁的线程的调度策略。
-
工作原理:
- 公平锁 内部维护了一个等待队列。当锁被释放时,锁会优先分配给在队列中等待时间最长的线程(即队首的线程),严格按照FIFO(先进先出)顺序进行 。
- 非公平锁 则允许"插队"。一个新线程尝试获取锁时,不管等待队列中是否有其他线程在排队,它都会先尝试直接获取。如果此时锁恰好可用,它就能立即获取到,而不用排队 。
-
性能与特点:
- 公平锁 的优点是公平,可以防止线程"饥饿"(即某个线程长期得不到锁)。缺点是吞吐量相对较低,因为需要维护队列并按顺序唤醒线程,增加了上下文切换的开销 。
- 非公平锁 的优点是吞吐率高。因为它减少了线程的挂起和唤醒次数(新来的线程有可能直接拿到锁,避免了不必要的挂起),性能通常优于公平锁。缺点是有可能导致某些线程长时间等待(饥饿)。
-
实现与默认策略 :在Java中,
synchronized关键字是非公平 的。ReentrantLock类则提供了灵活性,通过构造函数参数new ReentrantLock(true)可以创建公平锁,默认(无参或false)则是非公平锁 。由于性能优势,在大多数情况下,非公平锁是默认且更优的选择。
🚀 锁的优化与升级
为了在保证线程安全的同时提升性能,JVM(特别是synchronized)还进行了一系列锁优化,即锁升级 。这个过程是单向的,旨在减少获得锁和释放锁带来的性能消耗 。其路径是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
- 偏向锁 :适用于实际上只有一个线程访问同步块的场景。JVM会利用CAS操作将线程ID记录在对象头中,之后该线程再进入同步块时无需进行任何同步操作,降低开销 。
- 轻量级锁 :当有轻微竞争 (如两个线程交替执行)时,偏向锁会升级为轻量级锁。线程不会立即阻塞,而是通过自旋(循环尝试)的方式尝试获取锁 。
- 重量级锁 :当竞争加剧 (自旋超过一定次数或有多线程激烈竞争)时,锁会升级为重量级锁。此时,未获取到锁的线程会被挂起,进入阻塞状态,等待操作系统的调度,这是开销最大的锁状态 。
💡 项目实战与锁的选择
在实际项目中,选择合适的锁至关重要。以下是一些指导原则和常见场景的解决方案 。
2.1 悲观锁 vs 乐观锁:两种并发哲学
悲观锁 的基本思路是"先取锁,再访问 "。在Java中,最典型的实现是 synchronized关键字和 ReentrantLock。它们通过在访问共享资源前加锁,确保操作的原子性。
csharp
// 悲观锁示例 - synchronized
public synchronized void deductStock(int quantity) {
if (stock >= quantity) {
stock -= quantity;
return true;
}
return false;
}
// 悲观锁示例 - ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public boolean transfer(int amount) {
lock.lock(); // 获取锁
try {
if (balance >= amount) {
balance -= amount;
return true;
}
return false;
} finally {
lock.unlock(); // 释放锁
}
}
乐观锁 并不真正锁定数据,而是采用一种冲突检测机制 ,通常基于 CAS (Compare-And-Swap) 或版本号机制实现 。
java
// 乐观锁示例 - CAS(AtomicInteger)
private AtomicInteger count = new AtomicInteger(0);
public boolean increment() {
int oldValue, newValue;
do {
oldValue = count.get();
newValue = oldValue + 1;
} while (!count.compareAndSet(oldValue, newValue)); // CAS操作
return true;
}
// 乐观锁示例 - 版本号机制
public class OptimisticProduct {
private int stock = 100;
private int version = 0; // 版本号
public boolean deductStock(int quantity) {
synchronized(this) {
if (this.version == version) { // 检查版本
if (stock >= quantity) {
stock -= quantity;
version++; // 更新版本号
return true;
}
}
return false;
}
}
}
应用场景对比:
- 悲观锁适用 :数据一致性要求极高、写操作非常频繁且冲突概率大的场景,如银行转账系统、库存扣减 。
- 乐观锁适用 :读多写少的场景,如用户信息更新、文章阅读计数、购物车商品数量调整 。
2.2 公平锁 vs 非公平锁:调度策略的选择
公平锁 内部维护了一个等待队列。当锁被释放时,锁会优先分配给在队列中等待时间最长的线程(即队首的线程),严格按照FIFO(先进先出)顺序进行 。
非公平锁 则允许"插队"。一个新线程尝试获取锁时,不管等待队列中是否有其他线程在排队,它都会先尝试直接获取。如果此时锁恰好可用,它就能立即获取到,而不用排队 。
ini
// 创建公平锁
ReentrantLock fairLock = new ReentrantLock(true);
// 创建非公平锁(默认)
ReentrantLock nonFairLock = new ReentrantLock();
性能与特点:
- 公平锁 的优点是公平,可以防止线程"饥饿"。缺点是吞吐量相对较低,因为需要维护队列并按顺序唤醒线程 。
- 非公平锁 的优点是吞吐率高。减少了线程的挂起和唤醒次数,性能通常优于公平锁。缺点是有可能导致某些线程长时间等待 。
在Java中,synchronized关键字是非公平 的。ReentrantLock类则提供了灵活性,通过构造函数参数可以创建公平锁。由于性能优势,在大多数情况下,非公平锁是默认且更优的选择 。
3 Java锁的实现原理与特性
3.1 synchronized的实现与优化
synchronized是Java内置的同步机制,依赖于Java虚拟机(JVM)实现 。它可以用于同步方法或同步代码块:
csharp
// 同步实例方法:锁住当前实例(this)
public synchronized void synchronizedMethod() {
// 临界区代码
}
// 同步静态方法:锁住类对象(Class)
public static synchronized void staticSynchronizedMethod() {
// 临界区代码
}
// 同步代码块:可以锁定特定对象
public void method() {
synchronized (lockObject) {
// 临界区代码
}
}
synchronized具有可重入性:一个线程获取锁后可以多次进入同步代码 。这避免了线程自己造成死锁的情况。
锁升级过程 是现代JVM对synchronized的重要优化,其路径是:无锁 → 偏向锁 → 轻量级锁 → 重量级锁 :
- 偏向锁 :适用于实际上只有一个线程访问同步块的场景,通过记录线程ID避免重复加锁开销。
- 轻量级锁 :当有轻微竞争 时,通过自旋(循环尝试)的方式尝试获取锁。
- 重量级锁 :当竞争激烈 时,锁会升级为重量级锁,未获取到锁的线程会被挂起 。
3.2 ReentrantLock的高级特性
ReentrantLock是 java.util.concurrent.locks包中的高级锁,它提供了比synchronized更丰富的功能 :
csharp
public class ReentrantLockExample {
private final ReentrantLock lock = new ReentrantLock();
public void execute() {
lock.lock(); // 获取锁
try {
// 临界区代码
} finally {
lock.unlock(); // 务必在finally中释放锁
}
}
// 尝试获取锁(可超时、可中断)
public boolean tryExecute(long timeout, TimeUnit unit) throws InterruptedException {
if (lock.tryLock(timeout, unit)) {
try {
// 临界区代码
return true;
} finally {
lock.unlock();
}
}
return false;
}
}
主要优势特性:
- 可中断的锁获取 :支持
lockInterruptibly()方法,允许在等待锁时响应中断 。 - 超时获取锁:尝试获取锁可以设置超时时间,避免无限期等待 。
- 条件变量(Condition):支持多个条件队列,提供更精细的线程等待/通知机制 。
3.3 读写锁(ReadWriteLock)与StampedLock
ReadWriteLock 是一种更细粒度的锁,它允许多个读线程同时访问共享资源,但在有写操作时,写线程会独占资源 。
csharp
public class ReadWriteLockExample {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private int data = 0;
public int read() {
rwLock.readLock().lock(); // 获取读锁(共享)
try {
return data;
} finally {
rwLock.readLock().unlock();
}
}
public void write(int value) {
rwLock.writeLock().lock(); // 获取写锁(独占)
try {
data = value;
} finally {
rwLock.writeLock().unlock();
}
}
}
StampedLock 是Java 8引入的改进型读写锁,提供乐观读模式,性能比ReadWriteLock更高 :
csharp
public class StampedLockExample {
private final StampedLock lock = new StampedLock();
private int data = 0;
public int read() {
// 尝试乐观读
long stamp = lock.tryOptimisticRead();
int currentData = data;
// 验证是否有写操作发生
if (!lock.validate(stamp)) {
// 升级为悲观读锁
stamp = lock.readLock();
try {
currentData = data;
} finally {
lock.unlockRead(stamp);
}
}
return currentData;
}
}
4 锁的性能优化与最佳实践
4.1 锁性能优化的10个核心原则
- 尽量使用无锁方案:对于简单计数器、累加操作,优先使用Atomic类 。
- 减小锁粒度:只锁必要的代码块,而不是整个方法 。
- 避免锁嵌套:锁嵌套容易导致死锁,且会增加锁竞争 。
- 使用读写锁:读多写少场景用ReadWriteLock,进一步优化用StampedLock 。
- 锁分离:不同业务逻辑用不同的锁,避免竞争 。
- 避免在锁内执行耗时操作:如网络请求、IO操作等 。
- 使用concurrent包:ConcurrentHashMap、CopyOnWriteArrayList等线程安全集合性能更好 。
- 锁的公平性:非必要不使用公平锁,公平锁性能比非公平锁差 。
- 考虑使用分段锁:如ConcurrentHashMap的实现方式 。
- 监控锁竞争:使用JDK自带的jstack、jconsole等工具监控锁竞争情况 。
4.2 项目实战中的锁选择策略
在实际项目中,选择合适的锁至关重要。以下是一些指导原则和常见场景的解决方案:
1. 判断冲突概率:这是首要步骤
- 如果是读多写少 ,或者冲突概率很低的情况(如缓存更新、商品库存查询),乐观锁(通常是CAS或版本号)或读写锁是首选 。
- 如果是写多读少 ,或者冲突很频繁的场景(如账户余额修改、核心库存扣减),悲观锁能提供最直接和安全的保障 。
2. 具体场景的锁选择建议:
- 简单同步需求 :优先考虑
synchronized(JVM优化完善,使用方便)。 - 复杂同步需求 :需要可中断、超时、公平性等高级功能时,选择
ReentrantLock。 - 读多写少的缓存系统 :使用
ReadWriteLock或性能更好的StampedLock。 - 高并发计数/状态更新:使用原子类(乐观锁,无锁开销)。
- 高并发集合操作:考虑使用分段锁 。
3. 避免常见陷阱:
- 锁对象选择:锁对象应该是全局的,而不是方法内部创建的 。
- 死锁预防 :确保多个线程以固定的顺序获取锁,可以有效避免死锁。
- 锁粒度 :尽量减小锁的持有范围和时间,只在必要的代码块上加锁。