Java多线程基础-14:并发编程中常见的锁策略(一)

简介: 乐观锁和悲观锁是并发控制的两种策略。悲观锁假设数据容易产生冲突,因此在读取时即加锁,防止其他线程修改,可能导致效率较低。

1、乐观锁&悲观锁


乐观锁和悲观锁不是真正的“锁”,而是两种思想,用于解决并发场景下的数据竞争问题。乐观锁与悲观锁的概念是从程序员的角度进行划分的,锁的实现者预测接下来数据发生并发冲突(也可以说说发生锁冲突)的概率大还是不大,如果预测冲突的概率很大,那么这就是悲观锁;如果预测冲突概率不大,那么这就是乐观锁。


(1)悲观锁


悲观锁是从非常悲观保守的角度去考虑和解决问题。


它每次总是假设最坏的情况:每次去拿数据的时候,别的线程也会同时来访问和修改该数据,从而造成结果错误。所以悲观锁为了确保数据的一致性,会在每次获取并修改数据时将数据锁定,让其他线程无法访问该数据。如果别的线程也想要拿到这个数据,就必须阻塞等待,直到它拿到锁。


这也和我们人类中悲观主义者的性格是一样的,悲观主义者做事情之前总是担惊受怕,所以会严防死守,保证别人不能来碰我的东西,这就是悲观锁名字的含义。


举个例子来说明一下这就是悲观锁的操作流程:


1、假设线程 A 和 B 使用的都是悲观锁,所以它们在尝试获取同步资源时,必须要先拿到锁。




2、假设线程 A 拿到了锁,并且正在操作同步资源,那么此时线程 B 就必须进行等待。




3、而当线程 A 执行完毕后,CPU 才会唤醒正在等待这把锁的线程 B 再次尝试获取锁。



4、如果线程 B 现在获取到了锁,才可以对同步资源进行自己的操作。


悲观锁要操作数据必须先获取锁的特点可以确保数据的一致性,但也带来了并发性能的下降,因为其他线程需要等待锁的释放。一般而言,悲观锁和乐观锁相比要做的工作更多,效率也会更低。(不绝对)


(2)乐观锁


乐观锁总是假设最好的情况,认为自己在操作资源的时候不会有其他线程来干扰,每次去拿数据的时候别的线程都不会来修改,所以并不会锁住被操作对象。同时,为了确保数据正确性,线程会在更新数据的时候判断一下在自己修改数据这期间,还有没有被别的线程来修改过数据(通过版本号或CAS算法判断):


如果数据没被别的线程修改过,就说明真的只有我自己在操作,那我就可以正常地修改数据;

如果发现数据和我一开始拿到的不一样了,说明其他线程在这段时间内也修改过数据,那说明我更新迟了一步,所以我会放弃本次修改,并选择报错、重试等策略。

这和我们生活中乐天派的人的性格是一样的,乐观的人并不会担忧还没有发生的事情,相反,他会认为未来是美好的,所以他在修改数据之前,并不会把数据给锁住。当然,乐天派也不会盲目行动,如果他发现事情和他预想的不一样,也会有相应的处理办法,他不会坐以待毙,这就是乐观锁的思想。


同样,举个乐观锁的例子:

1、假设线程 A 此时运用的是乐观锁。那么它去操作同步资源的时候,不需要提前获取到锁,而是可以直接去读取同步资源,并且在自己的线程内进行计算。



2、当它计算完毕之后、准备更新同步资源之前,会先判断这个资源是否已经被其他线程所修改过。



3、如果这个时候同步资源没有被其他线程修改更新,也就是说此时的数据和线程 A 最开始拿到的数据是一致的话,那么此时线程 A 就会去更新同步资源,完成修改的过程。



4、而假设此时的同步资源已经被其他线程修改更新了,线程 A 会发现此时的数据已经和最开始拿到的数据不一致了,那么线程 A 不会继续修改该数据,而是会根据不同的业务逻辑去选择报错或者重试。



乐观锁通常不会阻塞其他事务,因此并发性能较高,但在发生并发冲突时需要处理冲突的情况。一般而言,它的工作相对少,效率也相对高。(也不绝对)


.


可以用一个生活中的例子来类比乐观锁和悲观锁:同学 A 和 同学 B 请教老师一个问题。


