在多线程编程中,锁是保证数据一致性的关键工具。Java 从 JDK 1.5 开始提供了 java.util.concurrent.locks 包,其中的 Lock 接口及其实现类比传统的 synchronized 更加强大和灵活。本文将带你深入理解 ReentrantLock 和 ReentrantReadWriteLock 的核心特性,并通过实战代码展示它们的用法。
一、为什么需要 Lock?
在 synchronized 时代,我们通过关键字隐式地获取和释放锁,虽然使用简单,但存在一些局限性:
-
不可中断:线程获取不到锁时会一直阻塞,无法响应中断。
-
无法限时等待:无法设置获取锁的超时时间。
-
无法实现公平锁:所有等待线程竞争锁,可能导致某些线程长时间获取不到锁。
-
锁粒度单一:所有同步代码块共享同一把锁,无法细分读写操作。
Lock 接口的出现弥补了这些不足,它提供了更灵活的锁操作,让开发者可以手动控制锁的获取和释放,并支持公平锁、可中断锁、限时等待等高级特性。
二、ReentrantLock -- 可重入的独占锁
ReentrantLock 是 Lock 接口最常用的实现类,它是一个可重入的互斥锁 ,具备与 synchronized 相同的基础行为,但功能更丰富。
2.1 基本用法
ReentrantLock 的使用需要显式地 lock() 和 unlock(),推荐在 finally 块中释放锁,确保即使发生异常也能释放。
下面是一个经典的"卖票"示例,展示如何使用 ReentrantLock 保证线程安全:
java
class Ticket {
private final ReentrantLock lock = new ReentrantLock();
private int number = 20;
public void sale() {
lock.lock(); // 加锁
try {
if (number <= 0) {
System.out.println(Thread.currentThread().getName() + " 票已售罄!");
return;
}
System.out.println(Thread.currentThread().getName() + " 开始买票,当前票数:" + number);
Thread.sleep(200);
System.out.println(Thread.currentThread().getName() + " 买票结束,剩余票数:" + --number);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // 解锁
}
}
}
2.2 可重入性
可重入锁(递归锁)允许同一个线程多次获取同一把锁,而不会产生死锁。synchronized 和 ReentrantLock 都支持可重入。
java
public class ReentrantDemo {
private final ReentrantLock lock = new ReentrantLock();
public void methodA() {
lock.lock();
try {
methodB(); // 内部再次获取锁
System.out.println("methodA");
} finally {
lock.unlock();
}
}
public void methodB() {
lock.lock();
try {
System.out.println("methodB");
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
new ReentrantDemo().methodA(); // 输出:methodB methodA
}
}
2.3 公平锁与非公平锁
-
非公平锁(默认):线程竞争锁时不考虑等待时间,谁抢到谁执行,吞吐量更高。
-
公平锁:按照线程请求锁的顺序依次获取锁(FIFO),避免线程"饥饿",但会降低吞吐量。
创建公平锁只需在构造时传入 true:
java
private final ReentrantLock lock = new ReentrantLock(true);
2.4 限时等待 -- tryLock()
tryLock() 方法允许线程在指定时间内尝试获取锁,如果超时仍未获取到,则返回 false,从而避免无限阻塞。这一特性可用于预防死锁。
java
// 无参:立即返回结果
boolean locked = lock.tryLock();
// 带超时参数:等待5秒,若未获取到则返回false
boolean locked = lock.tryLock(5, TimeUnit.SECONDS);
下面是一个使用 tryLock 解决死锁的示例(两个线程相互等待对方持有的锁):
java
public class DeadlockResolveDemo {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public static void main(String[] args) {
new Thread(() -> {
boolean gotLock1 = lock1.tryLock();
if (gotLock1) {
try {
System.out.println("Thread1 获得 lock1");
Thread.sleep(500);
boolean gotLock2 = lock2.tryLock();
if (gotLock2) {
try {
System.out.println("Thread1 获得 lock2,任务完成");
} finally {
lock2.unlock();
}
} else {
System.out.println("Thread1 获取 lock2 失败,释放 lock1");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock1.unlock();
}
}
}).start();
new Thread(() -> {
boolean gotLock2 = lock2.tryLock();
if (gotLock2) {
try {
System.out.println("Thread2 获得 lock2");
Thread.sleep(500);
boolean gotLock1 = lock1.tryLock();
if (gotLock1) {
try {
System.out.println("Thread2 获得 lock1,任务完成");
} finally {
lock1.unlock();
}
} else {
System.out.println("Thread2 获取 lock1 失败,释放 lock2");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock2.unlock();
}
}
}).start();
}
}
运行结果中,两个线程会尝试获取对方的锁,若超时未成功则主动释放已持有的锁,从而避免死锁。
2.5 ReentrantLock 与 synchronized 的区别
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 锁获取与释放 | 自动 | 手动 lock() / unlock() |
| 可重入性 | 支持 | 支持 |
| 公平锁 | 不支持 | 支持(构造参数 true) |
| 响应中断 | 不支持,一直阻塞 | 支持 lockInterruptibly() 或 tryLock |
| 限时等待 | 不支持 | 支持 tryLock(timeout, unit) |
| 条件变量(Condition) | 通过 wait/notify 实现 |
支持多个 Condition 对象 |
| 性能(JDK 1.6+) | 经过优化,与 ReentrantLock 接近 | 相差不大 |
总结 :当需要更灵活的锁控制(如公平锁、超时等待、可中断)时,选择 ReentrantLock;如果只是简单的同步需求,synchronized 更简洁。
三、ReentrantReadWriteLock -- 读写分离的共享锁
在实际业务中,很多场景是读多写少 (如缓存系统),如果使用独占锁,读操作也会互斥,严重影响并发性能。ReentrantReadWriteLock 将锁分为读锁(共享锁) 和写锁(独占锁),允许多个读线程同时访问,而写线程与其他所有线程互斥。
3.1 读写锁的基本规则
-
写锁:独占,不允许其他读锁或写锁同时持有。
-
读锁:共享,可以同时被多个线程持有。
-
锁的互斥关系:
-
写写互斥
-
读写互斥 / 写读互斥
-
读读并发
-
3.2 示例:缓存读写
首先,不使用任何锁,模拟一个简单的缓存,观察并发读写时的问题:
java
class MyCache {
private Map<String, String> cache = new HashMap<>();
public void put(String key, String value) {
System.out.println(Thread.currentThread().getName() + " 开始写入");
try { Thread.sleep(300); } catch (InterruptedException e) { }
cache.put(key, value);
System.out.println(Thread.currentThread().getName() + " 写入成功");
}
public void get(String key) {
System.out.println(Thread.currentThread().getName() + " 开始读出");
try { Thread.sleep(300); } catch (InterruptedException e) { }
String value = cache.get(key);
System.out.println(Thread.currentThread().getName() + " 读出成功:" + value);
}
}
public class ReadWriteDemo {
public static void main(String[] args) {
MyCache cache = new MyCache();
// 5个写线程
for (int i = 1; i <= 5; i++) {
int finalI = i;
new Thread(() -> cache.put(String.valueOf(finalI), String.valueOf(finalI)), "写" + finalI).start();
}
// 5个读线程
for (int i = 1; i <= 5; i++) {
int finalI = i;
new Thread(() -> cache.get(String.valueOf(finalI)), "读" + finalI).start();
}
}
}
运行结果中,写操作会被读操作打断,出现数据不一致或脏读的情况。
加入读写锁后,写操作互斥,读操作并发:
java
class MyCacheWithLock {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private Map<String, String> cache = new HashMap<>();
public void put(String key, String value) {
rwLock.writeLock().lock(); // 写锁
try {
System.out.println(Thread.currentThread().getName() + " 开始写入");
Thread.sleep(300);
cache.put(key, value);
System.out.println(Thread.currentThread().getName() + " 写入成功");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rwLock.writeLock().unlock();
}
}
public void get(String key) {
rwLock.readLock().lock(); // 读锁
try {
System.out.println(Thread.currentThread().getName() + " 开始读出");
Thread.sleep(300);
String value = cache.get(key);
System.out.println(Thread.currentThread().getName() + " 读出成功:" + value);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rwLock.readLock().unlock();
}
}
}
此时,多个读线程可以同时执行,写线程之间互斥,保证了数据一致性,同时提高了读并发性能。
3.3 锁降级
锁降级是指在持有写锁的情况下,获取读锁,然后释放写锁的过程。这样做的目的是为了在完成写操作后,依然保持对数据的读取能力,同时允许其他读线程并发访问。
java
public class LockDowngradeDemo {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private int data = 0;
public void updateData(int newData) {
rwLock.writeLock().lock();
try {
data = newData; // 写操作
System.out.println("数据更新为:" + newData);
// 锁降级:获取读锁
rwLock.readLock().lock();
System.out.println("锁降级为读锁");
} finally {
rwLock.writeLock().unlock(); // 释放写锁,仍持有读锁
}
// 此时其他读线程可以同时读取
try {
System.out.println("当前数据:" + data);
} finally {
rwLock.readLock().unlock(); // 最后释放读锁
}
}
public static void main(String[] args) {
LockDowngradeDemo demo = new LockDowngradeDemo();
new Thread(() -> demo.updateData(42)).start();
}
}
注意 :锁降级是合法的,但锁升级(从读锁升级为写锁)是不允许的,因为读锁被多个线程持有,无法安全地升级为写锁,容易造成死锁。
3.4 读写锁的注意事项
-
公平性:读写锁也支持公平/非公平策略,公平模式下等待时间最长的线程优先获取锁。
-
可重入:读锁可以被同一线程多次获取,写锁也可以被同一线程多次获取,但获取写锁后可以再获取读锁(降级),而获取读锁后不能再获取写锁。
-
锁饥饿:在读多写少的场景下,写线程可能长时间获取不到锁("饥饿")。虽然公平策略能缓解,但会牺牲一定吞吐量。
四、总结
| 锁类型 | 特点 |
|---|---|
| synchronized | 简单、自动释放,但功能单一,不可中断,无法公平 |
| ReentrantLock | 手动控制锁,支持公平锁、可中断、限时等待,可重入 |
| ReentrantReadWriteLock | 读写分离,读读并发,写写互斥,适用于读多写少场景,支持锁降级 |
在实际开发中,应根据具体需求选择合适的锁:
-
简单的同步逻辑,优先使用
synchronized。 -
需要高级功能(公平、超时、可中断)时,选用
ReentrantLock。 -
读多写少的场景,使用
ReentrantReadWriteLock能显著提升并发性能。
掌握这些锁的特性,能够帮助我们编写出更高效、更可靠的多线程程序。