You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

181 lines
10 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 04 | 互斥锁(下):如何用一把锁保护多个资源?
在上一篇文章中,我们提到**受保护资源和锁之间合理的关联关系应该是N:1的关系**,也就是说可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源,并且结合文中示例,我们也重点强调了“不能用多把锁来保护一个资源”这个问题。而至于如何保护多个资源,我们今天就来聊聊。
当我们要保护多个资源时,首先要区分这些资源是否存在关联关系。
## 保护没有关联关系的多个资源
在现实世界里,球场的座位和电影院的座位就是没有关联关系的,这种场景非常容易解决,那就是球赛有球赛的门票,电影院有电影院的门票,各自管理各自的。
同样这对应到编程领域,也很容易解决。例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。
相关的示例代码如下账户类Account有两个成员变量分别是账户余额balance和账户密码password。取款withdraw()和查看余额getBalance()操作会访问账户余额balance我们创建一个final对象balLock作为锁类比球赛门票而更改密码updatePassword()和查看密码getPassword()操作会修改账户密码password我们创建一个final对象pwLock作为锁类比电影票。不同的资源用不同的锁保护各自管各自的很简单。
```
class Account {
// 锁:保护账户余额
private final Object balLock
= new Object();
// 账户余额
private Integer balance;
// 锁:保护账户密码
private final Object pwLock
= new Object();
// 账户密码
private String password;
// 取款
void withdraw(Integer amt) {
synchronized(balLock) {
if (this.balance > amt){
this.balance -= amt;
}
}
}
// 查看余额
Integer getBalance() {
synchronized(balLock) {
return balance;
}
}
// 更改密码
void updatePassword(String pw){
synchronized(pwLock) {
this.password = pw;
}
}
// 查看密码
String getPassword() {
synchronized(pwLock) {
return password;
}
}
}
```
当然我们也可以用一把互斥锁来保护多个资源例如我们可以用this这一把锁来管理账户类里所有的资源账户余额和用户密码。具体实现很简单示例程序中所有的方法都增加同步关键字synchronized就可以了这里我就不一一展示了。
但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。**用不同的锁对受保护资源进行精细化管理,能够提升性能**。这种锁还有个名字,叫**细粒度锁**。
## 保护有关联关系的多个资源
如果多个资源是有关联关系的那这个问题就有点复杂了。例如银行业务里面的转账操作账户A减少100元账户B增加100元。这两个账户就是有关联关系的。那对于像转账这种有关联关系的操作我们应该怎么去解决呢先把这个问题代码化。我们声明了个账户类Account该类有一个成员变量余额balance还有一个用于转账的方法transfer()然后怎么保证转账操作transfer()没有并发问题呢?
```
class Account {
private int balance;
// 转账
void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
```
相信你的直觉会告诉你这样的解决方案用户synchronized关键字修饰一下transfer()方法就可以了,于是你很快就完成了相关的代码,如下所示。
```
class Account {
private int balance;
// 转账
synchronized void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
```
在这段代码中临界区内有两个资源分别是转出账户的余额this.balance和转入账户的余额target.balance并且用的是一把锁this符合我们前面提到的多个资源可以用一把锁来保护这看上去完全正确呀。真的是这样吗可惜这个方案仅仅是看似正确为什么呢
问题就出在this这把锁上this这把锁可以保护自己的余额this.balance却保护不了别人的余额target.balance就像你不能用自家的锁来保护别人家的资产也不能用自己的票来保护别人的座位一样。
![](https://static001.geekbang.org/resource/image/1b/d8/1ba92a09d1a55a6a1636318f30c155d8.png)
用锁this保护this.balance和target.balance的示意图
下面我们具体分析一下假设有A、B、C三个账户余额都是200元我们用两个线程分别执行两个转账操作账户A转给账户B 100 元账户B转给账户C 100 元最后我们期望的结果应该是账户A的余额是100元账户B的余额是200元 账户C的余额是300元。
我们假设线程1执行账户A转账户B的操作线程2执行账户B转账户C的操作。这两个线程分别在两颗CPU上同时执行那它们是互斥的吗我们期望是但实际上并不是。因为线程1锁定的是账户A的实例A.this而线程2锁定的是账户B的实例B.this所以这两个线程可以同时进入临界区transfer()。同时进入临界区的结果是什么呢线程1和线程2都会读到账户B的余额为200导致最终账户B的余额可能是300线程1后于线程2写B.balance线程2写的B.balance值被线程1覆盖可能是100线程1先于线程2写B.balance线程1写的B.balance值被线程2覆盖就是不可能是200。
![](https://static001.geekbang.org/resource/image/a4/27/a46b4a1e73671d6e6f1bdb26f6c87627.png)
并发转账示意图
## 使用锁的正确姿势
在上一篇文章中,我们提到用同一把锁来保护多个资源,也就是现实世界的“包场”,那在编程领域应该怎么“包场”呢?很简单,只要我们的**锁能覆盖所有受保护资源**就可以了。在上面的例子中this是对象级别的锁所以A对象和B对象都有自己的锁如何让A对象和B对象共享一把锁呢
稍微开动脑筋你会发现其实方案还挺多的比如可以让所有对象都持有一个唯一性的对象这个对象在创建Account时传入。方案有了完成代码就简单了。示例代码如下我们把Account默认构造函数变为private同时增加一个带Object lock参数的构造函数创建Account对象时传入相同的lock这样所有的Account对象都会共享这个lock了。
```
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;
}
}
}
}
```
这个办法确实能解决问题但是有点小瑕疵它要求在创建Account对象的时候必须传入同一个对象如果创建Account对象时传入的lock不是同一个对象那可就惨了会出现锁自家门来保护他家资产的荒唐事。在真实的项目场景中创建Account对象的代码很可能分散在多个工程中传入共享的lock真的很难。
所以,上面的方案缺乏实践的可行性,我们需要更好的方案。还真有,就是**用Account.class作为共享的锁**。Account.class是所有Account对象共享的而且这个对象是Java虚拟机在加载Account类的时候创建的所以我们不用担心它的唯一性。使用Account.class作为共享的锁我们就无需在创建Account对象时传入了代码更简单。
```
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来保护不同对象的临界区的。
![](https://static001.geekbang.org/resource/image/52/7c/527cd65f747abac3f23390663748da7c.png)
## 总结
相信你看完这篇文章后,对如何保护多个资源已经很有心得了,关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁,这个过程可以类比一下门票管理。
我们再引申一下上面提到的关联关系关联关系如果用更具体、更专业的语言来描述的话其实是一种“原子性”特征在前面的文章中我们提到的原子性主要是面向CPU指令的转账操作的原子性则是属于是面向高级语言的不过它们本质上是一样的。
**“原子性”的本质**是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,**操作的中间状态对外不可见**。例如在32位的机器上写long型变量有中间状态只写了64位中的32位在银行转账的操作中也有中间状态账户A减少了100账户B还没来得及发生变化。所以**解决原子性问题,是要保证中间状态对外不可见**。
## 课后思考
在第一个示例程序里,我们用了两把不同的锁来分别保护账户余额、账户密码,创建锁的时候,我们用的是:`private final Object xxxLock = new Object();`,如果账户余额用 this.balance 作为互斥锁账户密码用this.password作为互斥锁你觉得是否可以呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。