前面几篇文章,我们一直在关注如何解决并发问题,也就是程序的原子性、可见性、有序性。这些问题一旦出现,程序的结果就没法保证。
好在 Java 是一门强大的语言,锁-synchronized
是一味万能药。你只要用好锁,几乎能解决所有并发问题。
不过,并发编程有一个特点:解决完一个问题,总会冒出另一个新问题。
锁带来的性能问题
实际开发中,锁虽然是一副万能药,但使用起来要非常小心。因为你不但要考虑锁和资源的关系,还得考虑性能问题。
我们之所以写并发程序,不就是想提高性能吗?
然而,锁的本质是串行化,程序要排队轮流执行。这样一来,多线程的优势就没法发挥了,性能自然会下降。
比如,银行的转账操作,如果想保证结果的正确,就得用到锁。
class Account {
// 余额
private Integer balance;
// 转账
void transfer(Account target, Integer amt) {
synchronized (Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
在这里,我们对 Account.class
进行加锁,解决了银行转账的并发问题,代码也特别简单,看似很完美。但是,这里有一个致命缺陷:性能太差,所有账户的转账操作都是串行的。
在现实世界中,账户 A 转账户 B,账户 C 转账户 D,这些都是可以并行处理的。但在这个方案中,却没考虑这些,转账只能一笔一笔的处理。
试想一下,中国网民即使每天只交易一次,就有 10 亿笔转账,平均每秒转账超过 1 万次。如果交易只能一笔笔处理,那结果是对了,性能却根本没法看。
因此,在实际工作中,我们不光要考虑程序的结果对不对,还得考虑程序的性能好不好。
如何提高锁的性能
我们曾提到过,如果想用好锁,那么锁要覆盖所有受保护的资源。不然的话,就没法发挥锁的互斥作用。PS.可以复习这篇文章:用锁的正确姿势
然而,如果锁覆盖的范围太大,程序的性能也会大幅下降。
比如,前面的转账操作实在是牵连巨大,你再看一下代码:
class Account {
// 余额
private Integer balance;
// 转账
void transfer(Account target, Integer amt) {
synchronized (Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
每笔转账只涉及了两个账号,但我们却把整个 Account.class
锁住了。这个方案虽然简单,但 Account.class
这个资源覆盖的范围实在太大了。
而且,账户不止转账一个功能,还有查余额、提现等等操作,可这些操作也得串行处理的,那性能自然好不了。
那这样行不行?既然问题是锁覆盖的范围太大,那我缩小覆盖范围,问题不就解决了吗?
非常正确,这样的锁叫:细粒度锁。
所谓细粒度锁,就是缩小资源的覆盖范围,然后用不同的锁对资源做精细化管理,从而提高程序的并行度,以此来提升性能。
那按照这个思路,我们来分析一下代码,转账只涉及到两个账户,分别是:this
、target
。既然如此,我们就不用锁定整个 Account.class
,只需要锁定两个账户,做两次加锁操作就好了。
首先,我们尝试锁定转出账户 this
;然后,再尝试锁定转入账户 target
。只有两个账户都锁定成功时,才能执行转账操作。你可以看下面这副图:
思路有了,接下来,就得转换成代码了。
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;
}
}
}
}
}
相比原来的代码,现在只是多用了一个 synchronized
,好像没有什么变化,但转账的并行度却大大提高。
你想一下,现在同时出现两笔交易,账户 A 转账户 B,账户 C 转账户 D。
原本的转账操作是锁定了整个 Account.class
,转账只能一笔一笔地处理。
但经过改造后,第一笔转账只锁定了 A、B 两个账户,第二笔转账只锁定了 C、D 两个账户,这两笔转账完全可以并行处理。
这样一来,程序的性能提升了好几个档次,而这都是细粒度锁的功劳。
细粒度锁的代价——死锁
在转账这个例子中,我们一开始用是 Account.class
来做锁,但为了优化性能,我们用了细粒度锁,只锁定和转账相关的两个账号。这样一来,性能有了很大的提升。
然而,天下没有免费的午餐。细粒度锁看上去这么简单,是不是也有代价呢?
没错,细粒度锁可能造成死锁。所谓死锁,就是两个以上的线程在执行的时候,因为竞争资源造成互相等待,从而进入“永久”阻塞的状态。
听起来有点复杂,我们还是继续看转账的例子吧。
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。
那么,问题来了。线程一想继续对账户B 加锁,但发现账户B 被锁定,转账没法执行下去,只能进入等待。
同样的道理,线程二想继续对账户A 加锁,但发现账户A 被锁定,转账也没法执行,也进入了等待。
这样一来,线程一、线程二都在死死地等着,这就是经典的死锁问题了,你可以看下这副图。
在这副图中,两个线程形成一个完美的闭环,根本没法出去。
而且,转账随时都会发生,但这两个线程却一直占着资源,新的订单没法处理,只能堆在一起,越来越多。这不仅浪费大量的计算机资源,还影响其它功能的运行。
此外,程序一旦发生死锁,那除了重启应用外,没有别的路能走。但银行分分钟进出几十亿,重启应用也是死路一条。
可以说,如何彻底解决死锁问题,就是程序员价值所在。
写在最后
锁的本质是串行化,如果锁覆盖的范围太大,会导致程序的性能低下。
为了提升性能,我们用了细粒度锁,但这又带来了死锁问题。
既然如此,死锁该怎么解决?我们下次再聊。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。