目录
🌳 常见的锁策略
🚩 乐观锁 vs 悲观锁
🚩 重量级锁 vs 轻量级锁
🚩 自旋锁 vs 挂起等待锁
🚩 可重入锁 vs 不可重入锁
🚩 公平锁 vs 非公平锁
🚩 互斥锁 vs 读写锁
🎄 相关面试题
🍀 synchronized 实现原理
🚩 锁升级
🙂 无锁
🙂 偏向锁
🙂 轻量级锁
🙂 重量级锁
🚩 锁消除
🚩 锁粗化
面试题:
🌳 常见的锁策略
接下来讲解的锁策略不仅仅是局限于 Java . 任何和 “锁” 相关的话题, 都可能会涉及到以下内容. 这些特性主要是给锁的实现者来参考的.
普通的程序猿也需要了解一些, 对于合理的使用锁也是有很大帮助的
所说的策略,是这把锁,在加锁/解锁/遇到锁冲突的时候,都会怎么做,"策略"可以理解为"做法"。
🚩 乐观锁 vs 悲观锁
举个栗子: 同学 A 和 同学 B 想请教老师一个问题.
同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: “老师你忙嘛? 我下午两点能来找你问个问题嘛?” (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题.如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.
同学 B 认为 “老师是比较闲的, 我来问问题, 老师大概率是有空解答的”. 因此同学 B 直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁
注意:
这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.
如果当前老师确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 “白跑很多趟”, 耗费额外的资源.
如果当前老师确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低
在Java中,Synchronized 关键字初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略
🚩 重量级锁 vs 轻量级锁
🚩 自旋锁 vs 挂起等待锁
自旋锁是轻量级锁的一种典型实现
伪代码:
挂起等待锁是重量级锁一种典型的实现
借助系统中的线程调度机制,当尝试加锁,并且锁被占用了,出现锁冲突,就会让当前这个尝试加锁的线程被挂起(阻塞状态),此时这个线程就不参与调度了,知道这个锁被释放,然后系统才能去唤醒这个线程,去尝试重新获取锁。(这个过程消耗的时间更长)
那么 synchronized 的轻量级部分(基于 CAS 机制来实现的),基于自旋锁实现的,重量级部分(调用系统 API 通过内核完成),基于挂起等待锁实现的。
例子:
🚩 可重入锁 vs 不可重入锁
这个呢之前已经讲过了:
🚩 公平锁 vs 非公平锁
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C也获取失败, 也阻塞等待.当线程 A 释放锁的时候, 会发生啥呢?
公平锁: 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁
这就好比一群男生追同一个女神.
当女神和前任分手之后, 先来追女神的男生上位, 这就是公平锁;
如果是女神不按先后顺序挑一个自己看的顺眼的, 就是非公平锁
总结:
- 严格按照先来后到的顺序来获取锁,哪个线程等待时间长,哪个线程就获取到锁,这就是公平锁
-
操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁.
-
如果要想实现公平锁, 就需要依赖额外的数据结构(队列), 来记录线程们的先后顺序.
-
公平锁和非公平锁没有好坏之分, 关键还是看适用场景
我们的 synchronized 是非公平锁,多个线程尝试获取到锁,此时是按照概率均等的方式来进行获取,系统本身线程调度的顺序就是随机的。
🚩 互斥锁 vs 读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。
所以读写锁因此而产生。读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
总结:
-
两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
-
两个线程都要写一个数据, 有线程安全问题.
-
一个线程读另外一个线程写, 也有线程安全问题
读写锁就是把读操作和写操作区分对待. Java 标准库提供了ReentrantReadWriteLock 类, 实现了读写锁
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
- ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁
读写锁的应用场景:
读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是非常广泛存在的)
比如学校的教务系统.
每节课老师都要使用教务系统点名, 点名就需要查看班级的同学列表(读操作). 这个操作可能要每天就要执行好几次.
而什么时候修改同学列表呢(写操作)? 就新同学加入的时候. 可能一个月都不必改一次.
再比如, 同学们使用教务系统查看自己课表的时候(读操作), 一个班级的同学很多, 读操作一天就要进行几十次,一学期可能就几百次几千次.但是这一学期的课表, 学校可能只用发布一次(写操作)
Synchronized 不是读写锁
🎄 相关面试题
1.你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
- 悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.
- 乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.
- 悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
- 乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突.
2.介绍下读写锁?
- 读写锁就是把读操作和写操作分别进行加锁.
- 读锁和读锁之间不互斥.
- 写锁和写锁之间互斥.
- 写锁和读锁之间互斥.
- 读写锁最主要用在 “频繁读, 不频繁写” 的场景中
3.什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
- 如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.相比于挂起等待锁,
- 优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
- 缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源
4.synchronized 是可重入锁么?
- 是可重入锁.
- 可重入锁指的就是连续两次加锁不会导致死锁.
- 实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增
🍀 synchronized 实现原理
🚩 锁升级
Synchronized的加锁过程:
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级
🙂 无锁
无锁大家都能理解,大致意思是如果这个锁无人竞争,或者只有一个线程的时候,这时候不存在线程安全问题,Synchronized不会对其进行加锁
🙂 偏向锁
第一个尝试加锁的线程, 优先进入偏向锁状态
- 偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.
- 如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
- 如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别,当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
- 偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
- 但是该做的标记还是得做的, 否则无法区分何时需要真正加锁
🙂 轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现
-
通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
-
如果更新成功, 则认为加锁成功
-
如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
值得注意的是:
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.
也就是所谓的 "自适应"
🙂 重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
- 执行加锁操作, 先进入内核态.
- 在内核态判定当前锁是否已经被占用
- 如果该锁没有占用, 则加锁成功, 并切换回用户态.
- 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
- 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁
图示与例子:
除上述的加锁过程中做的优化,Synchronized还有一些其他的优化措施
分别为:锁消除 和 锁粗化