在前面的分享中咱们提到。java
一个或者多个操做在 CPU 执行的过程当中不被中断的特性,称为“原子性”
思考:在32位的机器上对long型变量进行加减操做存在并发问题,什么缘由!?编程
咱们已经知道原子性问题是线程切换
,而操做系统作线程切换是依赖 CPU 中断的,因此禁止 CPU 发生中断就可以禁止线程切换。并发
在单核 CPU 时代,这个方案的确是可行的。这里咱们以 32 位 CPU 上执行 long 型变量的写操做为例来讲明这个问题,long 型变量是 64 位,在 32 位 CPU 上执行写操做会被拆分红两次写操做(写高 32 位和写低 32 位,以下图所示)。app
在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断,得到 CPU 使用权的线程就能够不间断地执行,因此两次写操做必定是:要么都被执行,要么都没有被执行,具备原子性。性能
可是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行,若是这两个线程同时写 long 型变量高 32 位的话,仍是会出现问题。this
同一时刻只有一个线程执行
这个条件很是重要,咱们称之为互斥
。
若是咱们可以保证对共享变量的修改是互斥的,那么,不管是单核 CPU 仍是多核 CPU,就都能保证原子性了。spa
互斥的解决方案,锁
。你们脑中的模型多是这样的。操作系统
线程在进入临界区以前,首先尝试加锁 lock(),若是成功,则进入临界区,此时咱们称这个线程持有锁;不然就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁 unlock()。线程
这样理解自己没有问题,但却很容易让咱们忽视两个很是很是重要的点:设计
咱们知道在现实世界里,锁和锁要保护的资源是有对应关系的,好比我用我家的锁保护我家的东西。在并发编程世界里,锁和资源也应该有这个关系,但这个关系在咱们上面的模型中是没有体现的,因此咱们须要完善一下咱们的模型。
首先,咱们要把临界区要保护的资源标注出来,如图中临界区里增长了一个元素:受保护的资源 R;其次,咱们要保护资源 R 就得为它建立一把锁 LR;最后,针对这把锁 LR,咱们还需在进出临界区时添上加锁操做和解锁操做。另外,在锁 LR 和受保护资源之间,增长了一条连线,这个关联关系很是重要,这里很容易发生BUG,容易出现了相似锁自家门来保护他家资产的事情。
锁是一种通用的技术方案,Java 语言提供的synchronized
关键字,就是锁的一种实现。synchronized
关键字能够用来修饰方法,也能够用来修饰代码块,基本使用:
class X { // 修饰非静态方法 synchronized void foo() { // 临界区 } // 修饰静态方法 synchronized static void bar() { // 临界区 } // 修饰代码块 Object obj = new Object(); void baz() { synchronized(obj) { // 临界区 } } }
参考咱们上面提到的模型,加锁 lock() 和解锁 unlock() 这两个操做在Java 编译会自动加上。这样作的好处就是加锁 lock() 和解锁 unlock() 必定是成对出现的。
上面的代码咱们看到只有修饰代码块的时候,锁定了一个 obj 对象,那修饰方法的时候锁定的是什么呢?这个也是 Java 的一条隐式规则:
当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;当修饰非静态方法的时候,锁定的是当前实例对象 this。
class X { // 修饰静态方法 synchronized(X.class) static void bar() { // 临界区 } } class X { // 修饰非静态方法 synchronized(this) void foo() { // 临界区 } }
咱们来尝试下用synchronized
解决以前遇到的 count+=1
存在的并发问题,代码以下所示。SafeCalc 这个类有两个方法:一个是 get() 方法,用来得到 value 的值;另外一个是 addOne() 方法,用来给 value 加 1,而且 addOne() 方法咱们用 synchronized 修饰。那么咱们使用的这两个方法有没有并发问题呢?
class SafeCalc { long value = 0L; long get() { return value; } synchronized void addOne() { value += 1; } }
咱们先来看看 addOne() 方法,首先能够确定,被 synchronized 修饰后,不管是单核 CPU 仍是多核 CPU,只有一个线程可以执行 addOne() 方法,因此必定能保证原子操做,那是否有可见性问题呢?
让咱们回顾下以前讲一条 Happens-Before的规则。
管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
管程,就是咱们这里的 synchronized.咱们知道 synchronized 修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;而这里指的就是前一个线程的解锁操做对后一个线程的加锁操做可见.咱们就能得出前一个线程在临界区修改的共享变量(该操做在解锁以前),对后续进入临界区(该操做在加锁以后)的线程是可见的。
按照这个规则,若是多个线程同时执行 addOne() 方法,可见性是能够保证的,也就说若是有 1000 个线程执行 addOne() 方法,最终结果必定是 value 的值增长了 1000。
咱们在来看下,执行 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() 也是互斥的。
咱们前面提到,受保护资源和锁之间的关联关系很是重要,他们的关系是怎样的呢?一个合理的关系是:
受保护资源和锁之间的关联关系是 N:1 的关系
上面那个例子我稍做改动,把 value 改为静态变量,把 addOne() 方法改为静态方法,此时 get() 方法和 addOne() 方法是否存在并发问题呢?
class SafeCalc { static long value = 0L; synchronized long get() { return value; } synchronized static void addOne() { value += 1; } }
若是你仔细观察,就会发现改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class。咱们能够用下面这幅图来形象描述这个关系。因为临界区 get() 和 addOne() 是用两个锁保护的,所以这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就致使并发问题了。
互斥锁,在并发领域的知名度极高,只要有了并发问题,你们首先容易想到的就是加锁,加锁可以保证执行临界区代码的互斥性。
synchronized 是 Java 在语言层面提供的互斥原语,其实 Java 里面还有不少其余类型的锁,但做为互斥锁,原理都是相通的:锁,必定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁 / 解锁,就属于设计层面的事情。
当咱们要保护多个资源时,首先要区分这些资源是否存在关联关系。
一样这对应到编程领域,也很容易解决。例如,银行业务中有针对帐户余额(余额是一种资源)的取款操做,也有针对帐户密码(密码也是一种资源)的更改操做,咱们能够为帐户余额和帐户密码分配不一样的锁来解决并发问题,这个仍是很简单的。
相关的示例代码以下,帐户类 Account 有两个成员变量,分别是帐户余额 balance 和帐户密码 password。取款 withdraw() 和查看余额 getBalance() 操做会访问帐户余额 balance,咱们建立一个 final 对象 balLock 做为锁(类比球赛门票);而更改密码 updatePassword() 和查看密码 getPassword() 操做会修改帐户密码 password,咱们建立一个 final 对象 pwLock 做为锁(类比电影票)。不一样的资源用不一样的锁保护,各自管各自的,很简单。
class Account { // 锁:保护帐户余额 private final Object balLock = new Object(); // 帐户余额 private Integer balance; // 锁:保护帐户密码 private final Object pwLock = new Object(); // 帐户密码 private String password; // 取款 void withdraw(Integer amt) { synchronized(balLock) { if (this.balance > amt){ this.balance -= amt; } } } // 查看余额 Integer getBalance() { synchronized(balLock) { return balance; } } // 更改密码 void updatePassword(String pw){ synchronized(pwLock) { this.password = pw; } } // 查看密码 String getPassword() { synchronized(pwLock) { return password; } } }
固然,咱们也能够用一把互斥锁来保护多个资源,例如咱们能够用 this 这一把锁来管理帐户类里全部的资源:可是用一把锁就是性能太差,会致使取款、查看余额、修改密码、查看密码这四个操做都是串行的。而咱们用两把锁,取款和修改密码是能够并行的。
用不一样的锁对受保护资源进行精细化管理,可以提高性能 。这种锁还有个名字,叫 `细粒度锁`
若是多个资源是有关联关系的,那这个问题就有点复杂了。例如银行业务里面的转帐操做,帐户 A 减小 100 元,帐户 B 增长 100 元。这两个帐户就是有关联关系的。那对于像转帐这种有关联关系的操做,咱们应该怎么去解决呢?先把这个问题代码化。咱们声明了个帐户类:Account,该类有一个成员变量余额:balance,还有一个用于转帐的方法:transfer(),而后怎么保证转帐操做 transfer() 没有并发问题呢?
class Account { private int balance; // 转帐 void transfer(Account target, int amt){ if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } }
相信你的直觉会告诉你这样的解决方案:用户 synchronized 关键字修饰一下 transfer() 方法就能够了,因而你很快就完成了相关的代码,以下所示。
class Account { private int balance; // 转帐 synchronized void transfer(Account target, int amt){ if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } }
在这段代码中,临界区内有两个资源,分别是转出帐户的余额 this.balance
和转入帐户的余额 target.balance
,而且用的是一把锁this
,符合咱们前面提到的,多个资源能够用一把锁来保护,这看上去彻底正确呀。真的是这样吗?惋惜,这个方案仅仅是看似正确,为何呢?
问题就出在 this 这把锁上,this 这把锁能够保护本身的余额 this.balance,却保护不了别人的余额 target.balance,就像你不能用自家的锁来保护别人家的资产,也不能用本身的票来保护别人的座位同样。
下面咱们具体分析一下,假设有 A、B、C 三个帐户,余额都是 200 元,咱们用两个线程分别执行两个转帐操做:帐户 A 转给帐户 B 100 元,帐户 B 转给帐户 C 100 元,最后咱们指望的结果应该是帐户 A 的余额是 100 元,帐户 B 的余额是 200 元, 帐户 C 的余额是 300 元。
咱们假设线程 1 执行帐户 A 转帐户 B 的操做,线程 2 执行帐户 B 转帐户 C 的操做。这两个线程分别在两颗 CPU 上同时执行,那它们是互斥的吗?咱们指望是,但实际上并非。由于线程 1 锁定的是帐户 A 的实例(A.this),而线程 2 锁定的是帐户 B 的实例(B.this),因此这两个线程能够同时进入临界区 transfer()。同时进入临界区的结果是什么呢?线程 1 和线程 2 都会读到帐户 B 的余额为 200,致使最终帐户 B 的余额多是 300(线程 1 后于线程 2 写 B.balance,线程 2 写的 B.balance 值被线程 1 覆盖),多是 100(线程 1 先于线程 2 写 B.balance,线程 1 写的 B.balance 值被线程 2 覆盖),就是不多是 200。
在上一篇文章中,咱们提到用同一把锁来保护多个资源,也就是现实世界的“包场”,那在编程领域应该怎么“包场”呢?很简单,只要咱们的 锁能覆盖全部受保护资源
就能够了。
这里咱们用 Account.class· 做为共享的锁
。Account.class 是全部 Account 对象共享的,并且这个对象是 Java 虚拟机在加载 Account 类的时候建立的,因此咱们不用担忧它的惟一性。
class Account { private int balance; // 转帐 void transfer(Account target, int amt){ synchronized(Account.class) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } }
下面这幅图很直观地展现了咱们是如何使用共享的锁 Account.class 来保护不一样对象的临界区的。
思考下:上面的写法不是最佳实践,锁是可变的。
对如何保护多个资源已经颇有心得了,关键是要分析多个资源之间的关系。若是资源之间没有关系,很好处理,每一个资源一把锁就能够了。若是资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该可以覆盖全部相关的资源。除此以外,还要梳理出有哪些访问路径,全部的访问路径都要设置合适的锁。
问题:在第一个示例程序里,咱们用了两把不一样的锁来分别保护帐户余额、帐户密码,建立锁的时候,咱们用的是:
private final Object xxxLock = new Object();
若是帐户余额用 this.balance 做为互斥锁,帐户密码用 this.password 做为互斥锁,你以为是否能够呢?