互斥锁
我们知道,互斥是解决并发问题的两大核心手段之一,所谓互斥指的是同一时刻,只允许一个线程访问共享变量,而互斥的实现方式主要是通过锁机制,所以也叫互斥锁。
这里借助银行业务里面的转账操作来理解锁和资源的对应关系,并给出相应的关于互斥锁的代码实现,从而指导我们如何真正用好互斥锁。
假设有两个银行账户需要互相转账,比如账户A减少100元,账户B增加50元。转账相关的初始代码如下,针对这个代码操作如何实现资源的保护避免多线程下的安全性问题呢?
java
class Account {
private int balance;
// 转账
void transfer(
Account target, int amt) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
有关联关系的资源如何加锁
首先,这两个账户就是有关联关系的,如果要保护有关联关系的多个资源,可以共享同一把锁,使得锁能覆盖所有受保护资源。 所以账户A和B需要用同一个对象lock共享同一把锁,我们可以创建一个实例对象,在每次创建Account实例时都传入这个对象,这样不同的Account实例就持有同个对象可以作为同一把锁
java
public class Account {
private Object lock;
private int balance;
private Account() {}
// 创建Account时传入同一个lock对象
public Account(Object lock) {
this.lock = lock;
}
// 转账
void transfer(Account target, int amt) {
// 此处检查所有对象共享的锁
synchronized(lock) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
public static void main(String[] args) {
Object lock = new Object();
Account a = new Account(lock);
Account b = new Account(lock);
// 初始化账户余额
a.balance = 200;
b.balance = 200;
// 线程1:a 转 b
Thread t1 = new Thread(() - > {
System.out.println("线程1: 准备从 a 转 100 到 b");
a.transfer(b, 100);
System.out.println("线程1: 转账完成");
});
// 线程2:b 转 a
Thread t2 = new Thread(() - > {
System.out.println("线程2: 准备从 b 转 50 到 a");
b.transfer(a, 50);
System.out.println("线程2: 转账完成");
});
// 启动线程
t1.start();
t2.start();
// 等待线程结束(实际会因死锁无法结束)
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("a 的余额: " + a.balance);
System.out.println("b 的余额: " + b.balance);
}
}
然后在实际项目中,创建Account对象的代码很可能分散在多个工程中,传入共享的lock比较困难。用Account.class作为共享的锁是更好的方案
Account.class作为共享的锁。Account.class是所有Account对象共享的,而且这个对象是Java虚拟机在加载Account类的时候创建的,所以具备唯一性
java
public class Account {
private int balance;
public Account() {}
// 转账
void transfer(Account target, int amt) {
// 此处检查所有对象共享的锁
synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
public static void main(String[] args) {
Account a = new Account();
Account b = new Account();
// 初始化账户余额
a.balance = 200;
b.balance = 200;
// 线程1:a 转 b
Thread t1 = new Thread(() - > {
System.out.println("线程1: 准备从 a 转 100 到 b");
a.transfer(b, 100);
System.out.println("线程1: 转账完成");
});
// 线程2:b 转 a
Thread t2 = new Thread(() - > {
System.out.println("线程2: 准备从 b 转 50 到 a");
b.transfer(a, 50);
System.out.println("线程2: 转账完成");
});
// 启动线程
t1.start();
t2.start();
// 等待线程结束(实际会因死锁无法结束)
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("a 的余额: " + a.balance);
System.out.println("b 的余额: " + b.balance);
}
}
综上,对于如何更好使用锁我们有了心得。首先要分析多个资源之间的关系,如果多个资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁。
死锁的产生
上面用Account.class作为互斥锁保护了有关联关系的资源,虽然这个方案不存在并发问题,但是它将所有账户的转账操作都做成了串行,比如账户A 转账户B、账户C 转账户D这两个转账操作现实世界里是可以并行的,但这里却是串行的,这样性能太差
为了提升转账操作的并发度,我们可以使用细粒度锁,一把锁的锁定范围太大,可以拆成两把,这样锁定范围就小很多。在这个转账操作里可以用两把锁分别锁住转出账户和转入账户,只有线程都成功拿到这两把锁时,才执行转账操作。具体代码实现如下:
java
public class Account {
private int balance;
void transfer(Account target, int amt) {
// 锁定转出账户
synchronized(this) {
// 模拟线程调度延迟,增加死锁概率
// try {
// Thread.sleep(10);
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
// 锁定转入账户
synchronized(target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
public static void main(String[] args) {
Account a = new Account();
Account b = new Account();
// 初始化账户余额
a.balance = 200;
b.balance = 200;
// 线程1:a 转 b
Thread t1 = new Thread(() - > {
System.out.println("线程1: 准备从 a 转 100 到 b");
a.transfer(b, 100);
System.out.println("线程1: 转账完成");
});
// 线程2:b 转 a
Thread t2 = new Thread(() - > {
System.out.println("线程2: 准备从 b 转 50 到 a");
b.transfer(a, 50);
System.out.println("线程2: 转账完成");
});
// 启动线程
t1.start();
t2.start();
// 等待线程结束(实际会因死锁无法结束)
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("a 的余额: " + a.balance);
System.out.println("b 的余额: " + b.balance);
}
}
使用细粒度锁可以提高并行度,是性能优化的一个重要手段。 上面的转账方法本来每次只允许一个线程进行转账,但使用细粒度锁后可以允许多个线程访问执行。
然而,如果将上面模拟线程调度延迟的注释代码打开,运行这个程序就会发现这个程序产生了死锁:线程t1获取了Account a对象锁后sleep等待10 毫秒,线程t2获取了Account b对象锁后也sleep等待10 毫秒,接下来线程t1要获取Account b对象锁,线程t2要获取Account a对象,两个线程互相持有对方需要的资源并且不释放,就构成了死锁。
-
死锁的定义
一组互相竞争资源的线程因互相等待,导致"永久"阻塞的现象
-
总结下造成死锁的条件:
- 互斥,共享资源X和Y只能被一个线程占用;
- 占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
- 不可抢占,其他线程不能强行抢占线程T1占有的资源
- 循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待
-
如何预防死锁
如果要避免死锁的发生就要从破坏死锁的条件开始,互斥这个条件没办法破坏,因为这是锁的基本特性,互斥都无法保证那还要使用锁干嘛。
- 破坏占有且等待条件
通过增加一个分配者角色实现资源的一次性申请。这个角色定为Allocator。它有两个重要功能,分别是:同时申请资源apply()和同时释放资源free()。账户Account 类里面持有一个Allocator的单例,当执行转账操作时向Allocator同时申请转出账户和转入账户这两个资源,这样就避免了两个资源不同时被一个线程持有。
js
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);
}
}
public class Account {
private final Allocator actr = new Allocator();
private int balance;
void transfer(Account target, int amt) {
// 一次性申请转出账户和转入账户,直到成功
while (!actr.apply(this, target)) {
try {
// 锁定转出账户
synchronized(this) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 锁定转入账户
synchronized(target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
} finally {
actr.free(this, target);
}
}
}
public static void main(String[] args) {
Account a = new Account();
Account b = new Account();
a.balance = 200;
b.balance = 200;
Thread t1 = new Thread(() - > {
System.out.println("线程1: 准备从 a 转 100 到 b");
a.transfer(b, 100);
System.out.println("线程1: 转账完成");
});
Thread t2 = new Thread(() - > {
System.out.println("线程2: 准备从 b 转 50 到 a");
b.transfer(a, 50);
System.out.println("线程2: 转账完成");
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("a 的余额: " + a.balance);
System.out.println("b 的余额: " + b.balance);
}
}
- 破坏不可抢占条件
破坏这个条件,核心是要能够主动释放它占有的资源 - 破坏循环等待条件
破坏这个条件,可以对资源进行排序,然后按序申请资源。
用"等待-通知"机制优化循环等待
在破坏死锁的占用且等待条件时,我们使用死循环while的方式来进行循环等待条件是否满足。
但在操作耗时长,或者并发冲突量大的时候,循环等待这种方案就不适用了,因为循环上万次才能获取到锁,这太消耗CPU。
所以理想的方案是:如果线程要求的条件不满足,则线程阻塞自己,进入等待状态;当线程要求的条件满足后,通知等待的线程重新执行。其中,使用线程阻塞的方式就能避免循环等待消耗CPU的问题。这就是等待-通知机制
等待-通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁 。
下面使用synchronized配合wait()、notify()、notifyAll()这三个方法实现等待通知机制:
js
class Allocator {
private List < Object > als = new ArrayList < > ();
synchronized void apply(Object from, Object to) throws InterruptedException {
// 等待直到两个资源都可用
while (als.contains(from) || als.contains(to)) {
wait();
}
als.add(from);
als.add(to);
}
// 归还资源
synchronized void free(
Object from, Object to) {
als.remove(from);
als.remove(to);
notifyAll(); // 唤醒所有等待的线程
}
}
public class Account {
private final Allocator actr = new Allocator();
private int balance;
void transfer(Account target, int amt) {
// 一次性申请转出账户和转入账户,直到成功
try {
// 申请资源(锁定账户)
actr.apply(this, target);
// 执行转账操作
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 释放资源(解锁账户)
actr.free(this, target);
}
}
public static void main(String[] args) {
Account a = new Account();
Account b = new Account();
a.balance = 200;
b.balance = 200;
Thread t1 = new Thread(() - > {
System.out.println("线程1: 准备从 a 转 100 到 b");
a.transfer(b, 100);
System.out.println("线程1: 转账完成");
});
Thread t2 = new Thread(() - > {
System.out.println("线程2: 准备从 b 转 50 到 a");
b.transfer(a, 50);
System.out.println("线程2: 转账完成");
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("a 的余额: " + a.balance);
System.out.println("b 的余额: " + b.balance);
}
}
使用等待-通知机制的几个注意点:
- 代码要使用while进行条件判断,这是等待通知机制的编程范式,因为当wait()返回时,有可能条件已经发生变化了,曾经条件满足,但是现在已经不满足了,所以要重新检验条件是否满足
- 尽量使用notifyAll(),notify()只是会随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中的所有线程
- wait()、notify()、notifyAll()这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以wait()、notify()、notifyAll()都是在synchronized{}内部被调用的
参考
- 极客时间-《Java并发编程实战》