2

通过前面的 7 篇文章,你可能觉得并发编程很复杂,既要考虑程序结果是不是正确,又要考虑程序能不能执行,还要考虑服务器能不能扛住,实在不知道从哪入手~

别怕,虽然看起来很多,但这其实是一个打怪升级的过程,里面有 3 道关卡:安全性问题、活跃性问题、性能问题,每一关都能有收获,如果三关全过了,也就掌握了这门高阶技能。

今天,我们先过第一个关:安全性问题。

安全性问题

所谓安全性问题,是指程序有没有按照我们的期待执行。说白了,就是正确性。相信你在工作的时候,一定会被人问到:这方法有没有实现线程安全?这个类是不是线程安全的?

那什么是线程安全呢?线程安全的本质也是正确性,程序按照我们的期望执行。具体来说,无论在单线程环境下,还是在多线程环境下,最终的运行结果都是一样的,不会随着环境而变化。

一个程序要想实现线程安全,就必须避免这么三个问题,分别是:可见性问题、有序性问题、原子性问题。关于这几个问题,可以看下以前的文章:Java并发编程-并发根源,里面详细讲了这三个问题的前因后果。

在弄清问题的来龙去脉后,我们得动手解决问题了。然而,一说到并发编程,各路大神就嗨起来了,各种专业术语,各种解读源码,就是不提怎么写代码。

但其实,实现线程安全,写好一个多线程应用没那么难。你可以看看这篇文章,Java并发编程-解决并发,里面就是为了破除你的畏难情绪,从实际工作出发,一个个的解决问题。

问题的原因都搞清楚了,解决方案也有了。那是不是要仔细检查所有代码,百分百保证线程安全呢?

当然不是,实现线程安全的成本很高,需要耗费你大量的时间精力。而且,并发编程毕竟是高阶技能,这就意味着,这项技能虽然非常重要,但使用场景却很少。

只有在共享数据会变化的情况下,才需要实现线程安全。具体来说,就是只有在多个线程同时读写一个数据时,你才需要去考虑程序的可见性、有序性、原子性。

换句话说,增删改查之类的业务,差不多就得了,你得把时间精力放在重要业务上。比如说,银行的转账、提现业务,电商的下单减库存业务等等。

看到这儿,你可能会这么想:我已经知道了并发问题的根源,也有了解决方案,也知道要特别注意某些业务,可我还是很懵,完全不知道该从哪里开始。

没关系,我再给你两个抓手。

在数据竞争的场景下,必须考虑并发

数据竞争就是,多个线程同时访问、并修改一个共享数据,就会导致并发BUG。这里有两个关键词:多线程、修改一个共享数据,你看下面的代码。

class Account {
    // 余额
    private Integer balance = 1000;

    // 充值
    void charge(Integer amt) {
        this.balance += amt;
    }
}

上面是一段充值的代码,如果充值一笔一笔地进行,这完全没问题。因为两个条件没凑齐,balance-余额虽然是共享变量,但一天也没几笔充值进来,更别提有人同时充值了。

可公司总有做大的一天,到时如果同时进来几万笔充值,那两个条件就凑齐了,最后的余额肯定一塌糊涂。我们来具体分析一下,这段代码会同时出现可见性、原子性问题。

先来说可见性问题,现在是多核CPU时代,每颗核心都有自己的CPU缓存。如果一笔充值运行在CPU-1上,另一笔充值运行在CPU-2上。这就相当于,它们同时读取了 balance-余额,又同时修改了 balance,但双方没有任何沟通,完全不知道对方做了什么,最后 balance 肯定错得一塌糊涂。

可见性问题

再来看原子性问题,Java是一门高级编程语言,一条语句往往会被拆成多个 CPU 指令。比如说,第八行代码 this.balance -= amt; 就被拆成:

  1. 读取内存,把 balance 加载到 CPU;
  2. CPU 执行 balance - amt 操作;
  3. 把最终的 balance 写入内存;

这本来是一个完整的过程,可计算机有一个线程切换的机制,一旦发生了线程切换,那结果也就没法保证了。

原子性问题

关于可见性、原子性问题,可以看下以前的文章:Java并发编程-并发根源,里面有更详细的分析。

总结一下,当多个线程同时访问、并修改一个共享数据,会导致数据竞争,从而出现并发BUG。而数据竞争要满足两个条件:多线程、修改一个共享数据,只要筹齐这两个条件,你就得采取防护措施了。

至于要采取什么防护措施,可以参考这篇文章:Java并发编程-解决并发

有竞态条件的代码,必须要实现互斥

所谓竞态条件,是指程序的执行结果,会随着线程的执行顺序而变化。这听起来有点拗口,我们还是来看一个实际的例子吧。

在提现操作中,有一个条件判断:提现金额不能大于账户余额。但如果同时出现好几笔提现,又没做任何预防措施,就会出现超额提现的问题。

class Account {
    // 余额
    private Integer balance = 150;

    // 提现
    void withdraw(Integer amt) {
        if (balance >= amt) {
            this.balance -= amt;
        }
    }
}

比方说,账户A 只有 150 块,但线程一、线程二都要提现 100 块。那正常来说,只有一笔转账能成功。

可如果线程一、线程二同时执行到第 7 行 if (balance >= amt),它们都发现提现金额是 100 块,小于账户余额 150 块,于是两笔提现都继续执行,你白白亏了 50 块吗?

看到这儿,相信你大概能明白什么是竞态条件。简单来说,你得特别留意这样的代码:

if (状态变量 满足 执行条件) {
    状态变量 = new 状态变量
}

而且,竞态条件非常特殊,没法做简单的归类。它既不是原子性问题,也不是可见性问题,更不是有序性问题,纯粹是因为程序不支持并发访问,必须一个个排队处理。

既然这样,我们只能采用互斥这种方案了,具体来说,就是:锁。你可以回顾一下这两篇文章:Java并发编程-解决并发Java并发编程-用锁的正确姿势

写在最后

并发编程是一个打怪升级的过程,里面有 3 个关卡:安全性问题、活跃性问题、性能问题。

第一关就是安全性问题,是指程序有没有按照我们的期待执行。

第一关其实不难,我们只要注意:程序会不会出现数据竞争、竞态条件,然后做好防护措施就行,你可以回顾一下这些文章:Java并发编程-解决并发Java并发编程-用锁的正确姿势


JerryWu
73 声望292 粉丝