多线程进阶之常见的锁策略

本文介绍了乐观锁与悲观锁的区别,以及它们在并发场景中的应用,包括乐观锁预测冲突少减少工作量、悲观锁对冲突的高概率导致更多工作。此外,还讨论了重量级锁与轻量级锁(如自旋锁)、公平锁与非公平锁,以及可重入锁的概念。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1. 乐观锁 VS 悲观锁

锁冲突: 两个线程尝试获取一把锁

一个线程能获取成功,  另一个线程阻塞等待

锁冲突的概率大 还是 小, 对后续工作是有一定影响的

乐观锁: 预测该场景中, 不太会出现锁冲突的情况, 后续做的工作会更少

悲观锁: 预测该场景中, 非常容易出现锁冲突的情况, 后续做的工作会更多

悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观锁:
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

假设我们需要多线程修改 "用户账户余额".  

设当前余额为 100. 引入一个版本号 version, 初始值为 1. 并且我们规定 "提交版本必须大于记录
当前版本才能执行更新余额"
1) 线程 A 此时准备将其读出( version=1, balance=100 ),线程 B 也读入此信息( version=1,
balance=100 )

 2) 线程 A 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20

( 100-20 );
3) 线程 A 完成修改工作,将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=50
),写回到内存中;

4) 线程 B 完成了操作,也将版本号加1( version=2 )试图向内存中提交数据( balance=80
),但此时比对版本发现,操作员 B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不
满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败.

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

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

2. 重量级锁 VS 轻量级锁

重量级锁: 加锁的开销是比较大的(花的时间多, 占用系统资源多)

                一个悲观锁, 很可能是重量级锁 (不绝对)

轻量级锁: 加锁开销比较小的 (花的时间少, 占用系统资源少)       

                一个乐观锁, 也可能是轻量级锁 (不绝对)

悲观乐观, 是在加锁之前, 对锁冲突概率的预测, 决定工作的多少

重量轻量, 是在加锁之后, 考量实际的锁的开销

正因为这样的概念存在重合, 针对一个具体的锁, 可能把它叫做乐观锁, 也可能叫做轻量锁

那什么叫做 做的工作多 , 做的工作少呢 ?
我们一般认为 , 锁这个东西要保持互斥的 , 那保持互斥要有力量来源的
我们 Java 中实现一把锁 , 需要使用 synchronized 关键字 (后续还会讲解 ReentrantLock)
那 Java 里面实现锁 , 主要是 JVM 提供的 synchronized 和 ReentrantLock 这两个机制
那 JVM 之所以能够实现锁机制 , 是因为操作系统提供了 mutex 互斥锁
操作系统之所以能够加锁 , 是因为 CPU 提供了一些用来加锁的 , 能够保证原子操作的指令

image.png

重量级锁主要是依赖了操作系统提供的锁 , 使用操作系统提供的锁 , 就很容易产生阻塞等待
轻量级锁主要是尽量的避免使用操作系统提供的锁 , 尽量在用户态完成功能 , 也就是尽量的避免用户态和内核态的切换 , 尽量避免挂起等待 (阻塞等待)

重量级锁: 加锁机制重度依赖了 OS 提供了 mutex
大量的内核态用户态切换
很容易引发线程的调度
这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 "沧海桑田".

轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex.
少量的内核态用户态切换.
不太容易引发线程调度.

synchronized 是自适应锁 , 既是轻量级锁 , 又是重量级锁
也是根据锁冲突的情况来决定的
冲突的不高就是轻量级锁 , 冲突的很高就是重量级锁

3. 自旋锁 VS 挂起等待锁

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

按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.
但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.
一旦锁被其他线程释放, 就能第一时间获取到锁.

自旋锁 : 当我们发现锁冲突的时候 , 不会挂起等待 , 它会迅速再次尝试这个锁能不能获取到 (超级舔狗)
他就相当于是一个 while 循环一直获取锁的状态
一旦锁被释放 , 就可以第一时间获取到
如果锁一直不释放 , 就会消耗大量的 CPU
自旋锁是更加轻量的 , 效率更高的

