[线程]常见锁策略, synchronized的优化策略, CAS

2024-09-04 06:12

本文主要是介绍[线程]常见锁策略, synchronized的优化策略, CAS,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

文章目录

  • 一. 常见的锁策略
    • 1. 悲观锁 乐观锁
    • 2. 重量级锁 轻量级锁
    • 3. 自旋锁 挂起等待锁
    • 4. 可重入锁 不可重入锁
    • 5. 公平锁 非公平锁
    • 6. 互斥锁 读写锁
  • 二. 编译器对synchronized锁的优化策略
    • 1. synchronized锁的"自适应"
      • 偏向锁
    • 2. 锁消除
    • 3. 锁粗化
  • 三. CAS
    • CAS的介绍
    • 标准库中的CAS
    • CAS实现自旋锁Spin Lock
    • CAS的ABA问题
    • 解决ABA问题

注意: 接下来介绍的内容, 秋招面试中会考, 但是实际工作中不会用到!!!

一. 常见的锁策略

锁策略, 其实就是在 加锁 / 解锁 / 遇到锁冲突的时候, 都会怎么做

先介绍几个锁的类型:

1. 悲观锁 乐观锁

根据加锁的时候, 预测当前锁冲突的概率大还是小, 还区分悲观锁和乐观锁
如果预测当前锁冲突概率大, 后续要做的工作往往会更多, 加锁的开销就更大, 就叫悲伤锁
如果预测当前锁冲突概率小, 后续要做的工作往往会更少, 加锁的开销就更小, 就叫乐观锁

那么java中使用的synchronized属于哪种锁?
答案: 即使乐观锁, 也是悲观锁
synchronized支持自实行, 能够自动统计出当前的锁冲突的次数, 进行判定当前是锁冲突概率高还是概率低

c++中的std::mutex, 就属于悲伤锁

2. 重量级锁 轻量级锁

一般来说,
悲伤锁后续做的工作往往会很多, 所以是重量级锁
乐观锁后续做的工作往往会很少, 所以是轻量级锁

这两组概念, 可能会混着用

那么java中的synchronized就是既属于轻量级锁, 也属于重量级锁

3. 自旋锁 挂起等待锁

这两个概念可以理解为是获取锁的方式

如果是轻量级锁, 他获取锁的方式就是自旋锁
自旋锁伪代码的实现大概是这样
在这里插入图片描述
此时, cpu在空转, 忙等的状态, 消耗了更多的cpu资源,
但是一旦锁被释放, 就能第一时间拿到锁, 拿到所得速度快

如果是重量级锁, 他获取锁的方式就是挂起等待锁
借助系统中的线程调度机制, 当尝试加锁, 并且这个锁被占用了, 出现锁冲突, 就会让当前这个尝试加锁的线程被挂起(阻塞状态), 此时线程就不参与调度了, 直到这个锁被释放, 然后系统才能唤醒这个线程, 去尝试重新获取锁
此时, 节省了cpu
但是拿到锁的速度就慢了

那么, java中的synchronized
轻量级的部分, 基于自旋锁实现
重量级的部分, 基于挂起等待锁实现

4. 可重入锁 不可重入锁

针对一把锁, 可以连续加锁两次, 就是可重入锁
针对一把锁, 不可以连续加锁两次, 就是不可重入锁

那么, java中的synchronized属于可重入锁

5. 公平锁 非公平锁

这组概念, 可以理解为是获取锁的顺序

公平锁: 严格按照先来后到的顺序来获取锁, 哪个线程等待的时间长, 哪个线程就拿到锁
非公平锁: 若干个线程, 各凭本事, 随机获取到锁, 和线程的等待顺序无关

那么, java中的synchronized属于非公平锁

系统本身的线程调度就是随机的
如果需要引入公平锁, 就需要引入额外的队列, 按照加锁顺序, 把这些获取锁的线程入队列, 再按顺序取

6. 互斥锁 读写锁

这组概念, 可以理解为是锁的种类

互斥锁, 只有两种操作: 加锁和解锁
读写锁, 有三种操作: 加读锁, 加写锁, 解锁

java的读写锁是这样设定的:

  1. 读锁和读锁之间, 不会产生互斥
  2. 写锁和写锁之间, 会产生互斥
  3. 读锁和写锁之间, 会产生互斥

因为多个线程之间读同一个变量, 是不会有安全问题的
在日常开发中, 很多场景, 属于du多写少, 大部分操作都是读
如果使用普通的互斥锁, 此时每次读操作之间, 都会互斥, 就比较影响效率
如果使用读写锁, 就能够有效的降低锁冲突的概率, 提高效率

注意, 这里的读写锁和对mysql中的事务操作不同:
mysql中,
给读操作加锁: 读的时候不能写
给写操作加锁: 写的时候不能读

总结一下上面:
synchronized
即使乐观锁, 也是悲伤锁
即使轻量级锁, 也是重量级锁
即使自旋锁, 也是挂机等待锁
是可重入锁
是非公平锁
是互斥锁

