-
Notifications
You must be signed in to change notification settings - Fork 0
Description
【个人浏览后的大致上的总结:**
主要讲的就是锁对于保护 存在不可变性条件的对象状态与存在后验条件的对象状态 以及 无关联关系的对象状态的不同方式。】
上一篇文章中提到,受保护资源和锁之间的关系应该是 N:1 的关系,也就是多个资源用一把锁进行保护。
那么具体该怎样做呢? 这就是这篇文章的主要内容。
当我们要保护多个资源时,首先要区分这些资源是否存在关联关系。
保护没有关联关系的多个资源
现实世界中,球场的作为和电影院的作为是没有逻辑上的关联关系的,这种场景非常容易解决,球赛有球赛的门票,电影院有电影院的门票,各自进行管理。
对应到编程领域中的概念,也很好解决。
例如:「银行业务」 中有针对 余额 进行取款的操作,也有针对账户密码进行修改的操作。我们可以为 针对账户余额 和 修改账户密码 这两个动作分配不同的锁来解决并发问题。
相关的示例代码如下:
账户类 Account
有两个成员变量,分别是账户余额 balance
和 账户密码 password
。
取款 withdraw()
和 查看余额 getBalance()
操作会访问账户余额 banlance
, 我们创建一个 final
对象 balLock
作为锁。
而更改密码 updatePassword()
和 查看密码 getPassWorkd()
操作会修改账户密码 passWord
,我们创建一个 final
对象 pwLock
作为锁。
不同的资源使用不同的锁进行保护,各自进行管理:
public class Account {
// 保护账余额的锁
private final Object balanceLock = new Object();
// 账户余额
private Integer balance;
// 保护账户密码的锁
private final Object passwordLock = new Object();
// 账户密码
private String password;
//取款
void withdraw(Integer amt) {
synchronized (balanceLock) {
if (this.balance > amt) {
// 取款操作
this.balance -= amt;
}
}
}
// 查看余额
Integer getBalance() {
synchronized (balanceLock) {
return balance;
}
}
// 更改密码
void updatePassword(String pw) {
synchronized (passwordLock) {
this.password = pw;
}
}
// 查看密码
String getPassword() {
synchronized (passwordLock) {
return this.password;
}
}
}
我们也可以使用一把互斥锁来保护多个资源,例如用 this 这一把锁来管理账户类中的所有资源:余额 和 密码。
这样的话所有示例程序中的代码使用 synchronized
来修饰就可以了。
但是使用一把锁的话,所有方法都会变成串行方法,极大的削弱了可伸缩性,变成了一个单线程的形式,而使用两把锁的话,取款和修改密码这2个操作是可以并行的。
用不同的锁对受保护的资源进行精细化管理,可以提升性能,这种锁也被称为**「细粒度锁」**
保护有关联关系的多个资源
如果多个资源是有关联关系的,那么问题就比较复杂。比如银行中的转行操作,账户A 减少100元,账户B 增加100元,这两个账户的余额就是有关联关系的。
对于 「转账」 这类有关联关系的操作,该怎样解决呢?
下面将这个问题用代码描述:
我们声明了一个账户类:Account
, 该类有一个成员变量 余额:balance
,还有一个用于转账的方法:transfer()
,然后怎样保证转账操作 transfer()
没有并发问题呢?
public class Account {
private int balance;
// 转账
synchronized void transfer(Account targer, int amt) {
if (this.balance > amt) {
this.balance -= amt;
targer.balance += amt;
}
}
}
也许你的第一直觉想到的是这样的方法,用锁来保护这个方法,把 余额的增加和减少 做为一组原子操作。
这段代码中,临界区内有两个资源,分别是转出账户的余额 this.balance
和 转入账户的余额 target.balance
,并且用的是一把锁 this
,符合前面提到的: 多个资源可以用一把锁来保护。
但是这个例子只是看上去正确,问题就出在这个锁 this
上, 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。
使用锁的正确姿势
在上一篇文章中,我们提到了用「同一把锁」 来保护多个资源,对应现实中的 "包场",在编程领域中怎么对应包场进行操作呢?
将 锁覆盖所有受保护的资源就可以了。 上个例子中 this 是对象级别的锁,所以 A 和 B 都有自己对象的锁?如果和让A 和 B 共享一把锁呢?
方案很多,可以让所有对象都持有一个唯一性的对象,这个对象在创建 Account
时传入。
下面是示例代码,将 Account 的默认构造函数变为 private
,同时增加一个 带 Object lock
参数的构造函数,创建 Account
对象时,传入相同的 lock
,这样所有 Account
对象都会共享这个 lock
。
public class Accoutn3 {
// 用来作为保护多个资源的单一锁对象
private Object lock;
private int balance;
// 创建 Account 时需要传入同一个 lock 对象
private Accoutn(Object lock) {
this.lock = lock;
}
// 转账
void transfer(Accoutn target, int amt) {
synchronized (lock) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
这个办法可以解决问题,但是要求对象的构造函数中必须传入一个对象,如果创建 Account
对象时传入的锁对象不一致,则又会出现问题。
在真实的项目场景中,创建 Account 对象的代码可能分散在多个工程中,所以传入几个工程都能共享的 lock
对象不容易。【从真实项目的角度出发说明这种方法的局限性】
其实另一个方法大家应该都能想到,就是用 类的 Class
文件作为锁,这样所有相同的类的锁对象就是唯一的。
在这个例子中就是使用 Account.class
作为共享的锁,这个对象是 JVM
在加载 Account
类的时候创建的,所以不需要担心唯一性,而且这样的话就不需要在构造对象时传入额外的锁对象代码了。
// 使用 Account.Class 作为锁
public class Account {
private int balance;
void transger(Account target,int amt) {
synchronized (Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
下面这幅图很直观的展示了 我们是如何使用共享的 「锁」 Account.class
来保护不同对象的临界区的。
总结:
关于如何使用一个锁保护多个资源的关键在于 「分析多个资源之间的关系」,如果资源之间没有关系,也就是不存在可变性条件,则每个资源一把锁就可以解决问题。
如果资源之间存在关联关系,则需要选择一个粒度更大的锁,这个锁可以覆盖所有相关的资源。除此之外还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁,这个过程可以类比 「门票管理」
上面的「关联关系」 如果使用更具体 更专业的语言来描述应该叫做 「原子性」 ,这里转账操作的原子性属于面向高级语言层面的,与之前提到的 面向CPU指令的原子性不同,但是本质上相同。
这里作者对原子性有一个描述:原子性的本质:不是不可分割,不可分割只是外在表现,其本质是多个资源之间有一致性的要求,「操作的中间状态对外不可见。」。
例如在 32位机器上写 long
型变量有中间状态(只写了 64位中的32位),银行转账中也有中间状态(转出账户减少了,转入账户还没有增加),所以解决原子性问题,本质就是要保证中间状态对外不可见。
思考题:
第一个示例程序中使用了两把锁来保护账户余额,和账户密码,创建锁的时候使用的是 private final Object xxLock = new Object()
。如果账户余额用 this.balance 作为锁,账户密码用 this.password 作为锁 是否可行?
思考:锁的重要条件是不可变,而这里这两个字段都是可变的,所以不行。
当你这样写的时候,IDEA 也会提示你你在使用一个非不可变的字段作为锁,很智能。
举个例子,假如this.balance
= 10 ,多个线程同时竞争同一把锁this.balance
,此时只有一个线程拿到了锁,其他线程等待,拿到锁的线程进行this.balance -= 1
操作,this.balance = 9
。 该线程释放锁, 之前等待锁的线程继续竞争this.balance=10
的锁,新加入的线程竞争this.balance=9
的锁,导致多个锁对应一个资源
个人总结:
专栏相比于 《jcip》,知识密度低了很多,讲的比较详细,个人感觉价值更大的是看评论区,从别人的留言中看到对一个问题不同的思考角度下的思考结果。
精选留言:
使用 类对象作为锁的粒度太大,整个类都变成串行的类了,虽然能保证线程安全性,但是性能太低。
转账例子中粗粒度导致性能太差,细粒度可能导致死锁,专栏的深度还是有点浅,结合几本书进行学习作为补充资料更好。