在使用多线程,如果有共享变量被多个线程读写,可能会产生线程安全问题。如果发生线程切换,可能会产生性能问题。实际上,在使用多线程,可能还会产生死锁,活锁,饥饿锁等线程活跃性问题。
死锁
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
我们用Account.class作为互斥锁,来解决银行业务里面的转账问题,虽说这个方法不存在并发问题,但是所有账户的操作都是串行的,导致性能太差。
我们需要两把锁,一把锁锁住this,另一把锁锁住target
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
// 锁定转出账户
synchronized(this) { //1
// 锁定转入账户
synchronized(target) { //2
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
这样账户A转账户B,账户C转账户D就可以并行了。
我们使用细粒度锁,可以提高并行度,是性能优化的一个重要手段。但是有可能会导致死锁。
死锁的定义
一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
上述代码如何发生死锁?
我们假设线程T1执行账户A转账户B的操作,同时线程T2执行账户B转账户A的操作。当T1和T2同时执行到①处的代码时,T1获得了A的锁,T2获得了B的锁。之后T1和T2在执行②时,T1试图获取B的锁时发现,B已经被T2锁定了,所以T1开始等待,T2则试图获取A的锁时,发现A已经被T1锁定,所以T2也开始等待。于是T1和T2会无线等待下去,导致死锁。
上述代码还算比较简单,能够很快分析出死锁的发生原因,但在实际坏境中,死锁的检测并非这么简单,会出现非常复杂的情况,那么计算机就需要一定的算法来检测死锁。一个比较简单的方法就是有向图+DFS算法来检测有没有环,如果有环,则存在发生死锁的可能,如果没有则不会发生死锁。
juc并没有实现死锁检测,但是可以通过jstack等java工具来把线程的运行状态打印出来。
发生死锁的条件
- 互斥,共享资源X和Y只能被一个线程占用;
- 占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
- 不可抢占,其他线程不能强行抢占线程T1占用的资源;
- 循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,这就是循环等待。
如何预防死锁
上述代码有个比较简单预防死锁的做法,就是使用类锁,但类锁粒度太大,会导致转账业务均无法并发执行,严重影响转账业务的执行效率。
我们只要破坏其中一个发生死锁的条件,就可以成功避免死锁的发生。
- 对于占有且等待条件,我们可以一次性申请所有的资源,这样就不存在等待了。
- 不可抢占,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
- 循环等待,可以靠按序申请资源来预防。所谓按序申请,是指资源时有线性顺序的,申请时可以先申请资源序号小的,再申请资源序号大的。
破坏占有且等待条件
转账操作,需要两个资源:1.转出账户,2.转入账户。我们可以增加一个账本管理员,然后只允许管理员管理账本的转入转出。
class Allocator {
private List<Object> als =
new ArrayList<>();
// 一次性申请所有资源
synchronized boolean apply(
Object from, Object to){
if(als.contains(from) ||
als.contains(to)){
return false;
} else {
als.add(from);
als.add(to);
}
return true;
}
// 归还资源
synchronized void free(
Object from, Object to){
als.remove(from);
als.remove(to);
}
}
class Account {
// actr 应该为单例
private Allocator actr;
private int balance;
// 转账
void transfer(Account target, int amt){
// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))
;
try{
// 锁定转出账户
synchronized(this){
// 锁定转入账户
synchronized(target){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
} finally {
actr.free(this, target)
}
}
}
破坏不可抢占条件
synchronized做不到,lock可以做到。
void transfer3(Account target, double amt) {
while(true){
lock1.lock();
try {
boolean tryLock = lock2.tryLock();
try {
if (tryLock && (this.balance > amt)) {
this.balance -= amt;
target.balance += amt;
}
return;
} finally {
lock2.unlock();
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
lock1.unlock();
}
}
}
破坏循环等待条件
我们假设每个账户都有不同的id,这个id可以作为排序字段,申请的时候,我们可以按照从小到大的顺序申请。
1-6处的代码对转出账户this,和转入账户target排序。然后按照序号从小到大的顺序锁定账户。
class Account {
private int id;
private int balance;
// 转账
void transfer(Account target, int amt){
Account left = this //1
Account right = target; //2
if (this.id > target.id) { //3
left = target; //4
right = this; //5
} //6
// 锁定序号小的账户
synchronized(left){
// 锁定序号大的账户
synchronized(right){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
活锁
预防死锁的方式二(破坏不可抢占的条件)存在问题。
线程t1和t2一直循坏加锁,尝试加锁,释放锁,我们把这种情况叫做活锁。
死锁与活锁的区别:处于死锁状态的两个线程均处于阻塞状态,不消耗cpu资源,但活锁线程处于一直循坏加锁,尝试加锁,释放锁的状态,非常消耗cpu资源。
预防活锁的方法也很简单,只需要调用tryLock的超时方法,随机sleep一段时间即可。
lock1.lock();
Random r = new Random();
try {
boolean tryLock = false;
try {
tryLock = lock2.tryLock(r.nextLong() % 10, TimeUnit.MILLISECONDS);
} catch (Exception e) {
//
}
try {
if (tryLock && (this.balance > amt)) {
this.balance -= amt;
target.balance += amt;
}
} finally {
lock2.unlock();
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
lock1.unlock();
}
饥饿锁
死锁和活锁是两个或者两个以上线程的整体状态,饥饿则是一个线程的状态,死锁和活锁都会导致线程处于饥饿状态,一直获取不到锁。
非公平锁由于新来的线程不需要排队,就可以竞争锁,所以排队中的线程就有可能一直竞争不到锁,就会出现饥饿现象。
自旋+CAS也有可能一直无法成功执行CAS,导致一直自旋,从而出现饥饿现象。