主要有三个问题:
安全性问题,活跃性问题,性能问题。
首先安全性问题,表现是怎么样的呢?我们写并发程序时,希望程序按照我们期望的执行顺序来执行,但是他们都没按照我们期望的方式运行,那么怎样才能写出线程安全的程序呢?先来讨论一下引起并发程序bug的三个源头:
原子性,可见性,和有序性。
适用于一条规则就是,存在共享数据并且该数据会发生变化,有多个线程会同时读写一个数据。那面对需要多个线程共享的数据的情况下,怎么保证安全性呢?
于是引入了一个数据竞争的概念。即多个线程同时读写同一数据的情况,会产生数据竞争。可能通常情况下,认为通过加锁就能解决并发问题,其实这是片面的。
数据竞争的名字叫竞态条件,指的是程序的执行结果依赖线程执行的顺序。
demo:
class Account {
private int balance;
// 转账
void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
当2个线程同时执行transfer方法时,并发进入,同时判断if条件,2个线程同时满足条件,就会发现各自线程当前都读取了this.balance于是都会执行下列转账操作,于是导致错误。
这类竞态条件的一种通用形式类似:
if (状态变量 满足 执行条件) {
执行操作
}
再来一个demo:
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)
}
}
}
给get,set方法加上 synchronized关键字,保证串行化。当2个线程同时执行add10K方法时,进入到get()+1.虽然set.get方法保证了串行化,但是2个线程分别得到了同一个get()+1值的可能性还是有的。于是满足了这样其中一个线程执行了一次set方法后,第二个线程再执行set方法就会把第一个线程的值给覆盖掉。
怎么解决这个方法呢,继续加锁,只不过锁的范围控制好竞态条件即可。
活跃性问题。指某个操作无法执行下去,常见的死锁就是一种典型的活跃性问题,除了死锁外,分别是活锁和饥饿。
活锁:有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的活锁。
即线程双方主动放弃资源后,最后同时竞争,还是同时阻塞。
解决办法:设置等待一个随机的时间,来降低发生竞争的概率。
饥饿:指的是线程因无法访问所需资源而无法执行下去的情况。
如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
解决饥饿方法:
一保证资源充足,二公平的分配资源,三就是避免长时间持有锁的线程的执行。三个方案中,方案一和方案三适用场景比较有限。那怎么实现方案二呢,使用Java里的公平锁即可。
性能问题:
使用锁,除了小心安全性,和活跃性,还要注意使用锁带来的性能开销。如果锁的范围过大,那么就会带来性能不佳。假设串行百分比是 5%,使用多核多线程能提升多少性能呢?20倍。阿姆达尔(Amdahl)定律告诉我们。
那么解决办法是什么:
一使用锁带来性能问题,那么就使用无锁的算法和数据结构,线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好……
二减少锁持有的时间,互斥锁本质上是将并行的程序串行化,增加性能,减少锁的执行时间,那么其它线程就会拥有更多的执行的机会,实现的技术例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。