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



相关文章
|
4天前
|
Java
Java—多线程实现生产消费者
本文介绍了多线程实现生产消费者模式的三个版本。Version1包含四个类:`Producer`(生产者)、`Consumer`(消费者)、`Resource`(公共资源)和`TestMain`(测试类)。通过`synchronized`和`wait/notify`机制控制线程同步,但存在多个生产者或消费者时可能出现多次生产和消费的问题。 Version2将`if`改为`while`,解决了多次生产和消费的问题,但仍可能因`notify()`随机唤醒线程而导致死锁。因此,引入了`notifyAll()`来唤醒所有等待线程,但这会带来性能问题。
Java—多线程实现生产消费者
|
6天前
|
安全 Java Kotlin
Java多线程——synchronized、volatile 保障可见性
Java多线程中,`synchronized` 和 `volatile` 关键字用于保障可见性。`synchronized` 保证原子性、可见性和有序性,通过锁机制确保线程安全;`volatile` 仅保证可见性和有序性,不保证原子性。代码示例展示了如何使用 `synchronized` 和 `volatile` 解决主线程无法感知子线程修改共享变量的问题。总结:`volatile` 确保不同线程对共享变量操作的可见性,使一个线程修改后,其他线程能立即看到最新值。
|
6天前
|
消息中间件 缓存 安全
Java多线程是什么
Java多线程简介:本文介绍了Java中常见的线程池类型,包括`newCachedThreadPool`(适用于短期异步任务)、`newFixedThreadPool`(适用于固定数量的长期任务)、`newScheduledThreadPool`(支持定时和周期性任务)以及`newSingleThreadExecutor`(保证任务顺序执行)。同时,文章还讲解了Java中的锁机制,如`synchronized`关键字、CAS操作及其实现方式,并详细描述了可重入锁`ReentrantLock`和读写锁`ReadWriteLock`的工作原理与应用场景。
|
6天前
|
安全 Java 编译器
深入理解Java中synchronized三种使用方式:助您写出线程安全的代码
`synchronized` 是 Java 中的关键字,用于实现线程同步,确保多个线程互斥访问共享资源。它通过内置的监视器锁机制,防止多个线程同时执行被 `synchronized` 修饰的方法或代码块。`synchronized` 可以修饰非静态方法、静态方法和代码块,分别锁定实例对象、类对象或指定的对象。其底层原理基于 JVM 的指令和对象的监视器,JDK 1.6 后引入了偏向锁、轻量级锁等优化措施,提高了性能。
22 3
|
6天前
|
存储 安全 Java
Java多线程编程秘籍:各种方案一网打尽,不要错过!
Java 中实现多线程的方式主要有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口和使用线程池。每种方式各有优缺点,适用于不同的场景。继承 Thread 类最简单,实现 Runnable 接口更灵活,Callable 接口支持返回结果,线程池则便于管理和复用线程。实际应用中可根据需求选择合适的方式。此外,还介绍了多线程相关的常见面试问题及答案,涵盖线程概念、线程安全、线程池等知识点。
69 2
|
4月前
|
存储 监控 Java
Java多线程优化:提高线程池性能的技巧与实践
Java多线程优化:提高线程池性能的技巧与实践
139 1
|
7月前
|
设计模式 监控 Java
Java多线程基础-11:工厂模式及代码案例之线程池(一)
本文介绍了Java并发框架中的线程池工具,特别是`java.util.concurrent`包中的`Executors`和`ThreadPoolExecutor`类。线程池通过预先创建并管理一组线程,可以提高多线程任务的效率和响应速度,减少线程创建和销毁的开销。
242 2
|
7月前
|
Java 数据库
【Java多线程】对线程池的理解并模拟实现线程池
【Java多线程】对线程池的理解并模拟实现线程池
75 1
|
4月前
|
安全 算法 Java
17 Java多线程(线程创建+线程状态+线程安全+死锁+线程池+Lock接口+线程安全集合)(下)
17 Java多线程(线程创建+线程状态+线程安全+死锁+线程池+Lock接口+线程安全集合)
87 6
|
4月前
|
存储 安全 Java
17 Java多线程(线程创建+线程状态+线程安全+死锁+线程池+Lock接口+线程安全集合)(中)
17 Java多线程(线程创建+线程状态+线程安全+死锁+线程池+Lock接口+线程安全集合)
94 5