二. 编译器对synchronized锁的优化策略

关于synchronized的锁优化策略, 主要分为以下三块

1. synchronized锁的"自适应"

synchronized是有一个锁升级的过程的:
偏向锁 -> 轻量级锁 -> 重量级锁
未加锁的状态(无锁) -----代码中开始调用synchronized-----> 偏向锁
偏向锁 -----遇到锁冲突-----> 轻量级锁
轻量级锁 -----冲突进一步提升-----> 重量级锁

注意:上述升级过程是不可逆的, 只能升级, 不能降级

偏向锁

synchronized首次对对象进行加锁时, 不是真的加锁, 而只是做了一个"标记", 这个操作非常轻量级, 几乎没有开销
后续如果没有别的线程尝试对这个对象加锁, 就可以保持这个关系, 一直到解锁(修改上述标记), 也几乎没有开销
但是, 如果在偏向锁的状态下, 有某个线程也尝试对这个对象加锁, 就立马把偏向锁升级成轻量级锁, 此时就是真正的加锁了, 真的会发生互斥了

偏向锁本质上就是"懒"字的体现

2. 锁消除

代码中写了加锁操作, 编译器和JVM会对当前的代码做出判定, 看这个地方是否真的需要加锁
如果不需要加锁, 就会自动把加锁操作给优化掉
这样做的目的, 是为了提高效率, 因为加锁是个效率很低的操作

最典型的, 就是在单线程中, 使用synchronized, 就会被优化掉

3. 锁粗化

先介绍一个概念: 锁的粒度
锁的粒度: 表示加锁的范围内, 包含了多少代码,
包含的代码越多, 就认为锁的粒度就越粗
包含的代码越少, 就认为锁的粒度就越细

锁粗化, 就是在有些逻辑中, 需要频繁地对同一对象加锁解锁, 那么编译器就会自动的把多次细粒度的锁, 合并成一次粗粒度的锁, 本质上也是在提高效率

锁粗化的伪代码如下:
在这里插入图片描述

三. CAS

CAS的介绍

CAS是compare and swap , 比较和交换
这时一条cpu指令(是原子的), 可以完成 比较和交换 这样的一套操作下来

为了理解CAS, 可以把CAS想象成一个方法:
在这里插入图片描述
*address: 表示获取内存地址中的值
reg1: 表示寄存器1中的值
reg2: 表示寄存器2中的值

那么此时, CAS做的工作, 其实就是
先比较address内存地址中的值和reg1中的值是否相同
如果相同, 则交换address地址中的值和reg2中的值
其实, 此时的交换操作, 更多理解成是赋值, 把reg2中的值赋值给了内存(因为我们并不关心reg2中的值)

其实, CAS就相当于, 对比较和交换(赋值)操作, 进行了加锁, 但是CAS比加锁高效很多!!

标准库中的CAS

由于CPU提供了上述指令, 因此操作系统内核, 能够完成CAS, 提供了CAS的api
JVM又对系统的CASapi进行进一步封装, 那么我们在java代码中也就可以使用CAS操作了(但是CAS被封装到了一个"unsafe"包中, 不建议使用, 容易出错)
在java中, 也有一些类, 对CAS进行了进一步的封装, 典型的就是"原子类"
在这里插入图片描述
原子类都存放在这个包中
在这里插入图片描述
包中包含了这么多方法, 我们就简单了解一下AtomicInteger类
这个类就相当于对int进行了封装, 可以保证此处的+±-操作, 是原子的
在这里插入图片描述
下面我们写一个多线程代码, 如果我们直接用count++, 可能会出现bug, 原因是count++不是一个原子操作, 我们的解决办法就是加锁, 现在我们就可以使用AtomicInteger来解决

public class Demo31 {private static AtomicInteger count = new AtomicInteger(0);public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count.getAndIncrement();//count++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count.getAndIncrement();}});t1.start();t2.start();t1.join();t2.join();System.out.println("count = " + count);}
}

此时运行的结果:
在这里插入图片描述

像这样, 基于CAS, 不加锁来实现线程安全代码的方式, 也称为==“无锁编程”==

CAS实现自旋锁Spin Lock

自旋锁是基于CAS实现的
自旋锁的伪代码:

public class SpinLock {
//owner表示持有锁的线程是谁, 未加锁的状态, 此时owner就是nullprivate Thread owner = null;public void lock(){// 通过 CAS 看当前锁是否被某个线程持有. // 如果这个锁已经被别的线程持有, 那么就⾃旋等待. // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. while(!CAS(this.owner, null, Thread.currentThread())){}}public void unlock (){this.owner = null;}
}

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
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过⼜改成 A 了. 这个时
候 t1 究竟是否要更新 num 的值为 Z 呢?
到这⼀步, t1 线程⽆法区分当前这个变量始终是 A, 还是经历了⼀个变化过程.

