【多线程】锁策略

发布时间:2024-01-01 17:30

目录

1.乐观锁和悲观锁

2.读写锁和普通的互斥锁

3.重量级锁和轻量级锁

4. 挂起等待锁和自旋锁

4. 公平锁和不公平锁

5. 可重入锁 和 不可重入锁

6. synchronized 的锁总结

7. CAS 

7.1 CAS 伪代码

7.2 CAS是怎么实现的 

7.3 CAS 实现原子类 

7.4  实现自旋锁

8. CAS 的 ABA 问题

8.1解决方案

8.2 相关面试题


1.乐观锁和悲观锁

1)乐观锁,即预期锁冲突的概率很低。

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

例如,下一波疫情即使来了,也不用担心,生活还能正常运转,很多吃的和用品都可以买到,不需要专门做准备。(乐观锁)

2)悲观锁,即预期锁冲突的概率很高

假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并 发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
例如,下一波疫情要是来了,可能买不到吃的,就大量囤货。 (悲观锁)
3)Synchronized 初始使用乐观锁策略。 当发现锁竞争比较频繁的时候 , 就会自动切换成悲观锁策略。
就好比同学 C 开始认为 "老师比较闲的", 问问题都会直接去找老师(乐观锁)
但是直接来找两次老师之后, 发现老师都挺忙的, 于是下次再来问问题, 就先发个消息问问老师忙不
忙, 再决定是否来问问题(悲观锁)。

2.读写锁和普通的互斥锁

1)对于普通的互斥锁,只有两个操作:加锁和解锁

两个线程针对同一个对象加锁,就会产生互斥

2)对于读写锁来说,分为三个操作
加读锁:如果代码只是进行读操作,就加读锁
加写锁:如果代码中进行了修改操作,就加写锁
解锁

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

读写锁就是把读操作和写操作区分对待 .。Java 标准库提供了 ReentrantReadWriteLock , 实现了读写锁。
ReentrantReadWriteLock.ReadLock 类表示一个读锁 . 这个对象提供了 lock / unlock 方法进行加锁解锁。
ReentrantReadWriteLock.WriteLock 类表示一个写锁 . 这个对象也提供了 lock / unlock 方法进 行加锁解锁
其中

针对读锁和读锁之间,是不存在互斥关系的(多线程同时读一个变量,不会有线程安全问题)

读锁和写锁之间,写锁和写锁之间,才需要互斥 

注意:
  只要是涉及到 "互斥", 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多
久了。因此尽可能减少 "互斥" 的机会, 就是提高效率的重要途径。
读写锁特别适合于 " 频繁读 , 不频繁写 " 的场景中。
Synchronized 不是读写锁

3.重量级锁和轻量级锁

1)锁的核心特性 " 原子性 ", 这样的机制追根溯源是 CPU 这样的硬件设备提供的。
CPU 提供了 " 原子操作指令 "。
操作系统基于 CPU 的原子指令 , 实现了 mutex 互斥锁。
JVM 基于操作系统提供的互斥锁 , 实现了 synchronized ReentrantLock 等关键字和类。

 【多线程】锁策略_第1张图片

注意, synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作

synchronized 开始是一个轻量级锁。 如果锁冲突比较严重, 就会变成重量级锁

2)重量级锁

加锁机制重度依赖了 OS 提供了 mutex
大量的内核态用户态切换
很容易引发线程的调度

3)轻量级锁 

加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成 . 实在搞不定了 , 再使用 mutex。
少量的内核态用户态切换。
不太容易引发线程调度。

4) 理解用户态 vs 内核态

想象去银行办业务

在窗口外, 自己做, 这是用户态. 用户态的时间成本是比较可控的.
在窗口内, 工作人员做, 这是内核态. 内核态的时间成本是不太可控的.
如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的。

4. 挂起等待锁和自旋锁

挂起等待锁是 重量级锁 的实现方式

自旋锁是一种典型的 轻量级锁 的实现方式 .
优点 : 没有放弃 CPU, 不涉及线程阻塞和调度 , 一旦锁被释放 , 就能第一时间获取到锁 .
缺点 : 如果锁被其他线程持有的时间比较久 , 那么就会持续的消耗 CPU 资源 . ( 而挂起等待的时候是
不消耗 CPU
理解挂起等待锁和自旋锁:
去一个很富有的朋友家借米来用,人家不愿意开门
挂起等待锁:一直想着人家的米,就干在门口等着,等了十天半个月,人家终于开门了(这个等待的过程中,可能米已经花光了)
自旋锁:死皮赖脸的两三分钟敲一次门,一旦人家不耐烦了就来开门,然后就里面冲进去,人家受不了了就把米借给他了。

4. 公平锁和不公平锁

假设三个线程 A, B, C. A 先尝试获取锁 , 获取成功 . 然后 B 再尝试获取锁 , 获取失败 , 阻塞等待 ; 然后
C 也尝试获取锁 , C 也获取失败 , 也阻塞等待
公平锁 : 遵守 " 先来后到 ". B C 先来的 . A 释放锁的之后 , B 就能先于 C 获取到锁。
非公平锁 : 不遵守 " 先来后到 "。每个人的机会都均等,B C 都有可能获取到锁。
注意:
操作系统内部的线程调度就可以视为是随机的。 如果不做任何额外的限制 , 锁就是非公平锁。
如果要 想实现公平锁, 就需要依赖 额外的数据结构 , 来记录线程们的先后顺序。
公平锁和非公平锁没有好坏之分 , 关键还是看适用场景。
synchronized 是非公平锁。
对于操作系统来说,本身线程之间的调度就是随机的(机会均等的) 。操作系统提供的 mutex 这个锁,就是属于非公平锁。
 

5. 可重入锁 和 不可重入锁

一个线程,针对同一把锁连续加锁两次,如果会死锁就是不可重入锁,如果不会死锁,就是可重入锁。synchronized 是可重入锁

6. synchronized 的锁总结

1)既是一个乐观锁,也是一个悲观锁. (根据锁竞争的激烈程度自适应)
2)不是读写锁只是一个普通互斥锁.
3)既是一个轻量级锁,也是一个重量级锁(根据锁竞争的激烈程度,自适应)
4)轻量级锁的部分基于自旋锁来实现.重量级的部分基于挂起等待锁来实现
5)非公平锁
6)可重入锁.

