1. 安全性问题
线程安全:程序按照我们的期望执行,不出现意外情况。实际上只有一种情况需要分析:存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。那如果能够做到不共享数据或者数据状态不发生变化,不就能够保证线程的安全性了嘛。有不少技术方案都是基于这个理论的,例如线程本地存储(Thread Local Storage,TLS)、不变模式等
数据竞争(data race): 多个线程同时访问同一数据,并且至少有一个线程会写这个数据.
竞态条件(Race Condition): 程序的执行结果依赖线程的执行顺序。如下:
if (状态变量 满足 执行条件) {
执行操作
}
当某个线程发现状态变量满足执行条件后,开始执行操作;可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。
2. 活跃性问题
指的是某个操作无法执行下去,包括死锁,活锁和饥饿。
1. 活锁
线程没有发生阻塞,但依然执行不下去。形象比喻:甲从左手边出门,乙从右手边进门,为了不碰撞,互相谦让,甲从右手边出门,乙从左手边出门,两人还是碰撞了。
解决方式:尝试等待一个随机的时间。
2. 饥饿
指的是线程因无法访问所需资源而无法执行下去的情况。
解决方式:使用公平锁,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
3. 性能问题
锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力:
- n , CPU 的核数;
- p ,并行百分比,(1-p),串行百分比了;
- 假设(1-p)是 5%,n无穷大,加速比 S 的极限就是 20。也就是说,如果我们的串行率是 5%,那么我们无论采用什么技术,最高也就只能提高 20 倍的性能。
解决方案:
-
第一,既然锁带来性能问题,最好是使用无锁的算法和数据结构,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好……
-
第二,减少锁持有的时间。分段锁,读写锁。
性能指标:
- 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
- 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
- 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。
4. 总结
并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。
我们在设计并发程序的时候,主要是从宏观出发,也就是要重点关注它的安全性、活跃性以及性能。安全性方面要注意数据竞争和竞态条件,活跃性方面需要注意死锁、活锁、饥饿等问题,性能方面我们虽然介绍了两个方案,但是遇到具体问题,你还是要具体分析,根据特定的场景选择合适的数据结构和算法。
5. 课后思考
Java 语言提供的 Vector 是一个线程安全的容器,有同学写了下面的代码,你看看是否存在并发问题呢?
void addIfNotExist(Vector v, Object o){
if(!v.contains(o)) { //1
v.add(o); // 2
}
}
我的思考:虽然contains()和add()两个方法都加锁了,但是存在竞态问题。AB两个线程先后执行了代码1,但还为能执行代码2,假设此时线程A执行了代码2,显然线程B再执行代码2的时候就有问题。
单个方法是原子性的,但是contains()和add()组合起来不一定是原子性的。
l