Java并发编程-活锁:一个不怎么出现,危害很小的Bug

JerryWu

在前面的两篇文章中,我们一直在关注程序的死锁问题,包括:造成死锁的原因、规避死锁的办法。

然而,除了死锁的问题外,还有另外两种情况。它们虽然没那么常见,可一旦发生,程序照样无法执行下去。

这一次,我们先看看其中一种情况—活锁。

线程的相互谦让—活锁

通过前面两篇文章,相信你已经知道了:发生死锁后,线程会相互等待,进入一个“永久”堵塞的状态。

归根到底,就是几个线程占有了资源,又没资格运行程序,也不肯释放手上的资源,结果谁都没法运行。

那如果线程让出资源,问题是不是就解决了呢?

当然不是,虽然线程让出了资源,没进入阻塞状态,但依然执行不下去,这就是活锁

所谓活锁,就是两个以上的线程在执行的时候,因为相互谦让资源,结果都拿不到资源,没法运行程序。

打个比方,你我在路上迎面碰到,那咱俩如果想过去,就相互谦让呀。结果,你从右边走,我从左边走,又撞上了。就像下面这样:

活锁例子

在现实世界中,人们会相互交流,一般谦让几次也就过去了。

但在编程世界中,几个线程可能会一直谦让下去,成为没有阻塞但依然没法执行的活锁

活锁带来的问题

说实话,在日常工作中,活锁很少出现。而且,就算出现了活锁,但还没等你采取行动,它就自动解开了。而这会导致一些很奇葩的问题。

比如说,业务量明明不算大,但转账却好几分钟都没完成。可当你开始检查,正一头雾水的时候,转账忽然就完成了。你看这段代码:

class Account {
    private int balance;
    private final Lock lock = new ReentrantLock();

    // 转账
    // 下面的代码是简化版,千万别模仿,解锁一定要用try-finally包裹
    void transfer(Account tar, int amt) {
        boolean flag = true;

        // 不断尝试加锁两个账户
        while (flag) {
            if(this.lock.tryLock()) {
                if (tar.lock.tryLock()) {
                    // 加锁成功,执行转账
                    this.balance -= amt;
                    tar.balance += amt;

                    // 跳出循环
                    flag = false;
                }
            }

            // 释放锁资源
            tar.lock.unlock();
            this.lock.unlock();
        }
    }
}

在这个例子中,Lock 是 Java 实现互斥的接口,它弥补了 synchronized 的一些缺陷。

比如,使用了 Lock后,如果线程没拿到锁,那并不会进入阻塞状态,而是直接退出。这样一来,线程就能释放出占用的资源,从而破坏了不可抢占条件,避免死锁的发生。

然而,新问题又来了。

现在同时出现两笔交易,账户A转账户B,账户B转账户 A。那么,线程一会占有账户A,线程二则会占有账户B。

结果,线程一拿不到账号B,转账没法执行,线程一结束;线程二也拿不到账号A,转账也没法执行,线程二结束。

到这里,第一轮循环结束,两笔转账都没有完成,新一轮循环开启。但在第二轮循环中,上面的过程又再次重复。如此不断地循环下去,两笔转账却一直没有完成。

简单来说,程序出现了活锁问题

活锁问题一旦出现,你要不就重启应用,要不就等问题自动消失,可这些都是非常消极的做法。

所以,要想彻底解决问题,我们还是得避免活锁,不让活锁发生。

如何避免活锁

程序之所以出现活锁,其实是因为线程的解锁时间是一样的。

比如说,两个线程如果同时解锁,重试时同时加锁,这个过程又不断循环,可不就是陷入死循环吗?

因此,想要避免活锁发生,我们可以在解锁之前,等待一个随机时间。由于每个线程的解锁时间都不一样,也就不存在一直让资源的情况。

比如,上面的转账业务,我们可以修改两行代码。

class Account {
    private int balance;
    private final Lock lock = new ReentrantLock();

    // 转账
    // 下面的代码是简化版,千万别模仿,解锁一定要用try-finally包裹
    void transfer(Account tar, int amt) {
        boolean flag = true;

        // 不断尝试加锁两个账户
        while (flag) {
            if(this.lock.tryLock(随机数, TimeUnit.MILLISECONDS)) {
                if (tar.lock.tryLock(随机数, TimeUnit.MILLISECONDS)) {
                    // 加锁成功,执行转账
                    this.balance -= amt;
                    tar.balance += amt;

                    // 跳出循环
                    flag = false;
                }
            }

            // 释放锁资源
            tar.lock.unlock();
            this.lock.unlock();
        }
    }
}

在锁定账户A的时候,线程一会先等待一个随机的时间;在锁定账户B的时候,线程二也会等待一个随机的时间。

这样一来,由于持有锁的时间是随机的,重复的概率很小,两个线程的加解锁操作就错开了,转账也就完成了。

写在最后

如果线程相互谦让资源,程序有可能出现活锁问题。

不过,你也不用太担心。活锁非常少见,即使出现了,程序也会自动解开。

当然,你也可以在解锁之前,等待一个随机时间,这样就能避免活锁的出现。

阅读 98

Java架构师之路
用直白的话,写高深的技术~

Java程序员,找工作中,不会断更,email:jiarupc@qq.com

58 声望
278 粉丝
0 条评论
你知道吗?

Java程序员,找工作中,不会断更,email:jiarupc@qq.com

58 声望
278 粉丝
宣传栏