🌈🌈🌈今天给大家分享的是关于锁策略方面的基础知识。
🛩️🛩️🛩️希望我的文章能对你有所帮助,有不足的地方还请各位看官多多指教,大家一起学习交流!
✈️✈️✈️动动你们发财的小手,点点关注点点赞!在此谢过啦!哈哈哈!😛😛😛
目录
前言
在正式开始了解 synchronized 之前,需要先理解一些关于锁策略的基本知识,了解乐观锁、悲观锁、公平锁、非公平锁、可重入锁、不可重入锁的一些基本概念。
一、常见的锁策略
1.1 乐观锁 vs 悲观锁
乐观锁 :假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
简言之,乐观锁就是预测某场景中,不太会出现锁冲突的情况;悲观锁就是预测某场景中非常容易出现锁冲突。
举个例子 : 同学 A 和 同学 B 想请教老师一个问题 .同学 A 认为 " 老师是比较忙的 , 我来问问题 , 老师不一定有空解答 "。 因此同学 A 会先给老师发消息 : " 老师,你忙嘛? 我下午两点能来找你问个问题嘛 ?" ( 相当于加锁操作 ) 得到肯定的答复之后 , 才会真的来问问题。如果得到了否定的答复, 那就等一段时间 , 下次再来和老师确定时间。 这个是悲观锁 .同学 B 认为 " 老师是比较闲的 , 我来问问题 , 老师大概率是有空解答的 "。 因此同学 B 直接就来找老师。 ( 没加锁, 直接访问资源 ) 如果老师确实比较闲 , 那么直接问题就解决了 . 如果老师这会确实很忙 , 那么同学 B 也不会打扰老师, 就下次再来 ( 虽然没加锁 , 但是能识别出数据访问冲突 )。 这个是乐观锁。
- 这两种思路不能说谁优谁劣, 而是看当前的场景是否合适。
- 如果当前老师确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 "白跑很多趟", 耗费额外的资源。
- 如果当前老师确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低。
Synchronized 初始使用乐观锁策略,当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略。
就好比同学 C 开始认为 "老师比较闲的", 问问题都会直接去找老师。但是直接来找两次老师之后, 发现老师都挺忙的, 于是下次再来问问题, 就先发个消息问问老师忙不忙, 再决定是否来问问题。
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突,我们可以引入一个 "版本号" 来解决
假设我们需要多线程修改 "用户账户余额"。
设当前余额为 100,引入一个版本号 version, 初始值为 1,并且我们规定 "提交版本必须大于记录当前版本才能执行更新余额。
1.2 读写锁
- 两个线程都只是读一个数据, 此时并没有线程安全问题,直接并发的读取即可
- 两个线程都要写一个数据, 有线程安全问题
- 一个线程读另外一个线程写, 也有线程安全问题
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行 加锁解锁
- ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进 行加锁解锁
其中:
- 读加锁和读加锁之间, 不互斥
- 写加锁和写加锁之间, 互斥
- 读加锁和写加锁之间, 互斥
读写锁特别适合于 "频繁读, 不频繁写" 的场景中。
1.3 重量级锁 vs 轻量级锁
锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的:
- CPU 提供了 "原子操作指令"
- 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁
- JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类
注意:
synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作
重量级锁: 加锁机制重度依赖了 OS 提供了 mutex
- 大量的内核态用户态切换
- 很容易引发线程的调度
简言之,重量级锁就是:加锁开销比较大,花的时间多,占用系统资源多。(一个悲观锁,很可能是重量级锁)。
轻量级锁就是:加锁开销比较小,花的时间小,占用系统资源少。 (一个乐观锁,很可能是轻量级锁)。
这两个操作, 成本比较高,一旦涉及到用户态和内核态的切换, 就意味着 "沧海桑田" 。
轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成,实在搞不定了, 再使用 mutex。
- 少量的内核态用户态切换
- 不太容易引发线程调度
理解用户态 vs 内核态:想象去银行办业务,在窗口外, 自己办理相关业务, 这是用户态,用户态的时间成本是比较可控的。在窗口内, 工作人员做, 这是内核态,内核态的时间成本是不太可控的。如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的。
synchronized 开始是一个轻量级锁, 如果锁冲突比较严重, 就会变成重量级锁。
1.4 自旋锁
while (抢锁(lock) == 失败) {}
理解自旋锁 vs 挂起等待锁想象一下, 去追求一个女神,当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~挂起等待锁 : 在发现女神已经有男朋友之后,(锁被占用了),就不再继续死缠烂打了.... 过了很久很久之后, 突然了解到女神单身了,才会继续联系女神。 (注意, 这个很长的时间间隔里, 女神可能已经换了好几个男票了)。自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安,一旦女神和上一任分手, 那么就能立刻抓住机会上位。
自旋锁是一种典型的 轻量级锁 的实现方式:
- 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁。
- 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源。 (而挂起等待的时候是不消耗 CPU 的)。
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的。
1.5 公平锁 vs 非公平锁
假设三个线程 A, B, C。A 先尝试获取锁, 获取成功。 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后 C 也尝试获取锁, C 也获取失败, 也阻塞等待。当线程 A 释放锁的时候, 会发生啥呢?
公平锁: 遵守 "先来后到"。B 比 C 先来的, 当 A 释放锁的之后, B 就能先于 C 获取到锁。
注意:
- 操作系统内部的线程调度就可以视为是随机的。如果不做任何额外的限制, 锁就是非公平锁, 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序。
- 公平锁和非公平锁没有好坏之分, 关键还是看适用场景。
synchronized 是非公平锁。
1.6 可重入锁 vs 不可重入锁
可重入锁:可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
不可重入锁:
理解 "把自己锁死:一个线程没有释放锁, 然后又尝试再次加锁。
// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待.
lock();
按照上述对于锁的设定, 第二次加锁的时候, 就会阻塞等待。直到第一次的锁被释放, 才能获取到第二个锁。 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作,这时候就会 死锁。
这样的锁称为 不可重入锁。
synchronized 是可重入锁。
二、锁策略常见问题
2.1 理解乐观锁和悲观锁的以及具体实现
悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁。乐观锁认为多个线程访问同一个共享变量冲突的概率不大,并不会真的加锁, 而是直接尝试访问数据。 在访问的同时识别当前的数据是否出现访问冲突。悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据,获取不到锁就等待。乐观锁的实现可以引入一个版本号,借助版本号识别出当前的数据访问是否冲突。
2.2 读写锁的基本介绍
- 读写锁就是把读操作和写操作分别进行加锁
- 读锁和读锁之间不互斥
- 写锁和写锁之间互斥
- 写锁和读锁之间互斥
- 读写锁最主要用在 "频繁读, 不频繁写" 的场景中
2.3 关于自旋锁
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止,第一次获取锁失败, 第二次的尝试会在极短的时间内到来。 一旦锁被其他线程释放, 就能第一时间获取到锁。相比于挂起等待锁:
- 优点:没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效。 在锁持有时间比较短的场景下非常有用。
- 缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源
2.4 synchronized 是否是可重入锁
是可重入锁。可重入锁指的就是连续两次加锁不会导致死锁。实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数),如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增。
🌈🌈🌈好啦,今天的分享就到这里!
🛩️🛩️🛩️希望各位看官读完文章后,能够有所提升。
🎉🎉🎉创作不易,还希望各位大佬支持一下!
✈️✈️✈️点赞,你的认可是我创作的动力!
⭐⭐⭐收藏,你的青睐是我努力的方向!
✏️✏️✏️评论:你的意见是我进步的财富!