不要让线程盲目的排队,线程等待/通知机制

我们最早时如何实现转账的

# 聊一聊你最爱的线程死锁吧 一文中,我们实现了这样一个转账逻辑。

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会使得这个线程被唤醒后重新进入排队,运气不好的话,这个线程会被其他线程疯狂的插队。如下图所示:

图片.png

所以根据《java并发编程》说法,内容大概是:一个线程从挂起转为运行态并不一定需要notify()、notifyAll()的通知,你也可以使用某种方式去不断试探当前资源是否可以被当前线程使用,这就是所谓的虚假唤醒。
如下所示,针对上述的**某种方式**,笔者基于之前的代码将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模型说起来也很简单,就是每一个条件都会对应一个条件等待队列,如下图所示:

图片.png

通俗的说在MESA模型下,两个操作保持互斥,入队有入队的条件变量,当入队操作完成后会通知出队操作有元素可用,让其进入可执行队列。出队操作完成后通知入队操作,让入队操作进入可执行队列中。正是这样的互斥条件保证了这两个操作在高并发操作下,可以保持操作的线程安全。
如果还有不明白的读者可以参考这篇文章[管程(Moniter): 并发编程的基本心法](https://juejin.cn/post/6844904024773230599)

再来聊聊java内置的管程模型

上文我们看到了标准的MESA模型,java与之不同的是,java内置的管程模型永远只有一个条件变量和一个等待队列,例如上文中的转账操作,条件变量永远是this和target。

图片.png

猜你喜欢

转载自juejin.im/post/7034150294829465614