并发编程中我们需要注意的问题有很多,主要有三个问题:
1、安全性问题:可见性,同步机制;
2、活跃性问题:死锁,活锁;
3、性能问题:线程的上下文切换;
1.安全性问题
什么时候会出现线程安全?存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。
对此还有一个专业的术语,叫做数据竞争(Data Race)。当多个线程调用时候就会发生数据竞争,如下所示:
public class Test {
private int count = 0;
public void add() {
int idx = 0;
while (idx ++ <100) {
count +=1;
}
}
}
对于共享变量count增加两个被 synchronized 修饰的 get() 和 set() 方法,add() 方法里面通过 get() 和 set() 方法来访问 count变量,修改后的代码如下所示。对于修改后的代码,所有访问共享变量 count的地方,我们都增加了互斥锁,此时是不存在数据竞争的。但很显然修改后的 add() 方法并不是线程安全的。
public class Test {
private int count = 0;
synchronized int get() {
return count;
}
synchronized void set(int v) {
count = v;
}
public void add() {
int idx = 0;
while (idx++ < 100) {
set(get()+1);
}
}
}
假设 count=0,当两个线程同时执行 get() 方法时,get() 方法会返回相同的值 0,两个线程执行 get()+1 操作,结果都是 1,之后两个线程再将结果 1 写入了内存。你本来期望的是 2,而结果却是 1。
这是因为set(get()+1)的执行等价于三行指令:
- int tmp = get();
- tmp = tmp + 1;
- set(tmp);
当两个线程先后都通过get()方法获取count的值为0时,再各自的栈中执行+1操作,然后先后执行set(1)写入内存,就会造成错误的结果,分析思路和volatile变量++差不多。这种问题,有个官方的称呼,叫竞态条件(Race Condition)。所谓竞态条件,指的是程序的执行结果依赖线程执行的顺序。
下面再结合一个例子来说明下竞态条件,转账操作里面有个判断条件——转出金额不能大于账户余额,但在并发环境里面,如果不加控制,当多个线程同时对一个账号执行转出操作时,就有可能出现超额转出问题。假设账户 A 有余额 200,线程 1 和线程 2 都要从账户 A 转出 150,在下面的代码里,有可能线程 1 和线程 2 同时执行到//line 6,这样线程 1 和线程 2 都会发现转出金额 150 小于账户余额 200,于是就会发生超额转出的情况。
class Account {
private int balance;
// 转账
void transfer(
Account target, int amt){
if (this.balance > amt) { //line 6
this.balance -= amt;
target.balance += amt;
}
}
}
在并发场景中,程序的执行依赖于某个状态变量,也就是类似于下面这样:
if (状态变量 满足 执行条件) {
执行操作
}
当某个线程发现状态变量满足执行条件后,开始执行操作;可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。当然很多场景下,这个条件不是显式的,例如前面 add 的例子中,set(get()+1) 这个复合操作,其实就隐式依赖 get() 的结果。那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?其实这两类问题,都可以用互斥这个技术方案,而实现互斥的方案有很多,CPU 提供了相关的互斥指令,操作系统、编程语言也会提供相关的 API。从逻辑上来看,我们可以统一归为:锁。
2.活跃性问题
所谓活跃性问题是指某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。
死锁:线程永久地阻塞了;
活锁: 线程没有发生阻塞,但仍然执行不下去 ;
可以类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。这种情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。
解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。例如上面的那个例子,路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。
-
饥饿: 所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。“不患寡,而患不均”,如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
解决“饥饿”问题有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
3.性能问题
使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
- 特定场景下,使用无锁的算法和数据结构
- 减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间
并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。
参考自王宝令老师Java并发编程实战