一、锁的策略( 面试题 )
锁的策略与编程语言无关
1. 乐观锁和悲观锁
乐观锁:假设一般情况下都不会产生锁冲突,因此就尝试直接访问数据,如果发现了锁冲突,之后再去处理。
悲观锁:假设一般情况下都会产生锁冲突,因此会先进行处理,然后再尝试访问数据。
锁冲突:线程阻塞等待,影响程序效率。
举个例子:有两个同学晚上在家做作业的时候,有一道数学题不会,准备发 QQ 问老师。
同学A 认为老师随时都有空,能够随时为自己解答,所以直接就问老师了。这就是乐观锁。
同学B 认为,既然已是放学时间,老师可能在吃晚饭,也可能已经休息了,所以自己就又把不会的数学题再做了一遍,等过了晚餐时间,同学B 再问老师问题。这就是悲观锁。
这两种思路不能说谁好谁坏,而是看当前的场景是否合适。
如果当前老师确实在休息,那么使用悲观锁的策略更合适。使用乐观锁会 " 浪费了时间 ",耗费额外的资源,而当前老师确实比较闲,那么使用乐观锁的策略更合适,使用悲观锁会让效率比较低。
Synchronized 初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。而乐观锁的一个重要功能就是要检测出数据是否发生访问冲突。
2. 读写锁
在日常开发中,很多场景都是读数据较多,写数据较少,那么此时我们就可以应用读写锁。
一个线程对于数据的访问,主要存在两种操作:读数据 和 写数据。
(1) 两个线程同时读一个数据,此时并不会造成线程不安全,直接并发读取即可。
(2) 两个线程同时写一个数据,可能就会造成线程不安全。
(3) 一个线程读,另一个线程写,也可能会造成线程不安全。
Java 标准库里提供了一个关于读和写分开的类:
某个线程如果只是读数据,就使用 ReentrantReadWriteLock.readLock 类
某个线程如果只是写数据,就使用 ReentrantReadWriteLock.writeLock 类
假设现在有10个线程。t0、t9是写线程,t1 - t8 是读线程。
如果 t1 和 t2 两个线程同时访问数据,此时两个读锁之间不会互斥,完全并发地执行,这就和没加锁无区别。
如果 t0 和 t1 两个线程同时访问数据,此时读锁和写锁之间就可能会互斥,要么是读完再写,要么是写完再读。
如果 t0 和 t9 两个写线程同时访问数据,此时写锁和写锁之间也可能会互斥,一定是一个线程写完,另一个线程再写。
对于这种读比较多,写比较少的情况,使用读写锁,就能大大地提高效率,实际上就是降低了锁冲突的概率,因为一旦锁冲突发生,就会导致线程阻塞等待,这样的阻塞等待是比较影响程序的效率的。
我们使用 synchronized 关键字为对象加锁的时候,它并没有将读和写进行区分,只要使用,就会让线程之间发生互斥,这样一来,开销大,效率也低。
3. 重量级锁和轻量级锁
重量级锁:加锁解锁开销更大,往往是通过内核来完成的。
轻量级锁:加锁解锁开销更小,往往只是在用户态完成的。
重量级锁和轻量级锁这两个锁策略和应用场景没什么关系,它们的设计策略主要与加锁解锁开销大不大相关。
(1) 内核态和用户态
用户态就是应用程序执行的代码,内核态就是操作系统执行的代码,一般来说,两者之间的切换,是一个开销较大的过程。
而内核是很忙的,要给许许多多个进程同时提供服务,因此,一旦要将某个进程把某个任务交给内核来去做,此时什么时候能把事情做好,有些难以把握了。
这就像买高铁票一样,如果我们去高铁站的人工服务窗口购票,显然是很麻烦的一件事情,第一,你要排队,第二,售票员询问你乘坐的火车类型、车次、时间…,第三,售票员要为很多人服务,所以在面对形形色色的业务,他们所执行的时间较慢。而如果你直接从网上购票就不一样了,你可以按照自己的需求,很简单方便地就选购好了,退票、换票等等也比较直接。繁忙的售票员与你购票的场景就相当于内核态和用户态交互的过程,而你自己通过网上购票的场景就是用户态自己实现的过程,除了使用手机,就是自己与自己交互的过程。
如果当前的锁就是通过内核的 mutex 来完成的,此时这样的锁往往就开销比较大。
如果当前的锁是在用户态,通过一些其他的手段来完成的,那么这样的锁往往就开销更小。
而 synchronized 初始是一个轻量级锁的状态,如果锁冲突比较严重,就会变成重量级锁。也就是说,它既是一个轻量级锁,又是一个重量级锁,根据场景,自适应。
4. 自旋锁和挂起等待锁
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度。但实际上,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题。
自旋锁伪代码:
while ( 抢锁(lock) == 失败 ) { }
上述的代码就是无限循环,也就是说:线程如果获取锁失败,就会立即再次尝试获取锁,无限循环,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。一旦锁被其他线程释放,就能第一时间获取到锁。
举个例子:打篮球的小伙伴都喜欢在回家之前投一个 " 回家球 ",意思就是,最后一个球,我投进了,才离开球场,反正我是这样…那么我第一次投球没进,就会尝试第二次投球,直到投进球为止…或者说,换个不恰当的说法:不达目的,誓不罢休。这就是自旋锁。
而换作是挂起等待锁,它就比较佛系了,我只投一个 " 回家球 ",进或不进篮筐,我都离开球场了,如果我没进球,下次来球场打球再说…
自旋锁是一种典型的 轻量级锁 的实现方式。
优点:没有放弃 CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。
缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源。( 而挂起等待的时候是不消耗 CPU 的 )
自旋锁和挂起等待锁的使用场景是什么?
① 如果锁冲突的概率比较低,使用自旋锁比挂起等待锁,更合适。
② 如果线程持有锁的时间比较短,使用自旋锁也比挂起等待锁更合适。
③ 如果对 CPU 比较敏感不希望吃太多的CPU资源,那么就不太适合使用自旋锁。
而在 Java 中,synchronized 关键字可以自适应这两者的情况。
5. 公平锁和非公平锁
举个例子:用户1、用户2、用户3…排队,在银行的 ATM 机取钱,当用户1 取钱的时候,给门上锁了。当用户1 取完钱后,锁打开了,那么就轮到用户2 取钱了,接着再轮到用户3…而像这种按排队顺序组织的线程执行就是采用了公平锁策略。
而如果用户1取完钱后,还没轮到用户2 进入房间,用户5 突然插队,去取钱了,这就是采用了非公平锁策略。
对于操作系统的调度器来说,默认就是不公平锁。而要想实现公平锁,就需要有额外的数据结构 ( 比方说,利用队列来记录线程先来后到的过程 )。那么,具体什么时候使用公平锁,什么时候使用非公平锁,就得看具体的需求。大部分情况下,使用非公平锁就够用了。有些场景下,我们期望对于线程的调度的时间成本是可控的,这个时候就需要公平锁了。
6. 可重入锁 vs 不可重入锁
我们针对一个线程加锁,第一次加锁的时候没有释放,又尝试加锁第二次,如果是不可重入锁,就会出现 " 死锁 " 现象;如果是可重入锁,就不会死锁。
代码:假设 func1 方法和 fun2 方法针对同一个对象 this 来进行加锁。
synchronized void func1() { func2(); } synchronized void func2() { }
在上面的代码中,我们发现,func1 方法先进行加锁操作,能够成功。而我们尝试对 func2 方法加锁,就会阻塞等待。
按照原有的理解,此时锁已经被 func1 占用了,因此 func2 就应该阻塞等待,等待 func1 执行完了,把锁释放了,func2 才能获取到锁。但由于 func2 在 func1 内部,所以当 func2 发生阻塞,就导致了 func1 也阻塞,于是 func1 就无法执行到最后的括号,也就释放不了锁。而这个时候,程序就发生了死锁。
而我们引入可重入就是在解决死锁这个问题,解决方式:
让当前的锁记录一下这把锁是谁持有的,如果发现当前有同一个线程再次尝试获取锁,就让代码能够继续运行,而不是阻塞等待。同时在这个锁里面也维护一个计数器,这个计数器记录了当前这个线程针对这把锁加了几次锁,每次加锁,就计数器+ +,每次解锁,就计数器- -,直到计数器为0了,此时才真的释放锁,此时才能够让其他线程获取到这个锁。
在 Java 中,其实上面的代码没有问题,因为 synchronized 就是一个可重入逻辑的锁。
二、CAS
1. 什么是 CAS
CAS:Compare and swap字面意思:比较并交换,一个 CAS 涉及到以下操作:
假设内存中的原数据 a,旧的预期值 b,需要修改的新值 c
① 比较 a 与 b 是否相等
② 若相等,将 c 写入 a;反之,不做任何操作
③ 操作成功返回 true,操作失败返回 false
2. 原子类
//原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个 AtomicBoolean AtomicInteger AtomicIntegerArray AtomicLong AtomicReference AtomicStampedReference
//以 AtomicInteger 举例,常见方法有 addAndGet(int delta); i += delta; decrementAndGet(); --i; getAndDecrement(); i--; incrementAndGet(); ++i; getAndIncrement(); i++;
3. CAS 的原子性
在之前的博客,我们了解到了:一个变量自增操作实际上是三个步骤
count++:
① 把内存中 count 的值读到 CPU 中
② 在 CPU 中执行++操作
③ 把 CPU 中的操作后的值再写回到内存中
那么,如果使用加锁的办法,将上述的操作变为原子性,其实是比较低效的,因为它要求了其他线程需要阻塞等待…因此我们就可以使用 CAS ,既能够高效地完成自增操作,又能保证原子性,从而使多线程执行安全。
Java 标准库中,为我们提供了原子类 AtomicInteger
程序清单1:
import java.util.concurrent.atomic.AtomicInteger; public class Test { public static void main(String[] args) { //括号中的0 就是 num 的初始值 AtomicInteger num = new AtomicInteger(0); //这个操作就相当于 num++ num.getAndIncrement(); System.out.println(num); //这个操作就相当于 num-- num.getAndDecrement(); System.out.println(num); } }
输出结果:
程序清单2:
在程序清单2中,这是我们之前使用多线程,让 count 自增的经典案例,t1 线程和 t2 线程各自增加 5w 次,我们尝试利用原子类来解决问题,前面我们提到了原子类具有原子性,那么此时的自增代码,我们无需进行加锁,只需要使用原子类的方法即可。
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; public class Test { static class Counter { //定义 count 初始值为 0 AtomicInteger count = new AtomicInteger(0); public ReentrantLock locker = new ReentrantLock(); //让 count 变量自增 public void increase() { count.getAndIncrement(); } } static Counter counter = new Counter(); public static void main(String[] args) { //线程1 自增 5w 次 Thread t1 = new Thread() { @Override public void run() { for (int i = 0; i < 50000; i++) { counter.increase(); } } }; //线程2 自增 5w 次 Thread t2 = new Thread() { @Override public void run() { for (int i = 0; i < 50000; i++) { counter.increase(); } } }; t1.start(); t2.start(); try { t1.join(); t2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(counter.count); } }
输出结果:
那么,CAS 在底层是怎么实现这个原子类的呢?我们就拿刚刚的自增操作来举例子:
伪代码:
class AtomicInteger { private int value; public int getAndIncrement() { int oldValue = value; while ( CAS(value, oldValue, oldValue+1) != true ) { oldValue = value; } return oldValue; } }
4. CAS 原子性的原理
首先,我们拿 oldValue 来记录当前内存中的 value. 如果 value 和 oldValue 相等,就将 value 替换成 oldValue+1;如果不相等,就循环将 oldValue 拿到新的 value.
在上图中,我们就可以看到,两个线程并发自增变量的时候,由于 CAS 操作具有原子性,那么两个线程对同一个值修改,将十分高效!此外,它并不会引入线程不安全。
所以我们就明白了,CAS 是直接读写内存的,而不是操作寄存器,CAS 的读、写内存操作是一条硬件指令,就是一个原子的操作,对于线程使用 CAS 操作来说,可视为不可分割。
5. CAS 的 ABA 问题
ABA 问题:假设存在两个线程 t1 和 t2,有一个共享变量 num,初始值为 A,接下来,线程 t1 想使用 CAS 操作 把 num 值改成 C。而与此同时,可能会发生一种情况,线程2 在中间过程中,将 num 的值变成了 B,之后 num 的值又被改回了 A;而最终线程t1 依旧将 num 的值改回了 C。虽然 num 的结果是 C 不错,当实际上 num 这个变量还是多经历了一个变化过程。
举个例子:有些黑心商家,你去店里面买手机的时候,买了一部手机,然而,用了一段时间,你会发现手机很卡,之后自己把手机拆了,发现里面的很多部件都是旧的,而这部手机就是 " 翻新机 ",它表面上和新手机看起来一模一样,但实际上,它在售卖之前,黑心商家对它进行了一顿处理,将一个二手手机换了换电池,换了换壳…然而这就是从 A 转换成 B,再从 B 转换成 A 的问题,表面上看上去是一样,但本质就不一样了。
在计算机中的大部分情况下,CAS 的 ABA 问题影响并不大,因为它所得出的结果并没有问题,只是过程不一样,但也有些地方会出现问题。
情况一
假设,Jack 去银行的 ATM 取钱,他的账户有 1000 元,他准备从 ATM 取出 500元,取的时候,ATM 顿时卡了一下, Jack 就不小心按了两下取钱。
那么此时有两个线程,线程1和线程2都尝试进行 -500 的操作
① 线程1 获取到当前的存款值为1000,线程2 也获取到存款值1000.
② 线程1尝试执行 -500 操作之前,对比 ATM 中的存款值和 oldValue 是一致的,一致就扣钱,线程1执行完毕之后,账户余额就被改成了 500
③ 线程2尝试执行 -500 操作之前,对比 ATM 中的存款值和 oldValue ,结果发现不一致,就不会进行扣钱。
此时线程1 修改成功,线程2 修改失败,ATM 虽然卡了一下,出现了两个线程,但由于 CAS 操作的机制,保证了最终的结果仍然是对的,结果存款都是 500元。
情况二
此情况下,ATM 依旧顿时卡了一下, Jack 依旧不小心按了两下取钱。
那么此时有两个线程,线程1和线程2都尝试进行 -500 的操作
① 线程1 获取到当前的存款值为1000,线程2 也获取到存款值1000.
② 线程1 尝试执行 -500 操作之前,对比 ATM 中的存款值和 oldValue 是一致的,一致就扣钱,线程1执行完毕之后,账户余额就被改成了 500
③ 在线程2尝试执行 -500 之前,突然有人给 Jack 的账户上又打了500 块钱账户,余额又变成1000了。
④ 线程2 时对比 ATM 值和 oldValue,发现结果是一致的,于是又扣了一次钱。
这个时候就出错了,本来卡里面最终剩余 500 + 500 = 1000元的,现在就只剩下 500元了。而问题呢,就出现在情况二的第 ④ 步骤,它分不清当前的1000元变过还是没变过!
情况二在 ABA 问题中,属于小概率事件,那么小概率事件需不需要被解决呢?答案是肯定的,因为这种情况已经涉及到 " 账户和金钱 " 了,比方说,现在某大型网络平台出现金额交易出错这种事故,对于那么多的用户基数,无疑是一种打击。
解决 ABA 问题的办法
CAS 操作在读取旧值的同时,也要读取版本号,按这个版本号来判断是否真正需要进行修改操作。
此处给 ATM 这个存款数据搭配一个版本号1,后续每次操作,都要对版本号+1,注意,这里版本号只允许加,不允许往后减,这样一来,我们就能明白了,版本号不可逆。而现在CAS 操作在进行对比数据的时候,就不是对比数值本身了,而是对比版本号。版本号相同,就修改;反之,就不修改。
① 线程1 获取到当前版本号是1,线程2 也获取到当前版本号1
② 线程1 尝试执行 -500 操作,对比 ATM 中版本号和自己当前记录的版本号是不是一致,一致就扣钱,结果发现一致,账户余额就被改成了 500,由于此时发生了更改操作,所以就把当前版本号改成了 2
③ 突然有人给 Jack 的账户上又打了500 块钱账户,余额又变成1000了。同时 ATM 中 的版本号变成了 3
④ 线程2 对比 ATM 中版本号和自己当前记录的版本号是不是一致,结果 ATM 的版本号为 3,自己记录的版本号为1,版本号不相等,于是不扣钱。