但是, 大多是情况下, 区分不区分不太影响, 也不会有啥问题
但是在一些极端情况, 就可能会产生bug
举例:
假设 滑稽⽼哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. (假设取款操作是按照CAS的方式执行的)
假设下面是取款的伪代码:
在这里插入图片描述
balance为当前用户余额

在取款的过程中, 发生了bug, 按了一下取款, 卡住了, 他又按了一下
取款机创建了两个线程, 并发的来执⾏ -50 操作.
正常的情况:
在这里插入图片描述

如果在t2取款的同时, 有另一个人给滑稽老铁转了500, 引入了t3线程:
在这里插入图片描述

此时就导致, 取款500, 但是余额少了1000
这就是CAS问题的典型bug场景

解决ABA问题

引入版本号, 约定版本号, 只能加, 不能减, 每次操作一次余额, 版本号都要+1
在这里插入图片描述
此时, t1就不会再取款一次了

这篇关于[线程]常见锁策略, synchronized的优化策略, CAS的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1135224

相关文章

JDK21对虚拟线程的几种用法实践指南

《JDK21对虚拟线程的几种用法实践指南》虚拟线程是Java中的一种轻量级线程,由JVM管理,特别适合于I/O密集型任务,:本文主要介绍JDK21对虚拟线程的几种用法,文中通过代码介绍的非常详细,... 目录一、参考官方文档二、什么是虚拟线程三、几种用法1、Thread.ofVirtual().start(

Java 虚拟线程的创建与使用深度解析

《Java虚拟线程的创建与使用深度解析》虚拟线程是Java19中以预览特性形式引入,Java21起正式发布的轻量级线程,本文给大家介绍Java虚拟线程的创建与使用,感兴趣的朋友一起看看吧... 目录一、虚拟线程简介1.1 什么是虚拟线程?1.2 为什么需要虚拟线程?二、虚拟线程与平台线程对比代码对比示例:三

前端缓存策略的自解方案全解析

《前端缓存策略的自解方案全解析》缓存从来都是前端的一个痛点,很多前端搞不清楚缓存到底是何物,:本文主要介绍前端缓存的自解方案,文中通过代码介绍的非常详细,需要的朋友可以参考下... 目录一、为什么“清缓存”成了技术圈的梗二、先给缓存“把个脉”:浏览器到底缓存了谁?三、设计思路:把“发版”做成“自愈”四、代码

Java 线程池+分布式实现代码

《Java线程池+分布式实现代码》在Java开发中,池通过预先创建并管理一定数量的资源,避免频繁创建和销毁资源带来的性能开销,从而提高系统效率,:本文主要介绍Java线程池+分布式实现代码,需要... 目录1. 线程池1.1 自定义线程池实现1.1.1 线程池核心1.1.2 代码示例1.2 总结流程2. J

Java JUC并发集合详解之线程安全容器完全攻略

《JavaJUC并发集合详解之线程安全容器完全攻略》Java通过java.util.concurrent(JUC)包提供了一整套线程安全的并发容器,它们不仅是简单的同步包装,更是基于精妙并发算法构建... 目录一、为什么需要JUC并发集合?二、核心并发集合分类与详解三、选型指南:如何选择合适的并发容器?在多

Redis高性能Key-Value存储与缓存利器常见解决方案

《Redis高性能Key-Value存储与缓存利器常见解决方案》Redis是高性能内存Key-Value存储系统,支持丰富数据类型与持久化方案(RDB/AOF),本文给大家介绍Redis高性能Key-... 目录Redis:高性能Key-Value存储与缓存利器什么是Redis?为什么选择Redis?Red

Docker多阶段镜像构建与缓存利用性能优化实践指南

《Docker多阶段镜像构建与缓存利用性能优化实践指南》这篇文章将从原理层面深入解析Docker多阶段构建与缓存机制,结合实际项目示例,说明如何有效利用构建缓存,组织镜像层次,最大化提升构建速度并减少... 目录一、技术背景与应用场景二、核心原理深入分析三、关键 dockerfile 解读3.1 Docke

深度解析Java @Serial 注解及常见错误案例

《深度解析Java@Serial注解及常见错误案例》Java14引入@Serial注解,用于编译时校验序列化成员,替代传统方式解决运行时错误,适用于Serializable类的方法/字段,需注意签... 目录Java @Serial 注解深度解析1. 注解本质2. 核心作用(1) 主要用途(2) 适用位置3

Java中如何正确的停掉线程

《Java中如何正确的停掉线程》Java通过interrupt()通知线程停止而非强制,确保线程自主处理中断,避免数据损坏,线程池的shutdown()等待任务完成,shutdownNow()强制中断... 目录为什么不强制停止为什么 Java 不提供强制停止线程的能力呢?如何用interrupt停止线程s

从原理到实战解析Java Stream 的并行流性能优化

《从原理到实战解析JavaStream的并行流性能优化》本文给大家介绍JavaStream的并行流性能优化:从原理到实战的全攻略,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的... 目录一、并行流的核心原理与适用场景二、性能优化的核心策略1. 合理设置并行度:打破默认阈值2. 避免装箱