并发编程有 3 道关卡,分别是:安全性问题、活跃性问题、性能问题,如果三关全过,也就掌握并发编程这门高阶技能。
今天,我们就来过第二关:解决活跃性问题。
活跃性问题
所谓活跃性问题,是指程序没法执行下去。
比如说,公司有一个转账的业务,你已经实现了线程安全,解决了安全性问题,并发再高也不出错。那这样是不是完全没问题了呢?
当然不是,程序还会出现活跃性问题,包括:死锁、饥饿、活锁。
其中,活锁是难度最低的一个问题,解决起来非常容易。而且,即使你不解决,活锁也很可能自动解开,完全不用担心。当然,如果你对活锁感兴趣,可以看这篇文章:Java并发编程-活锁:它是那种很少见,又没啥危险的Bug,这里就不多说了,我们得抓重点。
死锁、饥饿是活跃性问题的关键,只要有办法解决这两个问题,就能打通第二关了。
死锁
死锁,是指两个以上的线程在执行的时候,因为竞争资源造成互相等待,从而进入“永久”阻塞的状态。这听起来有点拗口,我们还是直接看代码:
class Account {
// 余额
private Integer balance;
// 转账
void transfer(Account target, Integer amt) {
synchronized (this) {
synchronized (target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
上面是一段转账的代码,假设现在同时有两笔交易,账户A
转账户B
,账户B
转账户A
,这就有了线程一、线程二。那么,问题来了,线程一锁定了账户A
,线程二锁定了账户B
,它们都需要对方的资源才能执行,可资源已经被锁定了,只有执行完程序才能释放。
这样一来,线程一、线程二都只能死死等着,永远没法执行。这就是经典的死锁问题,你可以看下面的图:
在这副图中,两个线程形成一个完美的闭环,根本没法出去。你可以看下这篇文章:Java并发编程-死锁(上),里面从头到尾,写了死锁产生的过程。
既然如此,死锁问题该怎么解决呢?除了重启应用外,死锁没法解决,唯一可行的办法是:规避死锁,不让死锁出现。至于怎么规避,你可以看这篇文章:Java并发编程-死锁(下),里面有规避死锁的思路。
饥饿
饥饿,就是线程拿不到需要的资源,一直没法执行。比如说,下面这段代码:
class Account {
// 余额
private Integer balance;
// 转账
void transfer(Account target, Integer amt) {
synchronized (Account.class) {
// 本系统操作:修改余额,花费 0.01 秒
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
// 调用外部系统:转账,花费 5 秒
payService.transfer(this, target, amt);
}
}
}
这是一段转账的代码,我们如果想要执行转账,那么必须锁定 Account.class
。然而,Account.class
由 Java 虚拟机创建,只有一个。这就意味着,所有的转账交易都是串行的,只能一笔一笔的处理,效率极低。
此外,payService.transfer(this, target, amt)
这行代码实在是浪费时间,无论电脑配置多好,速度也完全没法提升。
最致命的是,完成时间没法确定。synchronized
是非公平锁,处理顺序是随机的,可能等待时间短的交易反而先处理,等待时间长的一直不处理。
这就导致,一旦业务量大了,公司的投诉电话很可能被打爆。不过,幸运的是,虽然转账很慢,但程序本身没有问题,只是资源太少,一直没机会运行。
那么,该怎么解决饥饿问题呢?
你可以看看这篇文章:Java并发编程-饥饿,里面讲到了缓解饥饿的三个思路。
写在最后
并发编程有 3 个关卡:安全性问题、活跃性问题、性能问题,我们今天过的是第二关:活跃性问题。
从这一关开始,我们要特别注意:死锁、饥饿。你可以回顾一下这些文章:Java并发编程-死锁(上)、Java并发编程-死锁(下)、Java并发编程-饥饿。