In-depth understanding and use of mutex locks

Hello everyone, I am Yi An!

We know that one or more operations are not interrupted during CPU execution, which is called "atomicity". Understanding this feature will help you analyze the causes of concurrent programming bugs. For example, you can use it to analyze the weird bugs that may occur when long variables are read and written on a 32-bit machine. Obviously, the variable has been successfully written into the memory, but it is read out again. Not written by myself.

How to solve the atomicity problem?

The source of the atomicity problem is thread switching . If thread switching can be disabled, wouldn't this problem be solved? The operating system depends on CPU interrupts for thread switching, so prohibiting CPU interrupts can prohibit thread switching.

In the early single-core CPU era, this solution was indeed feasible, and there were many application cases, but it was not suitable for multi-core scenarios. Here we take the write operation of a long variable on a 32-bit CPU as an example to illustrate this problem. The long variable is 64 bits, and the write operation performed on a 32-bit CPU will be split into two write operations (write high 32-bit and write the lower 32 bits, as shown in the figure below).

alt

In a single-core CPU scenario, only one thread executes at a time, and disabling CPU interrupts means that the operating system will not reschedule threads, that is, thread switching is prohibited, and the thread that obtains the right to use the CPU can execute without interruption. The first write operation must be: either all of them are executed, or none of them are executed, which is atomic.

However, in a multi-core scenario, at the same time, there may be two threads executing at the same time, one thread is executed on CPU-1, and the other thread is executed on CPU-2. At this time, CPU interrupts are disabled, and only threads on the CPU can be guaranteed. Continuous execution does not guarantee that only one thread executes at the same time. If these two threads write the upper 32 bits of the long variable at the same time, the weird bug we mentioned at the beginning may appear.

The condition " only one thread executes at the same time " is very important, we call it mutual exclusion . If we can ensure that the modification of shared variables is mutually exclusive, then, whether it is a single-core CPU or a multi-core CPU, atomicity can be guaranteed.

simple lock model

When it comes to mutual exclusion, I believe you must have thought of the killer solution: locks. At the same time, the following models will also appear in the brain:

alt

simple lock model

We call a section of code that requires mutual exclusion a critical section . Before the thread enters the critical section, it first tries to lock lock(). If it succeeds, it enters the critical section. At this time, we say that the thread holds the lock; otherwise, it waits until the thread holding the lock is unlocked; After the thread executes the code in the critical section, it executes unlock().

This process is very similar to seizing the pit in the office during peak hours. Everyone enters the pit and locks the door (locks it), and exits the pit to open the door (unlocks it). Going to the toilet is the critical area. For a long time, I also understood this way. There is no problem with this understanding in itself, but it is easy for us to overlook two very, very important points: What are we locking? What are we protecting?

Improved lock model

We know that in the real world, there is a corresponding relationship between locks and the resources to be protected by locks. For example, you use your home lock to protect your home's things, and I use my home's lock to protect my home's things. In the world of concurrent programming, locks and resources should also have this relationship, but this relationship is not reflected in our above model, so we need to improve our model.

alt

Improved lock model

First of all, we need to mark the resources to be protected in the critical section, as shown in the figure, an element is added in the critical section: the protected resource R; secondly, if we want to protect the resource R, we have to create a lock LR for it; finally, For this lock LR, we also need to add lock operation and unlock operation when entering and exiting the critical section. In addition, between the lock LR and the protected resource, I specially made an association with a line, which is very important. Many concurrent bugs appear because of ignoring it, and then things like locking one’s own door to protect other’s assets appear. Such bugs are very difficult to diagnose, because subconsciously we think that the lock has been correctly locked.

Lock provided by JDK: synchronized

Lock is a general technical solution, and the synchronized keyword provided by the Java language is an implementation of lock. The synchronized keyword can be used to modify methods or code blocks. Its usage examples basically look like this:

class X {
    
    
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }
  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }
  // 修饰代码块
  Object obj = new Object();
  void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}

