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

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

好在 Java 是一门强大的语言,锁-synchronized 是一味万能药。你只要用好锁,几乎能解决全部并发问题。编程

不过,并发编程有一个特色:解决完一个问题,总会冒出另外一个新问题。segmentfault

锁带来的性能问题

实际开发中,锁虽然是一副万能药,但使用起来要很是当心。由于你不但要考虑锁和资源的关系,还得考虑性能问题。多线程

咱们之因此写并发程序,不就是想提升性能吗?并发

然而,锁的本质是串行化,程序要排队轮流执行。这样一来,多线程的优点就无法发挥了,性能天然会降低。性能

好比,银行的转帐操做,若是想保证结果的正确,就得用到锁。优化

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 进行加锁,解决了银行转帐的并发问题,代码也特别简单,看似很完美。可是,这里有一个致命缺陷:性能太差,全部帐户的转帐操做都是串行的。this

在现实世界中,帐户 A 转帐户 B,帐户 C 转帐户 D,这些都是能够并行处理的。但在这个方案中,却没考虑这些,转帐只能一笔一笔的处理。spa

试想一下,中国网民即便天天只交易一次,就有 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。这个就有了两个线程,分别是:线程1、线程二。

其中,线程一先锁定了帐户A,线程二先锁定了帐户B。

那么,问题来了。线程一想继续对帐户B 加锁,但发现帐户B 被锁定,转帐无法执行下去,只能进入等待。

一样的道理,线程二想继续对帐户A 加锁,但发现帐户A 被锁定,转帐也无法执行,也进入了等待。

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

死锁的资源分布

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

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

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

能够说,如何完全解决死锁问题,就是程序员价值所在。

写在最后

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

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

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

相关文章
相关标签/搜索