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

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

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

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

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

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

🍬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执行相同操作时发现版本号不相同,就写入失败

具体过程如下图:

乐观锁从Java层面来看是无锁操作,直接修改共享变量

返回true,修改成功

返回false,修改失败

🌾2. 自旋锁(Spin Lock)

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

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

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默认下是非公平锁,但可以修改构造方法的参数来启用公平锁

🌾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是否相等

🍀相等就写入成功,不相等就写入失败

🍀返回修改操作的结果

画图说明如下:

🌾2. jdk如何实现CAS

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

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--操作看结果

java 复制代码
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

🌳4. CAS中的ABA问题

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

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

🍖ABA问题的解决?

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

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

🍏四. Synchronized的原理

🌴1.synchronized的特性

synchronized是基于对象头加锁,实现线程间的同步互斥

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

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

无锁

偏向锁

轻量级锁

重量级锁

🌾2. 加锁的工作过程

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

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

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

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

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

🌵3. JVM对synchronized的优化方案

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

比如这段代码:

java 复制代码
        StringBuffer sb = new StringBuffer();
        sb.append("a");
        sb.append("b");
        sb.append("c");
        sb.append("d");

此时每个 append 的调用都会涉及加锁和解锁,但如果只是在单线程中执行这个代码, 那么这些加

锁解锁操作是没有必要的, 白白浪费了一些资源开销

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

相关推荐
曹牧26 分钟前
文档格式:OFD
java
豆角焖肉1 小时前
Maven进阶与搭建私服
java·maven
大不点wow1 小时前
Java序列化与反序列化:让对象走出JVM
java·开发语言·jvm
止语Lab1 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活1 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
噢,我明白了1 小时前
Java中日期和字符串的处理
java·开发语言·日期
程序员天天困1 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn1 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
dkbnull1 小时前
Spring Boot请求处理组件对比详解
java·spring boot
jun_bai1 小时前
Orthanc服务器使用java上传dicom影像文件
java·运维·服务器