• java常见锁策略与CAS


    目录

    1.常见的锁策略

    2.CAS

    3.Synchronized 原理


    1.常见的锁策略

    1.1 乐观锁与悲观锁

    悲观锁:

    总是假设最坏的情况每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这 样别人想拿这个数据就会阻塞直到它拿到锁。

    乐观锁:

    假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

    那么该如何去选择两种锁策略呢?

    从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。 

    Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略 

    这里我们接下来会讲解

    1.2 读写锁 

    多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。

    读写锁其实就是将读和写这两种操作分离成了读锁和写锁

    一个线程对于数据的访问, 主要存在两种操作: 读数据和写数据

    两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.

    两个线程都要写一个数据, 有线程安全问题.

    一个线程读另外一个线程写, 也有线程安全问题.

     

    读加锁和读加锁之间, 不互斥.

    写加锁和写加锁之间, 互斥.

    读加锁和写加锁之间, 互斥. 

    这一点和我们读写所造成的线程安全问题情况类似

    读写锁特别适合于 "频繁读, 不频繁写" 的场景中. 。比如学校的教务系统,除了新同学加入很少需要修改。Synchronized 不是读写锁.

    1.2 重量级锁和轻量级锁

    我们简单了解一下锁的基本原理:

    锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的.

    CPU 提供了 "原子操作指令".

    操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.

    JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类.

    重量级锁:悲观锁经常是重量级锁,重量级锁的加锁机制重度依赖了 OS 提供了 mutex 。由于涉及大量内核态的操作,所以效率较低,占用资源多,适合锁冲突严重的情况。

    轻量级锁:乐观锁经常是轻量级锁,轻量级锁:加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex。较少涉及内核态操作,适用于锁冲突不严重的情况(频繁读的情况)。

    1.3 自旋锁和挂起等待锁

     自旋锁:

    按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度. 但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个 时候就可以使用自旋锁来处理这样的问题.

    我们用一行伪代码来描述自旋锁的行为:

    while (抢锁(lock) == 失败) {}

    如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止。 第一次获取锁失败, 第二次的尝试会 在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁. (这个行为我们可以想象成一个天狗对女神不懈追求的场景

    自旋锁是一种典型的轻量级锁的实现方式.

    优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.

    缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是 不消耗 CPU 的). 

    挂起等待锁:

    它在发现锁冲突后会直接挂起等待,而不会立即尝试获取锁。就好像一个人追女神,但是人家已经有男朋友了,但是他并不会等女神一分手就去追求,而是先不管不顾放下一段时间,等到一段时间间隔后再去尝试。

     synchronized作为轻量级锁时内部是自旋锁,而作为重量级锁时内部是挂起等待锁

    1.4 公平锁与非公平锁 

    首先这里的公平其实是一个相对的概念,大家不用去纠结。

    假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后 C 也尝试获取锁, C 也获取失败, 也阻塞等待. 当线程 A 释放锁的时候, 会发生啥呢?

    公平锁: 遵守 "先来后到". B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.

    非公平锁: 不遵守 "先来后到". B 和 C 都有可能获取到锁. 

    操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要 想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.

    公平锁和非公平锁没有好坏之分, 关键还是看适用场景.

    synchronized 是非公平锁.

    1.5 可重入锁和不可重入锁 

    可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁

    比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗(俗称死锁)?如果不会,那么这个锁就是可重入 锁(因为这个原因可重入锁也叫做递归锁)。

    Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的。

    而 Linux 系统提供的 mutex 是不可重入锁

     比如下面这串代码:

    1. synchronized (Demo.class){//第一次加锁
    2. synchronized (Demo.class){//第二次加锁
    3. }
    4. }

     由于我们synchronized可重入锁,所以不会造成死锁现象。但是假如是不可重入锁的话,锁2等待锁1释放,而锁1的释放又等待内部的锁2加锁,两者互相矛盾就造成了死锁。

    2. CAS 

    2.1 概念

    CAS: 全称Compare and swap,字面意思:”比较并交换“

    一个 CAS 涉及到以下操作:

    我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

    1. 比较 A 与 V 是否相等。(比较)

    2. 如果比较相等,将 B 写入 V。(交换)

    3. 返回操作是否成功。

    CAS 伪代码:

     下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解 CAS 的工作流程.。

    1. boolean CAS(address, expectValue, swapValue) {
    2. if (&address == expectedValue) {
    3. &address = swapValue;
    4. return true;
    5. }
    6. return false;
    7. }

    address相当于我们上面提到的V,expectValue相当于A,swapValue相当于B。

    当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。

    CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式) 

    CAS的实现比较复杂,我们简单了解一下就好。

    java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;

    unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg; Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子 性。

    因为硬件予以了支持,软件层面才能做到CAS。 

    2.2 CAS的应用 

    2.2.1 实现原子类

    标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的. 典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

    1. AtomicInteger atomicInteger = new AtomicInteger(0);
    2. // 相当于 i++
    3. atomicInteger.getAndIncrement();

    我们简单了解一下java中有那些原子类:

     一些常用方法:

    get():获取当前的值
    getAndSet():获取当前值big设置新的值
    getAndIncrement():获取当前的值并自增
    getAndDecrement():获取当前的值并自减
    getAndAdd(int delta):获取当前的值,并加上预期的值
    compareAndSet(int expect,int update):如果当前的数组等于预期值,则以原子方式将该值设置为输入值(update)

    这里我们只是简单介绍一下原子类,想详细了解的话可以自行上网搜索。

    接下来我们看到一段代码:

    1. public class Demo {
    2. public static AtomicInteger count=new AtomicInteger(0);
    3. public static void main(String[] args) throws InterruptedException {
    4. Thread t1 = new Thread(() -> {
    5. for (int i = 0; i < 50000; i++) {
    6. // count++;
    7. // 这个方法就相当于 count++
    8. count.getAndIncrement();
    9. }
    10. });
    11. Thread t2 = new Thread(() -> {
    12. for (int i = 0; i < 50000; i++) {
    13. count.getAndIncrement();
    14. }
    15. });
    16. t1.start();
    17. t2.start();
    18. t1.join();
    19. t2.join();
    20. System.out.println("count = " + count);
    21. }
    22. }

     两个线程各运行5w次自增操作,注意这里我们是没有加锁的。结果如下:

    由于我们的count和自增操作都是原子类实现的,所以原本非原子性的自增操作也拥有了原子性。实现了在多线程情况下即使不加锁也实现线程安全。

    我们这里简单用伪代码帮大家理解一下刚才所用到的原子类。

    1. class AtomicInteger {
    2. private int value;
    3. public int getAndIncrement() {
    4. int oldValue = value;
    5. while ( CAS(value, oldValue, oldValue+1) != true) {
    6. oldValue = value;//不满足CAS条件则将旧数据更新
    7. //否则执行CAS操作
    8. }
    9. return oldValue;
    10. }
    11. }

    2.2.2 实现自旋锁

    自旋锁伪代码:

    1. public class SpinLock {
    2. private Thread owner = null;
    3. public void lock(){
    4. // 通过 CAS 看当前锁是否被某个线程持有.
    5. // 如果这个锁已经被别的线程持有, 那么就自旋等待.
    6. // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
    7. while(!CAS(this.owner, null, Thread.currentThread())){
    8. }
    9. }
    10. public void unlock (){
    11. this.owner = null;
    12. }
    13. }

    2.3 CAS 的 ABA 问题

    什么是 ABA 问题:

    ABA 的问题:

    假设存在两个线程 t1 和 t2.。有一个共享变量 num, 初始值为 A. 接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要 先读取 num 的值, 记录到 oldNum 变量中。 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z. 但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从B改成了 A

    用一句话来概括就是CAS无法判断你要修改的变量是一直都是A没有改变过,还是发生了A->B->A的变化

    ABA 问题引来的 BUG: 

    大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的。但是不排除一 些特殊情况。

    比如张三有1000存款。一天他去ATM想取走500(假设ATM按照CAS的方式工作)。但是它在取钱的时候不小心按了两下取款按钮(代表创建了两个取款线程)。本来按照正常CAS的方式,这两个线程并不会有影响,但是此时李四想起自己还欠张三500,便赶紧转了500给张三,此时张三存款又变回了1000,所以第二个取款线程也成功执行了,于是就产生了bug。

    虽然发生这种事情的概率很小,但是我们依然要想办法解决。

    解决方案:

    给要修改的值, 引入版本号。

    在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.

    CAS 操作在读取旧值的同时, 也要读取版本号.

    真正修改的时候, 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.

    如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了). 

    3. Synchronized 原理 

    3.1 基本特点

    jdk1.8的情况下,我们可以总结出以下特点:

    1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.

    2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.

    3. 实现轻量级锁的时候大概率用到的自旋锁策略

    4. 是一种不公平锁

    5. 是一种可重入锁

    6. 不是读写锁

    3.2 加锁工作过程 

    JVM将synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态。会根据情况进行依次升级

    1.无锁(没加锁)

    2.偏向锁(刚开始加锁,还未产生竞争)

    3.轻量级锁(产生锁竞争)

    4.重量级锁(锁竞争加剧) 

    其中1,3,4种情况我们都已经在上面讲过了,所以我们这里只讲解偏向锁 

    偏向锁并不是真正加锁 ,只是给中做一个 "偏向锁的标记", 记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,那么就不用进行后续加锁的操作了(节省加锁解锁的开销)

    如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别 当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态. 偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销。

    这个过程类似于单例模式中的懒汉模式,只在必要时加锁,节省开销。

    3.3 其他优化操作 

    锁消除

    编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除

    有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)

    1. StringBuffer sb = new StringBuffer();
    2. sb.append("a");
    3. sb.append("b");
    4. sb.append("c");
    5. sb.append("d");

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

    锁粗化

    一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化

    锁的粒度: 粗和细

    这里锁的粒度粗细实际上指的是锁的范围,范围越大锁的粒度越粗。

    实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.

    但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁. 

    比如下面这个例子:

    假如张三需要给领导汇报工作,他有两种方式

    方式一:

    打电话, 交代任务1, 挂电话.

    打电话, 交代任务2, 挂电话.

    打电话, 交代任务3, 挂电话.

    方式二: 打电话, 交代任务1, 任务2, 任务3, 挂电话. 

    很明显假如你是老板你肯定更喜欢方式一,方式二的话可能你已经被烦死了准备开除张三。

    这就是粗粒度锁的一种适用情景。

  • 相关阅读:
    数据库事务——数据库的锁
    美国网站服务器SSL证书介绍
    C++之static
    mysql函数
    SQL必需掌握的100个重要知识点:创建和操纵表
    学习笔记——《LINUX设备驱动程序(第三版)》Linux设备模型:内核添加、删除设备、驱动程序
    linux命令与makefile学习
    Semtech 12亿美元重大并购,物联网领域有机会出现下一个“LoRa 生态”吗?
    ZYNQ RFSoc开发板-usrp软件无线电X410mini开发板-5G评估板
    2023 (ICPC) Jiangxi Provincial Contest -- Official Contest
  • 原文地址:https://blog.csdn.net/weixin_60778429/article/details/126089701