0


[多线程进阶] 常见锁策略


专栏简介: JavaEE从入门到进阶

题目来源: leetcode,牛客,剑指offer.

创作目标: 记录学习JavaEE学习历程

希望在提升自己的同时,帮助他人,,与大家一起共同进步,互相成长.

学历代表过去,能力代表现在,学习能力代表未来!


目录:


1. 常见的锁策略

锁策略之所以被叫做策略 , 是因为它并不是一个具体的锁 , 而是一系列供锁的实现者来参考的特性 , 对普通程序猿合理的使用锁也是有很大的帮助.

1.1 乐观锁 vs 悲观锁

乐观锁:

假设数据一般情况下不会产生并发冲突 , 所以在数据进行提交更新的时候 , 才会正式对数据是否产生并发冲突进行检测 , 如果发现并发冲突了 , 则返回错误的信息 , 让用户决定如果去做.

悲观锁:

总是假设最坏的情况 , 每次拿数据的时候都认为别人会修改 , 所以每次都会上锁 , 这样别人想拿数据就会阻塞直到它拿到锁.

例如: 同学A 和同学B 想请教老师一个问题:

  • 同学A 认为老师一定是比较忙的 , 因此他会先给老师法消息: "老师忙吗? 我上午10点向您请教一个问题."(相当于加锁操作)得到肯定回复后才会来 , 如果得到否定回复 , 那就等一段时间 , 下次再问老师. 这是个悲观锁
  • 同学B 认为老师一定是比较闲的 , 因此他会直接去找老师(没加锁 , 直接访问资源) , 如果老师确实比较闲 , 那么问题就解决了. 如果老师比较忙 , 那么也不会打扰老师下次再来(虽然每加锁 , 但能识别出数据访问冲突). 这是个乐观锁.

这两种思路的优劣要看具体的实现场景:

  • 如果当前老师确实比较忙 , 那么就适合使用悲观锁 , 使用乐观锁会导致"白跑很多趟" , 耗费额外的资源.
  • 如果当前老师比较闲 , 那么就适合使用乐观锁 , 使用悲观锁锁让效率

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

同学C (相当于Synchronized) , 开始认为"老师应该是比较闲的" , 有问题会直接去问老师.

但直接来找老师几次后 , 发现老师都挺忙的 , 于是下次来问老师会先发消息 , 在决定是否来问问题.


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

假设需要多线程来修改"账户余额"

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

  1. 线程 A 此时读出信息( version = 1 , balance = 100) , 线程 B 也读出信息(version = 1 , balance = 100)

  1. 线程 A 操作的过程中并从其账户中扣除 50 (100-50) , 线程 B 从其账户中扣除 20(100-20).

  1. 线程 A 完成修改工作 , 将数据版本号+1(version = 2) , 连同账户扣除余额(balance = 50)写到内存中.

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


1.2 读写锁:

多线程之间 , 数据的读取方之间不会产生线程安全 , 但数据的写入方之间以及读者之间都需要进行互斥 , 如果两种情况下都用同一个锁 , 就会产生极大的性能损耗 , 所以读写锁因此而产生.

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

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

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

其中:

  • 加锁和加锁之间 , 不互斥.
  • 加锁和加锁之间 , 互斥.
  • 加锁和加锁之间 . 互斥.

Tips: 只要涉及到"互斥" , 就存在线程的挂起等待 , 一但线程被挂起 , 再次调用就不知在什么时候 , 因此尽可能的减少"互斥"的机会 , 就是提高效率的重要途径.

Synchronized 不是读写锁.


1.3 重量级锁 vs 轻量级锁

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

  • CPU 提供了"原子操作指令".
  • 操作系统基于 CPU 的原子指令 , 实现了 mutex 互斥锁.
  • JVM 基于操作系统提供的互斥锁 , 实现 synchronized 和 ReentrantLock 等关键字和类.

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

  • 大量的内核态用户切换
  • 很容易引发线程的调度

这两个操作 , 成本比较高 , 一但涉及到用户态内核态的切换 , 就意味着"沧海桑田".

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

  • 少量的用户态切换
  • 不容易引发线程调度

用户态 vs 内核态

假设去银行办理业务:

在窗口外 , 自己在 ATM 机办理业务就相当于用户态 , 用户态的时间成本是比较可控的.

在窗口内 , 工作人员办理 , 就是内核态 , 内核态的时间成本不可控.

如果办理业务需要和工作人员反复沟通 , 还需要重新排队 , 这样的效率是很低的.

