并发编程中的安全性、活跃性以及性能问题
安全性
并发bug三大源头
源头
- 原子性问题
- 可见性问题
- 有序性问题
bug风险点
存在共享数据并且该数据会发生变化(即多个线程会同时读写同一数据)
分类
-
数据竞争
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据。假设 count=0,当两个线程同时执行 get() 方法时,get() 方法会返回相同的值 0,两个线程执行 get()+1 操作,结果都是 1,之后两个线程再将结果 1 写入了内存。你本来期望的是 2,而结果却是 1。
-
竞态条件
当多个线程或进程同时访问并试图改变同一份数据时,由于执行顺序的问题,导致程序行为变得不可预测。
竞争条件可能导致数据的不完整、不准确或不可靠。
在编程中,解决竞争条件的方法包括使用锁、原子操作、信号量等同步机制,以确保在任何时候只有一个线程或进程可以访问共享数据。
存在竞态条件的例子:public class Test { private long count = 0; synchronized long get(){ return count; } synchronized void set(long v){ count = v; } void add10K() { int idx = 0; while(idx++ < 10000) { set(get()+1); //set和get联合使用并非原子操作,存在竞态条件 } } }
假设 count=0,如果两个线程完全同时执行,那么结果是 1;如果两个线程是前后执行,那么结果就是 2。
活跃性
指某个操作无法执行下去,存在原因:死锁、活锁、饥饿。
死锁
线程会互相等待,而且会一直等待下去。
同时满足以下条件则发生死锁:
- 资源互斥
- 持有并等待
- 循环等待
- 资源不可剥夺
针对不同条件可以考虑对应措施:
-
持有并等待
一次性申请所有的资源,这样就不存在等待 -
循环等待
资源按序申请 -
资源不可剥夺
占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
活锁
线程间资源冲突激烈,引起线程不断的尝试获取资源,不断的失败。活锁有可能自己解开。
解决方案:等待一个随机时间。
饥饿
线程因无法访问所需资源而无法执行下去的情况。
在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
3种解决方案:
- 保证资源充足
- 公平分配资源
- 避免持有锁的线程长时间执行
方案1和3的适用场景有限,一般2多点,如公平锁。
公平锁:先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源
性能问题
“锁”的过度使用可能导致串行化的范围过大,降低性能。
阿姆达尔(Amdahl)定律:处理器并行运算之后效率提升的能力
n 可以理解为 CPU 的核数,p 可以理解为并行百分比,那(1-p)就是串行百分比。
假设 CPU 的核数(也就是 n)无穷大,那加速比 S 的极限就是 20。
性能解决方案
-
使用无锁的算法和数据结构
如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列 -
减少锁持有的时间
互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap(jdk1.7及以下),它使用了所谓分段锁的技术;还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。
性能指标
-
吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
-
延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
-
并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。