常见的锁策略
乐观锁 vs 悲观锁
悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观锁:
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
* 这两种思路不能说谁优谁劣, 而是看当前的场景是否合适
Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(
readers-writer lock
),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。
一个线程对于数据的访问
,
主要存在两种操作
:
读数据 和 写数据
.
* 两个线程都只是读一个数据
,
此时并没有线程安全问题
.
直接并发的读取即可
.
* 两个线程都要写一个数据
,
有线程安全问题
.
* 一个线程读另外一个线程写
,
也有线程安全问题
.
读写锁就是把读操作和写操作区分对待
. Java
标准库提供了
ReentrantReadWriteLock
类
,
实现了读写锁.
* ReentrantReadWriteLock.ReadLock
类表示一个读锁
.
这个对象提供了
lock / unlock
方法进行
加锁解锁
.
* ReentrantReadWriteLock.WriteLock
类表示一个写锁
.
这个对象也提供了
lock / unlock
方法进
行加锁解锁
.
其中
,
* 读加锁和读加锁之间,
不互斥
.
* 写加锁和写加锁之间,
互斥
.
* 读加锁和写加锁之间,
互斥
.
注意
,
只要是涉及到
"
互斥
",
就会产生线程的挂起等待
.
一旦线程挂起
,
再次被唤醒就不知道隔了多
久了
.
因此尽可能减少
"
互斥
"
的机会
,
就是提高效率的重要途径
读写锁特别适合于
"
频繁读
,
不频繁写
"
的场景中
. (
这样的场景其实也是非常广泛存在的
).
Synchronized 不是读写锁
重量级锁
vs
轻量级锁
重量级锁
:
加锁机制重度依赖了
OS
提供了
mutex
* 大量的内核态用户态切换
* 很容易引发线程的调度
这两个操作
,
成本比较高
.
一旦涉及到用户态和内核态的切换
,
就意味着
"
沧海桑田
".
轻量级锁
:
加锁机制尽可能不使用
mutex,
而是尽量在用户态代码完成
.
实在搞不定了
,
再使用
mutex.
* 少量的内核态用户态切换
.
* 不太容易引发线程调度
synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁
自旋锁
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃
CPU
,需要过很久才能再次被调度
.
但实际上
,
大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃
CPU.
这个时候就可以使用自旋锁来处理这样的问题.
伪代码:
while (抢锁(lock) == 失败) {}
如果获取锁失败
,
立即再尝试获取锁
,
无限循环
,
直到获取到锁为止
.
第一次获取锁失败
,
第二次的尝试会在极短的时间内到来.
一旦锁被其他线程释放
,
就能第一时间获取到锁
.
自旋锁是一种典型的 轻量级锁 的实现方式
.
* 优点
:
没有放弃
CPU,
不涉及线程阻塞和调度
,
一旦锁被释放
,
就能第一时间获取到锁
.
* 缺点
:
如果锁被其他线程持有的时间比较久
,
那么就会持续的消耗
CPU
资源
. (
而挂起等待的时候是不消耗 CPU
的
).
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的
公平锁
vs
非公平锁
假设三个线程
A, B, C。 A
先尝试获取锁
,
获取成功
.
然后
B
再尝试获取锁
,
获取失败
,
阻塞等待
;
然后C 也尝试获取锁
, C
也获取失败
,
也阻塞等待
.
当线程
A
释放锁的时候
,
会发生啥呢
?
公平锁:
遵守
"
先来后到
". B
比
C
先来的
.
当
A
释放锁的之后
, B
就能先于
C
获取到锁
.
非公平锁:
不遵守
"
先来后到
". B
和
C
都有可能获取到锁
.
注意:
* 操作系统内部的线程调度就可以视为是随机的
.
如果不做任何额外的限制
,
锁就是非公平锁
.
如果要想实现公平锁,
就需要依赖
额外的数据结构
,
来记录线程们的先后顺序
.
* 公平锁和非公平锁没有好坏之分
,
关键还是看适用场景
.
synchronized 是非公平锁
可重入锁
vs
不可重入锁
可重入锁的字面意思是
“
可以重新进入的锁
”
,即
允许同一个线程多次获取同一把锁
。
比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是
可重入
锁
(因为这个原因可重入锁也叫做
递归锁
)
。
Java
里只要以
Reentrant
开头命名的锁都是可重入锁,而且
JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
而
Linux
系统提供的
mutex
是不可重入锁
.
相关面试题
1)
你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁认为多个线程访问同一个共享变量冲突的概率较大
,
会在每次访问共享变量之前都去真正加
锁
.
乐观锁认为多个线程访问同一个共享变量冲突的概率不大
.
并不会真的加锁
,
而是直接尝试访问数
据
.
在访问的同时识别当前的数据是否出现访问冲突
.
悲观锁的实现就是先加锁
(
比如借助操作系统提供的
mutex),
获取到锁再操作数据
.
获取不到锁就
等待
.
乐观锁的实现可以引入一个版本号
.
借助版本号识别出当前的数据访问是否冲突
. (
实现细节参考上
面的图
).
2)
介绍下读写锁
?
读写锁就是把读操作和写操作分别进行加锁
.
读锁和读锁之间不互斥
.
写锁和写锁之间互斥
.
写锁和读锁之间互斥
.
读写锁最主要用在
"
频繁读
,
不频繁写
"
的场景中
.
3)
什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败
,
立即再尝试获取锁
,
无限循环
,
直到获取到锁为止
.
第一次获取锁失败
,
第二次的尝
试会在极短的时间内到来
.
一旦锁被其他线程释放
,
就能第一时间获取到锁
.
相比于挂起等待锁
,
优点
:
没有放弃
CPU
资源
,
一旦锁被释放就能第一时间获取到锁
,
更高效
.
在锁持有时间比较短的场
景下非常有用
.
缺点
:
如果锁的持有时间较长
,
就会浪费
CPU
资源
.
4) synchronized
是可重入锁么?
是可重入锁
.
可重入锁指的就是连续两次加锁不会导致死锁
.
实现的方式是在锁中记录该锁持有的线程身份
,
以及一个计数器
(
记录加锁次数
).
如果发现当前加锁
的线程就是持有锁的线程
,
则直接计数自增
.