Java并发编程实战 05等待-通知机制和活跃性问题

  • A+

Java并发编程系列

Java并发编程实战 01并发编程的Bug源头
Java并发编程实战 02Java如何解决可见性和有序性问题
Java并发编程实战 03互斥锁 解决原子性问题
Java并发编程实战 04死锁了怎么办

前提

Java并发编程实战 04死锁了怎么办中,讲到了使用一次性申请所有资源来避免死锁的发生,但是代码中却是使用不断的循环去获取锁资源。如果获取锁资源耗时短、且并发冲突量不大的时候,这个方式还是挺合适的。
如果获取所以资源耗时长且并发冲突量很大的时候,可能会循环上千上万次,这就太消耗CPU了。把上一章的代码贴下来吧。

/** 锁分配器(单例类) */
public class LockAllocator {
    private final List<Object> lock = new ArrayList<Object>();
    /** 同时申请锁资源 */
    public synchronized boolean lock(Object object1, Object object2) {
        if (lock.contains(object1) || lock.contains(object2)) {
            return false;
        }

        lock.add(object1);
        lock.add(object2);
        return true;
    }
    /** 同时释放资源锁 */
    public synchronized void unlock(Object object1, Object object2) {
        lock.remove(object1);
        lock.remove(object2);
    }
}

public class Account {
    // 余额
    private Long money;
    // 锁分配器
    private LockAllocator lockAllocator;
    
    public void transfer(Account target, Long money) {
        try {
            // 循环获取锁,直到获取成功
            while (!lockAllocator.lock(this, target)) {
            }

            synchronized (this){
                synchronized (target){
                    this.money -= money;
                    if (this.money < 0) {
                        // throw exception
                    }
                    target.money += money;
                }
            }
        } finally {
            // 释放锁
            lockAllocator.unlock(this, target);
        }
    }
}

解决这种场景的方案就是使用等待-通知机制。

等待-通知机制

当我们去麦当劳吃汉堡,首先我们需要排队点餐,就如线程抢着获取锁进synchronized同步代码块中。
当我们点完餐后需要等待汉堡完成,所以我们需要等待wait(),因为汉堡还没做好。
当汉堡做好后广播喊了一句“我做好啦!快来领餐”。广播就是notifyAll(),唤醒了所有线程。
然后每个人都过去看看是不是自己的餐。如果不是又进入了等待中。否则就可以拿到汉堡(获取到锁)开吃啦。

当然麦当劳只会说“xx号快来领餐”,我改了一下台词比较好做例子(例子感觉也是一般般,看不懂就看代码吧)。对不起麦当劳了。

在编程领域当中,若线程发现锁资源被其他线程占用了(条件不满足),线程就会进入等待状态wait(释放锁),当其它线程释放锁时,使用notifyAll()唤醒所有等待中的线程。被唤醒的线程就会重新去尝试获取锁。如图:
Java并发编程实战 05等待-通知机制和活跃性问题

那么何时等待? 何时唤醒?
何时等待:当线程的要求不满足时等待,在转账的例子当中就是不能同时获取到thistarget锁资源时等待。
何时唤醒:当有线程释放锁资源时就唤醒。
修改后的代码如下:

/** 锁分配器(单例类) */
public class LockAllocator {
    private final List<Object> lock = new ArrayList<>();

    /** 同时申请锁资源 */
    public synchronized void lock(Object object1, Object object2) throws InterruptedException {
        while (lock.contains(object1) || lock.contains(object2)) {
            wait(); // 线程进入等待状态 释放锁
        }

        lock.add(object1);
        lock.add(object2);
    }
    /** 同时释放资源锁 */
    public synchronized void unlock(Object object1, Object object2) {
        lock.remove(object1);
        lock.remove(object2);
        notifyAll(); // 唤醒所有等待中的线程
    }
}
public class Account {
    // 余额
    private Long money;
    // 锁分配器
    private LockAllocator lockAllocator;

