我们最早时如何实现转账的
在# 聊一聊你最爱的线程死锁吧 一文中,我们实现了这样一个转账逻辑。
public class Account2 {
private int money;
private AccountLockManager accountLockManager=new AccountLockManager();
public void transfer2(Account2 target, int transferMoney){
//如果目标对象正在和别人交易,那么我就拿不到他的锁,无限循环等着
while (!accountLockManager.getLock(this,target));
synchronized (this){
synchronized (target){
if (this.money > transferMoney) {
this.money -= transferMoney;
target.money += transferMoney;
}
}
}
}
}
复制代码
public class AccountLockManager {
List<Account2> accounts = new ArrayList<>();
/**
* 判断当前转账业务的锁有没有被人用过,只要有一个有人在用就返回false
*
* @param from
* @param to
* @return
*/
synchronized boolean getLock(Account2 from, Account2 to) {
if (accounts.contains(from) || accounts.contains(to)) {
return false;
} else {
accounts.add(from);
accounts.add(to);
return true;
}
}
/**
* 释放锁
*
* @param from
* @param to
*/
synchronized void releaseLock(Account2 from, Account2 to) {
accounts.remove(from);
accounts.remove(to);
}
}
复制代码
这个转账的弊端
可以看到这个转账逻辑实现是没有问题,但是当我在小明转账时,如果小明也在和别人进行转账交易的话,我就得无线while循环,直至小明和其他人完成交易之后,才能拿到小明的锁进行转账,很明显无线的while在大量用户交易场景下,是一件非常消耗的操作。
别让线程盲目的排队,使用等待/通知机制完美解决
有时候编程就和生活一样,既然等待没有结果,那我们就去着手别的事情,也许下一秒就会有你所要的结果。
线程也是如此,既然这个当前的资源在被其他人使用,那我们就索性等待使用资源的线程结束后再去尝试获取资源。
有等待就得有通知,所以java为thread类提供几个强大的方法,wait()、notify()、notifyAll()
修改后的示例
public class AccountLockManager {
List<Account2> accounts = new ArrayList<>();
/**
* 若当前线程需要拿锁,则进行判断,若被人用就直接等待
*
* @param from
* @param to
* @return
*/
synchronized boolean getLock2(Account2 from, Account2 to) {
if (accounts.contains(from) || accounts.contains(to)) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
accounts.add(from);
accounts.add(to);
}
return true;
}
/**
* 释放锁
*
* @param from
* @param to
*/
synchronized void releaseLock2(Account2 from, Account2 to) {
accounts.remove(from);
accounts.remove(to);
notify();
}
}
复制代码
你确定上述方案是最完美的嘛?
很抱歉,在if里面使用wait会使得这个线程被唤醒后重新进入排队,运气不好的话,这个线程会被其他线程疯狂的插队。如下图所示:
如下所示,针对上述的**某种方式**,笔者基于之前的代码将if改为while疯狂试探,我们也可以在ArrayBlockingQueue源码中看到这种虚假唤醒的骚操作
//入队相关源码
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
//用while去不断试探条件是否符合要求
while (count == items.length)
//如果队列满了,队列不满条件就不满足了,当前线程进入等待
notFull.await();
enqueue(e);
} finally {
lock.unlock();
}
}
复制代码
灵魂追问,关于notify和notifyAll你确定你真的会使用嘛?
我们了解的线程等待/通知相关概念之后,再来聊聊通知的不同姿势。不知道读者有没有想过,为什么java会为线程增加两个通知方法notify()、notifyAll()呢?
我们不妨看看以下代码吧
public class NotifyTest {
public static volatile Object lock=new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
System.out.println("线程A尝试取锁");
synchronized (lock){
System.out.println("假设锁被人使用,线程A进入等待状态");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程A等待结束");
}
});
Thread t2=new Thread(()->{
System.out.println("线程B尝试取锁");
synchronized (lock){
System.out.println("假设锁被人使用,线程B进入等待状态");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程B等待结束");
}
});
Thread t3=new Thread(()->{
System.out.println("线程C尝试取锁");
synchronized (lock){
System.out.println("线程C进入使用状态");
System.out.println("线程C使用结束");
lock.notify();
}
});
t1.start();
t2.start();
Thread.sleep(1000);
t3.start();
t1.join();
t2.join();
t3.join();
/**
* 线程A尝试取锁
* 线程C尝试取锁
* 线程B尝试取锁
* 假设锁被人使用,线程A进入等待状态
* 假设锁被人使用,线程B进入等待状态
* 线程C进入使用状态
* 线程C使用结束
* 线程A等待结束
*/
}
}
复制代码
可以看到使用notify的话,只会随机唤醒一个等待的线程,而还收到的通知的线程很可能因此直接消亡。而改为 notifyAll()后问题就得到解决。
所以这两个方法的是使用场景我们也需要注意,当你有一件事情需要马上完成,你可以创建多个线程去执行,由于每个线程工作内容都一样,所以一个线程完成后其他线程的死活都已经无所谓了。翻译成官话就是
1.所有线程都有一个相同的等待条件。
2.所有等待被唤醒之后,执行相同操作。
3.只需唤醒一个线程即可。
复制代码
而你有多件工作内容不同的事情需要马上完成的话,notifyAll就派上用场了,但需要注意notifyAll不会使其他线程消亡,但是活着的大量线程必然会为了激烈的资源争抢,如果使用不当也可能导致大量线程上下文切换导致的性能下降。
MESA模型
通过一段源码了解MESA模型
如下所示,这是一段关于ArrayBlockingQueue的入队和出队操作的核心代码,我们可以把await当做wait,把signal当作notify
//入队相关源码
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == items.length)
//如果队列满了,队列不满条件就不满足了,当前线程进入等待
notFull.await();
enqueue(e);
} finally {
lock.unlock();
}
}
private void enqueue(E x) {
// assert lock.getHoldCount() == 1;
// assert items[putIndex] == null;
final Object[] items = this.items;
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
//因为有个元素入队了,达到队列不空的条件要求了,
notEmpty.signal();
}
复制代码
出队相关源码
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
//队列已空,等待队列不空
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
}
private E dequeue() {
// assert lock.getHoldCount() == 1;
// assert items[takeIndex] != null;
final Object[] items = this.items;
@SuppressWarnings("unchecked")
E x = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length)
takeIndex = 0;
count--;
if (itrs != null)
itrs.elementDequeued();
//出队成功,通知队列不满
notFull.signal();
return x;
}
复制代码
以入队操作为栗子,我们看看能否使用wait/notify来替代我们可以把await/signal组合。首先我们使用notify的条件要求带入查看是否符合要求
1. 所有线程都拥有相同的等待条件:notFull.await();
2. 当线程被唤醒之后执行相同的动作:while (count == items.length)
3. 只需唤醒一个线程:notEmpty.signal();
复制代码
可以看到 await/signal玩法和wait/notify不太一样,原因也很简单,ArrayBlockingQueue使用的是MESA模型而java内置的监视器锁使用的是MESA模型的精简版。
简介
MESA模型说起来也很简单,就是每一个条件都会对应一个条件等待队列,如下图所示:
如果还有不明白的读者可以参考这篇文章[管程(Moniter): 并发编程的基本心法](https://juejin.cn/post/6844904024773230599)
再来聊聊java内置的管程模型
上文我们看到了标准的MESA模型,java与之不同的是,java内置的管程模型永远只有一个条件变量和一个等待队列,例如上文中的转账操作,条件变量永远是this和target。