前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >并发编程的奥秘:探索锁机制的多样性与应用

并发编程的奥秘:探索锁机制的多样性与应用

作者头像
小皮侠
发布于 2024-10-18 01:44:34
发布于 2024-10-18 01:44:34
12800
代码可运行
举报
运行总次数:0
代码可运行

任何设计锁的场所,都设计锁策略,本篇文章主要揭秘实现一个锁需要知道的特性。

1.乐观锁和悲观锁

悲观锁 :

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

乐观锁:

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

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

乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 " 版本号 " 来解决。 假设我们需要多线程修改 "用户账户余额"。设当前余额为 100。引入一个版本号 version, 初始值为 1. 并且我们规定 "提交版本必须大于记录当前版本才能执行更新余额"。

2.读写锁

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

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

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

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

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

锁 .

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

其中,

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

读写锁特别适合于 "频繁读, 不频繁写" 的场景中. (这样的场景其实也是非常广泛存在的).

Synchronized 不是读写锁。

3.重量级锁和轻量级锁

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

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

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

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

这两个操作的成本均相对较高。

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

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

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

4.自旋锁

按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.

但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题.

自旋锁伪代码:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
while (抢锁(lock) == 失败) {}

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

一旦锁被其他线程释放 , 就能第一时间获取到锁 .

自旋锁是一种典型的 轻量级锁 的实现方式. 优点 : 没有放弃 CPU, 不涉及线程阻塞和调度 , 一旦锁被释放 , 就能第一时间获取到锁 . 缺点 : 如果锁被其他线程持有的时间比较久 , 那么就会持续的消耗 CPU 资源 . ( 而挂起等待的时候是不消耗 CPU 的 ).

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

5.公平锁和非公平锁

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

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

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

synchronized 是非公平锁.

6.可重入锁和不可重入锁

可重入锁的字面意思是 “ 可以重新进入的锁 ” ,即 允许同一个线程多次获取同一把锁 。不会出现自己把自己锁死的情况。

比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是 可重入 (因为这个原因可重入锁也叫做 递归锁

Java 里只要以 Reentrant 开头命名的锁都是可重入锁,而且 JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。Linux 系统提供的 mutex 是不可重入锁。

synchronized 是可重入锁

7.相关面试题

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

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

2.介绍下读写锁?

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

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

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

4.synchronized 是可重入锁么?

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

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2024-10-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1.乐观锁和悲观锁
    • 2.读写锁
    • 3.重量级锁和轻量级锁
    • 4.自旋锁
    • 5.公平锁和非公平锁
    • 6.可重入锁和不可重入锁
    • 7.相关面试题
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档