7. CAS 

CAS: 全称 Compare and swap ,字面意思 :” 比较并交换 ,一个 CAS 涉及到以下操作:
我们假设内存中的原数据 V ,旧的预期值 A ,需要修改的新值 B
1. 比较 A V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V 。(交换)
3. 返回操作是否成功。

7.1 CAS 伪代码

下面写的代码不是原子的 , 真实的 CAS 是一个原子的硬件指令完成的 . 这个伪代码只是辅助理解
CAS 的工作流程
boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false; 
}

【多线程】锁策略_第2张图片

当多个线程同时对某个资源进行 CAS 操作,只能有一个线程操作成功,但是并不会阻塞其他线程 , 其他线程只会收到操作失败的信号。
CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)

7.2 CAS是怎么实现的 

针对不同的操作系统, JVM 用到了不同的 CAS 实现原理,简单来讲
java CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
unsafe CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg
Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性

7.3 CAS 实现原子类 

标准库中提供了 java.util.concurrent.atomic , 里面的类都是基于这种方式来实现的。
典型的就是 AtomicInteger . 其中的 getAndIncrement 相当于 i++ 操作。
代码实现
public class Demo5 {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger num = new AtomicInteger(0);

        Thread t1 = new Thread(() ->{
            for (int i = 0; i < 5000; i++) {
                //这个方法相当于num++
                num.getAndIncrement();
            }
        });

        Thread t2 = new Thread(() ->{
           for (int i = 0; i < 5000; i++) {
               //这个方法相当于num++
               num.getAndIncrement();
           }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(num.get());
    }
}

【多线程】锁策略_第3张图片

 上述代码里面不存在线程安全问题.
基于CAS实现的++操作。
这里面就可以保证既能够线程安全,又能够比synchronized高效。
synchronized会涉及到锁的竞争,两个线程要相互等待。
CAS不涉及到线程阻塞等待

7.4  实现自旋锁

基于 CAS 实现更灵活的锁 , 获取到更多的控制权。
自旋锁伪代码
public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}

和上面的原子类类似,也是通过一个循环来实现的。

循环里面调用CAS。CAS会比较当前的owner值是否是null,
如果是null就改成当前线程.意思就是当前线程拿到了锁。
如果不是null就返回false,进入下次循环。
下次循环仍然是进行CAS操作
如果当前这个锁一 直被别人持有,当前尝试加锁的线程就会在这个while的地方快速
反复的进行循环~~~ =>自旋~~ (忙等)
自旋锁是一个轻量级锁也可以视为是一个乐观锁。
 

8. CAS ABA 问题

ABA 的问题 :
假设存在两个线程 t1 t2. 有一个共享变量 num, 初始值为 A.
接下来 , 线程 t1 想使用 CAS num 值改成 Z, 那么就需要
先读取 num 的值 , 记录到 oldNum 变量中 .
使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是 , t1 执行这两个操作之间 , t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了A。A已经不再是原来的A了
ABA 问题引来的 BUG
大部分的情况下 , t2 线程这样的一个反复横跳改动 , 对于 t1 是否修改 num 是没有影响的 . 但是不排除一 些特殊情况
假设 A有 100 存款.,A想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败。(另一个线程就是在机器出故障时卡了,又按多了一个取款,启用了该线程,使用要取款失败)
如果使用 CAS 的方式来完成这个扣款过程就可能出现问题
正常过程:
1) 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
2) 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中。
3) 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败。
最后取出来50

 异常的过程:

1) 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
2) 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
3) 在线程2 执行之前, A的朋友正好给A转账 50, 账户余额变成 100 
4) 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作
最后取出来100

 这个时候, 扣款操作被执行了两次,就是ABA引起的

8.1解决方案

给要修改的值, 引入版本号.。 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。

CAS 操作在读取旧值的同时 , 也要读取版本号 .
真正修改的时候 ,
     如果当前版本号和读到的版本号相同 , 则修改数据 , 并把版本号 + 1。
     如果当前版本号高于读到的版本号 . 就操作失败 ( 认为数据已经被修改过了 )。

 在 Java 标准库中提供了 AtomicStampedReference . 这个类可以对某个类进行包装, 在内部就提 供了上面描述的版本管理功能。

8.2 相关面试题

1) 讲解下你自己理解的 CAS 机制
全称 Compare and swap, 即 "比较并交换". 相当于通过一个原子的操作, 同时完成 "读取内存, 比较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的支撑。

 2) ABA问题怎么解决?

给要修改的数据引入版本号 . CAS 比较数据当前值和旧值的同时 , 也要比较版本号是否符合预期。
如果发现当前版本号和之前读到的版本号一致 , 就真正执行修改操作 , 并让版本号自增 ; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败。

ItVuer - 免责声明 - 关于我们 - 联系我们

本网站信息来源于互联网,如有侵权请联系:561261067@qq.com

桂ICP备16001015号