当前位置: 首页 > article >正文

多线程进阶(一):锁策略 锁升级 锁消除 锁粗化 CAS

目录

1. 锁策略

1.1 悲观锁 vs 乐观锁

 1.2 重量级锁 vs 轻量级锁

1.3 挂起等待锁 vs 自旋锁

1.4 普通互斥锁 vs 读写锁

1.5 可重入锁 vs 不可重入锁

1.6 公平锁 vs 非公平锁

2. 锁升级

2.1 偏向锁

3. 锁消除

4. 锁粗化

5. CAS

5.1 CAS 指令概念及特点

5.2 基于 CAS 的实现原子类

5.3 基于 CAS 实现自旋锁

5.4 ABA 问题

5.4.1 解决方案 --- "版本号"


前言:

多线程初阶中的知识, 更加贴合实际工作中的情况.

而多线程进阶中知识, 更加贴合面试, 工作中很少用到, 面试却要考~(八股文).

1. 锁策略

当标准库给我们提供的锁不够用时, 我们需要自己实现一把锁, 那我们就需要关注锁策略.(其实 synchronized 足以覆盖绝大多数使用场景).

所谓锁策略, 就是锁在加锁时, 具有哪些特点, 表现出哪些行为.

1.1 悲观锁 vs 乐观锁

悲观锁 和 乐观锁, 并不是指某一特定的具体的锁, 而是锁具有 "悲观" 或 "乐观" 这样的特性. 是用来描述加锁时所遇到的场景的.

  • 悲观: 加锁的时候, 预测接下来的锁竞争的情况非常激烈, 就需要对这样的激烈情况额外做出一些工作.
  • 乐观: 加锁的时候, 预测接下来的锁竞争的情况不激烈, 就不需要额外做一些工作.

比如:

  1. 一把锁, 二三十个线程都在尝试获取锁, 每个线程加锁的频率都很高(都想要这把锁), 一个线程加锁的时候, 这把锁很可能已经被其他线程占用了. --- 悲观锁
  2. 一把锁, 只有两个线程在长丝获取锁, 每个线程加锁的频率很低, 一个线程加锁的时候, 另一个线程大概率没有占用这把锁 --- 乐观锁
  • 同样:
  1. 一个秋招大厂的 offer , 通常几千人在竞争, 竞争情况非常激烈, 这就是"悲观"的.
  2. 实习大厂的 offer , 就相对容易很多, 比如大三上学期时, 大部分人还没意识到实习, 竞争的人少, 机会自然就更大, 这就是 "乐观" 的.

 1.2 重量级锁 vs 轻量级锁

重量级锁 和 轻量级锁 可以说是 悲观锁 和 乐观锁 场景下的解决方案(但通常来说, 两组都会被理解为一个意思).

  • 重量级锁, 在悲观的场景下, 此时就要付出更多的代价(更低效)
  • 轻量级锁, 在乐观的场景下, 此时付出的代价就会更小(更高效)

1.3 挂起等待锁 vs 自旋锁

  • 挂起等待锁, 是重量级锁(竞争激烈情况下)的典型实现. 是操作系统内核级别的, 加锁的时候发现竞争, 该线程就会进入阻塞等待状态, 后续只能等待内核进行唤醒. (获取锁的周期更长, 锁被释放后, 很难做到及时获取, 但这个过程不会一直消耗 cpu)
  • 自旋锁, 是轻量级锁(竞争不激烈情况下)的典型实现. 是纯用户应用程序级别的, 加锁的时候发现竞争, 该线程一般不会进入阻塞, 而是通过忙等的形式进行等待.(获取锁的周期更短, 当锁被释放后, 可以及时获取到锁, 但这个过程会一直消耗 cpu)

我们之前谈到, 忙等, 就是不干实质性的活, 却一直占用消耗着 cpu 资源

忙等的形式不是不好吗? 为啥自旋锁会进行忙等呢???

因为 自旋锁 应对的是乐观锁的情景. 在乐观锁的情景下, 本身遇到锁竞争的概率就很低, 即使遇见竞争, 也可以在短时间内拿到锁, 虽然一直在消耗 cpu, 但实际上也消耗不了多少.


到这里, 做个小总结:

  1. 悲观锁 => 重量级锁 => 挂起等待锁
  2. 乐观锁 => 轻量级锁 => 自旋锁

