• 2023.10.27 常见的 锁策略 详解


    目录

    相关专业名词

    信号量 Semaphore

    互斥锁 和 读写锁

    乐观锁 和 悲观锁

    轻量级锁 和 重量级锁

    自旋锁 和 挂起等待锁 

    公平锁 和 非公平锁

    可重入锁 和 不可重入锁


    相关专业名词

    上下文切换

    • 上下文切换指的是将当前执行的线程或进程的上下文保存起来,然后切换到另一个线程或进程的上下文,使得它可以继续执行
    • 当切换回原来的线程或进程时,之前保存的上下文将被恢复,使得程序可以重新切换回之前的状态继续执行

    临界区

    • 指多线程或多进程环境下访问共享资源的一段代码区域
    • 临界区的目的时确保同时只有一个线程或进程可以进入该区域,并且对共享资源的访问是互斥的

    信号量 Semaphore

    • 是一种用于控制多个线程对共享资源访问的同步机制
    • 其本质上就是一个计数器,描述了 可用资源个数

    基本思路

    • 当线程需要访问共享资源时,首先尝试获取信号量
    • 如果信号量的计数器大于零,表示资源可用,线程可以继续执行并将计数器 -1
    • 如果信号量的计数器等于零,表示资源已经被占用,线程需要等待,加入到等待队列中
    • 当资源被释放时,信号量的计数器 +1,并从等待队列中选择一个线程唤醒,使其继续执行

    主要操作

    • 初始化:设置信号量的初始计数器值
    • P 操作(也称为 wait 操作):申请一个可用资源,如果计数器大于零则 -1,如果等于零,则线程阻塞等待
    • V 操作(也称为 signal 操作):释放一个可用资源,且计数器 +1,并唤醒一个等待的线程

    使用场景

    • 可以用于解决多线程环境下的资源互斥访问和线程同步问题
    • 通过适当设置信号量的计数器,可以控制对共享资源的并发访问数量,实现资源的有序访问和互斥访问

    注意

    • 常用的二元信号量,即只有 0 和 1 两种状态的信号量
    • 锁 可以视为是 计数器为 1 的信号量,即二元信号量
    • 所以我们可以在代码中使用 Semaphore 来实现类似于锁的效果的,来保证线程安全
    • 计数信号量,它的计数器可以是任意正整数,允许多个线程同时访问资源

    代码示例

    1. import java.util.concurrent.Semaphore;
    2. public class ThreadDemo32 {
    3. public static void main(String[] args) throws InterruptedException {
    4. // 初始化 Semaphore 对象,并填入相应参数
    5. // 此处 初始化了 3 个可用资源数
    6. Semaphore semaphore = new Semaphore(3);
    7. // 使用 acquire 方法执行一次 P 操作
    8. semaphore.acquire();
    9. // 使用 acquire 方法中可填入参数
    10. // 此处 使用 acquire 方法申请了两个资源
    11. semaphore.acquire(2);
    12. // 使用 release 方法执行一次 V 操作
    13. semaphore.release();
    14. }
    15. }

    总结

    • 信号量本身并不解决竞争条件和死锁问题,而是提供了一种机制来协调和控制线程对共享资源的访问

    互斥锁 和 读写锁

    互斥锁

    • 互斥锁是一种独占锁机制

    基本思路

    • 它确保在任何时刻只有一个线程可以获得锁,其他线程需要等待锁的释放
    • 当一个线程获得互斥锁后,它可以访问共享资源并执行相应的操作,其他线程则被阻塞,直到锁被释放

    优点

    • 确保了共享资源的独占访问问,避免了数据竞争和不一致的问题

    适用场景

    • 共享资源需要被互斥访问,即同一个时间只能有一个线程访问
    • 锁竞争激烈的情况下,只有一个线程可以获得锁

    读写锁

    • 读写锁是一种共享锁机制

    基本思路

    • 它允许多个线程同时对共享资源进行读操作,但在进行写操作时需要互斥访问

    特点

    • 多个线程可以同时获取读锁,实现共享读取
    • 写锁是独占的,当一个线程持有写锁时,其他线程无法获取读锁或写锁

    优点

    • 读写锁在读操作较多、写操作较少的场景下可以提供更高的并发性
    • 多个线程可以同时进行读操作,提高了系统的并发能力

    适用情况

    • 共享资源的读操作远远多于写操作
    • 写操作对共享资源的修改需要互斥访问,以确保数据的一致性

    乐观锁 和 悲观锁

    • 站在锁冲突概率的预测角度

    乐观锁

    • 它认为在大多数情况下,并发访问不会发生冲突

    基本机制

    • 当使用乐观锁时,一般会采用 版本号 或 时间戳 等机制来检查并发冲突
    • 在更新资源共享之前,会先读取当前的 版本号 或 时间戳,并在更新时再次检查是否发生了变化
    • 如果发生了变化,说明其他线程或用户已经修改了资源,当前操作可能会导致数据不一致
    • 因此需要重写读取最新的资源并重新尝试更新操作

    缺点

    • 乐观锁在处理并发冲突时,通常不会阻塞其他线程或用户的访问,而是允许并发操作,并在冲突发生时进行回滚或重试
    • 这增加了系统设计和实现的复杂性,需要考虑并发操作的正确性、一致性、异常处理等方面

    总结

    • 乐观锁就是指锁冲突的概率不高,因此做的工作就可以简单一些,因此性能也比较高,但往往不能处理到所有问题,需要一定的系统复杂度来应对这些情形

    悲观锁

    • 它认为在并发环境下,会发生竞争和冲突

    基本机制

    • 悲观锁的经典实现是使用 互斥锁 或 信号量
    • 在使用悲观锁的默认情况下,当一个线程或用户要访问共享资源时,他会先获取锁,阻止其他线程或用户对资源进行修改,直到自己完成操作后才会释放锁
    • 这样可以确保同一时间只有一个线程或用户可以访问共享资源,从而避免了并发冲突

    总结

    • 悲观锁会阻塞其他线程或用户的访问,以确保数据的安全性,但性能相对较低
    • synchronized 关键字就是一个典型的悲观锁机制

    轻量级锁 和 重量级锁

    • 站在加锁开销的角度

    轻量级锁

    • 加锁机制尽可能不通过系统调度来进行用户态和内核态的切换,而是尽量在用户态完成,实在不行再切换,即典型的纯用户态加锁逻辑,开销较小

    核心思想

    • 在没有锁竞争时,使用 CAS 操作将对象头部的一部分标记为锁标记(锁记录),而不是直接使用 互斥锁 来实现
    • 当线程尝试获取轻量级锁时,它会使用 CAS 操作尝试将锁标记变成自己的线程ID
    • 如果 CAS 操作成功,表示该线程成功获取到锁,可继续执行临界区代码
    • 如果 CAS 操作失败,表示有其他线程竞争锁,此时会升级为重量级锁 

    优点

    • 在无竞争的情况下性能较好
    • 因为它避免了线程阻塞和唤醒的开销,减少了上下文切换的代价

    缺点

    • 在有竞争的情况下,会频繁进行 CAS 操作,如果 CAS 操作一直失败,会升级为重量级锁,导致性能下降

    总结

    • 轻量级锁适用于竞争不激烈的情况,在无竞争的情况下性能较好

    重量级锁

    • 重量级锁是传统的线程同步机制,通常使用 互斥量 或 信号量 实现

    核心思想

    • 当一个线程获取重量级锁时,如果锁已经被其他线程占用,该线程会被阻塞,进入睡眠状态,直到获取到锁的线程释放锁并唤醒等待线程

    优点

    • 在有竞争的情况下可以确保线程的正确同步,不会出现数据竞争的问题

    缺点

    • 在线程切换和阻塞唤醒的过程中存在比较大的开销,包括切换上下文、线程调度、内核态于用户态切换等,会降低系统的性能

    总结

    • 重量级锁适用于竞争激烈的情况,能确保线程正确同步,但性能相对较低

    自旋锁 和 挂起等待锁 

    • 站在线程 加锁快慢 的角度 


    自旋锁

    • 是一种典型的轻量级锁实现方式

    基本思路

    • 自旋锁是一个 忙等 的锁机制
    • 线程尝试在回去锁时,不会立即进入睡眠状态,而是通过循环不断地检查锁的状态,直到获取到锁为止
    • 自旋锁通常使用原子操作(CAS 操作)来实现

    优点

    • 避免了线程切换和上下文切换的开销,因为线程不会进入睡眠状态

    缺点

    • 长时间的自旋会占用CPU资源,造成性能损失
    • 当线程竞争激烈或持有锁时间比较长时,自旋的效率会下降

    适用场景

    • 线程持有锁的时间较短,不会导致其他线程长时间等待
    • 线程的竞争不激烈,获取锁的时间短

    挂起等待锁

    • 是一种典型的重量级锁实现方式

    基本思路

    • 挂起等待所是一种线程阻塞的锁机制
    • 线程在尝试获取锁时,如果锁已经被其他线程占用,该线程会进入睡眠状态,释放 CPU 资源,直到锁被释放并且被唤醒后再重新尝试获取锁

    优点

    • 可以有效避免自旋锁的性能问题,因为线程再等待锁时会释放 CPU 资源,不会占用过多的 CPU 时间

    缺点

    • 线程的阻塞和唤醒需要操作系统的支持,会引入额外的开销
    • 线程的阻塞和唤醒可能会引起上下文切换,影响系统的性能

    适用场景

    • 线程竞争激烈,可能会有较长的等待时间
    • 线程持有锁时间较长,不希望占用 CPU 资源

    公平锁 和 非公平锁

    • 站在线程 获取锁 的角度

    公平锁

    • 指多个线程在竞争锁时,按照它们发出请求的顺序来获取锁

    基本思路

    • 当一个线程请求获取锁时,如果锁是可用的,该线程会直接获取锁
    • 如果锁已经被其他线程占用,该锁会进入等待队列,等待锁的释放
    • 当锁被释放时,等待时间最长的线程 会被唤醒并获取锁

    优点

    • 确保了锁的获取按照请求的顺序进行,避免了线程饥饿现象
    • 所有线程都有公平竞争的机会

    适用场景

    • 对线程的公平性有较高的要求,希望避免线程饥饿现象

    非公平锁

    • 指多个线程在竞争锁时,不考虑它们发出请求的顺序,直接尝试获取锁

    基本思路

    • 当一个线程请求获取锁时,如果线程时可用的,该线程会直接获取锁
    • 如果锁已经被其他线程占用,该线程会进入竞争,与其他线程一起竞争锁的所有权

    缺点

    • 可能会导致某些线程长时间等待,产生线程饥饿现象

    适用场景

    • 追求更高的系统吞吐量,并且对线程的公平性要求不高

    总结

    • 操作系统和 synchronized 原生都是 "非公平锁"
    • 操作系统对这里的针对加锁的控制,本身就依赖系统调度顺序的
    • 这个调度顺序是随机的,不会考虑到这个线程等待锁多久了
    • 要想实现公平锁,就得在这个基础上,引入一个队列,让这些想加锁的线程去排队

    可重入锁 和 不可重入锁

    • 站在 线程是否能重复获取同一个锁的 角度

    可重入锁

    • 指同一个线程可以多次获取同一个锁而不会造成死锁

    基本思路

    • 当线程第一次获取锁后,锁会记录该线程的持有者和持有计数
    • 在该线程持有锁的期间,它可以再次获取锁而不会被阻塞,而是增加持有计数
    • 当线程释放锁时,持有计数递减,直到计数为零时锁完全被释放

    优点

    • 方便了对共享资源的嵌套访问
    • 如果一个线程已经获取了某个锁,那么在持有这个锁的期间,它可以安全的调用其他需要获取通过一个锁的代码

    不可重入锁

    • 指同一个线程在持有锁的情况下,再次获取锁时会被阻塞

    基本思路

    • 不可重入锁的一个典型例子是简单的互斥锁,它只允许一个线程在任意时刻获取锁
    • 如果一个线程已经持有锁,再次请求获取锁时会被阻塞,直到锁被释放

    缺点

    • 使用不够方便,在编写复杂的嵌套代码结构时可能会导致死锁和其他问题

    总结

    • 可重入锁是更常见和推荐的选择
  • 相关阅读:
    《痞子衡嵌入式半月刊》 第 74 期
    209.Flink(四):状态,按键分区,算子状态,状态后端。容错机制,检查点,保存点。状态一致性。flink与kafka整合
    Java 字节输出流FileOutputStream的用法和概述
    给 「大模型初学者」 的 LLaMA 3 核心技术剖析
    请求的转发和重定向
    什么是Selenium?如何使用Selenium进行自动化测试?
    ROS仿真环境搭建
    方差和标准差哪些事儿
    Java高级特性-泛型类
    5款非常屌的办公软件,极大地提升工作效率
  • 原文地址:https://blog.csdn.net/weixin_63888301/article/details/134059078