前言
这篇文章主要从三个方面谈一谈并发编程。分别是为什么要用到并发?凡是总有好坏两面,之间的trade-off是什么,也就是说并发编程具有哪些缺点?以及在进行并发编程时应该了解和掌握的概念是什么?
一、为什么要用到并发编程?
在多核CPU的背景下,催生了并发编程的趋势,通过并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升。
在特殊的业务背景下先天的就适合于并发编程。比如在图像处理领域,一张1024*768像素的图片,包含达到78万6千多个像素。即时地将所有的像素都遍历一遍需要很长的时间,面对如此复杂的计算量就需要充分利用多核CPU的计算能力。又比如当我们在网上进行购物时,为了提升响应的速度,需要拆分,减库存,生成订单等等这些操作,就可以进行拆分利用多线程的技术完成。面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分,正是因为这些优点,使得多线程技术能够得到重视,也是一名CS学习者应该掌握的:
- 充分利用多核CPU的计算能力
- 方便进行业务拆分,提升应用性能
二、并发编程有哪些缺点
多线程技术有那么多的好处,难道就没有一点缺点么?在任何场景下就一定适用么?很显然不是。
1.频繁的上下文切换
时间片是CPU分配给各个线程的时间,因为时间非常短,所以CPU不断通过切换线程,让我们觉得多个线程是同时进行的,时间片一般是几十毫秒,而每次切换时,需要把当前状态保存起来,以便能够进行恢复先前状态,而这个切换时非常耗损性能,过于频繁反而无法发挥出多线程编程的优势。通常减少上下文切换可以采用无锁并发编程,CAS算法,使用最少的线程和使用协程。
- 无锁并发编程:可以参照concurrentHashMap锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。
- CAS算法:利用Atom下使用CAS算法来更新数据,使用了乐观锁,可以有效的减少一部分不必要的锁竞争带来的上下文切换。
- 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多的线程,这样会造成大量的线程处于等待状态。
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
由于上下文切换也是相对比较耗时的操作,所以在“Java并发编程的艺术”一书中有过一个实验,并发累加未必会比串行累加速度要快。可以使用Lmbench3测量上下文切换的时长vmstat测量上下文切换次数。
2.线程安全
多线程编程中最难以把握的就是临界区线程安全的问题,稍微不注意就会出现死锁的情况,一旦产生死锁就会造成系统功能不可用。
public class DeadLockDemo {
private static String resource_a = "A";
private static String resource_b = "B";
public static void main(String[] args) {
deadLock();
}
public static void deadLock() {
Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
synchronized (resource_a) {
System.out.println("get resource a");
try {
Thread.sleep(3000);
synchronized (resource_b) {
System.out.println("get resource b");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
synchronized (resource_b) {
System.out.println("get resource b");
synchronized (resource_a) {
System.out.println("get resource a");
}
}
}
});
threadA.start();
threadB.start();
}
}
在上面的这个demo中,开启了两个线程threadA,threadB,其中threadA占用了resource_a,并等待被threadB释放的resource_b。threadB占用了resource_b正在等待被threadA释放的resource_a。因此threadA,threadB出现线程安全问题,形成死锁。同样可以通过jps,jstack证明这种推论。
"Thread-1":
waiting to lock monitor 0x000000000b695360 (object 0x00000007d5ff53a8, a java.lang.String),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x000000000b697c10 (object 0x00000007d5ff53d8, a java.lang.String),
which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================
"Thread-1":
at learn.DeadLockDemo$2.run(DeadLockDemo.java:34)
- waiting to lock <0x00000007d5ff53a8(a java.lang.String)
- locked <0x00000007d5ff53d8(a java.lang.String)
at java.lang.Thread.run(Thread.java:722)
"Thread-0":
at learn.DeadLockDemo$1.run(DeadLockDemo.java:20)
- waiting to lock <0x00000007d5ff53d8(a java.lang.String)
- locked <0x00000007d5ff53a8(a java.lang.String)
at java.lang.Thread.run(Thread.java:722)
Found 1 deadlock.
如上所述,完全可以看出当前死锁的情况。
那么,通常可以用如下方式避免死锁的情况:
- 避免一个线程同时获得多个锁;
- 避免一个线程在锁内部占有多个资源,尽量保证每个锁只占用一个资源;
- 尝试使用定时锁,使用lock.tryLock(timeOut),当超时等待时当前线程不会阻塞;
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
所以,如何正确的使用多线程编程技术有很大的学问,比如如何保证线程安全,如何正确理解由于JMM内存模型在原子性,有序性,可见性带来的问题,比如数据脏读,DCL等这些问题(在后篇会叙述)。而在学习多线程编程技术的过程中也会让你收获颇丰。
三、应该了解的概念
1.同步和异步
同步和异步通常用来形容一次方法的调用。同步方法调用一开始,调用者必须等待被调用的方法结束后,调用者后面的代码才能执行。而异步调用,指的是,调用者不用管被调用方法是否完成,都会继续执行后面的代码,当被调用的方法完成后会通知调用者。比如,在超市购物,如果一件物品没了,你得等仓库人员跟你调货,直到仓库人员跟你把货物送过来,你才能继续去收银台付款,这就类似同步调用。而异步调用了,就像网购,你在网上付款下单后,什么事就不用管了,该干嘛就干嘛去了,当货物到达后你收到通知去取就好。
2.并发与并行
并发和并行是容易混淆的概念,并发指的是多个任务交替进行,而并行则是指真正意义上的”同时进行“。实际上,如果系统内只有一个CPU,而使用多线程时,那么真实系统环境下不能并行,只能通过切换时间片的方式交替进行,而成为并发执行任务。真正的并行也只能出现在拥有多个CPU的系统中。
3.阻塞和非阻塞
阻塞和非阻塞通常用来形容多线程之间的相互影响,比如一个线程占有了临界区资源,那么其他线程需要这个资源就必须进行等待该资源的释放,会导致等待的线程挂起,这种情况就是阻塞,而非阻塞就恰好相反,它强调没有一个线程可以阻塞其他线程,所有的线程都会尝试地往前运行。
4.临界区
临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每个线程使用时,一旦临界区资源被一个线程占有,那么其他线程必须等待。