Java 多线程之常见锁策略与 CAS 中的 ABA 问题
🍀1.常见的锁策略
🍂1.1 乐观锁与悲观锁
乐观锁与悲观锁是从处理锁冲突的态度方面来进行考量分类的。
乐观锁预期锁冲突的概率很低,所以做的准备工作更少,付出更少,效率较高。
悲观锁预期锁冲突的概率很高,所以做的准备工作更多,付出更多,效率较低。
🍂1.2 读写锁与普通互斥锁
对于普通的互斥锁只有两个操作:
加锁
解锁
而对于读写锁来说有三个操作:
加读锁,如果代码仅进行读操作,就加读锁。
加写锁,如果代码含有写操作,就加写锁。
解锁。
针对读锁与读锁之间,是没有互斥关系的,因为多线程中同时读一个变量是线程安全的,针对读锁与写锁之间以及写锁与写锁之间,是存在互斥关系的。
在 java 中有读写锁的标准类,位于java.util.concurrent.locks.ReentrantReadWriteLock
,其中ReentrantReadWriteLock.ReadLock
为读锁,ReentrantReadWriteLock.WriteLock
为写锁。
🍂1.3 重量级锁与轻量级锁
这两种类型的锁与悲观锁乐观锁有一定的重叠,重量级锁做的事情更多,开销更大,轻量级锁做的事情较少,开销也就较少,在大部分情况下,可以将重量级锁视为悲观锁,轻量级锁视为乐观锁。
如果锁的底层是基于内核态实现的(比如调用了操作系统提供的 mutex 接口)此时一般认为是重量级锁,如果是纯用户态实现的,一般认为是轻量级锁。
🍂1.4 挂起等待锁与自旋锁
挂起等待锁表示当获取锁失败之后,对应的线程就要在内核中挂起等待(放弃 CPU,进入等待队列),需要在锁被释放之后由操作系统唤醒,该类型的锁是重量级锁的典型实现。自旋锁表示在获取锁失败后,不会立刻放弃 CPU,而是快速频繁的再次询问锁的持有状态一旦锁被释放了,就能立刻获取到锁,该类型的锁是轻量级锁的典型实现。
🍄挂起等待锁与自旋锁的区别
最明显的区别就是,挂起等待锁开销比自旋锁要大,且挂起等待锁效率不如自旋锁。
挂起等待锁会放弃 CPU 资源,自旋锁不会放弃 CPU 资源,会一直等到锁释放为止。
自旋锁相较于挂起等待锁更能及时获取到刚释放的锁。
自旋锁相较于挂起等待锁的劣势就是当自旋的时间长了,会持续地销耗 CPU 资源,因此自旋锁也可以说是乐观锁。
🍂1.5 公平锁与非公平锁
公平锁遵循先来后到的原则,多个线程在等待一把锁的时候,谁先来尝试拿锁,那这把锁就是谁的。非公平锁遵循随机的原则,多个线程正在等待一把锁时,当锁释放时,每个线程获取到这把锁的概率是均等的。
🍂1.6 可重入锁与不可重入锁
一个线程连续加锁两次,不会造成死锁,那么这个锁就是可重入锁。反之,一个线程连续加锁两次,会造成死锁现象,那么这个锁就是不可重入锁。
关于死锁是什么,稍等片刻,后面就会介绍到。
🍄综合上述的几种锁策略,synchronized
加的所到底是什么锁?
它既是乐观锁也是悲观锁,当锁竞争较小时它就是乐观锁,锁竞争较大时它就是悲观锁。
它是普通互斥锁。
它既是轻量级锁也是重量级锁,根据锁竞争激烈程度自适应。
轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁实现。
它是非公平锁。
它是可重入锁。
🍂1.7 死锁问题
🍁1.7.1 常见死锁的情况
死锁是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。
🍄情况 1:一个线程一把锁比如下面这种情况
首先,首次加锁,可以成功,因为当前对象并没有被加锁,然后进去方法里面,再次进行加锁,此时由于当前对象已经被锁占用,所以会加锁失败然后尝试再次加锁,此时就会陷入一个加锁的死循环当中,造成死锁。
🍄情况 2:两个线程两把锁不妨将两个线程称为 A,B,两把锁称为 S1,S2,当线程 A 已经占用了锁 S1,线程 B 已经占用了锁 S2,当线程 A 运行到加锁 S2 时,由于锁 S2 被线程 B 占用,线程 A 会陷入阻塞状态,当线程 B 运行到加锁 S1 时,由于锁 S1 被线程 A 占用,会导致线程 B 陷入阻塞状态,两个线程都陷入了阻塞状态,而且自然条件下无法被唤醒,造成了死锁。
🍄情况 3:多个线程多把锁最典型的栗子就是哲学家就餐问题,下面我们来分析哲学家就餐问题。
🍁1.7.2 哲学家就餐问题
哲学家就餐问题是迪杰斯特拉这位大佬提出并解决的问题,具体问题如下:
有五位非常固执的科学家每天围在一张圆桌上面吃饭,这个圆桌上一共有 5 份食物和 5 根 筷子,哲学家们成天都坐在桌前思考,当饿了的时候就会拿起距离自己最近的 2 根筷子就餐,但是如果发现离得最近的筷子被其他哲学家占用了,这个哲学家就会一直等,直到旁边的哲学家就餐完毕,这位科学家才会拿起左右的筷子进行就餐,就餐完毕后哲学家们又开始进行思考状态,饿了就再次就餐。
当哲学家们每个人都拿起了左边的筷子或者右边的筷子,由于哲学家们非常地顽固,拿起一只筷子后发现另一只筷子被占用就会一直等待,所以所有的哲学家都会互相地等待,这样就会造成所有哲学家都在等待,即死锁。
🍄从上述的几种造成死锁的情况,可以总结发生死锁的条件:
互斥使用,一个锁被一个线程占用后,其他线程使用不了(锁本质,保证原子性)。
不可抢占,一个锁被一个线程占用后,其他线程不能将锁抢占。
请求和保持,当一个线程占据多把锁后,除非显式释放锁,否则锁一直被该线程锁占用。
环路等待,多个线程等待关系闭环了,比如 A 等 B,B 等 C,C 等 A。
🍄如何避免环路等待?只需约定好,线程针对多把锁加锁时有固定的顺序即可,当所有的线程都按照约定的顺序加锁就不会出现环路等待。
比如对于上述的哲学家就餐问题,我们可以对筷子进行编号,每次哲学家优先拿编号小的筷子就可以避免死锁。
🍀2.CAS 指令与 ABA 问题
🍂2.1CAS 指令
CAS 即compare and awap
,即比较加交换,具体说就是将寄存器或者某个内存上的值v1
与另一个内存上的值v2
进行比较,如果相同就将v1
与需要修改的值swapV
进行交换,并返回交换是否成功的结果。
伪代码如下:
上面的这一段伪代码很明显就是线程不安全的,CPU 中提供了一条指令能够一步到位实现上述伪代码的功能,即 CAS 指令。该指令是具有原子性的,是线程安全的。
java 标准库中提供了基于 CAS 所实现的“原子类”,这些类的类名以Atomic
开头,针对常用的 int,long 等进行了封装,它们可以基于 CAS 的方式进行修改,是线程安全的。
就比如上次使用多个线程对同一个变量进行自增操作的那个程序,它是线程不安全的,但是如果使用 CAS 原子类来实现,那就是线程安全的。【注:这个线程不安全的案例在博客:Java多线程之线程安全问题1.2 部分】
其中的getAndIncrement
方法相当于i++
操作。现在我们来使用原子类中的“getAndIncrement
方法(基于 CAS 实现)来实现该程序。
🍄运行结果:
从结果我们也能看出来,该程序是线程安全的。
上面所使用的 AtomicInteger 类方法getAndIncrement
实现的伪代码如下:
首先,对于 CAS 指令,它的执行逻辑就是先判断value
的值是否与oldValue
的值相同,如果相同就将原来value
的值与value+1
的值进行交换,相当于将value
的值加1
,其中oldValue
的值为提前获取的value
值,在单线程中oldValue
的值一定与value
的值相同,但是多线程就不一定了,因为每时每刻都有可能被其他线程修改。
然后,我们再来看看下面的while
循环,该循环使用 CAS 指令是否成功为判断条件,如果 CAS 成功了则退出循环,此时value
的值已经加1
,最终返回oldValue
,因为后置++
先使用后++
。如果 CAS 指令失败了,这就说明有新线程提前对当前的value
进行了++
,value
的值发生了改变,这时候需要重新保存value
的值给oldValue
,然后尝试重新进行 CAS 操作,这样就能保证有几个线程操作,那就自增几次,从而也就保证了线程安全,总的来说相当于传统的++
操作,基于 CAS 的自增操作只有两个指令,一个是将目标值加载到寄存器,然后在寄存器上进行 CAS 操作,前面使用传统++
操作导致出现线程安全问题是指令交错的情况,现在我们来画一条时间轴,描述 CAS 实现的自增操作在多个线程指令交错时的运行情况。
发现尽管指令交错了,但是运行得到的结果预期也是相同的,也就说明基于 CAS 指令实现的多线程自增操作是线程安全的。
此外,基于 CAS 也能够实现自旋锁,伪代码如下:
根据 CAS 与自旋锁的逻辑,如果当前锁对象被线程占用,则lock
方法会反复地取获取该锁是否释放,如果释放了即owner==null
,就会利用 CAS 操作将占用该锁对象的线程设置为当前线程,并退出加锁lock
方法。
解锁方法非常简单,就将占用锁对象的线程置为null
即可。
🍂2.2ABA 问题
根据上面的介绍我们知道 CAS 指令操作的本质是先比较,满足条件后再进行交换,在大部分情况下都能保证线程安全,但是有一种非常极端的情况,那就是一个值被修改后又被改回到原来的值,此时 CAS 操作也能成功执行,这种情况在大多数的情况是没有影响的,但是也存在问题。
像上述一个值被修改后又被改回来这种情况就是 CAS 中的 ABA 问题,虽说对于大部分场景都不会有问题,但是也存在 bug,比如有以下一个场景就说明了 ABA 问题所产生的 bug:
有一天。滑稽老铁到 ATM 机去取款,使用 ATM 查询之后,滑稽老铁发现它银行卡的余额还有200
,于是滑稽老铁想去100
块给女朋友买小礼物,但是滑稽老铁取款时,在点击取款按钮后机器卡了一下,滑稽老铁下意识又点了一下,假设这两部取款操作执行图如下:
如果没有出现意外,即使按下两次取款按钮也是正常的,但是在这两次 CAS 操作之间,如图滑稽老铁的朋友给它转账了 100 块,导致第一次 CAS 扣款 100 后的余额从 100 变回到了 200,这时第二次 CAS 操作也会执行成功,导致又被扣款 100 块,最终余额是 100 块,这种情况是不合理的,滑稽老铁会组织滑稽大军讨伐银行的,合理的情况应该是第二次 CAS 仍然失败,最终余额为 200 元。
为了解决 ABA 问题造成的 bug,可以引入应该版本号,版本号只能增加不能减少,加载数据的时候,版本号也要一并加载,每一次修改余额都要将版本号加1
, 在进行 CAS 操作之前,都要对版本号进行验证,如果版本号与之前加载的版本号不同,则放弃此次 CAS 指令操作。
上面的这张图是引入版本号之后,滑稽老铁账户余额变化图,我们不难发现余额的变化是合理的。
总结一下,本篇文章介绍了常见的锁策略,并说明了synchronized
关键字加的锁类型不是单一一种锁类型的,根据可重入锁与非可重入锁引出了死锁的概念与死锁条件,最后介绍了 CAS 指令以及 CAS 锁产生的 ABA 问题及其解决方案,不知道小伙伴们读了博客有何收获呢?
下期预告:Java 多线程之锁优化与 JUC 常见类
版权声明: 本文为 InfoQ 作者【未见花闻】的原创文章。
原文链接:【http://xie.infoq.cn/article/d82029af7cfbcdc56437723b6】。文章转载请联系作者。
评论