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