【多线程进阶】常见的锁策略

文章目录

  • 前言
  • 1. 乐观锁 vs 悲观锁
  • 2. 轻量级锁 vs 重量级锁
  • 3. 自旋锁 vs 挂起等待锁
  • 4. 读写锁 vs 互斥锁
  • 5. 公平锁 vs 非公平锁
  • 6. 可重入锁 vs 不可重入锁
  • 总结


前言

本章节所讲解的锁策略不仅仅是局限于 Java . 任何和 “锁” 相关的话题, 都可能会涉及到以下内容. 这些特性主要是给锁的实现者来参考的.

本文中讲解的锁, 并不是指某个具体的锁, 而是一个抽象的概念, 描述的是 “一类锁”. 即使是普通的程序猿也需要了解一些, 对于合理的使用锁也是有很大帮助的.

关注收藏, 开始学习吧


1. 乐观锁 vs 悲观锁

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

悲观锁:
预测该场景中, 非常容易出现锁冲突. 总是假设最坏的情况, 每次去拿数据的时候都认为别人会修改, 所以每次在拿数据的时候都会上锁, 这样别人想拿这个数据就会阻塞直到它拿到锁.

锁冲突: 指两个线程尝试去获取一把锁, 一个线程获取成功, 则另一个线程就会阻塞等待, 这就是锁冲突.

2. 轻量级锁 vs 重量级锁

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

  • CPU 提供了 “原子操作指令”.
  • 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
  • JVM 基于操作系统提供的互斥锁, 实现了 synchronizedReentrantLock 等关键字和类.
    【多线程进阶】常见的锁策略_第1张图片
    注意, synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的 工作

轻量级锁:
加锁机制尽可能的不使用 mutex, 而是尽量在用户态代码完成. 加锁开销比较小, 花费时间少, 占用资源较少.

重量级锁:
加锁机制重度依赖 OS 提供的 mutex. 加锁开销比较大, 花费时间多, 占用资源较多.

注意:

  • 一个乐观锁, 很可能是一把轻量级锁. 而一个悲观锁, 很可能是一把重量级锁. (并不绝对)
  • 悲观乐观, 是在加锁之前, 对锁冲突概率的一个预测, 决定之后工作的多少. 而重量轻量, 是在加锁之后, 考量实际的锁的开销.
  • 正是因为概念有些重合, 在针对某个具体的锁时, 可能把它叫做乐观锁, 也可能叫做轻量级锁.

3. 自旋锁 vs 挂起等待锁

挂起等待锁:
挂起等待锁, 是重量级锁的一种典型实现. 通过内核态, 借助系统提供的锁机制, 当出现锁冲突的时候, 会牵扯到内核对于线程的调度. 将冲突的线程挂起 (阻塞等待).

自旋锁:
自旋锁, 是轻量级锁的一种典型实现. 在用户态下, 通过自旋的方式 (while 循环), 实现类似于加锁的效果.

自旋锁伪代码:

while (抢锁(lock) == 失败) {}
  • 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
  • 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是
    不消耗 CPU 的, 但无法第一时间获取到锁).

4. 读写锁 vs 互斥锁

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

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

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

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

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

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

其中,

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

而在实际开发中, 读操作出现的频率, 往往比写操作要高得多. 在该场景中, 使用读写锁的话, 就可以尽可能的避免产生锁竞争, 此时, 多线程并发执行的效率就会更高. 而如果使用互斥锁的话, 就会产生不必要的挂起等待, 这就是前者读写锁存在的意义.

5. 公平锁 vs 非公平锁

假设有三个线程 A, B, C.
A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待. 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生什么呢?

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

非公平锁:
不遵守 “先来后到”. B 和 C 都有可能获取到锁. 谁先拿到锁就是谁的.

注意:

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

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

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

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

那么, 死锁具体是什么一个什么样的情况呢? 我们在下一篇博文详细讲述.


总结

✨ 本文主要讲述了锁的几个主要策略. 主要讲解了几大锁策略的概念, 以及其所对应的场景.
✨ 想了解更多的多线程知识, 可以收藏一下本人的多线程学习专栏, 里面会持续更新本人的学习记录, 跟随我一起不断学习.
✨ 感谢你们的耐心阅读, 博主本人也是一名学生, 也还有需要很多学习的东西. 写这篇文章是以本人所学内容为基础, 日后也会不断更新自己的学习记录, 我们一起努力进步, 变得优秀, 小小菜鸟, 也能有大大梦想, 关注我, 一起学习.

再次感谢你们的阅读, 你们的鼓励是我创作的最大动力!!!!!

你可能感兴趣的:(多线程学习之路,java,数据库,开发语言,多线程,锁策略)