而我们 Java 中使用的 synchronized 是自适应的. 当锁竞争不激烈时(乐观)采用自旋方式, 当锁竞争激烈时(悲观)升级为重量级锁.

因为 JVM 内部, 会统计每个锁的竞争的激烈程度:

  • 如果竞争不激烈, 此时 synchronized 就会以轻量级锁的形式(自旋)
  • 如果竞争激烈, 此时 synchronized 就会以重量级锁的形式(挂起等待)

1.4 普通互斥锁 vs 读写锁

  1. 普通互斥锁,  只有 加锁 和 解锁 这两种形式. 我们 Java 中的 synchronized 就是普通互斥锁.

  2. 读写锁, 分为 读方式加锁 和 写方式加锁 和 解锁 这三种形式. 

在 "多读写少" 的场景下, 使用读写锁可以提高效率.

 举个例子:
多个线程对一个变量进行读操作, 一个线程对一个变量进行写操作, 在这样的情况下, 必然是线程不安全的.

  • 如果给读和写操作都加上 synchronized 互斥锁, 那就意味着, 读和读操作间也存在锁竞争, 而多个线程进行读操作, 本身就是线程安全的, 加上锁就会影响效率.
  • 加上读写锁后, 就可以使读和读操作间就不会产生互斥(不会产生阻塞), 只有写锁和读锁, 写锁和写锁间才会互斥.

综上, 读和读之间是线程安全的, 但是为避免写操作对读操作的影响, 所以读操作间也需要加锁, 而读写锁就可以使读与读间不产生互斥.

所以, 读写锁, 可以在保证线程安全的前提下, 降低锁冲突的概率(读和读不互斥, 写和写 写和读才互斥), 提高效率.

Java 标准库中也提供了读写锁 : ReentrantReadWriteLock


1.5 可重入锁 vs 不可重入锁

之前就提到过, synchronized 就是可重入锁.

可重入, 就是 在一个线程中, 对一把锁重复进行加锁, 不会触发阻塞, 不会构成死锁.

而不可重入, 就是 一个线程, 对一把锁重复加锁, 会触发阻塞等待, 会构成死锁.

设计可重入锁核心要点:

  1. 第一次获取锁时, 记录一下是哪个线程获取的这把锁. 后续再有线程对这把锁进行加锁时, 判断一下是否是当前持有锁的这个线程, 若是则不会阻塞, 若不是则阻塞.
  2. 使用计数器, 记录加锁了多少次, 在正确的地方释放锁.

1.6 公平锁 vs 非公平锁

  • 公平锁 : 线程遵循 "先来后到" 的原则, 哪个线程先进行锁的获取, 哪个线程就先得到锁. 比如: A 目前持有一把锁, B 和 C 都为竞争这把锁而陷入阻塞等待, 但是 B 比 C 要先陷入阻塞状态(先进行的获取), 所以当 A 释放锁后, B 就会先获取锁, C 需要等 B 释放后才能获取.
  • 非公平锁 : 线程不遵循 "先来后到" 的原则. 比如: A 释放锁后, B 和 C 获取到锁的概率均等. 

 由于操作系统随机调度的原因, synchronized 是 "非公平锁".

要想实现公平锁, 就需要通过一些复杂的设计来实现. 例如:使用队列记录线程获取锁的顺序.


锁策略, 到这里就介绍完了.

综上, synchronized 是自适应(悲观/乐观), 互斥锁, 可重入锁, 非公平锁.


2. 锁升级

synchronized 自适应 的过程 (无锁 => 偏向锁 => 轻量级锁 => 重量级锁) , 其实就是 锁升级.

即 synchronized 根据锁竞争的激烈程度, 逐步自适应的过程.

这里, 需要为大家重点聊一下 偏向锁.

2.1 偏向锁

锁升级的过程:

  1. 进入 synchronized 代码块前, 为无锁状态
  2. 代码刚一进入 synchronized 代码块中时, 并不是真正的加锁, 而只是简单的做个标记(由无锁升级为偏向锁), 而这个标记非常的轻量, 相当于真加锁来说, 效率就会高很多.
  3. 如果没有其他线程来竞争这把锁, 那这个线程最终释放锁时, 只需清除掉这个标记即可(不会涉及到真加锁和真解锁, 效率就会高很多)
  4. 但是, 一旦发现有其他线程也在竞争这把锁, 那这个线程就会抢先一步先拿到锁, 进行真加锁操作(真加锁后, 就由偏向锁升级为轻量级锁), 加锁后, 竞争这把锁的另一个线程就会阻塞等待.

