通过前面的 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;
就被拆成:
- 读取内存,把 balance 加载到 CPU;
- CPU 执行 balance - amt 操作;
- 把最终的 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并发编程-用锁的正确姿势
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。