【Java并发基础】加锁机制解决原子性问题

时间:2024-01-26 07:28:55

前言

原子性指一个或多个操作在CPU执行的过程不被中断的特性。前面提到原子性问题产生的源头是线程切换,而线程切换依赖于CPU中断。于是得出,禁用CPU中断就可以禁止线程切换从而解决原子性问题。但是这种情况只适用于单核,多核时不适用。

以在 32 位 CPU 上执行 long 型变量的写操作为例来说明。
long 型变量是 64 位,在 32 位 CPU 上执行写操作会被拆分成两次写操作(写高 32 位和写低 32 位,如下图所示,图来自【参考1】)。

在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,即禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行。所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。
但是在多核场景下,同一时刻,可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上。此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行。如果这两个线程同时向内存写 long 型变量高 32 位的话,那么就会造成我们写入的变量和我们读出来的是不一致的。

所以解决原子性问题的重要条件还是为:同一时刻只能有一个线程对共享变量进行操作,即互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性。

下面将介绍实现互斥访问的方案,加锁机制。

锁模型

我们把一段需要互斥执行的代码称为临界区
线程在进入临界区之前,首先尝试加锁 lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;
否则就等待或阻塞,直到持有锁的线程释放锁。持有锁的线程执行完临界区的代码后,执行解锁 unlock()。

锁和锁要保护的资源是要对应的。这个指的是两点:①我们要保护一个资源首先要创建一把锁;②锁要锁对资源,即锁A应该用来保护资源A,而不能用它来锁资源B。

所以,最后的锁模型如下:(图来自【参考1】)

Java提供的锁技术: synchronized

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

synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例如下:

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

与上面的锁模型比较,可以发现synchronized修饰的方法和代码块都没有显式地有加锁和释放锁操作。但是这并不代表没有这两个操作,这两个操作Java编译器会帮我们自动实现。Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),这样的好处在于代码更简洁,并且Java程序员也不必担心会忘记释放锁了。

然后我们再观察可以发现:只有修饰代码块的时候,锁定了一个 obj 对象。那么修饰方法的时候锁了什么呢?
这是Java的一个隐式规则:

  • 当修饰静态方法时,锁的是当前类的 Class 对象,在上面的例子中就是 X.class;
  • 当修饰非静态方法时,锁定的是当前实例对象 this

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

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

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

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

内置锁

每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock)。被synchronized关键字修饰的方法或者代码块,称为同步代码块(Synchronized Block)。线程在进入同步代码块之前会自动获取锁,并且在退出同步代码块时自动释放锁,这在前面也提到过。

Java的内置锁相当于一种互斥体(或互斥锁),这也就是说,最多只有一个线程能够持有这个锁。由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子的方式执行

内置锁是可重入的

当某个线程请求一个由其他线程所持有的锁时,发出请求的线程会被阻塞。然而,由于内置锁是可重入的,所以当某个线程试图获取一个已经由它自己所持有的锁时,这个请求就会成功。

重入实现的一个方法是:为每个锁关联一个获取计数器和一个所有者线程。
当计数器值为0时,这个锁就被认为是没有被任何线程持有的。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将计数器加1。如果同一个线程再次获取这个锁,计数器将加1,而当线程退出同步代码块时,计数器会相应地减1。当计数器为0时,这个锁将被释放。

下面这段代码,如果内置锁是不可重入的,那么这段代码将发生死锁。

public class Widget{
    public synchronized void doSomething(){
        ....
    }
}
public class LoggingWidget extends Widget{
    public synchronized void doSomething(){
        System.out.println(toString() + ": call doSomething");
        super.doSomething();
    }
}

使用synchronized解决count+=1问题

前面我们介绍原子性问题时提到count+=1存在原子性问题,那么现在我们使用synchronized来使count+=1成为一个原子操作。

代码如下所示。

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

SafeCalc 这个类有两个方法:一个是 get() 方法,用来获得 value 的值;另一个是 addOne() 方法,用来给 value 加 1,并且 addOne() 方法我们用 synchronized 修饰。下面我们分析看这个代码是否存在并发问题。

addOne() 方法,被 synchronized 修饰后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行 addOne() 方法,所以一定能保证原子操作。
那么可见性呢?是否可以保证一个线程调用addOne()使value加一的结果对另一个线程后面调用addOne()时可见?
答案是可以的。这就需要回顾到我们上篇博客提到的Happens-Before规则其中关于管程中的锁规则对同一个锁的解锁 Happens-Before 后续对这个锁的加锁。即,一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。

此时还不能掉以轻心,我们分析get()方法。执行 addOne() 方法后,value 的值对 get() 方法是可见的吗?答案是这个可见性没有保证。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证。所以,最终的解决办法为也是用synchronized修饰get()方法。

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

代码转换成我们的锁模型为:(图来自【参考1】)

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

锁和受保护资源的关系

受保护资源和锁之间的关联关系非常重要,一个合理的关系为:锁和受保护资源之间的关联关系是 1:N

拿球赛门票管理来类比,一个座位(资源)可以用一张门票(锁)来保护,但是不可以有两张门票预定了同一个座位,不然这两个人就会fight。
在现实中我们可以使用多把锁锁同一个资源,如果放在并发领域中,线程A获得锁1和线程B获得锁2都可以访问共享资源,那么达到互斥访问共享资源的目的。所以,在并发编程中使用多把锁锁同一个资源不可行。或许有人会想:要同时获得锁1和锁2才可以访问共享资源,这样应该是就可行的。我觉得是可以的,但是能用一个锁就可以保护资源,为什么还要加一个锁呢?
多把锁锁一个资源不可以,但是我们可以用同一把锁来保护多个资源,这个对应到现实球赛门票就是可以用一张门票预定所有座位,即“包场”。

下面举一个在并发编程中使用多把锁来保护同一个资源将会出现的并发问题:

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

把 value 改成静态变量,把 addOne() 方法改成静态方法。
仔细观察,就会发现改动后的代码是用两个锁保护一个资源。get()所使用的锁是this,而addOne()所使用的锁是SafeCalc.class。两把锁保护一个资源的示意图如下(图来自【参考1】)。
由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题。

小结

Synchronized是 Java 在语言层面提供的互斥原语,Java中还有其他类型的锁。但是作为互斥锁,原理都是一样的,首先要有一个锁,然后是要锁住什么资源以及在哪里加锁就需要在设计层面考虑。
最后一个主题提的锁和受保护资源的关系非常重要,在使用锁时一定要好好注意。

参考:
[1]极客时间专栏王宝令《Java并发编程实战》
[2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016