    public void transfer(Account target, Long money) throws InterruptedException {
        try {
            // 获取锁
            lockAllocator.lock(this, target);

            this.money -= money;
            if (this.money < 0) {
                // throw exception
            }
            target.money += money;
        } finally {
            // 释放锁
            lockAllocator.unlock(this, target);
        }
    }
}

Account类中,对比上面的代码,我删掉了两层synchronized嵌套,如果涉及到账户余额都先去锁分配器LockAllocator 中获取锁,那么这两层synchronized嵌套其实可以去掉。而且使用wait()notifyAll()notify()也是)必须在synchronized代码块中,否则会抛出java.lang.IllegalMonitorStateException`异常。

尽量使用notifyAll

其实使用notify()也可以唤醒线程,但是只会随机抽取一位幸运观众(随机唤醒一个线程)。这样做可能有导致有些线程没那么快被唤醒或者永久都不会有机会被唤醒到。
假如有资源A、B、C、D,线程1申请到AB,线程2申请到CD,线程3申请AB需要等待。此时有线程4申请CD等待,若线程1释放资源时唤醒了线程4,但是线程4还是需要等待线程2释放资源,线程3却没有被唤醒到。
所以除非你已经思考过了使用notify()没问题,否则尽量使用notifyAll()

notify何时可以使用

notify需要满足以下三个条件才能使用

1.所有等待线程拥有相同的等待条件。
2.所有等待线程被唤醒后,执行相同的操作。
3.只需要唤醒一个线程。

活跃性问题

活跃性问题,指的是某个操作无法再执行下去,死锁就是其中活跃性问题,另外的两种活跃性问题分别为 饥饿活锁

饥饿

在上面的例子当中,我们看到线程3由于无法访问它所需要的资源而不能继续执行时,就发生了“饥饿”,如果在Java应用程序中对线程的优先级使用不当或者在持有锁时执行一些无法结束的结构(无线循环、无限制的等待某个资源),那么也可能发生饥饿。
解决饥饿的问题有三种:1.保证资源充足,2.公平地分配资源,3.避免线程持有锁的时间过长。但是只有方案2比较常用到。在并发编程里,主要是使用公平锁,也就是先来后到的方案,线程等待是有顺序的,不会去争抢资源。这里不展开讲公平锁.

活锁

活锁是另一种活跃性问题,尽管不会阻塞线程,但是也不能继续执行,这就是活锁,因为程序会不断的重复执行相同的操作,而且总是会失败。
就如两个非常有礼貌的人在路上相撞,两个人都非常有礼貌的让到另一边,这样就又相撞了,然后又....,不断地变道,不断地相撞。
在编程领域当中:假如有资源A、B,线程1获取到了资源A的锁,线程2获取到了资源B的锁,此时线程1需要再获取资源B的锁,线程2需要再获取资源A的锁,两个线程获取锁资源失败后释放自己所持有的锁,然后再此重新获取资源锁。这是就又发生了刚才的事情。就这样不断的循环,却又没阻塞。这就是活锁的例子。如图:
Java并发编程实战 05等待-通知机制和活跃性问题
解决活锁的问题就是各自等待一个随机的时间再做后续操作。这样同时相撞的概率就很低了。

总结

本文主要讨论了使用等待-通知获取锁来优化不断循环获取锁的机制。若获取锁资源耗时短和并发冲突少则也可以使用不断循环获取锁的机制,否则尽量使用等待-通知获取锁。唤醒线程的方式有notify()notifyAll(),但是notify()只会随机唤醒一个线程,容易导致线程饥饿,所以尽量使用notifyAll()方式来唤醒线程。

参考文章:
《Java并发编程实战》第10章 活跃性危险
极客时间:Java并发编程实战 06: 用“等待-通知”机制优化循环等待
极客时间:Java并发编程实战 07: 安全性、活跃性以及性能问题

个人博客网址: https://colablog.cn/

如果我的文章帮助到您,可以关注我的微信公众号,第一时间分享文章给您
Java并发编程实战 05等待-通知机制和活跃性问题

90DIR-CMD