【多线程】常见的锁策略

目录

一、乐观锁与悲观锁

二、读写锁

三、重量级锁与轻量级锁

四、自旋锁与挂起等待锁

五、公平锁与非公平锁

六、可重入锁与不可重入锁


一、乐观锁与悲观锁

举个栗子: 同学 A 和 同学 B 想请教老师一个问题. 同学 A 认为 "老师是比较忙的, 我来问问题, 老师不一定有空解答". 因此同学 A 会先给老师发消息: "老师 你忙嘛? 我下午两点能来找你问个问题嘛?" (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁

同学 B 认为 "老师是比较闲的, 我来问问题, 老师大概率是有空解答的". 因此同学 B 直接就来找老师.(没 加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B 也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.

乐观锁:假设锁冲突的概率比较低,甚至基本没有冲突,就只是简单处理一下冲突

悲观锁:假设锁冲突的概率比较高,甚至每次尝试加锁都会有冲突,此时就会愿意付出更多的成本来处理冲突

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

乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 "版本号" 来解决.

设当前余额为 100. 引入一个版本号 version, 初始值为 1. 并且我们规定 "提交版本必须大于记录 当前版本才能执行更新余额"

二、读写锁

多个线程同时尝试修改同一个变量,线程不安全

如果多个线程同时读取同一个变量,线程安全

一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.

两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.

两个线程都要写一个数据, 有线程安全问题.

一个线程读另外一个线程写, 也有线程安全问题

读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复 数读者之间并不互斥,而写者则要求与任何人互斥。

读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.

ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行 加锁解锁.

ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.

假设现在有10个线程,t0和t9是写线程,t1-t8就是读线程

假设如果是t1和t2两个读线程同时访问数据,此时两个读锁之间不会互斥,完全并发执行

假设如果是t0和t1两个线程同时访问,此时读锁和写锁之之间会互斥,要么读完再写,要么写完再读

假设如果t0和t9两个写线程同时访问,此时写锁和写锁之间会互斥,一定是一个线程写完,另外一个线程开始写

对于这种读比较多,写比较少的情况,使用读写锁,就能大大提高效率,因为降低锁冲突的概率,防止线程阻塞等待可以提高程序的运行效率

三、重量级锁与轻量级锁

重量级锁:加锁解锁的开销很大,往往是通过内核来完成的

轻量级锁:加锁解锁的开销更小,往往只是在用户态完成的

锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的.

CPU 提供了 "原子操作指令".

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

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

重量级锁: 加锁机制重度依赖了 OS 提供了 mutex

大量的内核态用户态切换

很容易引发线程的调度

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

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

不太容易引发线程调度.

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

四、自旋锁与挂起等待锁

如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会 在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁

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

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

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

理解自旋锁 vs 挂起等待锁

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

挂起等待锁: 陷入沉沦不能自拔.... 过了很久很久之后, 突然女神发来消息, "咱俩要不试试?" (注意, 这个很长的时间间隔里, 女神可能已经换了好几个男票了).

自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能 立刻抓住机会上位.

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

五、公平锁与非公平锁

假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后 C 也尝试获取锁, C 也获取失败, 也阻塞等待.

当线程 A 释放锁的时候, 会发生啥呢?

公平锁: 遵守 "先来后到". B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.

非公平锁: 不遵守 "先来后到". B 和 C 都有可能获取到锁.

操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要 想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.

公平锁和非公平锁没有好坏之分, 关键还是看适用场景.

synchronized 是非公平锁.

六、可重入锁与不可重入锁

可重入锁指的就是连续两次加锁不会导致死锁.

实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁 的线程就是持有锁的线程, 则直接计数自增

可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。 比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。

// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待. 
lock();

按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第 二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无 法进行解锁操作. 这时候就会死锁

synchronized 是可重入锁

猜你喜欢

转载自blog.csdn.net/qq_50156012/article/details/123435174