Java并发编程-死锁(上):追求性能的代价

JerryWu

前面几篇文章,我们一直在关注如何解决并发问题,也就是程序的原子性、可见性、有序性。这些问题一旦出现,程序的结果就没法保证。

好在 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 这个资源覆盖的范围实在太大了。

而且,账户不止转账一个功能,还有查余额、提现等等操作,可这些操作也得串行处理的,那性能自然好不了。

那这样行不行?既然问题是锁覆盖的范围太大,那我缩小覆盖范围,问题不就解决了吗?

非常正确,这样的锁叫:细粒度锁

所谓细粒度锁,就是缩小资源的覆盖范围,然后用不同的锁对资源做精细化管理,从而提高程序的并行度,以此来提升性能。

那按照这个思路,我们来分析一下代码,转账只涉及到两个账户,分别是:thistarget。既然如此,我们就不用锁定整个 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 被锁定,转账也没法执行,也进入了等待。

这样一来,线程一、线程二都在死死地等着,这就是经典的死锁问题了,你可以看下这副图。

死锁的资源分布

在这副图中,两个线程形成一个完美的闭环,根本没法出去。

而且,转账随时都会发生,但这两个线程却一直占着资源,新的订单没法处理,只能堆在一起,越来越多。这不仅浪费大量的计算机资源,还影响其它功能的运行。

此外,程序一旦发生死锁,那除了重启应用外,没有别的路能走。但银行分分钟进出几十亿,重启应用也是死路一条。

可以说,如何彻底解决死锁问题,就是程序员价值所在。

写在最后

锁的本质是串行化,如果锁覆盖的范围太大,会导致程序的性能低下。

为了提升性能,我们用了细粒度锁,但这又带来了死锁问题。

既然如此,死锁该怎么解决?我们下次再聊。

阅读 145

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

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

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

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

58 声望
278 粉丝
宣传栏