1. ReentrantLock 类:灵活的锁
关键字 synchronized 很适合基础场景:可以快速、简洁地保护方法或代码块。但有时我们需要更多能力:
- 显式地管理锁(例如尝试获取,如果失败则不等待)。
- 将对资源的“读取”和“写入”权限区分开。
- 可中断地等待锁。
- 诊断是谁、何时获取或释放了锁。
为此提供了类 ReentrantLock 和 ReadWriteLock。它们比传统的 synchronized 带来更多控制与功能。
它到底是什么?
ReentrantLock 是实现了接口 Lock 的一个类。它与 synchronized 的行为类似,但附带更多“增强功能”。最重要的区别在于锁的管理是显式的:需要由你自己调用 lock() 和 unlock()。
有趣的一点是,reentrant 表示“可重入”:同一个线程可以多次获取同一把锁而不会产生相互阻塞。这在方法递归调用或调用链共享同一把锁时非常有用。
用法示例
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int value = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // 获取锁
try {
value++;
} finally {
lock.unlock(); // 务必释放锁!
}
}
public int getValue() {
lock.lock();
try {
return value;
} finally {
lock.unlock();
}
}
}
请注意:
调用 lock() 和 unlock() 必须配合 try...finally 使用。若忘记调用 unlock(),其他线程将无法进入受保护的区域——会导致永久阻塞。
ReentrantLock 的功能
尝试获取:
可以尝试获取锁,但不要无限等待:
if (lock.tryLock()) {
try {
// 执行工作
} finally {
lock.unlock();
}
} else {
// 未能获取——去做其他事情
}
带超时的等待:
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
// 在 100 毫秒内获取到锁
}
检查是否已加锁:
if (lock.isLocked()) { ... }
等待队列诊断、“公平性”以及其他特性。
3. 示例:使用 ReentrantLock 递增计数器
让我们扩展一个控制台应用(例如,模拟来自不同线程的订单处理),对比使用 synchronized 与 ReentrantLock 的写法。
使用 synchronized 的示例
public class OrderCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
使用 ReentrantLock 的等效示例
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class OrderCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
有什么好处?
- 可以尝试获取而不无限等待(tryLock())。
- 可以实现更复杂的策略:例如按照固定顺序获取多把锁(适用于复杂数据结构)。
- 可以在不同位置执行“解锁”(但必须非常谨慎——务必记得成对调用 unlock()!)。
4. ReadWriteLock:读写锁
这是什么?
ReadWriteLock 不只是“一把锁”,更像是一个聪明的访问调度器。它的主要实现是 ReentrantReadWriteLock,将锁分为两类:读锁与写锁。
当线程只读取且没有任何修改时,可以并发读取——读与读之间互不阻塞。但一旦有线程要修改,其他线程就必须等待:写操作只能由一个线程进行,并且需要独占。
这种方式尤其适用于读多写少的场景,例如用户经常浏览但偶尔更新的商品目录。
用法示例
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ProductCatalog {
private final Map<String, String> products = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public void addProduct(String id, String name) {
rwLock.writeLock().lock();
try {
products.put(id, name);
} finally {
rwLock.writeLock().unlock();
}
}
public String getProduct(String id) {
rwLock.readLock().lock();
try {
return products.get(id);
} finally {
rwLock.readLock().unlock();
}
}
}
在我们的应用中的使用示例
假设我们有一个订单库,所有线程都会读取(例如搜索订单),但偶尔会有新订单写入(写操作)。
import java.util.*;
import java.util.concurrent.locks.*;
public class OrderDatabase {
private final List<String> orders = new ArrayList<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// 添加订单(需要 writeLock)
public void addOrder(String order) {
rwLock.writeLock().lock();
try {
orders.add(order);
} finally {
rwLock.writeLock().unlock();
}
}
// 获取所有订单的副本(可并行读取)
public List<String> getOrders() {
rwLock.readLock().lock();
try {
// 返回副本以避免竞争
return new ArrayList<>(orders);
} finally {
rwLock.readLock().unlock();
}
}
}
发生了什么?
- 只要没有写入,即便上千个线程也能同时读取订单列表。
- 一旦有线程开始添加订单——读操作会被阻塞,以避免得到“半更新”的数据。
5. 对比:何时使用哪一个?
| 场景 | synchronized | ReentrantLock | ReadWriteLock |
|---|---|---|---|
| 简单同步 | ✔ | ✔ | ✖(不必要) |
| 需要超时/尝试获取 | ✖ | ✔ | ✔ |
| 读多写少 | ✖ | ✖ | ✔(提升显著) |
| 需要诊断/统计 | ✖ | ✔ | ✔ |
| 可重入加锁 | ✔ | ✔(可重入) | ✔ |
结论:
- 简单场景——使用 synchronized。
- 需要灵活性——ReentrantLock。
- “读多写少”的场景——ReadWriteLock。
6. 可视化:ReadWriteLock 的工作示意
flowchart LR
subgraph 读取
T1[线程 1] -- 读取 --> Orders
T2[线程 2] -- 读取 --> Orders
T3[线程 3] -- 读取 --> Orders
end
subgraph 写入
T4[线程 4] -- 写入(addOrder) --> Orders
end
Orders[订单列表]
style Orders fill:#f9f,stroke:#333,stroke-width:2px
只要没有线程在写,大家都可以同时读取。一旦出现写入,其它线程将等待直到 writeLock 释放。
7. 实现细节与注意事项
“公平性”(fairness)
在 ReentrantLock 和 ReentrantReadWriteLock 中可以启用“公平”模式(fair mode):线程按队列顺序获得锁,而不是“谁抢到谁用”。这能避免线程“饥饿”,但可能降低性能。
Lock fairLock = new ReentrantLock(true); // true — 公平模式
ReadWriteLock fairRWLock = new ReentrantReadWriteLock(true);
潜在陷阱
- 忘记 unlock:如果不调用 unlock(),会导致永久阻塞。务必使用 try...finally。
- 在持锁代码中抛出异常:即使代码块中发生了异常,也必须释放锁!
- 过度使用 ReadWriteLock:对于小型集合或几乎总是写入的场景,使用 ReadWriteLock 意义不大,反而让代码更复杂。
8. 常见错误
错误 1:忘记调用 unlock()
最常见也最阴险的错误是:获取锁后忘记调用 unlock()。结果就是永久阻塞,线程一直“挂起”。无论看起来多么安全,都要使用 try...finally。
错误 2:在不需要的地方使用 ReadWriteLock
如果几乎没有并发读取,而写入很频繁,ReadWriteLock 只会让代码更复杂并降低性能。仅在确实有大量并发读者时再使用它。
错误 3:以不同顺序获取多把锁
如果你的代码需要获取多把 Lock(例如针对多个对象),请在所有线程中始终以相同的顺序获取。否则可能出现 deadlock —— 线程会相互一直等待。
错误 4:仅仅为了“替换”而把 synchronized 换成 ReentrantLock
不要盲目把所有 synchronized 都换成 Lock —— 这并不一定会加速程序,反而可能让代码更难读。
错误 5:忽略可重入性
同一线程连续多次调用 lock() 对于 ReentrantLock 来说是正常的,但别忘了需要调用同样次数的 unlock()!
GO TO FULL VERSION