- 博主简介:想进大厂的打工人
- 博主主页:@xyk:
- 所属专栏: JavaEE初阶
在Java多线程中,常见的锁策略都有哪些?这些锁策略应该怎么理解? (乐观锁vs悲观锁,轻量级锁vs重量级锁,自旋锁vs挂起等待锁,互斥锁vs读写锁,可重入锁vs不可重入锁,公平锁vs非公平锁)
常见的锁策略,注意: 接下来讲解的锁策略不仅仅是局限于 Java . 任何和 "锁" 相关的话题, 都可能会涉及到以下内容. 这些特性主要是给锁的实现者来参考的,普通的程序猿也需要了解一些, 对于合理的使用锁也是有很大帮助的.
目录
一、乐观锁vs悲观锁
1.1 乐观锁的功能
二、轻量级锁vs重量级锁
三、自旋锁vs挂起等待锁
四、互斥锁vs读写锁
五、可重入锁vs不可重入锁
六、公平锁vs非公平锁
七、相关面试题
7.1 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
7.2 介绍下读写锁?
7.3 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
7.4 synchronized 是可重入锁么?
一、乐观锁vs悲观锁
锁的实现者,预测接下来锁冲突的概率是大,还是不大,根据这个冲突的概率,来决定接下来该咋做~~
锁冲突就是锁竞争,俩个线程针对一个对象加锁,产生阻塞等待了~~
乐观锁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 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
自旋锁:是轻量级锁的一种经典实现
伪码:
while (抢锁(lock) == 失败) {}
挂起等待锁:是重量级锁的一种经典实现
自旋锁会一直去尝试获取锁,一旦锁被释放,就第一时间拿到锁,速度会更快,无时无刻都要去尝试获取,干不了别的(忙等,消耗cpu资源)通常是纯用户态的,不需要经过内核态(时间相对更短)
挂起等待锁不会去一直尝试获取锁,如果锁被释放,不能第一时间拿到锁,可能需要过很久才能拿到锁,这个时间是空闲出来的,可以趁机做别的(不消耗cpu资源)(通过内核的机制,来实现挂起等待,时间更长)
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~
挂起等待锁: 陷入沉沦不能自拔.... 过了很久很久之后, 突然女神发来消息, "咱俩要不试试?" (注意,这个很长的时间间隔里, 女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.
针对上述三组策略,synchronized属于哪把锁?
synchronized即是乐观锁,也是悲观锁,即使轻量级锁,也是重量级锁,轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁实现~~
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁:能够把 读 和 写 两种加锁区分开,Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
1.给读加锁
2.给写加锁
3.解锁
如果多个线程读同一个变量,不会涉及到线程安全问题!!!
读写锁中,约定:
1.读锁和读锁之间,不会锁竞争,不会产生阻塞等待(不会影响程序的速度,代码还是跑很快)
2.写锁和写锁之间,有锁竞争
3.读写和写锁之间,也有锁竞争
读写锁更适合于,一写多读的情况!!!
2,3点会减慢速度,但是保证准确性~~
互斥锁:
synchronized 不是读写锁,是互斥锁,加锁就只是单纯的加锁,没有更细化的区分了~~
像synchronized只有俩个操作:
1.进入代码块,加锁
2.出了代码块,解锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
如果一个锁,在一个线程中,连续对该锁加锁俩次,不死锁,就叫做可重入锁,如果死锁了,就叫不可能重入锁~~
按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的
synchronized 是可重入锁
公平锁: 遵守 "先来后到".B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁: 不遵守 "先来后到". B 和 C 都有可能获取到锁.
注意:
- 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
- 公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
synchronized是非公平锁
悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.
乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突
读写锁就是把读操作和写操作分别进行加锁.
读锁和读锁之间不互斥.
写锁和写锁之间互斥.
写锁和读锁之间互斥.
读写锁最主要用在 "频繁读, 不频繁写" 的场景中
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
相比于挂起等待锁,
优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源
是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁.
实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增