综上:

  • 无锁 => 偏向锁 : 代码进行 synchronized 代码块, 进行简单标记
  • 偏向锁 => 轻量级锁 : 拿到偏向锁后, 发现有其他线程尝试竞争这把锁, 进行真加锁操作
  • 轻量级锁 => 重量级锁 : JVM 发现, 当前竞争锁的情况非常激烈

 注意:

synchronized 只能进行 "锁升级", 而不能 "锁降级".


3. 锁消除

锁消除, 其实也是编译器优化的一种体现.

编译器会判定, 当前的代码逻辑是否真的需要进行加锁操作.

  • 当我们写了 synchronized 后, 但是被加锁的这段代码逻辑确实不需要加锁, 那么编译器就会自动把 synchronized 去除, 以此提高效率(即使是偏向锁, 效率也是不如完全不加锁的).

 这里编译器对 synchronized 消除的优化, 是比较保守的, 当编译器 100% 的确定这段代码是不需要加锁时, 才会触发锁消除.


4. 锁粗化

锁粗化, 也是编译器优化的一种体现.

锁的粒度分为 粗 和 细 两种:

  1. 加锁和解锁间, 代码越多, 就认为锁的粒度越粗.
  2. 加锁和解锁间, 代码越少, 就认为锁的粒度越细.

注意, 加锁解锁之间, 代码的多和少, 并不是指代码的行数, 而是这段代码实际执行指令的多少/实际执行时间的长短.

锁粗化 : 如果一段代码中, 反复对细粒度的代码进行加锁, 就可能会被优化为更粗粒度的加锁.(将多次的加锁解锁, 优化为一次的加锁解锁, 虽然锁中的代码逻辑变多了, 但是却避免了更多的锁竞争, 提高了效率)

但是, 并非锁越粗就越好, 还是要视情形而定, 该粗化的就粗化, 不该粗化的就不要粗化, 毕竟锁粗了就会影响并发程度.


5. CAS

5.1 CAS 指令概念及特点

CAS(compare and swap), 比较和交换.

CAS 本质上是交换, 由内存中的值和寄存器上的值进行交换, 将寄存器中的值写入内存中, 内存上的值读到寄存器上, 但是由于我们只关心内存中的值, 所以可以理解为寄存器上的值 "赋值" 到内存中.

cpu 的指令集分为两种:

  1. 精简指令集 : 每个指令都很简单, 一个时钟周期就是一个指令.
  2. 复杂指令集 : 指令比较复杂, 一条指令会做很多任务, 消耗多个时钟周期.

CAS 特点 :  

而 CAS 就是一条复杂指令, 既然是指令, 也就说明是原子的.

既然是原子的, 那我们就可以基于 CAS 来保证多线程下的线程安全.

5.2 基于 CAS 的实现原子类

CAS 最主要的用途, 就是实现原子类. 原子类是一个特有名词, 特制基于 CAS 实现的, atomic 包中的类.

使用原子类的主要目的, 就是避免加锁, 提高效率.

基于 CAS 封装的原子类, 就可以避免加锁时带来的效率开销, 在确保线程安全的同时, 又能提高效率.

public class Demo36 {
    // 使用原子类代替 int
    // int count
    private static AtomicInteger count = new AtomicInteger();
    public static void main(String[] args) {
        // OK
        // AtomicInteger count = new AtomicInteger();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                // count++
                count.getAndIncrement();
                // ++count
                // count.incrementAndGet()
                // count += x
                // count.getAndAdd()
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
        });
        t1.start();;
        t2.start();
        try {
            t1.join();
            t2.join();
        }catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(count);
    }
}

原子类是如何基于 CAS 实现的呢?? 我们往下看. 

如下图, 基于 CAS 的原子特性, 完成 value++ 操作.

假设 value 为要修改的变量, 将 oldValue 理解为寄存器, 寄存器读取 value 后, 进行 value 和 oldValue 值的判定, 完成 value++ 操作.

  • 当 value 和 oldValue 相等时, 将 oldValue + 1 赋值给 value, 返回 true.
  • 当 value 和 oldValue 不相等时, 返回 false, 再次读取 value, 更新给 oldValue.

