文章目录
并发编程旨在通过多线程执行任务来提高程序的运行效率,但实现并发并不总是能带来预期的速度提升。多线程的执行效率受到多个因素的影响,主要包括上下文切换、死锁和硬件/软件资源限制等问题。本章介绍了这些挑战以及相应的解决方案。
1.1上下文切换
(1)上下文切换的概念
-
定义:上下文切换是指CPU在不同线程之间切换执行时,需要保存当前线程的状态,并加载下一个线程的状态。
-
原理:单核处理器通过时间片(几十毫秒)分配CPU时间,使得多个线程看起来像是并行执行的。上下文切换会带来性能开销,因为每次切换都需要保存和恢复线程的上下文。
例子:通过读两本书来类比,类似于在读一本英文技术书时,每次查字典时都需要记住当前进度并切换任务,这种切换会影响效率。
(2)多线程一定比单线程快吗?
- 测试代码:比较并发和串行执行相同的累加任务的时间。
public class ConcurrencyTest {
private static final long count = 10000l;
public static void main(String[] args) throws InterruptedException {
concurrency();
serial();
}
private static void concurrency() throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
}
});
thread.start();
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis() - start;
thread.join();
System.out.println("concurrency :" + time+"ms,b="+b);
}
private static void serial() {
long start = System.currentTimeMillis();
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis() - start;
System.out.println("serial:" + time+"ms,b="+b+",a="+a);
}
}
测试结果:
- 结论:并发执行不一定比串行快,特别是任务次数较少时。原因是线程的创建和上下文切换的开销。测试表明,当任务量不够大时,线程的管理和切换反而可能让程序变慢。
(3)测量上下文切换
-
使用工具:
- Lmbench3:测量上下文切换的时长。
- vmstat:测量上下文切换的次数。
示例命令:
vmstat 1
输出中的 CS(Context Switches)表示每秒的上下文切换次数。
如何减少上下文切换
解决方案:
- 无锁并发编程:避免锁竞争,减少上下文切换。通过设计合适的数据分割和线程划分,减少锁的使用。
- CAS(Compare And Swap)算法:Java的
Atomic
包通过CAS实现无锁的并发操作。 - 使用最少的线程:避免创建不必要的线程,避免过多线程造成的上下文切换。
- 协程:通过协程在单线程中实现任务切换,从而避免多线程的上下文切换开销。
1.2 死锁
(1)死锁的定义
-
死锁是指两个或多个线程互相等待对方释放锁,从而导致无法继续执行。
示例代码:
public class DeadLockDemo { private static String A = "A"; private static String B = "B"; public static void main(String[] args) { new DeadLockDemo().deadLock(); } private void deadLock() { Thread t1 = new Thread(new Runnable() { @Override public void run() { synchronized (A) { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (B) { System.out.println("1"); } } } }); Thread t2 = new Thread(new Runnable() { @Override public void run() { synchronized (B) { synchronized (A) { System.out.println("2"); } } } }); t1.start(); t2.start(); } }
代码中的
Thread-1
和Thread-2
相互等待对方的锁,造成死锁。
(2)死锁产生的四个必要条件
- 互斥(Mutual Exclusion):资源只能被一个线程或进程占有,且其他线程或进程必须等待。例如,一个线程占用某个锁,其他线程就无法访问该资源,直到锁被释放。
- 占有且等待(Hold and Wait):线程或进程已经持有某些资源,同时又请求其他资源,而这些资源当前被其他线程或进程占用。
- 不可剥夺(No Preemption):已经分配给线程或进程的资源,在没有释放之前不能被其他线程或进程强行抢占。只有线程或进程自己释放资源,其他线程才能获得资源。
- 循环等待(Circular Wait):存在一个资源的等待链,其中每个线程或进程都在等待下一个线程或进程所持有的资源,形成一个闭环。
(2)死锁的解决方法
- 破坏循环等待条件
资源的顺序分配
:最常见的解决方法是对资源加锁时,按照一定的顺序来申请资源,避免出现循环等待。例如,为每个资源分配一个唯一的编号,然后线程总是按照资源编号的顺序来申请资源。如果线程按顺序申请资源,就不会出现循环等待的情况。 - 破坏占有且等待条件
一次性申请所有资源
:要求线程在执行时一次性申请它需要的所有资源,而不是在持有一部分资源时,再去申请其他资源。这样可以避免线程在持有部分资源的同时等待其他资源,从而避免占有且等待条件。 - 破坏非抢占条件
抢占资源
:当线程申请资源失败时,系统可以强制剥夺线程持有的资源并将其返回给资源池。被抢占的线程可以在稍后重新尝试获取资源。这种方法通过破坏非抢占条件来避免死锁。 - 破坏互斥条件
使用共享资源
:通过将资源的互斥性降低,即允许多个线程共享资源,来避免死锁。比如,对于读写操作,可以使用 读写锁,使得多个线程可以同时读取共享资源,但写操作仍然是独占的。此方法只适用于资源可以共享的场景,通常是读取操作较多的情况。 - 银行家算法
详情可见 银行家算法:死锁避免的经典策略
1.3 资源限制的挑战
(1)资源限制的定义
资源限制是指硬件或软件资源(如带宽、CPU、内存、数据库连接数等)的限制,可能会影响程序并发执行的效果。资源限制可能导致并发执行时,程序反而执行更慢。
(2)资源限制引发的问题
- 如果并发的代码段依赖于硬件或软件资源,过多的线程会使得程序反而变慢。例如:
- 带宽限制:如果有多个线程同时下载文件,而带宽有限,线程多了反而会因为等待带宽而导致执行时间变长。
- 数据库连接数限制:如果数据库的连接数有限,而线程数过多,则会因为线程被阻塞等待连接,导致程序性能下降。
(3)解决资源限制的方法
- 硬件资源限制:使用集群并行执行任务,分布式计算框架如Hadoop、ODPS等可以有效利用多台机器的资源。
- 软件资源限制:使用资源池技术,复用数据库连接池或Socket连接池。
(4)在资源限制下的并发编程
根据资源限制调整并发度。例如,在文件下载程序中,既依赖带宽又依赖硬盘读写速度。如果带宽有限,增加过多线程不会加速下载,反而可能导致过度的上下文切换,增加执行时间。