同学 A 认为 “老师是很忙的,我来问问题老师不一定有空解答”。因此同学 A 先给老师发消息:“老师,您忙吗? 我下午两点能来找你问个问题吗?”(这相当于加锁操作)。得到肯定的答复之后(获取锁后),才会真的来问问题。如果得到了否定的答复,那他就等一段时间,下次再尝试和老师确定时间。这个是悲观锁。

同学 B 则认为 “老师是很较闲的,我来问问题老师大概率是有空解答的”。因此同学 B 直接就没有事先询问,直接来找老师(即没加锁,直接访问资源)。如果老师确实比较闲,那么就能直接问老师问题;但如果发现老师这会确实很忙(发现数据访问有冲突),那么同学 B 也不会打扰老师,就下次再来。这个是乐观锁。

乐观锁和悲观锁这两种思路不能说谁优谁劣,而是要看当前的场景是否合适。(就好比如果当前老师确实比较忙,那么使用悲观锁的策略更合适,使用乐观锁会导致 “白跑很多趟”,耗费额外的资源。如果当前老师确实比较闲,那么使用乐观锁的策略更合适,使用悲观锁会让效率比较低。)

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


2、轻量级锁&重量级锁


简单来说,轻量级锁是加锁解锁的过程更快更高效的锁策略,而重量级锁是加锁解锁的过程更慢更低效的锁策略。它们和乐观锁悲观锁虽然不是一回事,但有一定的重合:一个乐观锁很可能也是一个轻量级锁。一个悲观锁很可能是一个重量级锁。


(1)定义


重量级锁 : 加锁机制重度依赖  OS 提供了 mutex(互斥量)。

大量的内核态用户态切换。

很容易引发线程的调度。

这两个操作的成本都比较高,而且一旦涉及到用户态和内核态的切换,效率就低了。


轻量级锁 : 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成。 实在搞不定了, 再使用 mutex。

少量的内核态用户态切换。

不太容易引发线程调度。


(2)什么是mutex?


Mutex(互斥锁)是一种在多线程编程中使用的同步原语。它是一种低级别的锁机制,由操作系统提供或由编程语言的库支持。使用互斥锁时,只有一个线程能够获取锁,并且其他线程必须等待直到锁被释放。互斥锁是一种不可重入锁,即同一线程在获取锁之后再次尝试获取会导致死锁。


Mutex提供了两个主要的操作:lock(加锁)和unlock(解锁)。当一个线程需要访问共享资源时,它首先尝试去获取Mutex的锁。如果该Mutex没有被其他线程持有,则该线程成功获取锁,可以访问共享资源。如果Mutex已经被其他线程持有,则当前线程会被阻塞,直到Mutex的锁被释放。一旦线程完成对共享资源的访问,它会释放Mutex的锁,允许其他线程去获取锁并进行访问。


通过Mutex可以实现线程间的互斥和同步,避免多个线程同时访问共享资源导致的数据竞争和不一致性。Mutex可以确保在同一时间只有一个线程能够修改共享资源,从而保证了线程安全性。


然而,Mutex也可能引发一些问题,如死锁(Deadlock)和饥饿(Starvation)。死锁指的是多个线程因相互等待对方持有的锁而无法继续执行的情况,而饥饿则是某些线程因无法获得锁资源而一直无法执行的情况。


除了Mutex,还有其他的并发控制机制来控制多线程并发访问共享资源(同步原语 Synchronization primitives),如读写锁(ReadWrite Lock)、信号量(Semaphore)等,可以根据具体需求选择适当的并发控制方式。


可以说互斥锁(Mutex)是一个更一般的概念,而 synchronized 是 Java 语言中特定的关键字用于实现互斥锁的机制。Java 中的 synchronized 可以看作是一种高级封装的互斥锁,提供了更方便的使用方式,并且支持可重入。在 Java 中,synchronized 通常被用来实现线程安全的访问控制,而不需要显式地使用互斥锁。


原理


锁的 “ 原子性”  机制追根溯源是 CPU 这样的硬件设备提供的:


  • CPU 提供了 “原子操作指令”。


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


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


synchronized 是对 mutex 进行封装(当然,它不仅仅是对mutex的封装。在 synchronized 内部还做了很多其他的工作)。



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


3、自旋锁&挂起等待锁


