在上篇文章中,我们提到了活锁是一个很少出现、危害很小的BUG。更何况,活锁即使出现了,最多等个一两秒就自动解开,根本不算大问题。
然而,除了活锁,还有一种情况,你只要没处理好,程序很可能一下就崩溃掉,这就是饥饿。
什么是饥饿
饥饿,就是线程拿不到需要的资源,一直没法执行。比如说,下面这段代码:
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
。当锁定成功后,就修改两个账户的余额。最后,我们再调用银行的接口,让钱真正到达银行账户,这笔转账才算完成。
这段代码非常完美,只要十几笔交易进来,就能扣掉你半个月的工资。原因出在哪呢?这段代码的效率实在太低了。
无论你电脑的配置有多好,转账都得花 5 秒的时间,速度极慢。这就算了,关键是每次只能处理一笔转账,其它交易只能排队,效率极低。排队也就算了,更致命的是,没有个先来后到的规矩,处理完一笔交易后,下一笔轮到谁完全不知道。
看到这儿,饥饿是怎么一回事,你大概能明白了吧?简单来说,饥饿就是程序本身没有问题,但资源太少,一直没机会运行。
如果你还是有点懵,可以想象这么一个场景:十几个人找你借钱,但钱就这么多。于是,你先借给一个人,等他还回来后,再看心情随便借给另一个人。
在并发编程领域,饥饿是最经常碰到的问题。而且,如果你不理解具体的业务,很可能连问题出在哪儿都不知道。
如何解决饥饿问题
我们已经知道,所谓饥饿,就是资源太少,线程拿不到资源,一直没机会运行。
换句话说,饥饿问题都是由资源太少导致的。那么,又是什么原因导致资源太少的呢?我们还是看看上面的转账代码。
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
只有一个,所以转账只能一笔一笔地处理。
然后,分配不均。synchronized
是非公平锁,先处理哪笔交易完全是随机的,先来的转账可能最后才处理。
最后,耗时太长。调用外部系统,这个操作实在是太浪费时间了。
可以说,这段转账的代码集齐了所有毛病,随便解决一个都够让你升职加薪了。
既然问题已经找到了,解决思路自然也有了,分别是这么几个:
- 保证资源充足;
- 公平地分配资源;
- 减少线程的执行时间;
接下来,我们分别照着这几个思路,看看怎样优化这个转账功能。
如何保证资源充足?
我们先来复习转账的代码:
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
只有一个,速度无论如何都没法提高。
换句话说,系统的资源总是稀缺的,这个问题看似无解。但转账真的要锁住整个Account.class
吗?
不需要!你仔细想,一笔转账只涉及两个账户,其它的交易只要不涉及到这两个账户,就完全可以并行处理。现在倒好,整个Account.class
被锁住了,转账只能一笔一笔地处理,效率当然没法看。
比如说,现在有两笔交易,分别是:账户A
转账户B
、账户C
转账户D
。那这两笔交易完全没有关联,即使同时处理也不会有问题,你不需要把整个银行给锁了。
既然问题找到了,我们直接改代码:
class Account {
// 余额
private Integer balance;
// 转账
void transfer(Account target, Integer amt) {
synchronized (this) {
synchronized (target) {
// 本系统操作:修改余额,花费 0.01 秒
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
// 调用外部系统:转账,花费 5 秒
payService.transfer(this, target, amt);
}
}
}
}
整段代码也没什么变化,我们只锁定了两个账户:this
、target
,多用了一个 synchronized
。这看似非常简单,但只要账户不冲突,转账就能并行处理,整体的效率高了几十个档次。
这样一来,资源不足的问题就被大大地缓解,我们保证了资源充足。
如何公平地分配资源?
我们继续来看转账的代码:
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);
}
}
}
这段代码中,我们为了解决程序的原子性问题,用到 Java 的关键字 synchronized
,这算是最简单的方案了。
然而,synchronized
是非公平锁,这可能导致某些线程一直没法执行。什么意思呢?
正常情况下,如果进来了好几笔交易,那么得讲个先来后到,谁等待的时间长就先处理。不过,如果你用的是synchronized
,那么顺序完全是随机的,可能等待时间短的交易反而先处理,等待时间长的一直不处理。
在交易量不算特别大的时候,转账慢点其实还能接受,但如果一笔交易进来,你完全搞不清什么时候能完成,这就说不过去了。想解决这个问题,我们得用到 Java 并发包的东西。
class Account {
// 余额
private Integer balance;
// 创建一把公平锁
private final Lock rtLock = new ReentrantLock(true);
// 转账:代码大大简化,请勿模仿
void transfer(Account33 target, int amt) {
if (this.rtLock.tryLock()) {
if (target.rtLock.tryLock()) {
// 本系统操作:修改余额,花费 0.01 秒
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
// 调用外部系统:转账,花费 5 秒
payService.transfer(this, target, amt);
target.rtLock.unlock();
}
this.rtLock.unlock();
}
}
}
在这里,线程如果想要转账,就必须拿到两个账户的锁。但不同的是,这次我们创建了一把公平锁,没拿到锁的线程会进入等待队列,当有线程释放锁的时候,会唤醒一个线程。这时候,谁等待的时间长,就先唤醒谁。
这样一来,我们保证了资源的分配最起码是公平的。
如何减少线程的执行时间?
我们最后再看一眼转账的代码:
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);
}
}
}
这段代码中,我们有两个操作:一个是修改余额,另一个是调用外部系统。只有完成这两个操作,一笔转账才算完成。
然而,你有没有发现,调用外部系统,这个操作耗费的时间太长了。而且,这个外部系统不归你管,速度也就那样了。耗时长还没法优化,这个问题又该怎么解决呢?
其实,你想一下,一笔转账既然被拆成两个操作,那这是不是也意味着,用户对我们的期待也能拆成两个呢?第一,是账户的钱有没有减少;第二,是钱什么时候到账。
既然如此,我们可以先修改账户的余额,先把结果返回给用户这边,等一会儿再调用外部系统也没问题。你看下面的代码:
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 秒,但会在后台异步执行
ThreadPool.execute(() ->
payService.transfer(this, target, amt)
);
// 返回结果给用户
System.out.println("转账成功,当前余额:" + this.balance);
}
}
}
在这里,转账依旧是一笔一笔地处理,但在调用外部系统的时候,我们开了一个异步线程。这样一来,转账速度就有种快了几百倍的感觉。
原本用户转账要等个 5 秒,但现在只需要 0.01 秒,就能看到余额的变化。正当用户惊讶的时候,银行的短信也来了,速度居然变得这么快,真的是太惊喜。
这样一来,主线程的执行时间就被大大缩短了。
写在最后
在并发程序中,我们会经常碰到饥饿问题,就是线程拿不到需要的资源,一直没法执行。
想要解决饥饿问题,我们有三个思路,分别是:
- 保证资源充足;
- 公平地分配资源;
- 减少线程的执行时间;
我们可以按照这三个思路,去分析我们的代码,缓解饥饿问题。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。