互斥锁

76 阅读6分钟

文章目录

一.原子问题

并发编程Bug源头中介绍过,原子问题的源头是线程切换,解决方案禁用线程切换。

CPU控制线程切换,无论单核CPU还是多核CPU,保证同一时刻只有一个线程执行,称为互斥,就能够保证对共享变量的修改时互斥,就能保证原子性。

二.锁模型

互斥的解决方案是锁,把一段需要互斥执行的代码称为临界区。

图片

这个锁模型展示的是锁和锁要保护的资源是有关系,很多并发Bug是因为关联关系,产生奇怪的问题。

三.解决方案

2.1 synchronized

锁是一种通用的技术方案,Java语言提供的synchronized 关键字,就是锁的一种实现。

//Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 //lock() 和解锁 unlock(),这样做的好处就是加锁 lock() 和解锁 //unlock() 一定是成对出现的。
class X {
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }
  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }
  // 修饰代码块
  Object obj = new Object();
  void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}  

Java隐式规则

  • 当synchronized 修饰静态方法时候,锁定的是当前类Class对象,在上面的例子中就是Class X;
  • 当synchronized 修饰非静态方法时候,锁定的是当前实例对象this。
class SafeCalc {
  long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

get() 方法和 addOne() 方法都需要访问 value 这个受保护的资源,这个资源用 this 这把锁来保护。线程要进入临界区 get() 和 addOne(),必须先获得 this 这把锁,这样 get() 和 addOne() 也是互斥的。

四.保护资源

4.1 关系简介

受保护资源和锁之间关系是N:1的关系,多把锁来保护同一个资源,在并发领域不行,同一把锁来保护多个资源是可以的。

4.2 保护没有关联关系的多个资源

账户类 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;
    }
  }
}

4.3 保护有关联关系的多个资源

例如银行业务里面的转账操作,账户 A 减少 100 元,账户 B 增加 100 元。这两个账户就是有关联关系的。

用 Account.class 作为共享的锁(有问题,后续解释)

class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    synchronized(Account.class) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  } 
}

该解决方案,存在性能问题,因为把所有银行业务转账行为,都变成串行了。
现实世界里,账户转账操作是支持并发的,而且绝对是真正的并行,银行所有的窗口都可以做转账操作。只要我们能仿照现实世界做转账操作,串行的问题就解决了。

并行解决银行转账问题

把问题具象化,假设柜员在拿账本的时候,可能遇到以下三种情况:

  • 文件架上恰好有转出账本和转入账本,那就同时拿走;
  • 如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其他柜员把另外一个账本送回来;
  • 转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。

图片

@Data
public class AccountClassLock3 {
    private int balance;
    public AccountClassLock3(int balance) {
        this.balance = balance;
    }
    public void transfer(AccountClassLock3 target,int amt){
        //锁定转出账户
        synchronized (this){
            //锁定转入账户
            synchronized (target){
                if(this.balance > amt){
                    this.balance -= amt;
                    target.balance += amt;
                }
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10000; i++) {
            AccountClassLock3 a = new AccountClassLock3(200);
            AccountClassLock3 b = new AccountClassLock3(200);
            AccountClassLock3 c = new AccountClassLock3(200);

            Thread t1 = new Thread(()->{
                a.transfer(b,100);
            });
            Thread t2 = new Thread(()->{
                b.transfer(c,100);
            });
            t1.start();
            t2.start();
            
            t2.join();
            System.out.println(a.getBalance());
            System.out.println(b.getBalance());
            System.out.println(c.getBalance());
            System.out.println("---------------");
        }
    }
}

这样的锁时细粒度锁,使用细粒度锁可以提高并行度,是优化性能的一个重要手段,但是使用细粒度锁是有代价的,这个代价就是可能会导致死锁。

死锁实验

现实世界里的一种特殊场景。如果有客户找柜员张三做个转账业务:账户 A 转账户 B 100 元,此时另一个客户找柜员李四也做个转账业务:账户 B 转账户 A 100 元,于是张三和李四同时都去文件架上拿账本,这时候有可能凑巧张三拿到了账本 A,李四拿到了账本 B。张三拿到账本 A 后就等着账本 B(账本 B 已经被李四拿走),而李四拿到账本 B 后就等着账本 A(账本 A 已经被张三拿走),他们要等多久呢?他们会永远等待下去…因为张三不会把账本 A 送回去,李四也不会把账本 B 送回去。我们姑且称为死等吧。

@Data
public class AccountClassLock3 {
    private int balance;
    public AccountClassLock3(int balance) {
        this.balance = balance;
    }
    public void transfer(AccountClassLock3 target,int amt){
        //锁定转出账户
        synchronized (this){
            //锁定转入账户
            synchronized (target){
                if(this.balance > amt){
                    this.balance -= amt;
                    target.balance += amt;
                }
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100000; i++) {
            AccountClassLock3 a = new AccountClassLock3(200);
            AccountClassLock3 b = new AccountClassLock3(200);
            AccountClassLock3 c = new AccountClassLock3(200);

            Thread t1 = new Thread(()->{
                a.transfer(b,100);
            });


            Thread t2 = new Thread(()->{
                b.transfer(a,100);
            });
            t1.start();
            t2.start();
            t2.join();
            System.out.println(a.getBalance());
            System.out.println(b.getBalance());
            //System.out.println(c.getBalance());
            System.out.println("---------------");
        }
    }
}

图片

这是示例运行情况,dump记录,线程正在WAITING,解决方案后续文章介绍。

4.4 小结

原子性本质,不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性要求,操作的中间状态对外不可见。

参考

《Java并发编程实战》

公众号

图片

微信公众号(bigdata_limeng)