以前说锁和被保护的资源是1:N的关系,这些被保护的资源有可能是彼此没关联的,也有可能彼此关联。那么有什么不同呢?死锁到底是怎么产生的?死锁该如何规避?
保护没有关联关系的多个资源
由于这些资源彼此没关系,我们可以把他们全都塞进this这把锁里一了百了,我们也可以给不同的资源加不同的锁分而治之。总的来说并行分而治之的性能肯定要比串行的同一把锁好的多。分而治之的这种锁也叫细粒度锁
保护有关联关系的多个资源
有关联的资源也可以像没关联的资源那样加锁吗?
下面我们举个栗子:
1.有A、B、C三个银行账户,账户余额都为200
2.A给B转账100,B给C转账100
如果我们不加锁:
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
这样的话就不存在互斥,不存在原子性操作,会有并发问题产生。
那么我们就加锁,首先将所有资源都塞到一个对象锁里面去试试:
class Account {
private int balance;
// 转账
synchronized void transfer(Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
两个相关联的资源都在当前对象锁的临界区,但是我们发现锁住当前对象只能锁住当前对象的变量balance 无锁住target对象的balance。因为对象锁的概念就是,要进入对象锁的代码块就首先要拿到该对象的锁。
线程1拿到了A账户的对象锁,它开始对A出账,对B入账;线程2拿到可B的对象锁,它开始对B出账对C入账。所以两个线程同时对B账户进行了操作:
我们期望转账后A、B、C的余额分别是100 200 300。
但事实上B账户的余额数值100 200 300这三种结果都会有,取决于两个线程谁先把B的结果写入内存。
既然对象锁不行那就加类锁试试:
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
类锁的好处就是临界区的操作绝对是唯一的,所有该类的对象操作都用同一把锁,这样就能保证不管多少线程,能进入临界区对B账户操作的只有。
但是,想象一下如果有上亿个账户都这样傻瓜式的顺序执行,那该有多慢。我们之前提到细粒度的锁性能会更好一点,账户A转账账户B,账户C转账账户D是不存在互斥的,是可以同时进行的。我们知足要给转入账户和转出账户加上锁就OK,没必要加全局的锁。
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
// 锁定转出账户
synchronized(this) {
// 锁定转入账户
synchronized(target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
当前对象的锁保护了this.balance ,target对象的锁保护了target.balance细粒度化了两个操作实现了可并行操作。但是看似美好的事物背后都藏着陷阱,编程也一样。
就拿上面这个例子来看,假如A要给B转账,B恰好也要给A转账,没毛病,可以并行。但是问题就出在,两个线程一开始就把当前对象this锁了,也就是把彼此的资源balance家里锁。A等B给它余额信息,同时B也在等A给他余额信息。然后就是互不让步,就是瞎等。这样就形成了一个无解的闭环,很坏的关系:死锁。
如何防止死锁呢?
最简单粗暴的解决方法就是重启,但这样治标不治本,遇到类似情况一样白瞎。所以说最好的办法是规避它,在设计之初就考虑到它的存在。
如何解决死锁问题,我们站在巨人的肩头,所以巨人们已有定论。有个叫 Coffman 的牛人早就总结过了死锁发生的条件:
1.互斥,共享资源 X 和 Y 只能被一个线程占用;
2.占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
3.不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
4.循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
如何解决,总结一句话就是破而后立,僵持中总有人要让步,破坏形成死锁的条件。
之前说过,要保持原子性操作,就要互斥。所以条件1,我们是没办法破坏的。那么剩下的解决办法就有:
1. 破坏占用且等待条件
一个线程一次性申请所有的资源。上面的例子,如果我们一次性申请到A B两个账户的余额信息,交给一个管理员T管理申请,如果A和B的账户余额信息缺一个都表示申请失败。那么A转账B 和B转账A就互斥了,也就不存在死锁了;
分布式事务貌似就可以这样实现
2. 破坏不可抢占条件
从字面上看就知道,如果抢占不到共享资源,让它释放线程占用的资源。然而synchronized是做不到这一点的,它抢占不到资源就会进入阻塞状态,不会释放线程占用资源的。
当然它做不到,不等于别人做不到,岂不小看了天下英雄。至少java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的。
我们经常会遇到说synchronized和Lock的区别,这就是其中一个。
3. 破坏循环等待条件
要破坏循环等待条件,就是那个无解的闭环,线程1占有A的资源,线程2占有B的资源。不妨给A和B的账户资源排个序。再让线程去申请资源。这样如果资源A排在前头,线程1 申请到了A的资源,加了锁后线程2就无法进入资源A锁的临界区,也就不可能去占用B的资源了。如下面代码:
class Account {
private int id;
private int balance;
// 转账
void transfer(Account target, int amt){
Account left = this ①
Account right = target; ②
if (this.id > target.id) { ③
left = target; ④
right = this; ⑤
} ⑥
// 锁定序号小的账户
synchronized(left){
// 锁定序号大的账户
synchronized(right){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
总结:
细颗粒的锁存在死锁的风险,要根据情况去规避。
破坏死锁的几个条件,酌情应用。
原子性的本质并非不可分割,而是要保持操作的中间状态的不可见性。例如账户转账的例子:
A转账B ,A减少100的时候B还没有变,这个就是中间状态。还没有操作完成的变量,要保持对外的不可见性。