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多线程:wait()、notify()、notifyAll()的奥秘
在Java多线程编程中,`wait()`、`notify()`和`notifyAll()`方法是实现线程间通信和同步的关键机制。这些方法定义在`java.lang.Object`类中,每个Java对象都可以作为线程间通信的媒介。本文将详细解析这三个方法的使用方法和最佳实践,帮助开发者更高效地进行多线程编程。 示例代码展示了如何在同步方法中使用这些方法,确保线程安全和高效的通信。
25 9
|
9天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####
|
6天前
|
监控 安全 Java
Java中的多线程编程:从入门到实践####
本文将深入浅出地探讨Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的摘要形式,本文将以一个简短的代码示例作为开篇,直接展示多线程的魅力,随后再详细解析其背后的原理与实现方式,旨在帮助读者快速理解并掌握Java多线程编程的基本技能。 ```java // 简单的多线程示例:创建两个线程,分别打印不同的消息 public class SimpleMultithreading { public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.prin
|
8天前
|
安全 Java
Java多线程集合类
本文介绍了Java中线程安全的问题及解决方案。通过示例代码展示了使用`CopyOnWriteArrayList`、`CopyOnWriteArraySet`和`ConcurrentHashMap`来解决多线程环境下集合操作的线程安全问题。这些类通过不同的机制确保了线程安全,提高了并发性能。
|
9天前
|
Java
java小知识—进程和线程
进程 进程是程序的一次执行过程,是系统运行的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如CPU时间,内存空间,文件,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中。 线程 线程,与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比
20 1
|
5月前
|
Java C++
关于《Java并发编程之线程池十八问》的补充内容
【6月更文挑战第6天】关于《Java并发编程之线程池十八问》的补充内容
49 5
|
2月前
|
缓存 监控 Java
Java中的并发编程:理解并应用线程池
在Java的并发编程中,线程池是提高应用程序性能的关键工具。本文将深入探讨如何有效利用线程池来管理资源、提升效率和简化代码结构。我们将从基础概念出发,逐步介绍线程池的配置、使用场景以及最佳实践,帮助开发者更好地掌握并发编程的核心技巧。
|
4月前
|
安全 Java 开发者
Java中的并发编程:深入理解线程池
在Java的并发编程中,线程池是管理资源和任务执行的核心。本文将揭示线程池的内部机制,探讨如何高效利用这一工具来优化程序的性能与响应速度。通过具体案例分析,我们将学习如何根据不同的应用场景选择合适的线程池类型及其参数配置,以及如何避免常见的并发陷阱。
56 1
|
4月前
|
监控 Java
Java并发编程:深入理解线程池
在Java并发编程领域,线程池是提升应用性能和资源管理效率的关键工具。本文将深入探讨线程池的工作原理、核心参数配置以及使用场景,通过具体案例展示如何有效利用线程池优化多线程应用的性能。
|
3月前
|
Java 数据库
Java中的并发编程:深入理解线程池
在Java的并发编程领域,线程池是提升性能和资源管理的关键工具。本文将通过具体实例和数据,探讨线程池的内部机制、优势以及如何在实际应用中有效利用线程池,同时提出一个开放性问题,引发读者对于未来线程池优化方向的思考。
43 0

热门文章

最新文章