自旋锁是轻量级锁的一种典型实现,而挂起等待锁是重量级锁的一种典型实现。


(1)自旋锁(Spin Lock)


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


自旋锁是一种忙等待锁的机制。当一个线程需要获取自旋锁时,它会反复地检查锁是否可用,而不是立即被阻塞。如果获取锁失败(锁已经被其他线程占用),当前线程会立即再尝试获取锁,不断自旋(空转)等待锁的释放,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。这样能保证一旦锁被其他线程释放,当前线程能第一时间获取到锁。


自旋锁伪代码


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


自旋锁是一种典型的轻量级锁的实现方式,它通常是纯用户态的,不需要经过内核态(时间相对更短)。


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

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

自旋锁适用于保护临界区较小、锁占用时间短的情况,因为自旋会消耗CPU资源。自旋锁通常使用原子操作或特殊的硬件指令来实现。


(2)挂起等待锁(Suspend-Resume Lock)


挂起等待锁是一种阻塞线程的锁机制。当一个线程需要获取挂起等待锁时,如果锁已被其他线程占用,当前线程会被挂起(暂停执行,不占用CPU资源),放入等待队列中,直到锁可用。一旦锁可用,线程会被唤醒并继续执行。


挂起等待锁是重量级锁的一种典型实现,通过内核的机制来实现挂起等待(时间更长了)。


优点:节省CPU资源,可以避免线程空转等待锁的释放,从而节省了CPU资源。

缺点:如果锁被释放,不能第一时间拿到锁,可能需要过很久才能拿到锁。

挂起等待锁适用于保护临界区较大、锁占用时间较长的情况,因为挂起等待不会占用CPU资源。挂起等待锁通常使用线程的阻塞机制或操作系统提供的同步原语来实现。


.


举一个生活中的例子来便于理解:


想象去追求一个女神。当男生向女神表白后,女神说:你是个好人,但是我有男朋友了。


挂起等待锁:先不理女神了,先去干别的。等未来某一天女神分手了,她又想起我,再主动来找我。(注意,在这个很长的时间间隔里,女神可能已经换了好几个男票了)


自旋锁:坚韧不拔锲而不舍,仍然每天持续的和女神说早安晚安……一旦女神哪天和上一任分手,那么就能立刻抓住机会上位~


自旋锁和挂起等待锁的选择取决于具体的应用场景和系统特点。自旋锁适用于锁占用时间短、线程竞争不激烈的情况,可以减少线程切换的开销。而挂起等待锁适用于锁占用时间长、线程竞争激烈的情况,可以防止线程空转,节省CPU资源。在实际使用中,需要根据具体情况进行权衡和选择,以实现最佳的性能和资源利用。


synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的。


Java多线程基础-14:并发编程中常见的锁策略(二)+

https://developer.aliyun.com/article/1520611?spm=a2c6h.13148508.setting.14.75194f0e8nK1As



