一、概述
在《并发编程—可见性、原子性、有序性 BUG源头》一章中我们提到了,一个或者多个操作在CPU执行过程中不被中断的特性称之为“原子性”,而其中原子性的源头又是线程切换,如果能够禁止线程切换那不就可以解决原子性问题了吗?而操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断就可以禁止线程切换了。
在早期的单核CPU时代,这个方案是可行的,而且也有很多应用案例,但在多核场景就不适用了。在单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就禁止了线程切换,获得CPU使用权的线程就可以不间断地执行,所以多个操作一定是:要么都被执行,要么都不被执行,具有原子性。
但是在多核场景下,同一时刻,有可能有两个线程在不同的CPU核心上执行,比如线程A在CPU-1上执行,线程B在CPU-2上执行,此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行,所有也就不能保证原子性,诡异的Bug还会存在。
“同一时刻只有一个线程执行” 这个我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就能保证原子性了。谈到互斥,我们首先想到的应该就是杀手级方案:锁。我们把需要互斥执行的代码成为临界区。线程进入临界区之前,首先尝试加锁,如果加锁成功,则进入临界区,此时这个线程就持有锁;如果加锁失败,就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁。如下图示:

<figcaption contenteditable="true" data-cke-widget-editable="caption" data-cke-enter-mode="2" data-cke-filter="17018" class="cke_widget_editable" data-cke-display-name="标题">加锁简易模型</figcaption>

二、Java中的锁技术:synchronized关键字
1、synchronized关键字简介
锁是一种通用技术方案,Java语言中提供了 synchronized关键字示锁。
synchronized关键字可以用来修饰方法,也可用来修饰代码块。如下所示:
public 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(意味着其他线程将会死等下去)。
上面代码我们只看到只有修饰代码块的时候,锁定了一个 obj 对象,那修饰方法的时候锁定的是什么呢?这个也是 Java 的一条隐式规则:
❑ 对于普通同步方法,锁是当前实例对象。
❑ 对于静态同步方法,锁是当前类的Class对象。
❑ 对于同步方法块,锁是Synchonized括号里配置的对象。
对于上面的例子,修饰静态方法相当于:
class X {
// 修饰静态方法
synchronized(X.class) static void bar() {
// 临界区
}
}

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

2、用synchronized解决 count+=1 问题
我们在前面的章节中提到过,类似于 i++, i+=1 这样的操作是非原子性的,那么我们使用 synchronized 来小试牛刀一把。如下代码所示:
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}

我们看到 addOne() 方法,首先可以肯定的是,被synchronized修饰后,无论是单核CPU还是多核CPU有,同一时刻只能有一个线程执行 addOne() 方法,所有保证了原子性,那么可见性呢?根据上一章提到的监视器规则。
对一个锁的解锁,happens-before于随后对这个锁的加锁。
可知又能保证可见性的。addOne() 方法通过 synchronized 关键字加锁后既保证了原子性,有保证了可见性。那么 get() 方法又有没有加锁就不能保证了,要想解决需要给 get() 方法也加上锁。代码如下所示:
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}

三、锁和受保护资源的关系
受保护资源和锁之间的关联关系非常重要,他们的关系是怎样的呢?一个合理的关系是:受保护资源和锁之间的关系时 N:1 的关系。也就是一个锁可以保护多个资源,就像我们现实生活中的抢坑位一样,一个坑位只能有一个门,一把锁,如果锁某个坑位有两个们分别两把锁锁着,那就回出问题了。
还是上面的代码如果改成如下所示:
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}

把 addOne() 改成了静态方法,由上文,我们可知静态方法的锁为 SafeCalc.class, 而 get() 方法的锁为 this,两把锁同时保护 value 这个共享资源,那么就不能保证并发问题了。
四、总结
互斥锁,在并发领域的知名度很高,只要有了并发问题,大家首先想到的就是加锁,因为大家知道,加锁能够保证临界区代码的互斥性。只有深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。
synchronized 是 Java 在语言层面提供的互斥原语,其实 Java 里面还有很多其他类型的锁,但作为互斥锁,原理都是相通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁 / 解锁,就属于设计层面的事情了。
参考:
[Java并发编程实战](https://time.geekbang.org/column/article/84344) [并发编程之synchronized原理分析](http://yby.ink/?p=16)
网友评论