显式锁之ReentrantLock与ReentrantReadWriteLock
大家好,我是欧阳方超,公众号同名。
1 概述
在Java中,除了使用synchronized关键字实现同步之外,还可以使用显式锁(explicit lock)。显式锁主要通过Lock接口及其实现类类完成。相比synchronized,显式锁提供了更多的功能和更好的性能。本文主要介绍下显式锁中两个比较典型的类:ReentrantLock与ReentrantReadWriteLock。
2 主要特点和核心概念
2.1 什么是显式锁
在 Java 中,显式锁是通过java.util.concurrent.locks包中的Lock接口及其实现类来提供的一种更灵活的线程同步机制。与内置的synchronized关键字不同,显式锁需要开发者在代码中显式地获取和释放锁。
2.2 Lock接口的主要实现类
ReentrantLock:可重入锁,是Lock接口最常用的实现类。
ReentrantReadWriteLock:读写锁,允许多个线程同时读,但只允许一个线程写。
StampedLock:JDK 8引入的新锁,在读写锁的基础上优化了读操作。
2.3 Lock接口的核心方法
void lock(); // 获取锁
void unlock(); // 释放锁
boolean tryLock(); // 尝试获取锁,立即返回
boolean tryLock(long time, TimeUnit unit); // 尝试在指定时间内获取锁
void lockInterruptibly(); // 获取可中断的锁
Condition newCondition(); // 获取等待通知组件
3 ReentrantLock使用示例
3.1 ReentrantLock的基本操作
获取锁(lock () 方法)
当一个线程调用lock()方法时,如果锁没有被其他线程占用,那么这个线程将获取到锁,然后可以执行被锁保护的代码块。如果锁已经被其他线程占用,那么这个线程将被阻塞,直到锁被释放。
释放锁(unlock () 方法)
当线程完成对共享资源的访问后,必须调用unlock()方法来释放锁,这样其他等待锁的线程才有机会获取锁。需要注意的是,unlock()方法应该放在finally块中,以确保即使在获取锁后的代码块中发生了异常,锁也能被正确释放。
Lock接口的主要实现类是ReentrantLock(可重入锁)。可重入锁意味着一个线程可以多次获取同一个锁,只要每次获取和释放的次数匹配即可。下面通过示例演示一下:
import java.util.concurrent.locks.ReentrantLock;
public class BankAccountDemo {
private final ReentrantLock lock = new ReentrantLock();
private double balance = 1000.0;
//
public double checkBalance() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获取第一层锁");
Thread.sleep(100);
return getBalance();
} catch (InterruptedException e) {
e.printStackTrace();
return -1;
} finally {
System.out.println(Thread.currentThread().getName() + " 获取第一层锁");
lock.unlock();
}
}
private double getBalance() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获取第二层锁");
System.out.println(Thread.currentThread().getName() + " 当前持有锁的次数:" + lock.getHoldCount());
return balance;
} finally {
System.out.println(Thread.currentThread().getName() + " 释放第二层锁");
lock.unlock();
}
}
public static void main(String[] args) {
BankAccountDemo account = new BankAccountDemo();
Runnable task = () -> {
System.out.println(Thread.currentThread().getName() + " 开始查询余额");
double balance = account.checkBalance();
System.out.println(Thread.currentThread().getName() + " 查询到余额:" + balance);
};
for (int i = 0; i < 3; i++) {
new Thread(task, "Thread-" + i).start();
}
}
}
上面的代码演示了同一个线程可以多次获取同一个锁,在进入checkBalance()方法时,会获取第一层锁,同时在finally中释放第一层锁,期间调用了getBalance()方法,会获取第二层锁,同时在其finally中是否第二层锁,执行上面的程序可以看到如下的输出结果:
Thread-0 开始查询余额
Thread-1 开始查询余额
Thread-2 开始查询余额
Thread-2 获取第一层锁
Thread-2 获取第二层锁
Thread-2 当前持有锁的次数:2
Thread-2 释放第二层锁
Thread-2 获取第一层锁
Thread-0 获取第一层锁
Thread-2 查询到余额:1000.0
Thread-0 获取第二层锁
Thread-0 当前持有锁的次数:2
Thread-0 释放第二层锁
Thread-0 获取第一层锁
Thread-1 获取第一层锁
Thread-0 查询到余额:1000.0
Thread-1 获取第二层锁
Thread-1 当前持有锁的次数:2
Thread-1 释放第二层锁
Thread-1 获取第一层锁
Thread-1 查询到余额:1000.0
3.2 ReentrantLock的优势
更高的灵活性
可以在更精细的控制下进行加锁和解锁操作。比如,可以在一个方法的中间部分获取锁,而不是像synchronized关键字那样只能在方法开始处获取锁。
可以实现非阻塞式的获取锁尝试。通过tryLock()方法,可以尝试获取锁,如果锁不可用,则立即返回,不会像synchronized那样一直阻塞等待。
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockExample {
private static final Logger log = LoggerFactory.getLogger(TryLockExample.class);
private final ReentrantLock lock = new ReentrantLock();
public void performTask() {
if (lock.tryLock()) {
try {
System.out.println(Thread.currentThread().getName() + ": Lock acquired, performing task.");
//模拟任务执行
Thread.sleep(10000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() + ": Lock released.");
}
} else {
System.out.println(Thread.currentThread().getName() + ": Unable to acquire lock.");
}
}
public void performTaskWithTimeout() {
try {
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
System.out.println(Thread.currentThread().getName() + ": Lock acquired with timeout, performing task.");
//模拟任务执行
Thread.sleep(1000);
} else {
System.out.println(Thread.currentThread().getName() + ": Unable to acquire lock with timeout.");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() + ": Lock released.");
}
}
public static void main(String[] args) {
TryLockExample tryLockExample = new TryLockExample();
Runnable performTask = tryLockExample::performTask;
Runnable performTaskWithTimeout = tryLockExample::performTaskWithTimeout;
new Thread(performTask, "Thread-1").start();
new Thread(performTaskWithTimeout, "Thread-2").start();
}
}
上面的代码,performTask()方法以非阻塞式的方式尝试获取锁,如果成功则执行任务,否则立即返回,performTaskWithTimeout()方法也以非阻塞式的方式尝试获取锁,唯一的不同在于它调用了带有两个参数的tryLock(long timeout, TimeUnit unit)方法,这意味着会在一定超时时间内获取锁,如果在超时时间内为获取到锁则返回flase。
支持公平锁和非公平锁
公平锁:在这种模式下,线程按照请求锁的顺序获得锁,即先到先得。这有效地避免了线程饥饿现象,因为所有线程都有机会按照它们请求的顺序获取锁。
非公平锁:在这种模式下,线程可以在任何时候尝试获取锁,而不考虑请求的顺序。这意味着后到的线程可能会抢占到锁,导致某些线程长时间无法获得锁。
ReentrantLock构造函数可以传入一个布尔值来指定是公平锁还是非公平锁。公平锁按照线程请求锁的顺序来分配锁,而非公平锁则允许插队的情况。非公平锁在性能上通常比公平锁要好,因为它减少了线程切换的开销。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class FairAndUnfairLockExample {
public static void main(String[] args) {
// 创建一个公平锁
Lock fairLock = new ReentrantLock(true);
// 创建一个非公平锁
Lock unfairLock = new ReentrantLock(false);
System.out.println("Using Fair Lock:");
testLock(fairLock);
/*System.out.println("Using Unfair Lock:");
testLock(unfairLock);*/
}
private static void testLock(Lock lock) {
Worker worker = new Worker(lock);
for (int i = 0; i < 5; i++) {
new Thread(worker, "Thread-" + i).start();
}
}
static class Worker implements Runnable {
private final Lock lock;
Worker(Lock lock) {
this.lock = lock;
}
@Override
public void run() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " acquired the lock");
// Simulate some work with the lock held
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() + " released the lock");
}
}
}
}
说明:
ReentrantLock(true): 创建一个公平锁。线程将按照请求锁的顺序依次获取锁。
ReentrantLock(false): 创建一个非公平锁。线程可能会插队获取锁,不保证顺序。
运行效果
公平锁,执行程序时会发现线程几乎按照它们启动的顺序获取锁。
非公平锁,线程获取锁的顺序可能会出现插队的情况,表现得更随机。
性能对比
公平锁,确保锁的分配顺序,但是可能会导致更高的线程切换开销,尤其是在竞争激烈的情况下。
非公平锁,通常在高并发环境下性能更好,因为减少了线程切换的开销。
饥饿问题:公平锁通过FIFO队列机制减少了线程饥饿的问题,但可能导致性能下降。
使用场景:根据具体需求选择合适的类型,如果对性能要求较高且可以接受一定程度的不公平,选择非公平锁;如果需要确保所有线程都有机会获取资源,则选择公平锁。
4 ReentrantReadWriteLock使用示例
4.1 ReentrentLock概念与使用场景
ReentrentLock是Java中的一种读写锁,旨在提高多线程环境中对共享资源的访问效率,读写锁是一种更高级的锁机制,它允许同时有多个线程对共享资源进行读取操作,但在有线程进行写入操作时,其他线程(包括读线程和写线程)都需要等待。java.util.concurrent.locks包中的ReentrantReadWriteLock是其实现类。
应用场景:适用于数据被读取的频率远高于被写入的情况,像一些配置文件的读取、缓存数据的读取等场景。
4.2 读写锁机制
4.2.1 锁的类型
读锁(共享锁):多个线程可以同时持有读锁,只要没有线程持有写锁,适用于并发读取数据的场景。
写锁(独占锁):只有一个线程可以持有写锁,且在写锁被持有时,其他任何线程(包括读锁)都无法获取该锁,即被阻塞。
4.2.2 进入条件
读锁:
没有其他线程持有写锁。
可以有多个线程同时获取读锁。
写锁:
没有其他线程持有读锁或写锁。
只有一个线程可以获取写锁。
4.2.3 锁的降级与升级
降级:一个线程可以在持有写锁的情况下,先释放写锁再获取读锁。这种情况被称为“降级”,允许从独占状态转变为共享状态。
升级:从读锁升级到写锁是不被允许的,这样做可能会导致死锁。
4.3 使用示例
例如,一个缓存系统,多个线程可以同时读取缓存中的数据,但当有一个线程要更新缓存数据时,其他线程必须等待更新完成。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Cacher {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
private int cacheData = 0;
public void readData() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " reading data: " + cacheData);
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
readLock.unlock();
}
}
public void writeData(int value) {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " writing data: " + value);
cacheData += value;
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
writeLock.unlock();
}
}
public static void main(String[] args) {
Cacher cacher = new Cacher();
Runnable readData = cacher::readData;
Runnable writeData = () -> cacher.writeData((int)(Math.random() * 100));
for (int i = 0; i < 2; i++) {
new Thread(writeData, "Writer-" + i).start();
}
for (int i = 0; i < 10; i++) {
new Thread(readData, "Reader-" + i).start();
}
Thread writer1 = new Thread(writeData, "Writer-1");
Thread reader1 = new Thread(readData, "Reader-1");
Thread reader2 = new Thread(readData, "Reader-2");
writer1.start();
reader1.start();
reader2.start();
}
}
以下是几组可能的运行结果:
Reader-0 reading data: 0
Reader-3 reading data: 0
Reader-6 reading data: 0
Reader-2 reading data: 0
Reader-5 reading data: 0
Reader-1 reading data: 0
Reader-4 reading data: 0
Writer-0 writing data: 4
Writer-1 writing data: 58
Reader-7 reading data: 62
Reader-9 reading data: 62
Reader-8 reading data: 62
Writer-1 writing data: 50
Reader-1 reading data: 112
Reader-2 reading data: 112
读锁是共享锁,允许多个线程同时持有,写锁是独占锁,只允许一个线程持有。这句还要在程序运行过程中才能体会到,如果只是静态观看上面的运行结果的话,是体会不到读写锁的含义的。在程序运行过程中会看到,读线程会并发地执行,虽然每个读线程执行逻辑前都获取了锁,但由于读锁是共享锁,因此多个读线程可以并发执行,当有写线程运行时,在其sleep的超时时间未到时,其他读、写(如果有的话)都会被阻塞,因为sleep并不会导致线程释放锁,这也是“写锁是独占锁”的体现。
5 总结
本文介绍了 Java 中的显式锁,包括 ReentrantLock 与 ReentrantReadWriteLock。对比了显式锁和 synchronized 的不同,阐述了 ReentrantLock 的特点、使用示例及优势,还介绍了公平锁和非公平锁的区别,最后详细讲解了 ReentrantReadWriteLock 的读写锁机制、使用场景及示例,对多线程同步机制进行了全面阐释。
我是欧阳方超,把事情做好了自然就有兴趣了,如果你喜欢我的文章,欢迎点赞、转发、评论加关注。我们下次见。