提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
一、ReentrantLock是什么?
当我们需要实现线程之间的一些同步操作时,从关键字层面来说,Java提供了如synchronized
、wait
、notify
等,但是在java并发包java.util.cucurrent
包下也提供了丰富的并发工具类,本文介绍的ReentrantLock
就是其中之一。
在Java中,ReentrantLock
(重入锁)是一种高级的线程同步机制,可以替代传统的synchronized
关键字来实现线程间的同步和互斥访问。相比于synchronized
来说,它的特点如下:
- 可中断
- 可设置超时时间
- 可以设置公平锁
- 支持多个条件变量
而对于synchronized
来说,其不可中断、不可设置超时时间、只能是非公平锁等。
二、基本使用
2.1 基本语法
ReentrantLock基本语法如下所示:
public class Demo1 {
public static void main(String[] args) {
//创建对象
ReentrantLock lock = new ReentrantLock();
//获取锁
lock.lock();
try {
//临界区
} finally {
//释放锁
lock.unlock();
}
}
}
使用ReentrantLock时,获取锁(lock
)和释放锁(unlock
)需要成对
出现,一般使用try-finally
块处理。
2.2 经典案例 - 取款问题
现假设我们有一个账户BankAccount类,余额为10000元,模拟使用100个线程进行取款操作,每个线程取款100元,在不使用锁和加锁情况下对比取款后账户的余额情况。账户接口Account
如下:
public interface Account {
/**
* 取款操作
* @param draw 取款金额
*/
void withdraw(int draw);
/**
* 获取账户余额情况
* @return
*/
Integer balance();
/**
* 取款案例测试
*/
static void testDemo(Account account) {
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Thread thread = new Thread(() -> {
account.withdraw(100);
}, "取款线程-" + (i + 1));
threads.add(thread);
}
//启动所有取款线程
threads.forEach(Thread::start);
threads.forEach(thread -> {
try {
//等待线程执行完毕
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println("账户余额为: " + account.balance());
}
}
2.2.1 不加锁情况
现模拟无锁情况下取款场景,对应账户类如下:
public class NoLockAccount implements Account {
/**
* 账户余额
*/
private Integer balance;
public NoLockAccount(int balance) {
this.balance = balance;
}
@Override
public void withdraw(int draw) {
this.balance -= draw;
System.out.println(Thread.currentThread().getName() + "取款100元...");
}
@Override
public Integer balance() {
return this.balance;
}
}
编写测试类如下:
public class TestDemo1 {
public static void main(String[] args) {
NoLockAccount account = new NoLockAccount(10000);
Account.testDemo(account);
}
}
在没有同步机制的处理下,账户余额balance
最终结果不正确。
2.2.2 使用ReentrantLock
现使用ReentrantLock处理上述取款案例中的线程安全问题,账户实现类改造如下:
public class LockAccount implements Account{
private Integer balance;
private final Lock lock;
public LockAccount(Integer balance) {
this.balance = balance;
this.lock = new ReentrantLock();
}
@Override
public void withdraw(int draw) {
//获取锁
lock.lock();
try {
this.balance -= draw;
System.out.println(Thread.currentThread().getName() + "取款100元...");
} finally {
lock.unlock();
}
}
@Override
public Integer balance() {
return this.balance;
}
}
编写测试类如下:
public class TestDemo1 {
public static void main(String[] args) {
LockAccount lockAccount = new LockAccount(10000);
Account.testDemo(lockAccount);
}
}
可以看到,取款完成后账户的余额显示正确。
三、特性
3.1 可重入
在Java并发编程中,锁的可重入性
指的是同一个线程
在持有锁的情况下,能够多次重复
地获取同一个锁
,而不会被自己持有的锁所阻塞。这种机制允许线程在进入同步代码块或方法时,可以重复地获取已经持有的锁,而不会引发死锁或阻塞自己。
ReentrantLock可重入示例代码如下:
public class Chara1 {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
lock.lock();
try {
log.debug("enter main...");
m1();
} finally {
lock.unlock();
}
}
private static void m1() {
lock.lock();
try {
log.debug("enter m1...");
m2();
} finally {
lock.unlock();
}
}
private static void m2() {
lock.lock();
try {
log.debug("enter m2...");
} finally {
lock.unlock();
}
}
}
运行结果如下所示:
在整个过程中,都是main线程
在重复地获取锁lock
。
3.2 可打断
在Java并发编程中,可打断性指的是在某个线程等待获取锁
的过程中,能够响应中断
请求而中断等待。这个特性在多线程编程中很重要,可以帮助避免线程因为等待锁而长时间阻塞,从而提高系统的响应性和灵活性。
3.2.1 无竞争
示例代码如下:
public class Demo1 {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("尝试获取锁...");
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
e.printStackTrace();
log.debug("被打断,没有获取到锁,返回...");
return;
}
try {
log.debug("获取到锁了...");
}
finally {
lock.unlock();
}
}, "t1");
t1.start();
}
}
运行结果如下:
当没有其他线程竞争获取锁时,线程获取锁的过程中并不会出现打断的情况。
3.2.2 有竞争-获取不到锁
示例代码如下:
public class Demo2 {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("尝试获取锁...");
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
e.printStackTrace();
log.debug("被打断,没有获取到锁,返回...");
return;
}
try {
log.debug("获取到锁了...");
}
finally {
lock.unlock();
}
}, "t1");
//主线程获取锁且不释放
lock.lock();
log.debug("获取到锁...");
t1.start();
}
}
从运行结果可看出,如果存在其他线程竞争时(main线程
),t1线程
若获取不到锁则阻塞运行,但是该阻塞的状态可被打断。
3.3.3 可被打断
示例代码如下:
public class Demo3 {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("尝试获取锁...");
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
e.printStackTrace();
log.debug("被打断,没有获取到锁,返回...");
return;
}
try {
log.debug("获取到锁了...");
}
finally {
lock.unlock();
}
}, "t1");
//主线程获取锁且不释放
lock.lock();
log.debug("获取到锁...");
t1.start();
//3秒后打断t1线程
TimeUnit.SECONDS.sleep(3);
log.debug("打断线程t1...");
t1.interrupt();
}
}
运行结果如下:
ReentrantLock
的可打断机制避免了线程在获取不到锁一直处于死等
的状态。
3.3 锁超时
Java并发编程中,锁的超时机制主要用于避免线程在获取锁时无限期地等待
,防止发生死锁
。
3.3.1 无竞争
public class Demo1 {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("尝试获取锁...");
if (!lock.tryLock()) {
log.debug("获取锁失败,返回...");
return;
}
try {
log.debug("获取到锁...");
}
finally {
lock.unlock();
}
}, "t1");
t1.start();
}
}
运行结果如下:
3.3.2 有竞争 - 获取锁失败
public class Demo2 {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("尝试获取锁...");
if (!lock.tryLock()) {
log.debug("获取锁失败,返回...");
return;
}
try {
log.debug("获取到锁...");
}
finally {
lock.unlock();
}
}, "t1");
//主线程获取锁
lock.lock();
log.debug("获取到锁...");
t1.start();
}
}
运行结果如下:
当存在其他线程竞争锁,t1线程
获取锁失败后退出执行。
3.3.3 设置超时时间
现假设t1
线程设置了获取锁的超时时间为1秒,主线程获取锁且不释放锁。示例代码如下:
public class Demo3 {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("尝试获取锁...");
//等待一秒
try {
if (!lock.tryLock(1, TimeUnit.SECONDS)) {
log.debug("获取锁失败,返回...");
return;
}
} catch (InterruptedException e) {
e.printStackTrace();
log.debug("被打断,结束运行...");
return;
}
try {
log.debug("获取到锁...");
}
finally {
lock.unlock();
}
}, "t1");
//主线程获取锁
lock.lock();
log.debug("获取到锁...");
t1.start();
}
}
运行结果如下所示:
若线程在设置的超时时间内没有获取到锁,则退出执行。
现假设t1
线程设置了获取锁的超时时间为2秒,主线程获取锁且在1秒后释放锁。示例代码如下:
public class Demo4 {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("尝试获取锁...");
//等待2秒
try {
if (!lock.tryLock(2, TimeUnit.SECONDS)) {
log.debug("获取锁失败,返回...");
return;
}
} catch (InterruptedException e) {
e.printStackTrace();
log.debug("被打断,结束运行...");
return;
}
try {
log.debug("获取到锁...");
}
finally {
lock.unlock();
}
}, "t1");
//主线程获取锁
lock.lock();
log.debug("获取到锁...");
t1.start();
TimeUnit.SECONDS.sleep(1);
//主线程释放锁
lock.unlock();
log.debug("释放了锁...");
}
}
若在设置的超时时间内获取到锁,则当前线程可以进入临界区执行自己的代码逻辑。
四、ReentrantLock - 哲学家就餐问题
4.1 哲学家就餐问题
哲学家就餐问题(Dining Philosophers Problem)是计算机科学中一个经典的同步问题
,用来演示并发程序中资源共享
的情况。这个问题由计算机科学家艾兹赫尔·戴克斯特拉(Edsger Dijkstra)提出。问题的描述如下:
- 有
五个哲学家
坐在圆桌旁,他们的生活模式是思考
和进餐
。 - 桌子上有五个碗和
五根筷子
。 - 每个哲学家需要
同时拿到左右两边的筷子
才能进餐,而每次进餐之后,他们需要放下筷子
并继续思考。
在这个问题中,每个哲学家
对应就是一个线程
,筷子
就是被竞争的共享资源
。
4.2 synchronized - 死锁演示
我们可以使用synchronized
关键字来模拟处理哲学家就餐问题,并演示出现死锁的情况。假设各大哲学家和筷子编号的场景图如下所示:
共享资源筷子类定义如下:
public class Chopstick {
private String name;
public Chopstick(String name) {
this.name = name;
}
@Override
public String toString() {
return "Chopstick{" +
"name='" + name + '\'' +
'}';
}
}
哲学家类如下所示:
public class Philosopher extends Thread {
private Chopstick left;
private Chopstick right;
public Philosopher(String name, Chopstick left, Chopstick right) {
super(name);
this.left = left;
this.right = right;
}
@Override
public void run() {
while (true) {
//获取左手边筷子
synchronized (left) {
log.debug("获得了左手边筷子: {}", left);
//获取右手边筷子
synchronized (right) {
log.debug("获得了右手边筷子: {}", right);
//已经获得了左右手的两只筷子,可以开始就餐
eat();
}
}
}
}
private void eat() {
log.debug("已获得左右手两边的筷子, eating...");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
编写测试类如下(结合上述的场景图):
public class TestDeadLock {
public static void main(String[] args) throws InterruptedException {
//5只筷子资源
Chopstick c1 = new Chopstick("1");
Chopstick c2 = new Chopstick("2");
Chopstick c3 = new Chopstick("3");
Chopstick c4 = new Chopstick("4");
Chopstick c5 = new Chopstick("5");
//哲学家
new Philosopher("苏格拉底", c1, c2).start();
new Philosopher("柏拉图", c2, c3).start();
new Philosopher("亚里士多德", c3, c4).start();
new Philosopher("赫拉克里克", c4, c5).start();
new Philosopher("阿基米德", c5, c1).start();
}
}
运行结果如下所示:
使用jconsole图形化工具查看死锁情况:
可以看到,每个哲学家都在等待自己的另外一只筷子资源但同时对自己已拥有的筷子资源不释放
,在5个哲学家之间形成了一个无限等待的闭环
。
4.3 synchronized - 解决死锁
在4.2中已经演示了死锁的问题,即形成了一个相互等待的闭环
。解决死锁可以从破坏闭环
入手。改写测试类如下所示:
public class TestDeadLock2 {
public static void main(String[] args) throws InterruptedException {
//5只筷子资源
Chopstick c1 = new Chopstick("1");
Chopstick c2 = new Chopstick("2");
Chopstick c3 = new Chopstick("3");
Chopstick c4 = new Chopstick("4");
Chopstick c5 = new Chopstick("5");
//哲学家
new Philosopher("苏格拉底", c1, c2).start();
new Philosopher("柏拉图", c2, c3).start();
new Philosopher("亚里士多德", c3, c4).start();
new Philosopher("赫拉克里克", c4, c5).start();
new Philosopher("阿基米德", c1, c5).start();
}
}
运行结果如下所示:
通过jconsole未检测到出现死锁情况:
但是仔细看运行结果却出现了另外一个问题,即总是某一位哲学家(本例中是赫拉克里克哲学家线程
)线程获得左右手筷子可以进餐,其他哲学家线程只能一直等待:
虽然破坏了闭环
问题,但是又出现了饥饿
问题。
4.4 ReentrantLock - 解决死锁
根据ReentrantLock特性,破坏死锁闭环
思路如下:如果某位哲学家线程获取到了一只筷子资源,当获取另一只筷子资源失败时,必须释放当前自己已获得的筷子资源
。
筷子类(继承ReentrantLock
)改造如下所示:
public class Chopstick extends ReentrantLock {
private String name;
public Chopstick(String name) {
this.name = name;
}
@Override
public String toString() {
return "Chopstick{" +
"name='" + name + '\'' +
'}';
}
}
哲学家类改造如下所示:
public class Philosopher extends Thread {
private Chopstick left;
private Chopstick right;
public Philosopher(String name, Chopstick left, Chopstick right) {
super(name);
this.left = left;
this.right = right;
}
@Override
public void run() {
while (true) {
//尝试获取左手边筷子
if (left.tryLock()) {
try {
//尝试获取右手边筷子
if (right.tryLock()) {
try {
//可以进餐
eat();
} finally {
right.unlock();
}
}
} finally {
left.unlock();
}
}
}
}
private void eat() {
log.debug("eating...");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
编写测试类如下:
public class TestDemo {
public static void main(String[] args) {
Chopstick c1 = new Chopstick("1");
Chopstick c2 = new Chopstick("2");
Chopstick c3 = new Chopstick("3");
Chopstick c4 = new Chopstick("4");
Chopstick c5 = new Chopstick("5");
//启动五个哲学家线程
new Philosopher("苏格拉底", c1, c2).start();
new Philosopher("柏拉图", c2, c3).start();
new Philosopher("亚里士多德", c3, c4).start();
new Philosopher("赫拉克利特", c4, c5).start();
new Philosopher("阿基米德", c5, c1).start();
}
}
运行结果如下所示:
从运行结果可以看到,破坏了死锁闭环
,且未出现饥饿
的情况。
五、公平锁 & 非公平锁
ReentrantLock根据创建时传入的参数提供了公平锁和非公平锁的实现,构造方法源码如下:
/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
sync = new NonfairSync();
}
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
ReentrantLock默认创建的是非公平锁,当在调用构造函数并传入参数为true时,创建的是公平锁。
5.1 公平锁
在Java并发编程中,公平锁是一种锁机制,它确保线程按照它们请求锁的顺序来获取锁
,从而避免了线程饥饿问题。现使用ReentrantLock来实现一个公平锁的小案例,代码如下所示:
public class FairLockDemo {
//创建公平锁
private static final Lock lock = new ReentrantLock(true);
//设置随机数
private static Random random = new Random();
/**
* 模拟计算业务处理
*/
private void calculate() {
lock.lock();
try {
log.debug("获取到了锁,开始计算业务处理...");
try {
//模拟业务耗时
int time = random.nextInt(3) + 1;
log.debug("本次业务耗时: {}", time);
TimeUnit.SECONDS.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("计算业务处理完毕...");
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
FairLockDemo demo = new FairLockDemo();
Runnable task = () -> {
for (int i = 0; i < 2; i++) {
demo.calculate();
}
};
//设置多个线程
Thread t1 = new Thread(task, "t1");
Thread t2 = new Thread(task, "t2");
Thread t3 = new Thread(task, "t3");
//启动
t1.start();
t2.start();
t3.start();
}
线程启动顺序:t1 -> t2 -> t3,查看运行结果(按严格来说这样观察打印输出不是很严谨+_+):
可以看到在设置了公平锁后,线程按照请求锁的顺序来获取锁。现调整启动顺序如下:t3 -> t2 -> t1。
...
//启动
t3.start();
t1.start();
t2.start();
....
查看运行结果如下(实际运行时也有概率出现t1先获得锁的情况,完全正确的情况不方便模拟):
5.2 非公平锁
在Java并发编程中,非公平锁是一种锁机制,它不保证线程按照请求锁的顺序来获取锁
。相反,它允许抢占式
锁获取,即新请求的线程可以在等待队列中的线程之前获得锁(哪个线程能获得锁和当前CPU调度有关,和请求顺序无关
)。ReentrantLock非公平锁的实现在上述内容中已经涉及,不再赘述。
六、条件变量 - Condition
6.1 概念
在Java并发编程中,ReentrantLock 提供了条件变量(Condition)
,允许线程在特定条件下等待
,并在条件满足时被唤醒
。比起传统的 synchronized 关键字和 Object 类的 wait/notify 方法,条件变量提供了一种更灵活的等待和通知机制
(ReentrantLock 的条件变量可以设置多个
)。
6.2 使用流程
ReentrantLock 条件变量Condition使用流程如下:
创建锁和条件变量
:首先创建一个ReentrantLock对象,然后从该锁中获取一个或多个Condition对象。获取锁
:在使用Condition之前,需要获取ReentrantLock对象的锁。等待条件
:调用Condition的await方法
让线程等待,直到被其他线程唤醒或被中断
。唤醒线程
:调用Condition的signal或signalAll方法
唤醒一个或所有等待在该条件上的线程。释放锁
:在使用完Condition后,释放ReentrantLock的锁。
6.3 案例
现我们假设有一个场景如下:开发人员张三
正在等自己的外卖
送到,只有外卖到了以后才会进行开发工作,否则在等待送外卖的休息室
等待,而测试人员李四
正在等自己的烟
送到,只有烟到了以后才会进行测试工作,否则在等待送烟的休息室
等待。现送外卖人员和送烟人员正在配送,当他们到达目的地后,需要通知
张三和李四,张三和李四分别拿到外卖和烟后,离开休息室结束等待
,开始工作。
根据场景编写案例代码如下:
public class ConditionDemo {
/**
* 锁对象
*/
private static final ReentrantLock lock = new ReentrantLock();
/**
* 等待外卖的休息室
*/
private static Condition takeOutWaitSet = lock.newCondition();
/**
* 等待送烟的休息室
*/
private static Condition cigaretteWaitSet = lock.newCondition();
/**
* 外卖是否送到
*/
private static boolean hasTakeOut = false;
/**
* 烟是否送到
*/
private static boolean hasCigarette = false;
public static void main(String[] args) throws InterruptedException {
//模拟等外卖的张三线程
new Thread(() -> {
lock.lock();
try {
log.debug("外卖到了吗: {}", hasTakeOut);
while(!hasTakeOut) {
log.debug("没外卖, 先去休息一下...");
try {
//进入等外卖的休息室
takeOutWaitSet.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("外卖到了,可以开始开发了...");
} finally {
lock.unlock();
}
}, "张三").start();
//模拟等烟的李四线程
new Thread(() -> {
lock.lock();
try {
log.debug("烟到了吗: {}", hasCigarette);
while(!hasCigarette) {
log.debug("没烟, 先去休息一下...");
try {
//进入等烟的休息室
cigaretteWaitSet.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("烟到了,可以开始测试了...");
} finally {
lock.unlock();
}
}, "李四").start();
//模拟送外卖的线程 => 2秒后外卖到了
TimeUnit.SECONDS.sleep(2);
new Thread(() -> {
lock.lock();
try {
log.debug("外卖到了!!!");
//通知等外卖的张三
hasTakeOut = true;
takeOutWaitSet.signal();
} finally {
lock.unlock();
}
}, "送外卖的").start();
//模拟送烟的线程 => 3秒后烟到了
TimeUnit.SECONDS.sleep(3);
new Thread(() -> {
lock.lock();
try {
log.debug("烟到了!!!");
//通知等烟的李四
hasCigarette = true;
cigaretteWaitSet.signal();
} finally {
lock.unlock();
}
}, "送烟的").start();
}
}
运行结果如下所示:
七、设计模式 - 交替输出
现我们假设有3个线程:t1、t2、t3,t1线程输出字符串a五次,t2线程输出字符串b五次,t3线程输出字符串c五次。现要求打印出目标字符串abcabcabcabcabc
。现结合ReentrantLock的条件变量Condition来实现,定义类AwaitSignal如下:
public class AwaitSignal extends ReentrantLock {
/**
* 循环次数
*/
private int loopNum;
public AwaitSignal(int loopNum) {
this.loopNum = loopNum;
}
/**
* 执行打印
*
* @param str 当前需打印的字符串
* @param current 当前打印线程的“休息室”
* @param next 下一个打印线程的“休息室”
*/
public void print(String str, Condition current, Condition next) {
for (int i = 0; i < loopNum; i++) {
//获取锁
lock();
try {
try {
current.await();
log.debug("{} -> {}", Thread.currentThread().getName(), str);
//唤醒
next.signal();
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
//释放锁
unlock();
}
}
}
}
编写测试类如下:
public class RoundPrintDemo {
public static void main(String[] args) throws InterruptedException {
AwaitSignal as = new AwaitSignal(5);
//创建条件变量
Condition a = as.newCondition();
Condition b = as.newCondition();
Condition c = as.newCondition();
//打印线程t1
new Thread(() -> {
as.print("a", a, b);
}, "t1").start();
//打印线程t2
new Thread(() -> {
as.print("b", b, c);
}, "t2").start();
//打印线程t3
new Thread(() -> {
as.print("c", c, a);
}, "t3").start();
//--------主线程发出通知信号
TimeUnit.SECONDS.sleep(2);
as.lock();
try {
log.debug("开始交替输出字符串~");
//唤醒a => t1线程从a开始打印
a.signal();
} finally {
//释放锁
as.unlock();
}
}
}
输入结果如下所示: