[JavaEE]多线程进阶(一)------常见的锁策略,CAS,synchronized原理
摘要:本文是 JavaEE 多线程进阶系列的第二篇,主要讲解了常见的锁策略,CAS,synchronized原理.每个部分都给出了对应的面试题.
目录
1.常见的锁策略
相关面试题
2.CAS
相关面试题
3.synchronized原理
相关面试题
正文
1.常见的锁策略
如果某一天,我们认为标准库中提供的锁不够用,这个时候我们就要关注锁策略.(实际开发中synchronized已经非常好用了,足以覆盖绝大多数的使用场景).此处的锁策略不仅仅是局限于Java这门语言,其他语言,但凡涉及到并发编程,涉及到锁都可以谈到这样的锁策略(这个锁在加锁的时候有什么特点,有什么行为).
1.1悲观锁&乐观锁
悲观锁和乐观锁不是针对某一种具体情况的锁,而是某个具体的锁具有"悲观"或者"乐观"这样的特性.是两种并发控制策略.
- 悲观的特性:加锁的时候,预测接下来的锁竞争非常激烈,就需要针对这样的情况额外做一些工作。(即悲观锁是:假设冲突大概率会发生,所以提前加锁防止别人改。)示例:有一把锁,有 20 个线程尝试获取这把锁,每个线程加锁的频率都很高,预测一个线程加锁的时候,很可能被另一个线程占用着.
- 乐观的特性:加锁的时候,预测接下来的锁竞争不激烈,就不需要再额外做一些工作。(即乐观锁是:假设冲突大概率不会发生,所以先不改锁,提交时再验证有没有冲突.)示例:有一把锁,只有两个线程尝试获取这把锁,每个线程加锁的频率都很低,一个线程加锁的时候,预测大概率另一个线程没有和它竞争.
1.2重量级锁&轻量级锁
悲观锁和乐观锁描述的是,加锁时候遇到的场景.重量级锁和轻量级锁则是描述遇到场景之后的解决方案.
- 重量级锁在悲观的场景下,此时要付出更多的代价,更低效.
- 轻量级锁在乐观的场景下,此时要付出的代价更小,更高效.
重量级锁和轻量级锁的区别,本质上在于锁的实现是否依赖操作系统内核,这就引出了内核态与用户态的概念:
- 用户态:应用程序代码运行时的状态,只能访问用户自己的内存空间,不能直接操作硬件或系统资源。
- 内核态:操作系统内核运行时的状态,拥有最高权限,可以访问所有硬件和系统资源。
重量级锁依赖操作系统提供的互斥量(Mutex)来实现。加锁、解锁时,线程需要从用户态切换到内核态,。这种切换开销很大,效率较低,适合锁竞争激烈的时候.
轻量级锁(如synchronized的轻量级锁、ReentrantLock的 CAS 实现)则不依赖内核,直接在用户态通过 CAS(Compare And Swap)等原子操作完成加锁。整个过程不涉及内核态切换,开销小、速度快,适合竞争不激烈的场景.
1.3挂起锁&自旋锁
- 挂起锁是重量级锁的典型实现,属于操作系统级别。加锁失败时,线程会进入阻塞等待状态,后续需要内核来唤醒。这一特性使得挂起锁在等待过程中不消耗 CPU 资源,但代价是锁释放时不能第一时间拿到锁。因此,挂起锁适合锁竞争激烈的场景。
- 自旋锁是轻量级锁的典型实现,属于用户态级别。加锁失败时,一般不会进入阻塞等待,而是通过忙等(自旋)的方式持续尝试获取锁。这一特性使得自旋锁在等待过程中一直消耗 CPU 资源,但好处是锁一旦释放,就能第一时间拿到锁。因此,自旋锁适合锁竞争不激烈的场景。
1.4普通互斥锁&读写锁
- 普通互斥锁:同一时刻只允许一个线程持有锁,无论是读还是写,都必须先拿到锁才能访问共享资源。
- 读写锁:把锁拆分为读锁和写锁两种。
- 读锁(共享锁):多个线程可以同时持有读锁,只要没有线程持有写锁,读操作就可以并行进行,互不阻塞。
- 写锁(独占锁):同一时刻只允许一个线程持有写锁,且写锁与读锁、写锁与写锁之间都互斥。
读写锁的核心思想是:读读共享、读写互斥、写写互斥。它适合读多写少的场景。(如服务器开发中的常见场景,MySQL索引,查询等)
注意:只要涉及“互斥”,就会产生线程的挂起等待。一旦挂起等待,再次被唤醒就不知道要等多久了。因此尽可能减少“互斥”的可能。因此读写锁适合读多写少的场景。(如服务器开发中的常见场景,MySQL索引,查询等)
读写锁就是把读操作和写操作区分对待。Java标准库中提供了ReentrantReadWriteLock类实现了读写锁。
ReentrantReadWriteLock.ReadLock类表示一个读锁。这个对象提供了lock/unlock方法进行加锁解锁操作。ReentrantReadWriteLock.WriteLock类表示一个写锁。这个对象也提供了lock/unlock方法进行加锁解锁
操作。
注意:synchronized不是读写锁。
1.5可重入锁&不可重入锁
- 可重入锁:同一个线程,若一把锁可以连续加锁多次不会导致死锁,这样的锁称为可重入锁。
synchronized就是可重入锁。 - **不可重入锁:**同一个线程,若一把锁可以连续加锁多次会导致死锁。
实现可重入锁的核心思想: - 1.锁要记录当前是哪个线程拿到了这把锁。
- 2.使用计数器,记录当前加锁了多少次,在合适的时候解锁。
1.6公平锁&非公平锁
假设有三个线程A,B,C竞争锁。A拿到了锁,然后这个时候B先尝试获取锁,获取失败,于是阻塞等待。接着C尝试获取锁,获取失败,于是阻塞等待。当A释放锁的时候,会发生什么呢?
- 公平锁:遵循先来后到原则。B比C先到,当A释放锁后,B比C先获取到锁。
- 非公平锁:不遵循先来后到原则。B和C都有可能获取到锁。
注意: - 操作系统内部的线程调度就可以视为是随机的.如果不做其他额外的限制,锁就是非公平锁.如果要实现公平锁,就可以依赖额外的数据结构(如队列)记录一下各个线程获取锁的顺序.
synchronized是非公平锁.
相关面试题
1.你是怎么理解悲观锁和乐观锁的?具体怎么实现?
- 悲观锁认为多个线程访问同一个共享变量冲突的概率比较大,会在每次访问共享变量之前都去做真正的加锁.(至于加锁时采用的是重量级锁还是轻量级锁,取决于当时锁竞争的激烈程度)乐观锁认为多个线程访问同一个共享变量的时,冲突的概率不大,并不会真正的加锁,而是尝试直接尝试访问数据.在访问的同时识别当前的数据是否出现访问冲突.
- 悲观锁的实现就是先加锁,获取到锁之后再操作数据,获取不到就阻塞等待.
- 乐观锁的实现可以通过引入一个版本号.借助版本号识别当前的数据访问是否冲突. 乐观锁不真正加锁,而是通过版本号比对或CAS 原子操作(后面小节会展开),在提交/更新时校验数据是否被他人修改过。冲突了就重试或失败,适合读多写少、冲突概率低的场景.
2.介绍下读写锁?
- 读写锁就是把读操作和写操作分别进行加锁.
- 读锁和读锁之间不互斥.
- 读锁和写锁之间互斥.
- 写锁和写锁之间互斥.
- 读写锁主要用于**"读多写少**的场景.
3.什么是自旋锁?为什么要使用自旋锁策略?缺点是什么?
- 如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止.第一次获取锁失败,第二次尝试获取锁会在极端的时间内到来,当其他线程释放锁的时候,线程能第一时间获取到这把锁.
- 相对于挂起等待锁,自旋锁没有放弃CPU资源,一直处于忙等状态,只要其他线程释放锁,这个线程就能第一时间获取到锁,更高效.在锁被其他线程持有时间表较短的场景下非常有用.
- 缺点:如果锁被其他线程持有的时间比较长,则会导致CPU资源的浪费.
4.synchronized是可重入锁吗?
- 是可重入锁.
- 可重入锁指的是,可以连续加锁两次,但是不会导致死锁.
- 实现的方式是记录哪个线程获取到的锁,以及一个计时器(记录加锁的次数).如果发现当前加锁的线程就是持有锁的线程,则计数器自增.解锁时计数器递减,直到减为 0 才真正释放锁,其他线程才能获取.(如实现一个count计数器)
2.CAS
2.1什么是CAS?
CAS:全称Compare and swap,字面意思:“比较并交换”,先给出CAS的伪代码:(伪代码不能真正运行,是表达我们代码逻辑的一种手段)
booleanCAS(address,expectValue,swapValue){if(address==expectValue){address==swapValue)returntrue;}returnfalse;}一个CAS涉及到以下操作:
- 1.比较:比较
address和expectValue是否相等. - 2.交换:相等则交换
address和swapValue. - 交换成功则返回
true,否则返回false.
两种典型的不是"原子性"的代码: - 1.判定然后设值(上面的CAS伪代码就是这种形式)
- 2.读然后更新(如之前的count++,这个操作实际上对应了3条指令(先从内存读取值到CPU寄存器,进行++操作,再把数据从CPU寄存器保存到内存中),因此这并不是原子的操作).
当有多个线程对某个资源进行CAS操作时,只能由一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号.
CAS可以视为是一种乐观锁.
2.2CAS有哪些应用?
1.实现原子类
jav标准库中提供了java.util.concurrent.atomic类,里面的类都是基于这种方式实现的.
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/atomic/package-summary.html
典型的就是AtomicInteger类.其中getAndIncrement相当于i++操作.
importjava.util.concurrent.atomic.AtomicInteger;publicclassDemo01{publicstaticvoidmain(String[]args){//初始值0AtomicIntegeratomicInteger=newAtomicInteger(0);//相当于i++,保证了线程安全,但却不用加锁.atomicInteger.incrementAndGet();System.out.println(atomicInteger.get());//输出1}}上述代码,使用原子类AtomicInteger完成了++操作.count++操作是线程不安全的,但是又认为加锁效率比较低,于是可以通过CAS来实现count++,确保新能又保证线程安全.
2.实现自旋锁
自旋锁的伪代码
publicclassSpinLock{privateThreadowner=null;//记录锁属于哪个线程publicvoidlock(){//通过CAS看当前锁是否被某个线程持有//如果这个线程已经被其他线程所持有,那么就进入忙等状态,即自旋等待//如果没有被其他线程所持有,那么就把owner设置为当前尝试加锁的线程while(!CAS(this.owner,null,Thread.currentThread())){//如果锁持有者==null,即this.owner==null,那么进行交换操作就把锁持有者owner==Thread.currentThread.// return True,取反为False,此时退出循环//反之,若锁的持有者不为空,即此时被其他线程占用,this.owner!=null,那么不会进行交换操作,// return False,取反则为True,进入死循环,即忙等自旋状态.}}publicvoidunlock(){this.owner=null;//释放锁了就把线程持有者置为空}}2.3CAS的ABA问题
使用CAS能够进行线程安全的编程,核心就是先比较’‘相等’',比较address和expectValue是否相等.比较address和expectValue是否相等的本质就是在判断是否有其他线程穿插进来,做了一些修改.认为如果这里的address和expectValue一致,则认为没有线程穿插进来修改,因此认为接下来的修改操作是线程安全的.但是实际上存在这样的一种情况:另一个线程把address改成了另外的值,又从这个值改回成原来的address的值.此时有其他线程穿插进来修改,但是值不变,就会依旧认为这个线程是安全的.
举例:转账
我的余额有1000,取500,我点击"取款",没反应,于是我连续点击了两次.
- 第一次,判断oldBalance==balance,True.执行oldbalance-500,此时剩500,
- 再进行第二次判断理应是500!=1000,False,不执行oldBalance.结果在进行第二次判断前这个时候有一个人给我转了500,使得我的余额又变成了1000,1000==1000,True,执行1000-500=500,最终余额是500.
- 也就是说我原本的余额是1000,我领了500,别人给我转了500,最终余额应该是1000,结果却变成余额只剩下500.
这就是ABA问题导致的!
解决方案:引入版本号
给要修改的值额外关联一个版本号,每次修改数据时版本号 +1。这样 CAS 比较的就不再是「数据值本身」,而是「版本号」:
- CAS 在读取旧值的同时,也要读取对应的版本号。
- 真正修改时:
- 如果当前版本号 == 读到的版本号,说明数据没有被其他线程修改过,则修改数据,并把版本号 +1(这两个操作必须原子完成)。
- 如果当前版本号 != 读到的版本号(通常表现为更高),说明数据已经被其他线程修改过,则操作失败,需要重试或放弃。
为什么能解决 ABA 问题?
回到转账的例子:即使余额从 1000 → 500 → 1000(值没变),但版本号已经 1 → 2 → 3,版本号不会变回原来的值。第二次点击取款时,读到的版本号是 3,而当前版本号也是 3,但中间其实已经发生过两次修改——不过由于版本号单调递增,只要版本号变了,就能识别出「数据被动过」,从而拒绝操作。
相关面试题
1.讲解下你所理解的CAS机制?
- CAS全称Compare and Swap,即比较并交换.相当于通过一个原子的操作,同时完成:读取内存,比较是否相等,修改内存这三个步骤.本质上需要CPU指令的支撑.
2.ABA问题怎么解决?
给要修改的数据引入版本号.在CAS比较数据当前值和旧值得同时,也要比较版本号是否符合预期.如果发现当前版本号和之前读到得版本号一致,就真正执行修改操作,并让版本号自增;如果发现当前数据得版本号比之前读到得版本号大,就认为操作失败.
3.synchronized原理
3.1基本特点
结合上面的锁策略,synchronized有以下特性:
- 1.开始的时候
synchronized是乐观锁,如果锁冲突频繁会转换成悲伤锁. - 2.开始是轻量级锁,如果锁被持有的时间较长时,就会转换成重量级锁.
- 3.实现轻量级锁的时候大概率用到的的是自旋锁策略.
- 4.是不公平锁.
- 5.是可重入锁
- 6.不是读写锁
3.2synchronized的锁升级过程
synchronized的加锁过程是一个自适应的过程,会根据实际情况进行升级.会按照以下的一个升级顺序:
下面解释一下synchronized的锁升级详细过程:
(1)无锁:
(2)偏向锁(并不是真的加速,知识做一个标记):
第一个尝试加锁的线程,优先进入偏向锁状态.
- 偏向锁不是真的加锁,只是给锁对象做一个**“偏向锁的标记”**,记录这把锁属于哪个线程.
- 如果后续没有其他线程来竞争这把锁,那么就不需要真的加锁了,避免了加锁释放锁的开销.
- 如果后续有其他线程来竞争这把锁,那么就会取消原本的偏向锁状态,真的进行加锁,此时状态是轻量级锁.
偏向锁的本质相当于**“延迟加锁”.能不加锁就不加锁,尽量避免加锁带来的开销.因此偏向锁也是懒汉模式**的体现.
(3)轻量级锁:
伴随其他线程进入竞争,偏向锁状态取消,进入轻量级锁状态.此时的轻量级锁通过CAS来实现.(上面小节有具体代码实现) - 通过
CAS检查并更新一块内存 - 如果更新成功,则认为加锁成功
- 如果更新失败,说明锁被其他线程占用,进入忙等状态,即自旋等待(不放弃CPU资源)
自旋操作是一直让CPU空转,比较浪费CPU资源,因此此处的自旋不会一直持续进行,而是达到一定时间或者次数,就不再进行**自旋.**这也是synchronized的自适应的特性的再一次体现.
(4)重量级锁:
如果锁竞争再进一步变得激烈,自旋不能快速获取到锁状态,就会膨胀为重量级锁.(用到内核态提供的mutex.) - 执行加锁操作,先进入内核态.
- 在内核态中判定当前锁是否被占用.
- 如果锁没有被占用,则加锁成功,切回到用户态.
- 如果锁被占用,则加锁失败,此时线程进入阻塞等待,挂起.等待被操作系统唤醒.
- 经过沧海桑田,这个锁被其他线程释放,操作系统才想起来这个被挂起的线程,于是唤醒,才有机会获取到锁.
3.3其他优化作用
1.锁消除(锁直接消失)
锁消除也是编译器优化的一种.编译器会判定当前这个代码逻辑是否需要真正的加锁,如果不需要加锁,但是又写了synchronized,就会自动把锁去掉.
锁消除的代码示例:
下面这段代码中,sb是方法内部的局部变量,不会被其他线程共享。编译器能分析出这一点,于是判定这里的synchronized加锁没有实际意义,会自动把锁去掉(即锁消除):
publicclassDemo03{publicstaticvoidmain(String[]args){// sb 是局部变量,不会被其他线程共享StringBuildersb=newStringBuilder();for(inti=0;i<1000;i++){// StringBuilder 的 append 方法内部有 synchronized 修饰// 但由于 sb 不会被其他线程访问,编译器会消除这里的锁sb.append(i);}System.out.println(sb.length());}}2.锁粗化(减少锁的数量)
锁粗化也是编译器优化的一种.一段代码中出现多次加锁,编译器+JVM会自动进行锁的粗化.
此时要引入另一个概念:锁的粒度.
锁的粒度分为粗和细.
在正常情况下我们要缩小锁的粒度以提高并发;但如果缩得太小导致频繁开关锁太慢,JVM 就会自动把锁的粒度适当放大(粗化)来提高效率。
**举例:**你给领导汇报工作(包括了三项工作内容)
方式一:
- 接通电话,汇报工作1,挂电话
- 接通电话,汇报工作2,挂电话
- 接通电话,汇报工作3,挂电话
方式二: - 接通电话,汇报工作1,2,3,挂电话.
相关面试题
1.什么是偏向锁?
- 偏向锁不是真的加锁,而是给锁对象做一个标记记录这把锁属于哪个线程.如果后面没有其他线程来竞争锁,就不会真的执行加锁操作.如果后面又其他线程竞争锁,就会取消原本的偏向锁状态,进行真的加锁操作,此时是轻量级锁状态.
2.synchronized的实现原理是什么?
参考上述章节.