看完之后你可能会觉得有点奇怪,这个和我们上面提到的模型有点对不上号啊,加锁lock()和解锁unlock()在哪里呢?其实这两个操作都是有的,只是这两个操作是被Java默默加上的,Java编译器会在synchronized修饰的方法或代码块前后自动加上加锁lock()和解锁unlock(),这样做的好处就是加锁lock()和解锁unlock()一定是成对出现的,毕竟忘记解锁unlock()可是个致命的Bug(意味着其他线程只能死等下去了)。

那synchronized里的加锁lock()和解锁unlock()锁定的对象在哪里呢?上面的代码我们看到只有修饰代码块的时候,锁定了一个obj对象,那修饰方法的时候锁定的是什么呢?这个也是Java的一条隐式规则:

当修饰静态方法的时候,锁定的是当前类的Class对象,在上面的例子中就是Class X;

当修饰非静态方法的时候,锁定的是当前实例对象this。

对于上面的例子,synchronized修饰静态方法相当于:

class X {
    
    
  // 修饰静态方法
  synchronized(X.class) static void bar() {
    // 临界区
  }
}

修饰非静态方法,相当于:

class X {
    
    
  // 修饰非静态方法
  synchronized(this) void foo() {
    // 临界区
  }
}

用synchronized解决count+=1问题

相信你一定记得我们前面文章中提到过的count+=1存在的并发问题,现在我们可以尝试用synchronized来小试牛刀一把,代码如下所示。SafeCalc这个类有两个方法:一个是get()方法,用来获得value的值;另一个是addOne()方法,用来给value加1,并且addOne()方法我们用synchronized修饰。那么我们使用的这两个方法有没有并发问题呢?

