@[TOC]
引言
在第一篇文章中咱们提到,一个或者多个操作在CPU执行的过程中不被中断的个性,称为“原子性”。了解这个个性有助于你剖析并发编程Bug呈现的起因,例如利用它能够剖析出long型变量在32位机器上读写可能呈现的诡异Bug,明明曾经把变量胜利写入内存,从新读出来却不是本人写入的。
那原子性问题到底该如何解决呢?
你曾经晓得,原子性问题的源头是线程切换,如果可能禁用线程切换那不就能解决这个问题了吗?而操作系统做线程切换是依赖CPU中断的,所以禁止CPU产生中断就可能禁止线程切换。
在晚期单核CPU时代,这个计划确实是可行的,而且也有很多利用案例,然而并不适宜多核场景。这里咱们以32位CPU上执行long型变量的写操作为例来阐明这个问题,long型变量是64位,在32位CPU上执行写操作会被拆分成两次写操作(写高32位和写低32位,如下图所示)。
在单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会从新调度线程,也就是禁止了线程切换,取得CPU使用权的线程就能够不间断地执行,所以两次写操作肯定是:要么都被执行,要么都没有被执行,具备原子性。
然而在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,此时禁止CPU中断,只能保障CPU上的线程间断执行,并不能保障同一时刻只有一个线程执行,如果这两个线程同时写long型变量高32位的话,那就有可能呈现咱们结尾提及的诡异Bug了。
“同一时刻只有一个线程执行”这个条件十分重要,咱们称之为互斥。如果咱们可能保障对共享变量的批改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保障原子性了。
解决原子问题
繁难锁模型
当谈到互斥,置信聪慧的你肯定想到了那个杀手级解决方案:锁。同时大脑中还会呈现以下模型:
咱们把一段须要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁lock(),如果胜利,则进入临界区,此时咱们称这个线程持有锁;否则呢就期待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁unlock()。
这个过程十分像办公室里高峰期抢占坑位,每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。很长时间里,我也是这么了解的。这样了解自身没有问题,但却很容易让咱们漠视两个十分十分重要的点:咱们锁的是什么?咱们爱护的又是什么?
改良后的锁模型
咱们晓得在事实世界里,锁和锁要爱护的资源是有对应关系的,比方你用你家的锁爱护你家的货色,我用我家的锁爱护我家的货色。在并发编程世界里,锁和资源也应该有这个关系,但这个关系在咱们下面的模型中是没有体现的,所以咱们须要欠缺一下咱们的模型。
首先,咱们要把临界区要爱护的资源标注进去,如图中临界区里减少了一个元素:受爱护的资源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) { // 临界区 } } }
看完之后你可能会感觉有点奇怪,这个和咱们下面提到的模型有点对不上号啊,加锁lock()和解锁unlock()在哪里呢?其实这两个操作都是有的,只是这两个操作是被Java默默加上的,Java编译器会在synchronized润饰的办法或代码块前后主动加上加锁lock()和解锁unlock(),这样做的益处就是加锁lock()和解锁unlock()肯定是成对呈现的,毕竟遗记解锁unlock()可是个致命的Bug(意味着其余线程只能死等上来了)。
那synchronized里的加锁lock()和解锁unlock()锁定的对象在哪里呢?下面的代码咱们看到只有润饰代码块的时候,锁定了一个obj对象,那润饰办法的时候锁定的是什么呢?这个也是Java的一条隐式规定:
- 当润饰静态方法的时候,锁定的是以后类的Class对象,在下面的例子中就是Class X;
- 当润饰非静态方法的时候,锁定的是以后实例对象this。
对于下面的例子,synchronized润饰静态方法相当于:
class X { // 润饰静态方法 synchronized(X.class) static void bar() { // 临界区 } } 润饰非静态方法,相当于: class X { // 润饰非静态方法 synchronized(this) void foo() { // 临界区 } }
用synchronized解决count+=1问题
置信你肯定记得咱们后面文章中提到过的count+=1存在的并发问题,当初咱们能够尝试用synchronized来小试牛刀一把,代码如下所示。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 于后续对这个锁的加锁。
管程,就是咱们这里的synchronized(至于为什么叫管程,咱们前面介绍),咱们晓得synchronized润饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;而所谓“对一个锁解锁 Happens-Before 后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,综合Happens-Before的传递性准则,咱们就能得出前一个线程在临界区批改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。
依照这个规定,如果多个线程同时执行addOne()办法,可见性是能够保障的,也就说如果有1000个线程执行addOne()办法,最终后果肯定是value的值减少了1000。看到这个后果,咱们长出一口气,问题终于解决了。
但兴许,你一不小心就漠视了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()也是互斥的。
这个模型更像事实世界外面球赛门票的治理,一个座位只容许一个人应用,这个座位就是“受爱护资源”,球场的入口就是Java类里的办法,而门票就是用来爱护资源的“锁”,Java里的检票工作是由synchronized解决的。
锁和受爱护资源的关系
咱们后面提到,受爱护资源和锁之间的关联关系十分重要,他们的关系是怎么的呢?一个正当的关系是:受爱护资源和锁之间的关联关系是N:1的关系。还拿后面球赛门票的治理来类比,就是一个座位,咱们只能用一张票来爱护,如果多发了反复的票,那就要打架了。事实世界里,咱们能够用多把锁来爱护同一个资源,但在并发畛域是不行的,并发畛域的锁和事实世界的锁不是齐全匹配的。不过倒是能够用同一把锁来爱护多个资源,这个对应到事实世界就是咱们所谓的“包场”了。
下面那个例子我稍作改变,把value改成动态变量,把addOne()办法改成静态方法,此时get()办法和addOne()办法是否存在并发问题呢?
static long value = 0L; synchronized long get() { return value; } synchronized static void addOne() { value += 1; } }
如果你仔细观察,就会发现改变后的代码是用两个锁爱护一个资源。这个受爱护的资源就是动态变量value,两个锁别离是this和SafeCalc.class。咱们能够用上面这幅图来形象形容这个关系。因为临界区get()和addOne()是用两个锁爱护的,因而这两个临界区没有互斥关系,临界区addOne()对value的批改对临界区get()也没有可见性保障,这就导致并发问题了。
如何用一把锁爱护多个资源
在上文中,咱们提到受爱护资源和锁之间正当的关联关系应该是N:1的关系,也就是说能够用一把锁来爱护多个资源,然而不能用多把锁来爱护一个资源,并且联合文中示例,咱们也重点强调了“不能用多把锁来爱护一个资源”这个问题。而至于如何爱护多个资源,咱们明天就来聊聊。
当咱们要爱护多个资源时,首先要辨别这些资源是否存在关联关系。
爱护没有关联关系的多个资源
在事实世界里,球场的座位和电影院的座位就是没有关联关系的,这种场景非常容易解决,那就是球赛有球赛的门票,电影院有电影院的门票,各自治理各自的。
同样这对应到编程畛域,也很容易解决。例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户明码(明码也是一种资源)的更改操作,咱们能够为账户余额和账户明码调配不同的锁来解决并发问题,这个还是很简略的。
相干的示例代码如下,账户类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这一把锁来治理账户类里所有的资源:账户余额和用户明码。具体实现很简略,示例程序中所有的办法都减少同步关键字synchronized就能够了,这里我就不一一展现了。
然而用一把锁有个问题,就是性能太差,会导致取款、查看余额、批改明码、查看明码这四个操作都是串行的。而咱们用两把锁,取款和批改明码是能够并行的。用不同的锁对受爱护资源进行精细化治理,可能晋升性能。这种锁还有个名字,叫细粒度锁。
爱护有关联关系的多个资源
如果多个资源是有关联关系的,那这个问题就有点简单了。例如银行业务外面的转账操作,账户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,就像你不能用自家的锁来爱护他人家的资产,也不能用本人的票来爱护他人的座位一样。用锁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。并发转账示意图:
应用锁的正确姿态
在上一篇文章中,咱们提到用同一把锁来爱护多个资源,也就是事实世界的“包场”,那在编程畛域应该怎么“包场”呢?很简略,只有咱们的锁能笼罩所有受爱护资源就能够了。在下面的例子中,this是对象级别的锁,所以A对象和B对象都有本人的锁,如何让A对象和B对象共享一把锁呢?
略微开动脑筋,你会发现其实计划还挺多的,比方能够让所有对象都持有一个唯一性的对象,这个对象在创立Account时传入。计划有了,实现代码就简略了。示例代码如下,咱们把Account默认构造函数变为private,同时减少一个带Object lock参数的构造函数,创立Account对象时,传入雷同的lock,这样所有的Account对象都会共享这个lock了。
class Account { private Object lock; private int balance; private Account(){}; // 创立Account时传入同一个lock对象 public Account(Object lock) { this.lock = lock; } // 转账 void transfer(Account target, int amt) { // 此处查看所有对象共享的锁 synchronized (lock) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } }
这个方法的确能解决问题,然而有点小瑕疵,它要求在创立Account对象的时候必须传入同一个对象,如果创立Account对象时,传入的lock不是同一个对象,那可就惨了,会呈现锁自家门来爱护他家资产的荒唐事。在实在的我的项目场景中,创立Account对象的代码很可能扩散在多个工程中,传入共享的lock真的很难。
所以,下面的计划不足实际的可行性,咱们须要更好的计划。还真有,就是用Account.class作为共享的锁。Account.class是所有Account对象共享的,而且这个对象是Java虚拟机在加载Account类的时候创立的,所以咱们不必放心它的唯一性。应用Account.class作为共享的锁,咱们就无需在创立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来爱护不同对象的临界区的。
小结
互斥锁,在并发畛域的知名度极高,只有有了并发问题,大家首先容易想到的就是加锁,因为大家都晓得,加锁可能保障执行临界区代码的互斥性。这样了解尽管正确,然而却不可能领导你真正用好互斥锁。临界区的代码是操作受爱护资源的门路,相似于球场的入口,入口肯定要检票,也就是要加锁,但不是轻易一把锁都能无效。所以必须深入分析锁定的对象和受爱护资源的关系,综合思考受爱护资源的拜访门路,多方面考量能力用好互斥锁。
synchronized是Java在语言层面提供的互斥原语,其实Java外面还有很多其余类型的锁,但作为互斥锁,原理都是相通的:锁,肯定有一个要锁定的对象,至于这个锁定的对象要爱护的资源以及在哪里加锁/解锁,就属于设计层面的事件了。
“原子性”的实质是什么?其实不是不可分割,不可分割只是外在体现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在32位的机器上写long型变量有中间状态(只写了64位中的32位),在银行转账的操作中也有中间状态(账户A缩小了100,账户B还没来得及发生变化)。所以解决原子性问题,是要保障中间状态对外不可见。