相关文章
|
1月前
|
SQL Java 数据库
解决Java Spring Boot应用中MyBatis-Plus查询问题的策略。
保持技能更新是侦探的重要素质。定期回顾最佳实践和新技术。比如,定期查看MyBatis-Plus的更新和社区的最佳做法,这样才能不断提升查询效率和性能。
77 1
|
19天前
|
Java 测试技术 API
现代化 java 分层开发实施策略与最佳实践指南
现代化Java分层开发采用清晰的多层架构,包括Controller、Service、Repository和DTO等核心层次。文章详细介绍了标准Maven/Gradle项目结构,各层职责与实现规范:实体层使用JPA注解,DTO层隔离数据传输,Repository继承JpaRepository,Service层处理业务逻辑,Controller层处理HTTP请求。推荐使用Spring Boot、Lombok、MapStruct等技术栈,并强调了单元测试和集成测试的重要性。这种分层设计提高了代码的可维护性、可测试
57 0
|
7月前
|
监控 算法 Java
Java虚拟机(JVM)垃圾回收机制深度剖析与优化策略####
本文作为一篇技术性文章,深入探讨了Java虚拟机(JVM)中垃圾回收的工作原理,详细分析了标记-清除、复制算法、标记-压缩及分代收集等主流垃圾回收算法的特点和适用场景。通过实际案例,展示了不同GC(Garbage Collector)算法在应用中的表现差异,并针对大型应用提出了一系列优化策略,包括选择合适的GC算法、调整堆内存大小、并行与并发GC调优等,旨在帮助开发者更好地理解和优化Java应用的性能。 ####
183 27
|
4月前
|
人工智能 自然语言处理 前端开发
从理论到实践:使用JAVA实现RAG、Agent、微调等六种常见大模型定制策略
大语言模型(LLM)在过去几年中彻底改变了自然语言处理领域,展现了在理解和生成类人文本方面的卓越能力。然而,通用LLM的开箱即用性能并不总能满足特定的业务需求或领域要求。为了将LLM更好地应用于实际场景,开发出了多种LLM定制策略。本文将深入探讨RAG(Retrieval Augmented Generation)、Agent、微调(Fine-Tuning)等六种常见的大模型定制策略,并使用JAVA进行demo处理,以期为AI资深架构师提供实践指导。
446 73
|
4月前
|
存储 架构师 安全
深入理解Java锁升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁(图解+史上最全)
锁状态bits1bit是否是偏向锁2bit锁标志位无锁状态对象的hashCode001偏向锁线程ID101轻量级锁指向栈中锁记录的指针000重量级锁指向互斥量的指针010尼恩提示,讲完 如减少锁粒度、锁粗化、关闭偏向锁(-XX:-UseBiasedLocking)等优化手段 , 可以得到 120分了。如减少锁粒度、锁粗化、关闭偏向锁(-XX:-UseBiasedLocking)等‌。JVM锁的膨胀、锁的内存结构变化相关的面试题,是非常常见的面试题。也是核心面试题。
深入理解Java锁升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁(图解+史上最全)
|
7月前
|
安全 Java 程序员
深入理解Java内存模型与并发编程####
本文旨在探讨Java内存模型(JMM)的复杂性及其对并发编程的影响,不同于传统的摘要形式,本文将以一个实际案例为引子,逐步揭示JMM的核心概念,包括原子性、可见性、有序性,以及这些特性在多线程环境下的具体表现。通过对比分析不同并发工具类的应用,如synchronized、volatile关键字、Lock接口及其实现等,本文将展示如何在实践中有效利用JMM来设计高效且安全的并发程序。最后,还将简要介绍Java 8及更高版本中引入的新特性,如StampedLock,以及它们如何进一步优化多线程编程模型。 ####
99 0
|
6月前
|
并行计算 安全 Java
Python GIL(全局解释器锁)机制对多线程性能影响的深度分析
在Python开发中,GIL(全局解释器锁)一直备受关注。本文基于CPython解释器,探讨GIL的技术本质及其对程序性能的影响。GIL确保同一时刻只有一个线程执行代码,以保护内存管理的安全性,但也限制了多线程并行计算的效率。文章分析了GIL的必要性、局限性,并介绍了多进程、异步编程等替代方案。尽管Python 3.13计划移除GIL,但该特性至少要到2028年才会默认禁用,因此理解GIL仍至关重要。
472 16
Python GIL(全局解释器锁)机制对多线程性能影响的深度分析
|
6月前
|
算法 安全 Java
Java线程调度揭秘:从算法到策略,让你面试稳赢!
在社招面试中,关于线程调度和同步的相关问题常常让人感到棘手。今天,我们将深入解析Java中的线程调度算法、调度策略,探讨线程调度器、时间分片的工作原理,并带你了解常见的线程同步方法。让我们一起破解这些面试难题,提升你的Java并发编程技能!
193 16
|
8月前
|
运维 Java 编译器
Java 异常处理:机制、策略与最佳实践
Java异常处理是确保程序稳定运行的关键。本文介绍Java异常处理的机制,包括异常类层次结构、try-catch-finally语句的使用,并探讨常见策略及最佳实践,帮助开发者有效管理错误和异常情况。
562 6
|
8月前
|
缓存 Java 开发者
Java多线程并发编程:同步机制与实践应用
本文深入探讨Java多线程中的同步机制,分析了多线程并发带来的数据不一致等问题,详细介绍了`synchronized`关键字、`ReentrantLock`显式锁及`ReentrantReadWriteLock`读写锁的应用,结合代码示例展示了如何有效解决竞态条件,提升程序性能与稳定性。
729 6