锁策略相关问题(面试常考)

2023年 10月 9日 55.7k 0

🍑一. JUC包(java.util.concurrent)下的常见类

juc包下的所有类都是提供多线程并发编程用的,不仅满足线程安全而且效率也很高

ReentranLock是可重入锁,具体用法:

        Lock lock = new ReentrantLock();
        try{
            lock.lock(); //锁对象加锁,只有一个线程获取到锁
            //需要保证线程安全的代码
        }finally{
            lock.unlock(); //不管是否出现异常都需要释放锁
        }

lock提供了更多获取锁的方式:

image.png

🍬synchronized与lock的区别:

🍂synchronized是一个关键字在JVM内部实现的,lock是标准库的一个类在JVM外部实现

🍂语法上看,sychronized是自动加锁与释放锁,lock是手动的加锁与释放锁,相对lock比较灵活,但需要保证不管是否发生异常都需要释放锁

🍂lock获取锁的方式相对更加丰富,提供了多种api

🍂从效率上看,当线程冲突严重时,lock性能要高很多

🍖线程冲突严重时lock性能高的原因:

synchronized在释放锁以后,之前因为申请锁失败而阻塞的线程,都会再次竞争
lock是基于aqs来实现,aqs是一个双端队列专门用来管理线程的状态,竞争失败的线程就入队列并设置状态,释放锁后把队列中的线程引用拿出来并设置状态(获取到锁)

🍬如何选择使用哪个锁呢?

🍃当锁竞争不激烈的时候,使用synchronized效率高,自动释放更方便

🍃当锁竞争激烈的时候,使用lock,搭配tryLock更灵活控制加锁的行为,而不是死等

🍃需要使用公平锁的时候使用lock,lock默认是非公平的锁,但可以通过构造方法传入true开启公平模式(后面在锁策略中介绍)

🍉二. 常见的锁策略

🌴1. 乐观锁与悲观锁

悲观锁: 大部分时间来看,都是存在线程冲突的(悲观的看待问题),每次拿数据的时候都认为会修改,所以每次拿数据的时候都先加锁,完成执行操作后再释放锁

乐观锁: 大部分时间来看,是不存在线程冲突的(乐观的看待问题),每次都直接执行修改数据的操作,在数据提交更新的结果时,才会检测是否产生并发冲突,返回是否修改成功的结果,让程序自行处理逻辑

🍖乐观锁最重要的就是检测出是否发生线程冲突,这里引入一个版本号(version)解决:

主存中有变量和一个版本号,两个线程同时将主存中的信息读到各自的寄存器中,然后进行修改,线程1先修改然后往主存中写,然后对比版本号,相同的话version++,当线程2执行相同操作时发现版本号不相同,就写入失败

具体过程如下图:

image.png

乐观锁从Java层面来看是无锁操作,直接修改共享变量
返回true,修改成功
返回false,修改失败

🌾2. 自旋锁(Spin Lock)

线程在竞争锁失败的时候会进入阻塞状态,放弃CPU,需要过很久才能被再次调度,但是实际上,虽然当前竞争锁失败但是没有过多久就竞争到锁,没必要放弃CPU,这个时候就可以使用自旋锁来解决这种情况

自旋锁实际也是Java层面的无锁操作

while(true){
    boolean result = 乐观锁方式修改数据;
    if(result) break;
}

说明: 如果获取锁失败,就立即在尝试获取锁,无限循环直到获取到锁,一旦其他线程释放锁,就能第一时间获取到锁

如果满足乐观锁的使用条件:冲突概率比较小,如果发生冲突也能够很快的得到执行 
一般来说乐观锁都考虑结合自旋锁来操作

🌵3. 读写锁

多个线程在数据读取时不会存在线程安全问题,但多个线程的写与读和写需要进行加锁,如果这两种场景下都用同一个锁就会产生很大的性能损耗,所以就要使用读写锁

读写锁就是对读和写操作区分对待,Java标准库中提供了ReentrantReadWriteLock类,实现了读写锁

ReentrantReadWriteLock.ReadLock表示一个读锁,提供了lock/unlock方法进行加锁释放锁
ReentrantReadWriteLock.WriteLock表示一个写锁,提供了lock/unlock方法进行加锁释放锁

关于读写锁:

🍁读与读之间不互斥
🍁写与写之间互斥
🍁读与写之间互斥

🍬读写锁适用频繁读,不频繁写的场景中

🌳4. 重量级锁与轻量级锁

重量级锁: 加锁机制重度依赖OS提供的mutex 
大量的内核态与用户态切换
容易引发线程的调度
操作成本高

轻量级锁: 加锁机制尽可能不用mutex,而是尽量在用户态代码完成
少量的内核态与用户态切换
不太容易引发线程调度
操作成本相对低

🌴5. 公平锁与非公平锁

非公平锁:不以申请锁的时间先后顺序来获取到锁
缺陷:可能出现线程饥饿现象
优点:效率更高

