03.互斥锁(上):解决原子性问题

一个或多个操作在CPU执行的过程中不被中断的特性.称为"原子性".理解这个特性有助于你分析并发编程 Bug 出现的原因,例如利用它可以分析出 long 型变量在 32 位机器上读写可能出现的诡异 Bug,明明已经把变量成功写入内存,重新读出来却不是自己写入的

如何解决原子性问题?

原子性问题的源头是线程切换,如果能禁止线程切换那不就解决这个问题了吗?而操作系统ing做线程切换依赖CPU中断的,所以禁止CPU发生中断就能够禁止线程切换.

在单核CPU时代确实可行,但不适合多核场景.以32位CPU上执行long型变量的写操作为例,long型变量是64位,在32位CPU上执行写操作会被拆分成两次写操作(写高32位和写低32位)

但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写 long 型变量高 32 位的话,就会出现诡异bug.

同一时刻只有一个线程执行,称之为互斥.

简易锁模型

谈到互斥,杀手级解决方案就是:

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

但是我们锁的是什么,我们保护的又是什么?

改进后的锁模型

在现实世界里,锁和锁要保护的资源是有对应关系的,比如你用你家的锁保护你家的东西,我用我家的锁保护我家的东西。在并发编程世界里,锁和资源也应该有这个关系,但这个关系在我们上面的模型中是没有体现的,


把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源 R;其次,我们要保护资源 R 就得为它创建一把锁 LR;最后,针对这把锁 LR,我们还需在进出临界区时添上加锁操作和解锁操作。另外,在锁 LR 和受保护资源之间特地用一条线做了关联,这个关联关系非常重要。

Java 语言提供的锁技术:synchronized

锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本上都是下面这个样子:

Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁lock() 和解锁 unlock(),这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记解锁 unlock() 可是个致命的 Bug(意味着其他线程只能死等下去了)。

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

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

用 synchronized 解决 count+=1 问题

SafeCalc 这个类有两个方法:一个是get() 方法,用来获得 value 的值;另一个是 addOne() 方法,用来给 value 加 1,并且addOne() 方法我们用 synchronized 修饰。

先来看看 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 一下


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

锁和受保护资源的关系

受保护资源和锁之间的关联关系是 N:1 的关系。

把 value 改成静态变量,把 addOne() 方法改成静态方法,此时 get() 方法和 addOne() 方法是否存在并发问题呢?

总结

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

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

你可能感兴趣的:(03.互斥锁(上):解决原子性问题)