-
Notifications
You must be signed in to change notification settings - Fork 0
Description
原子性的定义:
一个或者多个操作在 CPU执行 的过程中不被中断的特性称为 「原子性」
理解这个特性有助于帮你分析并发中 Bug 出现的原因,比如可以使用给它分析出 long 型变量在 32 位的机器上读写可能出现诡异的 Bug,造成明明已经把变量成功写入内存,重新读出来却不是自己写入的。
【因为 long 是8个 byte组成的基本变量其长度为64位,而 Java 内存模型允许 64位的变量分两次分别读取其 高32位 和 低32位的值,所以对其读取和写入是非原子操作的,在并发环境下可能出现的问题。】
原子性问题到底如何解决?
原子性问题的源头:
线程切换,导致一组操作没有执行完就切换到了别的线程。操作系统中的线程切换依赖 CPU中断 这个特性,所以禁用 CPU发生中断就能够禁用线程切换。 【但是这不就把操作系统的意义完全抹杀了,又回到了当初一台电脑运行一个程序的年代?】
早期单核年代,使用禁用CPU中断 中断来禁止线程切换这个方案是可行的,并且也有不少应用案例。 但是现在早已经是多核年代,所以已经不适合我们这个时代了。
这里以 32位 CPU 上执行对 long 型变量的写操作为例来说明这个问题:long 型变量是64位的变量,在 32位 CPU 上执行写操作会被拆分为两次写操作 —— 写高32位 与 写低三十二位,如下图所示:
在单核 CPU 场景下,同一时刻只有一个线程执行该写入操作,禁止 CPU中断,意味着 操作系统 不会重新调度线程,也就是禁止了线程的切换。
那么获得了 CPU 使用权的线程就可以不间断地执行,所以两次的写入操作一定是:『要么都执行,要么都没有被执行』,这样的操作具有原子性。
但是在 「多核场景」 下,同一时刻,可能有两个线程在同时执行,一个线程在 「CPU-1」 上,一个线程在 **「CPU-2」**上,此时 禁止CPU 中断,只能保证 单个CPU 上的线程连续执行,而并不能保证 同一时刻,所有CPU上只有一个线程执行,所以如果这两个线程同时对 long 型变量写入其高32位的值,那么就有可能出现开头提到的 Bug。
"同一时刻只有一个线程执行",这个条件非常重要,我们称其为互斥。 如果我们能够保证对 共享变量 的修改是互斥的,那么无论是 单核CPU 还是 多核CPU ,就都能够保证原子性。
简易锁模型
谈到互斥,在结合现实生活中的经验,怎样保证某项物品被你独占?把东西放在某个上锁的房子里,且只有你有钥匙。 编程中也是如此,同时下面有一个简易的锁模型:
我们将一段需要 「互斥执行」 的代码称为 「临界区」。 线程在进入临界区之前,首先尝试加锁 lock(),如果成功,则进入临界区,此时我们称这个线程持有锁。 否则说明锁被其他线程持有,则该线程进入等待状态,直到有锁的线程解锁。持有锁的线程执行完临界区的代码后,执行解锁 unlock()。
这个过程不难理解,同时脑海中也会浮现对应的 Java 代码 。
// 同步代码块中的代码,就是所谓的临界区
synchronized {
// ...
}但是有没有想过,这里我们 锁的是什么? 保护的是什么?【我还要多几个疑问,比如锁的本质 到底是什么?】
改进后的锁模型
现实世界中,锁和锁要保护的资源有着对应关系,你家的锁保护你家的东西,我用我家的锁保护我家的东西。
在并发编程的世界中,锁和资源也有这个关系,但是这个关系在上面的模型中没有体现出来,所以需要将 简易的锁模型进行一下改进:
首先,我们需要将 「临界区」中要保护的资源标注出来,如图汇总临界区内增加了一个元素:「受保护的资源R」
其次,我们要保护资源 R 就得为它创建一把锁 LR。
最后,针对这把锁 LR,我们还需要在进出临界区时增加 「加锁」 和 「解锁」 操作。 另外在 「锁LR」 和 「受保护资源」之间,作者特意用了一条线进行关联,这个关联关系非常重要。 很多并发 BUG 的出现都是因为忽略了这个关系,然后出现了类似 锁自家门保护别人家财产 的事情。<---【这个比喻非常形象,一下就让我明白了这种对应关系的重要性】
而且这样的 BUG 非常不好诊断,因为表面上看起来是已经加锁了的。
Java 语言提供的锁技术: synchronized
锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。 synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本都是下面这种形式:
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized (obj) {
// 临界区
}
}
}这里只要使用了 synchronized 关键字,Java 编译器自动在被修饰的方法或代码块之前加锁,代码执行完出临界区时执行解锁。这样的好处是 lock() 加锁和 unlock() 解锁一定成对出现,同时避免了程序员显示的进行处理,提升了封装性。 同时还避免了可能出现的严重的问题 ---> 如果程序员手动编写解锁代码的话,一旦没有解锁则将出现死锁问题。其他线程只能一直等待获取锁。
问题又来了, synchronized 里的 加锁 lock() 和 解锁 unlock() 锁定的对象在哪里? 上面例子中只有一个 synchronized(obj) 是有明确对象的,那么方法上的锁,锁对象是什么呢?
这是 Java 的一条隐式规则:
当修饰静态方法时,锁对象是当前类的
Class对象,在修饰非静态方法时,锁对象是当前的实例对象this。
对于上面的例子, synchronized 修饰静态方法相当于:
class X {
// 修饰静态方法
synchronized(X.class)
static void bar() {
// 临界区
}
}修饰非静态方法,相当于:
class X {
// 修饰非静态方法
synchronized(this)
void foo() {
// 临界区
}
}【类似Java 中 隐式 this 的使用,不需要额外指明的规则。】
用 synchronized 解决 count += 1 问题
之前说过 count += 1 是一个非原子操作,存在并发问题,现在则可以尝试使用 synchronized 来解决这个问题了。【实际上之前的地方已经使用内置锁解决了这个问题,毕竟我先学完了 《jcip》 的前5章基础章节】
代码如下所示: SafeCalc 这个类有两个方法:一个 get() 方法,用来获得 value 的值,另一个是 addOne() 方法,用来给 value 加1,并且 addOne() 方法使用 synchronized 关键字修饰。
public 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 的传递性规则,我们就可以知道 一个线程在临界区修改共享变量(该操作在解锁之前) 对后续进入临界区(该操作在加锁之后)的线程是可见的。
【同时《jcip》中也强调了,锁不仅能保证操作的原子性,也能保证可见性,原来就是这个 Hapens-Before 保证的】
按照这个规则,如果多个线程同时执行 addOne() 方法,可见性是可以保证的,也就是如果有1000个 执行 addOne() 方法,最终 value 的值一定是1000。
但是这里对于 get() 方法就不一定了。 执行 addOne() 方法后, value 的值对 get() 方法的可见性是没法保证的。
管程中锁的规则只对后续对这个锁的加锁的可见性保证【也就是同一把锁,jcip 中反复强调的 同一把锁原则,在这里突然明白了】,而 get() 方法并没有加锁,所以无法保证可见性。 所以为了保证 get() 读到的是最新的 value 值, 只需对其也进行加锁即可:
public class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}将上面的代码转为之前提到的 「锁模型」 就是下面图示的这个样子。
get() 方法和 addOne() 方法都需要访问 value 这个受保护的资源,这个资源使用的是 this 这把锁来保护,线程进入临界区 getOne() 和 addOne() 必须先获得 this 这把锁,这样 get() 和 addOne() 也是互斥的。
这个模型更像现实世界中 电影票或者球赛门票的管理系统:一个座位只允许一个人使用,这个座位就是「受保护的资源」,进入的检票口 就是 Java 类里的方法,门票就是保护资源的 「锁」,Java 里的检票工作是由 synchronzied 解决的。
锁和受保护资源的关系
之前说过,受保护资源和锁之间的关联关系 非常重要,那么他们之间具体是怎样的关系呢?
一个**「合理」的关系是:受保护资源和锁之间是 N:1 的关系。也就是使用一把锁保护多个资源**。 比如之前球赛门票管理,一个座位只能对应一张票,如果产生重复的票,则代表多个人对应一个座位,肯定会出现问题。
在现实世界中,可以使用多把锁来保护同一个资源,但是在并发领域是不行的。并发领域的锁和现实世界的锁不是完全匹配的,但是可以用一把锁保护多个资源可以与现实中的 "包场" 做类比。
将上面的例子稍作改动,将 value 修改为静态变量,将 addOne() 修改为静态方法,此时 get() 和 addOne() 是否存在并发问题呢?
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}这里可以很明显的发现,改动后的代码中存在两把锁: this 和 SafeCalc.class 。用两把锁保护一个资源 静态变量 value 那么就可能导致并发问题。
所以 JCIP 中反复强调,使用保护多个资源需要使用 「同一把锁」
总结
互斥锁在并发领域中的知名度极高,只要有了并发问题大家最先容易想到的就是通过加锁来解决,因为大家都知道加锁能够保证临界区代码的互斥性。 这样的理解虽然正确,但是不够完善。
临界区的代码是操作受保护资源的有效路径,类似于球场的入口,入口一定要门票,也就是加锁。但不是随便一把锁都能有效,所以必须深入分析 锁定的对象和受保护资源之间的关系,综合考虑受保护资源的访问路径,多方面考量才能保证用好互斥锁。
synchronized 是 Java 在语言层面提供的互斥原语,Java 中还有很多其他类型的锁,但是作为互斥锁,其原理是相同的。
锁一定要有一个锁定的对象,至于这个对象要保护的资源以及在哪里进行加锁,就是设计层面的事情了。
个人总结:
本章对于 synchronized 进行了一个例子与简单的介绍,通过这一章的学习,我对 JCIP 中反复强调的 同一把锁的重要性背后的具体原因弄明白了,感觉很值得。
课后思考:
下面代码使用 synchronized 修饰的同步带吗块是否正确?存在哪些问题?能解决可见性和原子性问题吗?
class SafeCalc {
long value = 0L;
long get() {
synchronized (new Object()) {
return value;
}
}
void addOne() {
synchronized (new Object()) {
value += 1;
}
}
}个人思考:
之前反复强调了保护资源要使用同一把锁,这里每次的锁对象 都是一个变化的 Object 对象,所以存在锁对象不一致的问题。
高质量留言:
加锁本质就是在锁对象的对象头中写入当前线程id,但是new object每次在内存中都是新对象,所以加锁无效。
经过JVM逃逸分析的优化后,这个sync代码直接会被优化掉,所以在运行时该代码块是无锁的。 <---【指出了 JVM 底层的优化机制】




