《Java并发编程》解决原子性问题,互斥锁(上)

一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为“原子性”。原子性是指CPU执行层面的,而不是高级语言层面的。如i++的操作,到cpu就是三个指令:

指令1: 首先把变量i从内存中加载到cpu的寄存器;
指令2: 之后,在寄存器中执行 +1 操作;
指令3: 最后,写入内存或cpu缓存;

操作系统做任务切换,可以发生在任何一条CPU指令执行完,是的,是CPU指令,所以多线程间切换时,会产生所谓的“原子性问题”,高级语言层面,i++这个操作不是“原子性”了。

那么如何解决原子性问题呢?

我们已经知道,原子性问题源头是线程切换,如果能禁止线程切换,就能解决这个问题。而操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断就能禁止线程切换。在早期单核CPU时代,这个方案是可行的。但是并不适合如今的多核场景。这个可以以32位CPU和64位CPU执行long型变量的问题来理解(不理解的百度一下,哈哈)。

同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能保证对共享变量的修改是互斥的,那么无论是单核还是多核CPU,就都能保证原子性了。

简易锁模型

谈到互斥,聪明的你肯定想到了那个杀手级方案,锁。

简易锁模型

我们把一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁 lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;否则呢就等待,直到持有锁的线程解锁;持有解锁unlock()。
这本身没有问题,但却很容易让我们忽视两个非常非常重要的点:我们锁的是什么?我们保护的又是什么?

改进后的锁模型

现实生活中,锁和锁要保护的资源是有对应关系的,比如你家锁保护你家的,我家锁保护我家的。并发编程的世界里,锁与资源也应该有对应关系。所以我们要完善一下模型。

改进后的锁模型

首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源 R;其次,我们要保护资源 R 就得为它创建一把锁 LR;最后,针对这把锁 LR,我们还需在进出临界区时添上加锁操作和解锁操作。另外,在锁 LR 和受保护资源之间,我特地用一条线做了关联,这个关联关系非常重要。很多并发 Bug 的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,这样的 Bug 非常不好诊断,因为潜意识里我们认为已经正确加锁了。

Java 语言提供的锁技术:synchronized

在synchronized锁中,Java默默在临界区前后加上了lock和unlock方法,好处是,加锁和解锁一定是成对出现了,毕竟如果忘记unlock解锁,意味着其他线程只能死等下去了。看一个实例:

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

上面的代码我们看到只有修饰代码块的时候,锁定了一个 obj 对象,那修饰方法的时候锁定的是什么呢?这个也是 Java 的一条隐式规则:

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

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

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

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

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

用 synchronized 解决 count+=1 问题

小试牛刀:这里有两个方法,addOne()方法用synchronized修饰,这样的情况下是否会有并发问题呢?

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

这里我们来熟悉下管程中锁的规则。

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

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

但也许,你一不小心就忽视了 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()方法也是互斥的。


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

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

你可能感兴趣的:(《Java并发编程》解决原子性问题,互斥锁(上))