挂起等待锁, 是重量级锁的一种典型实现

                通过内核态, 借助系统提供的锁机制, 当出现锁冲突的时候会牵扯到内核对于线程的调度. 使冲突线程出现挂起 (阻塞等待)

挂起等待锁 : 发现锁冲突 , 就挂起等待
一旦锁被释放 , 不能第一时间获取到
在锁被其他线程占用的时候 , 会放弃 CPU 资源
挂起等待锁是更加重量的 , 效率更低的

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

4. 普通的互斥锁 VS 读写锁

读写锁, 把读操作和写操作加锁分开了, 一个事实: 多线程同时去读同一个变量, 不涉及到线程安全问题~~

        我们数据库事务, 隔离级别中学的 (读操作加锁 和 写操作加锁) 还不太一样

        事务中的读加锁, 写加锁, 要比此处谈到的读写锁, 粒度更细情况分的更多

        写加锁: 写的时候不能读

        读加锁: 读的时候不能写

如果两个线程, 一个线程读加锁, 另一个线程也是读加锁, 不会产生锁竞争

如果两个线程, 一个线程写加锁, 另一个线程也是写加锁, 会产生锁竞争

如果两个线程, 一个线程写加锁, 另一个线程读加锁, 也会产生锁竞争

实际开发中, 读操作的频率, 往往比写操作高很多 

java标准库里, 也提供了现成的读写锁~~

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

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

Synchronized 不是读写锁 

 5. 公平锁 VS 非公平锁

假设三个线程 A, B, C.  A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后
C 也尝试获取锁, C 也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生啥呢?
公平锁: 遵守 "先来后到". B C 先来的. A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁: 不遵守 "先来后到". B C 都有可能获取到锁.

啥样的情况才算是公平呢 ?
符合 “先来后到” 这样的规则 , 就是公平

先来的 , 先排在前面 . 来晚的 , 就在后面排着

非公平锁, 看起来使概率均等, 但是实际上是不公平 (每个线程阻塞时间是不一样的)

操作系统自带的锁(pthread_mutex) 属于是非公平锁

要想实现公平锁, 就需要有一些额外的数据结构来支持. (比如需要有办法纪录每个线程的阻塞等待时间)

synchronized 是非公平锁.

6. 可重入锁 vs 不可重入锁

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

image.png

问题: 引用计数

可重入锁, 是让锁记录了当前是哪个线程持有了锁~~

synchronized(this) {  // 真正加了锁
    synchronized(this) {  // 没有加锁 只是判定了持有线程就是当前线程
        synchronized(this) { // 没有加锁, 只是判定了持有线程就是当前线程
            ....
        } // 所以执行到这个代码, 出了这个代码块的时候, 刚才加上的锁不应该释放
    } // 如果最里层的} 处释放了锁, 意味着最外面的synchronized 和 中间的synchronized 后续的代            
      // 码部分就没有处在锁的保护之中了
} // 真正释放锁的地方   

如果加锁是N层, 在遇到 } , JVM咋知道当前这个 } 是最后一个(最外层真正释放锁的地方呢) 

让锁这里持有一个 "计数器" 就行了

让锁对象不光要记录是哪个线程持有锁, 同时再通过一个整形变量记录当前这个线程加了几次锁

每遇到一个加锁操作, 计数器就 +1, 每遇到一个解锁操作, 就 -1

当计数器被减为 0 的时候, 才真正执行释放锁操作, 其他时候不释放~~

相关面试题:

 1) 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?

悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.
乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突. (实现细节参考上面的图).

2) 介绍下读写锁?  

读写锁就是把读操作和写操作分别进行加锁.
读锁和读锁之间不互斥.
写锁和写锁之间互斥.
写锁和读锁之间互斥.
读写锁最主要用在 "频繁读, 不频繁写" 的场景中.

 3) 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么

如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
相比于挂起等待锁,
优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源

 4) synchronized 是可重入锁么

是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁.
实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值