class SafeCalc {
    
    
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

我们先来看看addOne()方法,首先可以肯定,被synchronized修饰后,无论是单核CPU还是多核CPU,只有一个线程能够执行addOne()方法,所以一定能保证原子操作,那是否有可见性问题呢?这里我们不得不提到 管程中锁的规则

管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

管程,就是我们这里的synchronized(至于为什么叫管程,我们后面介绍),我们知道synchronized修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;而所谓“对一个锁解锁 Happens-Before 后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,综合Happens-Before的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。

按照这个规则,如果多个线程同时执行addOne()方法,可见性是可以保证的,也就说如果有1000个线程执行addOne()方法,最终结果一定是value的值增加了1000。看到这个结果,我们长出一口气,问题终于解决了。

但也许,你一不小心就忽视了get()方法。执行addOne()方法后,value的值对get()方法是可见的吗?这个可见性是没法保证的。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而get()方法并没有加锁操作,所以可见性没法保证。那如何解决呢?很简单,就是get()方法也synchronized一下,完整的代码如下所示。

class SafeCalc {
    
    
  long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

上面的代码转换为我们提到的锁模型,就是下面图示这个样子。get()方法和addOne()方法都需要访问value这个受保护的资源,这个资源用this这把锁来保护。线程要进入临界区get()和addOne(),必须先获得this这把锁,这样get()和addOne()也是互斥的。

alt

保护临界区get()和addOne()的示意图

这个模型更像现实世界里面球赛门票的管理,一个座位只允许一个人使用,这个座位就是“受保护资源”,球场的入口就是Java类里的方法,而门票就是用来保护资源的“锁”,Java里的检票工作是由synchronized解决的。

锁和受保护资源的关系

我们前面提到,受保护资源和锁之间的关联关系非常重要,他们的关系是怎样的呢?一个合理的关系是: 受保护资源和锁之间的关联关系是N:1的关系。拿球赛门票的管理来类比,一个座位,我们只能用一张票来保护,如果多发了重复的票,那就要打架了。现实世界里,我们可以用多把锁来保护同一个资源,但在并发领域是不行的,并发领域的锁和现实世界的锁不是完全匹配的。不过倒是可以用同一把锁来保护多个资源,这个对应到现实世界就是我们所谓的“包场”了。

上面那个例子我稍作改动,把value改成静态变量,把addOne()方法改成静态方法,此时get()方法和addOne()方法是否存在并发问题呢?

class SafeCalc {
    
    
  static long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}

如果你仔细观察,就会发现改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量value,两个锁分别是this和SafeCalc.class。我们可以用下面这幅图来形象描述这个关系。由于临界区get()和addOne()是用两个锁保护的,因此这两个临界区没有互斥关系,临界区addOne()对value的修改对临界区get()也没有可见性保证,这就导致并发问题了。

alt

两把锁保护一个资源的示意图

刚刚我们谈过 受保护资源和锁之间合理的关联关系应该是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,就像你不能用自家的锁来保护别人家的资产,也不能用自己的票来保护别人的座位一样。

alt

用锁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。

alt

并发转账示意图

使用锁的正确姿势

刚刚,我们提到用同一把锁来保护多个资源,也就是现实世界的“包场”,那在编程领域应该怎么“包场”呢?很简单,只要我们的 锁能覆盖所有受保护资源 就可以了。在上面的例子中,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来保护不同对象的临界区的。

alt

这个方案不存在并发问题,但是所有账户的转账操作都是串行的,例如账户A 转账户B、账户C 转账户D这两个转账操作现实世界里是可以并行的,但是在这个方案里却被串行化了,这样的话,性能太差。现实世界里,账户转账操作是支持并发的,而且绝对是真正的并行,银行所有的窗口都可以做转账操作。只要我们能仿照现实世界做转账操作,串行的问题就解决了。

我们试想在古代,没有信息化,账户的存在形式真的就是一个账本,而且每个账户都有一个账本,这些账本都统一存放在文件架上。银行柜员在给我们做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。这个柜员在拿账本的时候可能遇到以下三种情况:

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

上面这个过程如何用编程实现呢?其实用两把锁就实现了,转出账本一把,转入账本另一把。在transfer()方法内部,我们首先尝试锁定转出账户this(先把转出账本拿到手),然后尝试锁定转入账户target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。这个逻辑可以图形化为下图这个样子。

alt

两个转账操作并行示意图

而至于详细的代码实现,如下所示。经过这样的优化后,账户A 转账户B和账户C 转账户D这两个转账操作就可以并行了。

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

死锁的产生

上面的实现看上去很完美,并且也算是将锁用得出神入化了。相对于用Account.class作为互斥锁,锁定的范围太大,而我们锁定两个账户范围就小多了,这样的锁,叫 细粒度锁使用细粒度锁可以提高并行度,是性能优化的一个重要手段

但是,使用细粒度锁是有代价的,这个代价就是可能会导致死锁。

alt

转账业务中的“死等”

现实世界里的死等,就是编程领域的死锁了。 死锁 的一个比较专业的定义是: 一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象

上面转账的代码是怎么发生死锁的呢?我们假设线程T1执行账户A转账户B的操作,账户A.transfer(账户B);同时线程T2执行账户B转账户A的操作,账户B.transfer(账户A)。当T1和T2同时执行完①处的代码时,T1获得了账户A的锁(对于T1,this是账户A),而T2获得了账户B的锁(对于T2,this是账户B)。之后T1和T2在执行②处的代码时,T1试图获取账户B的锁时,发现账户B已经被锁定(被T2锁定),所以T1开始等待;T2则试图获取账户A的锁时,发现账户A已经被锁定(被T1锁定),所以T2也开始等待。于是T1和T2会无期限地等待下去,也就是我们所说的死锁了。

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

关于这种现象,我们还可以借助资源分配图来可视化锁的占用情况(资源分配图是个有向图,它可以描述资源和线程的状态)。其中,资源用方形节点表示,线程用圆形节点表示;资源中的点指向线程的边表示线程已经获得该资源,线程指向资源的边则表示线程请求资源,但尚未得到。转账发生死锁时的资源分配图就如下图所示,一个“各据山头死等”的尴尬局面。

alt

转账发生死锁时的资源分配图

如何预防死锁

并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。

那如何避免死锁呢?要避免死锁就需要分析死锁发生的条件,有个叫Coffman的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:

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

反过来分析, 也就是说只要我们破坏其中一个,就可以成功避免死锁的发生

其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?

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

我们已经从理论上解决了如何预防死锁,那具体如何体现在代码上呢?下面我们就来尝试用代码实践一下这些理论。

1. 破坏占用且等待条件

从理论上讲,要破坏这个条件,可以一次性申请所有资源。在现实世界里,就拿前面我们提到的转账操作来讲,它需要的资源有两个,一个是转出账户,另一个是转入账户,当这两个账户同时被申请时,我们该怎么解决这个问题呢?

可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,也就是说柜员不能直接在文件架上拿账本,必须通过账本管理员才能拿到想要的账本。例如,张三同时申请账本A和B,账本管理员如果发现文件架上只有账本A,这个时候账本管理员是不会把账本A拿下来给张三的,只有账本A和B都在的时候才会给张三。这样就保证了“一次性申请所有资源”。

alt

通过账本管理员拿账本

对应到编程领域,“同时申请”这个操作是一个临界区,我们也需要一个角色(Java里面的类)来管理这个临界区,我们就把这个角色定为Allocator。它有两个重要功能,分别是:同时申请资源apply()和同时释放资源free()。账户Account 类里面持有一个Allocator的单例(必须是单例,只能由一个人来分配资源)。当账户Account在执行转账操作的时候,首先向Allocator同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知Allocator同时释放转出账户和转入账户这两个资源。具体的代码实现如下。

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

2. 破坏不可抢占条件

破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点synchronized是做不到的。原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。

你可能会质疑,“Java作为排行榜第一的语言,这都解决不了?”你的怀疑很有道理,Java在语言层次确实没有解决这个问题,不过在SDK层面还是解决了的,java.util.concurrent这个包下面提供的Lock是可以轻松解决这个问题的。

3. 破坏循环等待条件

破坏这个条件,需要对资源进行排序,然后按序申请资源。这个实现非常简单,我们假设每个账户都有不同的属性 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;
        }
      }
    }
  }
}

