互斥锁:解决原子性问题/保护共享资源
原子性
原子性:一个或多个操作在CPU执行的过程中不被中断的特性
原子性问题源头是线程切换,保证对共享变量的修改时互斥的,就可以保障原子性。
简易锁模型
临界区:一段需要互斥执行的代码
改进锁模型
明确锁的范围,能够锁住的资源。
简易锁模型容易出问题的地方:
1、锁住了错误的资源
2、锁的粒度太大,锁住的资源太多,导致性能太低
synchronized关键字
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
synchronized 关键字可以用来修饰方法,也可以用来修饰代码块。Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock()。
当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;当修饰非静态方法的时候,锁定的是当前实例对象 this。
synchronized 解决 count+=1 问题
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
管程修饰的临界区互斥,并且管程中的锁解锁先行发生于后续对这个锁的加锁。结合传递性,得
前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。
get方法保证可见性吗?
没法保证。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证。如何解决呢?很简单,就是 get() 方法也 synchronized 一下(或volidate)。
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
锁和受保护资源的关系
锁和受保护资源之间的关联关系是1:N的关系
异常情况
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
改动后的代码是用两个锁(this及SafeCalc.class)保护一个资源(value),因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题。
保护没有关联关系的多个资源
细粒度锁
可以用一把互斥锁来保护多个资源,例如我们可以用 this 这一把锁来管理账户类里所有的资源:账户余额和用户密码。但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。
保护有关联关系的多个资源
例如银行业务里面的转账操作,账户 A 减少 100 元,账户 B 增加 100 元。这两个账户就是有关联关系的。我们声明了个账户类:Account,该类有一个成员变量余额:balance,一个用于转账的方法:transfer(),转账操作transfer()有没有并发问题?
class Account {
private int balance;
// 转账
synchronized void transfer(Account target, int amt){
if (this.balance > amt) {
// 当前账户减少钱
this.balance -= amt;
// 目标账户增加钱
target.balance += amt;
}
}
}
有问题。因为修饰后this这把锁可以保护自己的余额 this.balance,却保护不了别人的余额target.balance。
假如A、B、C各有200,同一时刻,A给B转账100,B给转账100,由于this只能锁当前对象,故A、B对象能同时转账,此时会同时获取到B的200元,此后要么A后给B对象设置200+100=300,要么B后给自己设置200-100=100,都是错误结果。
解决方案
锁能覆盖所有受保护资源即可,要么传入一个对象作为锁由AB同时持有,要么更优雅的用Account.class作为锁即可。
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;
}
}
}
}
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
解决原子性问题,是要保证中间状态对外不可见。
可变对象不能作为锁
性能优化
上述方案导致所有转账都成为了串行操作,怎么优化呢?
拿到对应对象的锁即可。在 transfer() 方法内部,我们首先尝试锁定转出账户 this(先把转出账本拿到手),然后尝试锁定转入账户 target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。这便是之前提到的细粒度锁,提升并行度。
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;
}
}
}
}
}
细粒度锁的代价:死锁
死锁: 一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
上述方案,如果同时执行A给B转账和B给A转账,便可能因为都等到目标账户的锁而死锁。
死锁预防
解决死锁问题最好的办法是预防死锁发生,而打破任一死锁条件,便不会发生死锁。
发生死锁条件
- 互斥:共享资源X和Y只能被一个线程占用;
- 占有且等待:线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
- 不可抢占:其他线程不能强行抢占线程T1占有的资源;
- 循环等待:线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源。
预防死锁
互斥无法破坏,因为锁靠互斥实现。考虑后3个条件:
- 对于“占用且等待”这个条件,可以一次性申请所有的资源,这样就不存在等待;
- 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了;
- 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
破坏死锁条件代码实践
- 破坏占用且等待条件
由一个单例来统一管理锁,实例转账前需要申请自身及目标实例的锁,才能转账。
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无法实现,但是SDK层面java.util.concurrent提供的Lock可以解决。 -
破坏循环等待条件
破坏这个条件,需要对资源进行排序,然后按序申请资源。可以给账户类新增属性id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如下面代码中,①~⑥处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。
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;
}
}
}
}
}
在选择具体方案的时候,还需要评估一下操作成本,从中选择一个成本最低的方案。破坏占用且等待条件的成本就比破坏循环等待条件的成本高,破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));方法,不过好在 apply() 这个方法基本不耗时。 在转账这个例子中,破坏循环等待条件就是成本最低的一个方案。
synchronized 等待 - 通知机制优化循环等待
apply代码解决了占用且等待的问题,但是while循环会浪费cpu,可以用wait-notify机制优化。
class Allocator {
private List<Object> als;
// 一次性申请所有资源
synchronized void apply(Object from, Object to) {
// 经典写法
while (als.contains(from) || als.contains(to)) {
try {
wait();
} catch (Exception e) {
...
}
}
als.add(from);
als.add(to);
}
// 归还资源
synchronized void free(Object from, Object to) {
als.remove(from);
als.remove(to);
notifyAll();
}
}
当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区。
尽量使用 notifyAll()
notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。
使用 notify() 很有风险,可能导致某些线程永远不会被通知到。
假设我们有资源 A、B、C、D,线程 1 申请到了 AB,线程 2 申请到了 CD,此时线程 3 申请 AB,会进入等待队列(AB 分配给线程 1,线程 3 要求的条件不满足),线程 4 申请 CD 也会进入等待队列。我们再假设之后线程 1 归还了资源 AB,如果使用 notify() 来通知等待队列中的线程,有可能被通知的是线程 4,但线程 4 申请的是 CD,所以此时线程 4 还是会继续等待,而真正该唤醒的线程 3 就再也没有机会被唤醒了。