公平锁:以申请锁的时间先后顺序来获取到锁

🍂synchronized是非公平锁,因为多个线程之间申请锁是竞争关系,不存在时间先后顺序
🍂lock默认下是非公平锁,但可以修改构造方法的参数来启用公平锁

image.png

🌾6. 可重入锁与不可重入锁

可重入锁: 同一个线程可以重复申请到同一个对象的锁
不可重入锁: 同一个线程不可以重复申请到同一个对象的锁

Java中,只要以Reentrant开头命名的锁都是可重入锁,lock实现类,synchronized关键字锁都是可重入锁

🍋三. CAS

🌴1. 认识CAS

CAS全称Compared And Swap(比较并交换),是jdk提供的一种乐观锁的实现,能够满足线程安全,以乐观锁的方式修改变量

比较并交换(比较,+1,交换)为一条CAS指令,是线程安全的

此处所谓的CAS指的是CPU提供了一个单独的CAS指令,通过这个指令可以完成比较并交换的过程 

🍬CAS操作步骤:

🍀从主存拿值读取到自己的工作内存中,记为值A,执行修改操作后,值为B,主存的最新值记为C
🍀将B写入主存时比较A与主存的最新值C是否相等
🍀相等就写入成功,不相等就写入失败
🍀返回修改操作的结果

画图说明如下:

image.png

🌾2. jdk如何实现CAS

jdk提供了一个Unsafe的类,来执行cas的操作

image.png

jdk中unsafe提供的cas方法,是基于操作系统提供的cas方法,又是基于CPU提供的cas硬件支持,且CPU使用了lock加锁

简单说,Unsafe是基于操作系统和CPU提供的cas来实现的

🌵3. CAS的常见应用

Java的java.util.concurrent.atomic包下都是使用了cas来保证线程安全的,例如:++,--,!flag

原因: ++,--,!flag这种操作执行速度非常快,很快能得到执行,使用cas+自旋锁效率更高

既能够保证线程安全又能够比synchronized高效,因为不涉及竞争锁的竞争,线程要等待 

典型的就是AtomicInteger类:

🍂addAndGet(int delta) i += delta

🍂decrementAndGet() --i

🍂getAndDecrement() i--

🍂incrementAndGet() ++i

🍂getAndIncrement() i++

👁‍🗨️例如:执行1000次n++和n--操作看结果

import java.util.concurrent.atomic.AtomicInteger;
 
public class AutoInt {
    //共享变量n初始值为0
    private static AtomicInteger n = new AtomicInteger(0);
 
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0;i < 1000;i++){
                    n.getAndIncrement(); //1000次n++;
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0;i < 1000;i++) {
                    n.getAndDecrement(); //1000次n--
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(n);
    }
}

结果:打印结果为0

image.png

🌳4. CAS中的ABA问题

在没有引入版本号的情况下,CAS是基于变量的值,在读和写的时候比较的,但这个时候会存在下面的一个问题:

image.png

从中发现,如果在当前线程写入值进行比较的时候,如果有其他线程对主存中的的值进行修改,修改为当前线程从主存读取的值的时候,当前线程仍然能写入成功,但是存在线程安全问题

🍖ABA问题的解决?

引入版本号,线程每次修改共享变量的时候版本号+1,比较的时候比较读和写时候版本号是否相同,相同的话写入成功,否则写入失败

jdk中,提供了一个AtomicStampedReferenceE>的类,这个类可以对某个类进行包装,在其内部就提供了版本号的管理

🍏四. Synchronized的原理

🌴1.synchronized的特性

synchronized是基于对象头加锁,实现线程间的同步互斥
同步互斥:对同一个对象进行加锁的多个线程,同一个时间只有一个线程获取到锁,相当于多个线程获取锁执行同步代码是互斥的

image.png

🍬synchronized加锁操作,,只涉及对象头状态升级能升级不能降级,升级的过程:

无锁
偏向锁
轻量级锁
重量级锁

🌾2. 加锁的工作过程

image.png

偏向锁:第一次进入的线程,或是这个线程再次申请同一个对象锁

偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程.如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)如果后续其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态

轻量级锁:随着其他线程的竞争进入轻量级锁状态,CAS+(自适应的)自旋锁

自适应:自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源,因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了,也就是所谓的 "自适应"

重量级锁:竞争锁进一步激烈,就会升级为重量级锁(使用操作系统mutex加锁)

🌵3. JVM对synchronized的优化方案

锁消除,前提:变量只有一个线程可以操作

比如这段代码:

        StringBuffer sb = new StringBuffer();
        sb.append("a");
        sb.append("b");
        sb.append("c");
        sb.append("d");

此时每个 append 的调用都会涉及加锁和解锁,但如果只是在单线程中执行这个代码, 那么这些加
锁解锁操作是没有必要的, 白白浪费了一些资源开销

image.png

锁粗化,前提:变量是多个线程可以使用,,但是一个线程出现多次连续sychronized加锁

image.png

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论