总结

互斥锁,在并发领域的知名度极高,只要有了并发问题,大家首先容易想到的就是加锁,因为大家都知道,加锁能够保证执行临界区代码的互斥性。这样理解虽然正确,但是却不能够指导你真正用好互斥锁。临界区的代码是操作受保护资源的路径,类似于球场的入口,入口一定要检票,也就是要加锁,但不是随便一把锁都能有效。所以必须深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。

synchronized是Java在语言层面提供的互斥原语,其实Java里面还有很多其他类型的锁,但作为互斥锁,原理都是相通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁/解锁,就属于设计层面的事情了。

对如何保护多个资源,关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁,这个过程可以类比一下门票管理。

我们再引申一下上面提到的关联关系,关联关系如果用更具体、更专业的语言来描述的话,其实是一种“原子性”特征,在前面我们提到的原子性,主要是面向CPU指令的,转账操作的原子性则是属于是面向高级语言的,不过它们本质上是一样的。

“原子性”的本质 是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求, 操作的中间状态对外不可见。例如,在32位的机器上写long型变量有中间状态(只写了64位中的32位),在银行转账的操作中也有中间状态(账户A减少了100,账户B还没来得及发生变化)。所以 解决原子性问题,是要保证中间状态对外不可见

Finally, we also talked about the problem of deadlock when using fine-grained locks to lock multiple resources . This requires you to be able to strengthen it into a mindset. When you encounter this kind of scenario, you immediately think that there may be a deadlock problem. When you know the risks, you have the opportunity to talk about how to prevent and avoid them. Therefore, it is very important to identify the risks .

Preventing deadlock is mainly to destroy one of the three conditions. With this idea, the implementation is simple. But it still needs to be noted that sometimes the cost of deadlock prevention is also very high. For example, in the above transfer example, the cost of breaking the occupancy and waiting conditions is higher than the cost of breaking the loop waiting conditions. To destroy the occupancy and waiting conditions, we also lock all accounts, and we still use the infinite loop while(!actr.apply(this, target));method () This method is basically time-consuming. In the example of transfer, breaking the loop waiting condition is the lowest-cost solution.

Therefore, when we choose a specific solution, we also need to evaluate the operating cost and choose a solution with the lowest cost .

If this article is helpful to you, please like and share, it is very important for me to continue to share & create high-quality articles. grateful!

This article is published by mdnice multi-platform

Guess you like

Origin blog.csdn.net/qq_35030548/article/details/130436820