12. ReentrantLock:手动加锁和释放锁
发布于 • 阅读量 1
前面已经用 synchronized 解决了 count++ 的线程安全问题。
synchronized 的好处是简单,写在方法上或者代码块上,JVM 会自动帮我加锁和释放锁。大多数基础场景,用它就够了。
不过 Java 里还有一个比较常用的锁:ReentrantLock。
它和 synchronized 的作用有点像,都是为了让某段代码同一时间只能被一个线程执行。但它的写法更“手动”一点:我要自己加锁,也要自己释放锁。
这一节先不展开太多高级用法,先把最基本的使用方式跑通。
先看 synchronized 的写法
上一节的代码大概是这样:
private static synchronized void increment() {
count++;
}
或者:
synchronized (LOCK) {
count++;
}
这种写法里,加锁和释放锁都是 JVM 帮我处理的。
线程进入同步方法时自动加锁,方法执行完以后自动释放锁。
如果方法里抛异常,锁也会自动释放。
所以 synchronized 用起来比较省心。
ReentrantLock 的基本写法
用 ReentrantLock,代码会更明确。
新建类:
com.succos.thread.ReentrantLockDemo
代码如下:
package com.succos.thread;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo {
private static int count = 0;
private static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
increment();
}
}, "thread-1");
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
increment();
}
}, "thread-2");
long start = System.currentTimeMillis();
thread1.start();
thread2.start();
thread1.join();
thread2.join();
long end = System.currentTimeMillis();
System.out.println("最终结果:" + count);
System.out.println("耗时:" + (end - start) + " ms");
}
private static void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
}
这段代码运行后,结果也会稳定输出:
最终结果:200000
核心就是这几行:
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
lock() 和 unlock()
lock.lock() 的意思是加锁。
如果当前没有其他线程持有这把锁,那么当前线程就能拿到锁,然后继续往下执行。
如果锁已经被其他线程拿走了,那当前线程就会等待。
lock.unlock() 的意思是释放锁。
只有释放以后,其他等待的线程才有机会继续执行。
所以执行过程大概是这样:
thread-1 调用 lock.lock()
thread-1 拿到锁
thread-1 执行 count++
thread-1 执行 unlock() 释放锁
thread-2 原来在等待
thread-1 释放锁后
thread-2 拿到锁
thread-2 执行 count++
thread-2 释放锁
这和 synchronized 的互斥效果是一样的。
为什么 unlock() 一定要放在 finally 里?
这个地方很重要。
我不能这样写:
private static void increment() {
lock.lock();
count++;
lock.unlock();
}
这段代码看起来也能跑,但它有一个风险:如果 count++ 这块逻辑中间抛异常,unlock() 就不会执行。
一旦锁没有释放,其他线程就会一直等下去。
在真实业务里,这种问题很麻烦。程序不一定直接报错,而是某些请求、某些任务一直卡住。
所以标准写法应该是:
lock.lock();
try {
// 需要保护的代码
} finally {
lock.unlock();
}
这样不管 try 里面是正常执行完,还是抛异常,finally 都会执行。
锁就能被释放。
我现在看到 ReentrantLock,基本会形成一个固定反应:
lock() 后面必须配 try-finally;
unlock() 必须放 finally。
这个习惯比记 API 更重要。
ReentrantLock 保护的也是临界区
和 synchronized 一样,ReentrantLock 保护的也不是变量本身,而是代码块。
在这个例子里,需要保护的是:
count++;
所以我把它放到锁里面:
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
如果我在别的地方绕过这个方法,直接写:
count++;
那还是不安全。
所以加锁这件事,本质上要求所有线程都遵守同一个规则:
凡是要修改 count,都必须先拿同一把锁。
如果有些代码拿锁,有些代码不拿锁,那线程安全还是无法保证。
锁对象也必须是同一个
和 synchronized 一样,ReentrantLock 也要求多个线程竞争的是同一把锁。
上面的写法是:
private static final ReentrantLock lock = new ReentrantLock();
这是一个静态变量,两个线程调用 increment() 时,用的都是同一个 lock 对象。
所以它们会互斥。
如果我写成这样,就有问题:
private static void increment() {
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
这段代码每次调用 increment() 都会创建一把新锁。
结果就是:
thread-1 用自己的锁;
thread-2 用自己的锁;
两个线程没有抢同一把锁。
这样当然锁不住 count++。
所以不管是 synchronized 还是 ReentrantLock,都要看一个核心问题:
多个线程是不是在竞争同一把锁?
和 synchronized 的区别
从最基础的互斥效果看,synchronized 和 ReentrantLock 都能解决 count++ 的问题。
但写法上差别很明显。
synchronized 是自动释放锁:
private static synchronized void increment() {
count++;
}
ReentrantLock 是手动释放锁:
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
所以 ReentrantLock 写起来更麻烦。
但它也更灵活。
比如它可以尝试获取锁:
lock.tryLock();
可以设置公平锁:
new ReentrantLock(true);
也可以配合条件队列做更复杂的线程协作。
这些后面可以再慢慢看。
这一节我先只记住基本用法。
用 tryLock() 简单看一下灵活性
ReentrantLock 比 synchronized 灵活的一个地方是:它可以尝试获取锁。
比如:
boolean success = lock.tryLock();
意思是:
尝试拿锁;
拿到了就返回 true;
没拿到就返回 false,不会一直等。
可以写一个简单例子:
package com.succos.thread;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockTryLockDemo {
private static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
Runnable task = () -> {
boolean success = lock.tryLock();
if (!success) {
System.out.println(Thread.currentThread().getName() + " 没拿到锁,先不处理");
return;
}
try {
System.out.println(Thread.currentThread().getName() + " 拿到锁,开始处理");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
System.out.println(Thread.currentThread().getName() + " 处理完成");
} finally {
lock.unlock();
}
};
new Thread(task, "thread-1").start();
new Thread(task, "thread-2").start();
}
}
这个例子里,可能会看到:
thread-1 拿到锁,开始处理
thread-2 没拿到锁,先不处理
thread-1 处理完成
这和 synchronized 不一样。
synchronized 拿不到锁时,只能等。
而 tryLock() 可以让我决定:拿不到锁要不要放弃、重试,或者返回一个提示。
当然,这个例子只是为了感受一下区别,真实项目里要不要用 tryLock(),还要看业务场景。
公平锁和非公平锁先简单知道
ReentrantLock 默认是非公平锁:
new ReentrantLock();
也可以创建公平锁:
new ReentrantLock(true);
公平锁大概意思是:
等待时间更久的线程,优先拿到锁。
非公平锁则不保证严格排队。
从直觉上看,公平锁好像更合理。
但公平锁通常性能会差一点,因为它要维护更严格的排队顺序。
所以一般情况下,不需要刻意用公平锁。
我现在先简单记:
默认非公平锁;
需要严格排队时才考虑公平锁。
这部分不展开太多,不然容易偏离主线。
回到 PDF 项目里怎么用
在 PDF 水印项目里,如果每个 PDF 都是独立文件,正常情况下不应该给整个 PDF 处理过程加同一把锁。
比如我不会这样写:
lock.lock();
try {
service.addWaterMakerOfPDF(fileItemContext);
} finally {
lock.unlock();
}
如果所有线程都用这一把锁,效果就变成:
同一时间只能处理一个 PDF。
那多线程就没意义了。
但是如果有共享统计数据,比如成功数量、失败数量,倒是可以用锁保护。
比如:
private static int successCount = 0;
private static final ReentrantLock lock = new ReentrantLock();
private static void addSuccessCount() {
lock.lock();
try {
successCount++;
} finally {
lock.unlock();
}
}
不过这种计数场景,用 AtomicInteger 也很合适。
所以我在 PDF 项目里会这样判断:
处理不同 PDF 文件:一般不用锁;
修改共享统计变量:需要考虑锁或 AtomicInteger;
写同一个文件:要避免,最好从设计上保证输出路径不同;
访问共享资源:根据资源性质决定是否加锁。
锁不是为了“看起来安全”,而是为了保护真正的共享修改。
synchronized 和 ReentrantLock 怎么选
如果只是普通的互斥,synchronized 更简单。
比如:
private synchronized void updateStatus() {
// 修改状态
}
代码短,也不容易忘记释放锁。
如果需要更灵活的能力,比如:
尝试拿锁;
拿不到锁就放弃;
需要公平锁;
需要可中断等待;
需要多个条件队列。
那就可以考虑 ReentrantLock。
我现在的选择习惯是:
简单场景先用 synchronized;
需要更强控制能力时,再用 ReentrantLock。
不要为了显得高级就到处用 ReentrantLock。
因为手动加锁就意味着自己要承担释放锁的责任。
这一节小结
这一节我主要记住几点:
1. ReentrantLock 也可以解决线程安全问题;
2. lock.lock() 用来加锁,lock.unlock() 用来释放锁;
3. unlock() 一定要放在 finally 里;
4. 多个线程必须竞争同一把 ReentrantLock,锁才有效;
5. ReentrantLock 比 synchronized 更灵活,但写法也更容易出错;
6. PDF 独立文件处理一般不要锁整个流程,只在共享数据上加锁。
用一句话总结:
ReentrantLock 就是把加锁和释放锁这件事交给我自己控制。
下一节继续看“可重入锁”。
因为 ReentrantLock 这个名字里的 Reentrant,指的就是可重入。这个点理解了,后面看锁的执行过程会更清楚。