互斥锁:解决原子性问题/保护共享资源

原子性

原子性:一个或多个操作在CPU执行的过程中不被中断的特性

原子性问题源头是线程切换,保证对共享变量的修改时互斥的,就可以保障原子性。

简易锁模型

image

临界区:一段需要互斥执行的代码

改进锁模型

明确锁的范围,能够锁住的资源。

简易锁模型容易出问题的地方:
1、锁住了错误的资源
2、锁的粒度太大,锁住的资源太多,导致性能太低

image

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;
  }
}

image

锁和受保护资源的关系

锁和受保护资源之间的关联关系是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转账,便可能因为都等到目标账户的锁而死锁。

死锁预防

解决死锁问题最好的办法是预防死锁发生,而打破任一死锁条件,便不会发生死锁。

发生死锁条件

  1. 互斥:共享资源X和Y只能被一个线程占用;
  2. 占有且等待:线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
  3. 不可抢占:其他线程不能强行抢占线程T1占有的资源;
  4. 循环等待:线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源。

预防死锁

互斥无法破坏,因为锁靠互斥实现。考虑后3个条件:

  1. 对于“占用且等待”这个条件,可以一次性申请所有的资源,这样就不存在等待;
  2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了;
  3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

破坏死锁条件代码实践

  1. 破坏占用且等待条件
    由一个单例来统一管理锁,实例转账前需要申请自身及目标实例的锁,才能转账。

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)
    }
  }
}
  1. 破坏不可抢占条件
    语言层面synchronized无法实现,但是SDK层面java.util.concurrent提供的Lock可以解决。

  2. 破坏循环等待条件
    破坏这个条件,需要对资源进行排序,然后按序申请资源。可以给账户类新增属性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机制优化。

image

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 就再也没有机会被唤醒了。
posted @ 2023-03-13 18:17  kiper  阅读(50)  评论(0编辑  收藏  举报