synchronized 开始是一个轻量级锁 , 如果锁冲突较为严重 , 就会变成重量级锁. synchronized的轻量级锁是基于自旋锁实现的 , 重量级锁是基于挂起等待锁实现的.


1.4 自旋锁(Spin Lock)

按照上文的结论 , 线程在强锁失败后会进入阻塞状态 , 放弃 CPU , 需要过很久才能再次被调度.

但实际情况下 , 虽然强锁失败 , 但过不了多久锁就会被释放 , 没必要放弃 CPU , 此时就需要自旋锁来处理这样的问题.

自旋锁伪代码:

while(抢锁(lock) == 失败){}

如果获取锁失败 , 立即再次尝试获取锁 , 知道获取锁为止 , 第一次获取锁失败 , 第二次的尝试会在极短的时间内到来.

因此 , 一但锁被其他线程释放 , 就能第一时间获取锁.

自旋锁 vs 挂起等待锁

当小明去找老师问题 , 老师说: 稍等一会 , 这会已经正在给其他同学讲题.

挂起等待锁: 回去干自己的事 , 过了很久很久之后 , 老师突然发来消息 , "这会有空闲时间"(注意 , 这个很长的时间间隔里 , 老师可能已经给多个同学讲完题了)

自旋锁: 站在老师办公室门口 , 一旦上个同学出来 , 那么就能立即抓住机会问题.

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

  • 优点: 没有放弃 CPU , 不涉及线程的阻塞的调度 , 一旦锁被释放 , 就能第一时间获取到锁
  • 缺点: 如果锁被其他线程占用时间过长 , 那么就会持续的消耗 CPU 资源.(挂起等待的时候不消耗 CPU 资源)

synchronized 中的轻量级锁就是通过自旋锁的方式形成.


1.5 公平锁 vs 非公平锁

假设有三个线程 A , B , C. A成功获取锁 , B和C都尝试获取锁 , 但获取失败 , 阻塞等待. 那么当 A 线程释放锁时 , 会发生什么?

公平锁: 遵循"先来后到". B 比 C 先来 , 那么当 A 释放后 , B 就可能先于 C 获得锁.

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

Tips:

  • 操作系统内部的线程调度就是随机的 , 如果不做任何额外的限制 , 锁就是非公平锁. 如果想要实现公平锁 , 就需要依赖额外的数据结构. 记录线程的先后顺序.
  • 公平锁与非公平锁没有好坏之分 , 关键在于适用场景.

synchronized 是非公平锁.


1.6 可重入锁 vs 不可重入锁

可重入锁的字面意思是 "可重新进入的锁" , 即允许一个线程多次获取同一把锁.

例如一个递归函数中有加锁操作 , 递归过程中如果锁不会阻塞自己 , 那么这个锁就是可重入锁(递归锁).

Java 中只要是以 Reentrant 开头的都是可重入锁 , 而且 JDK提供的所有现成的Lock实现类 , 包括synchronized 关键字都是可重入的.

而 LInux 系统提供的 mutex 是不可重入锁.

Synchronized 是可重入锁.


1.7 相关面试题

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

  • 悲观锁认为多个线程访问同一个共享变量冲突概率较大 , 会在每次访问共享变量前真正加锁.
  • 乐观锁认为多个线程访问同一个共享变量的冲突概率不大 , 并不会真的加锁 , 而是直接尝试访问数据. 在访问的同时识别当前数据是否发生访问冲突.
  • 乐观锁的实现可以引入一个版本号 , 借助版本号识别当前数据是否访问冲突.

2. 介绍下读写锁?

读写锁就是把读操作和写操作分别进行加锁.

读操作和读操作之间不互斥.

读操作和写操作之间互斥.

写操作和写操作之间互斥.

读写锁的最长用场景就是 "频繁读 , 不频繁写".

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

自旋锁是一种轻量级锁 , 如果获取锁失败 , 会无限循环不停的获取锁 , 直到获取到锁为止 , 因此一但锁被其他线程释放可以第一时间获取.

相比于挂起等待锁:

优点: 没有放弃 CPU 资源 , 一但锁被释放就能第一时间获取到锁 , 更高效. 在锁持有时间比较短的情况下非常高效.

缺点: 如果锁长时间的持有机会浪费 CPU 资源.

4. synchronized 是可重入锁吗?

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

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


本文转载自: https://blog.csdn.net/liu_xuixui/article/details/128862990
版权归原作者 Node_Hao 所有, 如有侵权,请联系我们删除。

“[多线程进阶] 常见锁策略”的评论:

还没有评论