(下图中的 while 判定 和 赋值都是同一个 CAS 指令, 不会被其他线程插入打断) 

综上,  即使代码中存在线程的切换, 由于在进行自增之前, 会先判定寄存器中的值是否为 "科学的值"(即最新的值), 在保证该变量没有被其它线程修改后, 再进行赋值操作, 否则会重新读取.

5.3 基于 CAS 实现自旋锁

基于 CAS , 还可以实现自旋锁.

如下图, 当 owner 为 null 时, 说明当前锁没有被其他线程持有, 当 owner 不为 null 时, 说明当前锁已被其他线程持有.

  • 如果 owner 为 null , CAS 执行赋值交换, 把当前线程的引用赋值给 owner, 返回 true
  • 如果 owner 不为 null, 就返回 false, 进入 while 循环(自旋), 直到锁被释放后, 再获取锁.

循环体是空着的, 锁被占有时, 就会一直进行循环(忙等), 但是只要锁被释放, 就可以第一时间获取到锁.

5.4 ABA 问题

基于 CAS 的原子特性, 可以实现原子类和自旋锁.

但是, CAS 也有一个典型的缺陷: ABA 问题.

使用 CAS 能够进行线程安全编程的核心就是: 先进行"比较", 判断内存中的值和寄存器中的值是否相等, 如果相等, 就说明没有其他线程插入进行修改, 因此接下来的操作是线程安全的.

但是, 存在一种特殊情况:

  1. 线程1寄存器从内存中读取的值是 A, 线程1被切走
  2. 切到线程2, 线程2将 A 修改为 B, 又修改为 A, 线程2被切走
  3. 重新切回线程1

此时, 线程1经过寄存器和内存 "比较" 的结果仍然是相同的, 但是却已经被其他线程插入进行了修改, 只是值不变, 这类问题就称为 "ABA 问题".

即使出现了 ABA 问题, 大部分情况下, 对于程序的运行, 也不会有太大的影响(毕竟值还是没有改变的).

只是在一些极端的场景下, ABA 问题才会产生一些严重的 bug(比如汤老湿举的银行取钱存钱的例子).

5.4.1 解决方案 --- "版本号"

虽然 ABA 问题只在一些极端的情况下才会出现影响, 但是即使概率再小, 乘上一个很大的基数, 也会变成一个大问题.

上文讲导致 ABA 问题发生的原因, 本质是因为进行"比较"的指标, 被改后, 又被改了回去.

但是, 当我们将这个指标设置为 "只能进行加操作" 或者 "只能进行减操作" 时, 就能够有效避免 ABA 问题.

例如, 我们引入一个概念, "版本号".

每次成功进行一次 CAS 的交换赋值后, 版本号就 +1 

注意: 这个指标不能设置为 时间/时间戳, 因为可能存在"时间倒流"的情况出现 --- 闰秒.


END


http://www.kler.cn/news/368552.html

相关文章:

  • 量子计算突破:下一个科技革命的风口浪尖在哪里?
  • 配置smaba (Linux与windows通信)
  • 零一万物新模型Yi-Lightning:超越GPT-4o
  • itext自定义pdf
  • Python4
  • 有望第一次走出慢牛
  • 导出Git提交记录
  • 【论文阅读】Learning persistent homology of3D point clouds
  • 【华为HCIP实战课程二十五】中间到中间系统协议IS-IS配置实战续系统ID区域ID,网络工程师
  • 钉钉与金蝶云星空数据集成方案优化企业采购流程
  • STM32电压采集电路设计
  • Linux:认识文件
  • PCB(Process Control Block,进程控制块)和FCB(File Control Block,文件控制块)
  • 数据结构:“小猫钓鱼游戏”
  • java学习技巧分享
  • HTML作业
  • 了解python的错误与异常
  • Spring 设计模式之适配器模式
  • grafana 8.0 添加钉钉告警
  • Mysql之视图创建
  • 如何从示波器上得到时间常数
  • C#制作学生管理系统
  • 【热门主题】000010 深入 Vue.js 组件开发
  • 关于我的数据库——MySQL——第五篇
  • pandas习题 024:用字典构造 DataFrame
  • k8s的配置和存储(ConfigMap、Secret、Hostpath、EmptyDir以及NFS的服务使用)