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



相关文章
|
6天前
|
Java 开发者
Java多线程编程中的常见误区与最佳实践####
本文深入剖析了Java多线程编程中开发者常遇到的几个典型误区,如对`start()`与`run()`方法的混淆使用、忽视线程安全问题、错误处理未同步的共享变量等,并针对这些问题提出了具体的解决方案和最佳实践。通过实例代码对比,直观展示了正确与错误的实现方式,旨在帮助读者构建更加健壮、高效的多线程应用程序。 ####
|
14天前
|
安全 Java 测试技术
Java并行流陷阱:为什么指定线程池可能是个坏主意
本文探讨了Java并行流的使用陷阱,尤其是指定线程池的问题。文章分析了并行流的设计思想,指出了指定线程池的弊端,并提供了使用CompletableFuture等替代方案。同时,介绍了Parallel Collector库在处理阻塞任务时的优势和特点。
|
16天前
|
缓存 Java
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
本文介绍了几种常见的锁机制,包括公平锁与非公平锁、可重入锁与不可重入锁、自旋锁以及读写锁和互斥锁。公平锁按申请顺序分配锁,而非公平锁允许插队。可重入锁允许线程多次获取同一锁,避免死锁。自旋锁通过循环尝试获取锁,减少上下文切换开销。读写锁区分读锁和写锁,提高并发性能。文章还提供了相关代码示例,帮助理解这些锁的实现和使用场景。
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
|
5天前
|
安全 Java 开发者
Java 多线程并发控制:深入理解与实战应用
《Java多线程并发控制:深入理解与实战应用》一书详细解析了Java多线程编程的核心概念、并发控制技术及其实战技巧,适合Java开发者深入学习和实践参考。
|
5天前
|
Java 开发者
Java多线程编程的艺术与实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的技术文档,本文以实战为导向,通过生动的实例和详尽的代码解析,引领读者领略多线程编程的魅力,掌握其在提升应用性能、优化资源利用方面的关键作用。无论你是Java初学者还是有一定经验的开发者,本文都将为你打开多线程编程的新视角。 ####
|
4天前
|
存储 安全 Java
Java多线程编程中的并发容器:深入解析与实战应用####
在本文中,我们将探讨Java多线程编程中的一个核心话题——并发容器。不同于传统单一线程环境下的数据结构,并发容器专为多线程场景设计,确保数据访问的线程安全性和高效性。我们将从基础概念出发,逐步深入到`java.util.concurrent`包下的核心并发容器实现,如`ConcurrentHashMap`、`CopyOnWriteArrayList`以及`BlockingQueue`等,通过实例代码演示其使用方法,并分析它们背后的设计原理与适用场景。无论你是Java并发编程的初学者还是希望深化理解的开发者,本文都将为你提供有价值的见解与实践指导。 --- ####
|
10天前
|
安全 Java 开发者
深入解读JAVA多线程:wait()、notify()、notifyAll()的奥秘
在Java多线程编程中,`wait()`、`notify()`和`notifyAll()`方法是实现线程间通信和同步的关键机制。这些方法定义在`java.lang.Object`类中,每个Java对象都可以作为线程间通信的媒介。本文将详细解析这三个方法的使用方法和最佳实践,帮助开发者更高效地进行多线程编程。 示例代码展示了如何在同步方法中使用这些方法,确保线程安全和高效的通信。
35 9
|
7天前
|
安全 Java 开发者
Java多线程编程中的常见问题与解决方案
本文深入探讨了Java多线程编程中常见的问题,包括线程安全问题、死锁、竞态条件等,并提供了相应的解决策略。文章首先介绍了多线程的基础知识,随后详细分析了每个问题的产生原因和典型场景,最后提出了实用的解决方案,旨在帮助开发者提高多线程程序的稳定性和性能。
|
11天前
|
并行计算 数据处理 调度
Python中的并发编程:探索多线程与多进程的奥秘####
本文深入探讨了Python中并发编程的两种主要方式——多线程与多进程,通过对比分析它们的工作原理、适用场景及性能差异,揭示了在不同应用需求下如何合理选择并发模型。文章首先简述了并发编程的基本概念,随后详细阐述了Python中多线程与多进程的实现机制,包括GIL(全局解释器锁)对多线程的影响以及多进程的独立内存空间特性。最后,通过实例演示了如何在Python项目中有效利用多线程和多进程提升程序性能。 ####
|
13天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####