文章目录
- 常见锁策略
- 乐观锁 & 悲观锁
- 轻量级锁 & 重量级锁
- 自旋锁 & 挂起等待锁
- 互斥锁 & 读写锁
- 公平锁 & 非公平锁
- 可重入锁 & 不可重入锁
- synchronized对应以上的锁策略
- 锁策略中的面试题:
- CAS
- CAS的介绍
- CAS如何实现
- CAS的应用场景
- CAS的典型问题:ABA问题
- Synchronized原理
- 1.锁升级/锁膨胀
- 2.锁消除
- 3.锁粗化
常见锁策略
乐观锁 & 悲观锁
乐观锁:预测锁竞争不是很激烈。
悲观锁:预测锁竞争会很激烈。
以上定义并不是绝对的,具体看预测锁竞争激烈程度的结论。
轻量级锁 & 重量级锁
轻量级锁加锁解锁开销比较小,效率更高。
重量级锁加锁解锁开销比较大,效率更低。
多数情况下,乐观锁也是一个轻量级锁。
多数情况下,悲观锁也是一个重量级锁。
自旋锁 & 挂起等待锁
自旋锁:是一种典型的轻量级锁。
挂起等待锁:是一种典型的重量级锁。
举个🌰:
我给男神表白了,然后喜提好人卡一张o(╥﹏╥)o,男神告诉我:你是个好人,但是我有对象了。接下来我可以有两种操作。
自旋锁:每天给男神发早安午安晚安。一旦男神分手,我就可以知道。(一旦锁被释放,就能第一时间感知到,从而有机会获得锁。)自旋锁,占用了大量得系统资源。
挂起等待锁:我说我愿意等,一个人默默的等男神很久。这时候,如果男神分手了,有可能想起我,他分手了。但是也可能(大概率),当男神想起我的时候,已经过了很久很久了。(当真的被唤醒,中间已经是沧海桑田了。)省下了CPU资源,可以做别的事情。
互斥锁 & 读写锁
互斥锁:一个线程加锁了,另一个线程尝试加锁时,就会阻塞等待。(例如synchronized,提供了加锁和解锁的操作。)
读写锁:提供了三种操作
- 针对读加锁
- 针对写加锁
- 解锁
基于一个事实:多线程对同一个变量并发读,这个时候没有线程安全问题,不需要加锁控制。(读写锁就是针对这种情况锁采取的特殊处理。)
读锁和读锁之间没有互斥。
写锁和写锁之间存在互斥。
写锁和读锁之间存在互斥。
(假如当前有一组线程都去读(加读锁),这些线程之间没有锁竞争,也没有线程安全问题。)
公平锁 & 非公平锁
此处将公平定义为先来后道。
举个🌰:
公平锁:一号沸羊羊先追,当美羊羊分手后,就由等待队列中,最早来的沸羊羊上位。
非公平锁:雨露均沾。
操作系统和synchronized原生都是“非公平锁”
操作系统这里的针对加锁的控制,本身就以来于线程调度的顺序的。这个调度顺序也是随机的,不会考虑到这个线程等待锁多久。
可重入锁 & 不可重入锁
不可重入锁:一个线程针对一把锁,连续加锁两次,出现死锁。
可重入锁:一个线程针对一把锁,连续加锁多次都不会出现死锁。
synchronized对应以上的锁策略
- synchronized既是一个悲观锁,也是一个乐观锁。
synchronized默认是乐观锁,但是如果发现当前锁竞争比较激烈,就会变成悲观锁。 - synchronized既是轻量级锁,也是一个重量级锁。
synchronized默认是轻量级锁,但是如果发现当前锁竞争比较激烈,就会转化成重量级锁。 - synchronized这里的轻量级锁,是基于自旋锁的方式实现的。
synchronized这里的重量级锁,是基于挂起等待锁的方式实现的。 - synchronized不是读写锁
- synchronized是非公平锁。
- synchronized是可重入锁。
总结:上述谈到的六种锁策略,可以理解为“锁的形容词”。
锁策略中的面试题:
- 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁。
乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数
据. 在访问的同时识别当前的数据是否出现访问冲突。
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就
等待.
乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突.
- 介绍下读写锁?
读写锁就是把读操作和写操作分别进行加锁.
读锁和读锁之间不互斥.
写锁和写锁之间互斥.
写锁和读锁之间互斥.
读写锁最主要用在 “频繁读, 不频繁写” 的场景中
- 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝
试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
相比于挂起等待锁,
优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场
景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源.
- synchronized 是可重入锁么?
是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁.
实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁
的线程就是持有锁的线程, 则直接计数自增.
CAS
CAS的介绍
CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功。
此处最特别的地方,上述这个CAS的过程,并非是通过一段代码实现的。而是通过一条CPU指令完成的。也就是说**CAS操作是原子的。**原子的也就可以在一定程度上回避线程安全问题。
小结:CAS可以理解为CPU给我们提供的一个特殊指令,通过这个指令,就可以一定程度的处理线程安全问题。
CAS的伪代码(辅助理解,并不是真的代码):
boolean CAS(V, A, B) {
if (V == A) {
V = B;
return true;
}
return false;
}
CAS如何实现
针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:
- java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
- unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
- Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子 性。
CAS的应用场景
- 实现原子类
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadDemo28 {
public static void main(String[] args) {
AtomicInteger count = new AtomicInteger(0);
System.out.println(count.getAndIncrement());
System.out.println(count.getAndDecrement());
}
}
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
寄存器每一个线程都有自己的一份上下文。
- 实现自旋锁
基于CAS 实现更灵活的锁, 获取到更多的控制权.
自旋锁伪代码:
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
CAS的典型问题:ABA问题
CAS在运行中的核心,是检查value和oldValue是否一致,如果一致,就视为value中途没有被修改过,所以进行下一次交换操作。但是在判断value和oldValue是否一致时,这里的值可能改过,但是还原回来了。
也就是:把value的值设为A的话
CAS判定value为A,此时可能确实是A。但是也可能本来是A,被改成了B,但是又还原为A。
ABA这个情况,大部分情况下,不会对代码/逻辑产生影响的。但是不排除极端情况。
举个🌰:
当前滑稽老铁要去ATM上取钱给老婆买情人节礼物:假设滑稽的账户余额1000元,滑稽准备取500元。当按下取款这一瞬间,机器卡了,滑稽就多按了几下,可能就会产生bug,可能就会产生重复扣款的操作。此时可以考虑使用CAS的方式来扣款。
此时正确扣款。
但是如果当t2线程在执行CAS之前,有人给滑稽老铁转账500,导致之前扣除的500又变为了1000。此时CAS条件满足,执行扣款操作,导致扣款成功。这就出现了bug。
针对当前问题,采取的方案,就是加入一个版本号。假设初识版本号为1,每次修改版本号都+1.然后进行CAS的时候,不是以金额为准,而是以版本号为准。
Synchronized原理
两个对象,针对同一个对象加锁,就会产生阻塞等待。synchronized内部还有一些优化机制,存在的目的就是为了是synchronized锁更加高效。
1.锁升级/锁膨胀
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
synchronized(locker){
}
以上代码块就可以经历前面说的几个阶段。
进行加锁的时候,首先会进入到偏向锁的状态。偏向锁并不是真正加锁,只是标记一下。有需要再加锁。
举个🌰:
有一只老虎,他的捕食能力很强。他捕到了很多猎物,但是他一次性吃不完。所以就吃一部分,留一部分。但是留着的一部分有别的动物想要来抢。所以留下来的部分他要看着(标记),当别的动物来抢的时候,老虎就立即扑上去保护食物。对留下来的食物进行加锁。
上述例子,就是偏向锁的过程。
synchronized的时候,并不是真正加锁,先偏向锁状态,做个标记。(这个过程是非常轻量的)如果整个使用锁的过程中,都没有出现锁竞争,在synchronized执行完之后,取消偏向锁即可。
但是,如果使用过程中,另一个线程也尝试加锁,在它加锁之前,迅速的把偏向锁升级成真正的加锁状态。另一个线程也就只能阻塞等待。
当synchronized发生锁竞争的时候,就会从偏向锁升级成轻量级锁。此时,synchronized相当于是通过自旋的方式,来进行加锁的。
要是别人很快就释放锁,自旋是划算的。但是如果迟迟拿不到锁,就不划算。synchronized自旋不是无休止的自旋,自旋到一定程度之后,就会再次升级成为重量级锁。(挂起等待锁)
重量级锁(挂起等待锁):基于操作系统原生的API来进行加锁。Linux原生提供了mutex一组API,操作系统内核提供的加锁功能,这个锁会影响到线程的调度。
此时,如果线程进行了重量级锁的加锁,并且发生锁竞争,此时线程就会被放到阻塞队列中。暂时不参与CPU调度了。直到锁被释放,这个线程才可能会被调用到。
值得注意的是:一旦当前线程被切出CPU,就比较低效。
锁能升级,不能降级。
2.锁消除
编译器智能的判断,看当前代码是否真的需要加锁。如果这个场景不需要加锁,但是程序员加了,就自动将锁去掉了。
例如:StringBuffer 是线程安全的,关键方法中都带有synchronized。但是如果在单线程中使用StringBuffer,synchronized加锁操作是没有意义的。所以就会将锁优化掉。
3.锁粗化
锁的粒度:synchronized包含的代码越多,粒度就越粗,包含的代码越少,粒度就越细。
通常情况下,认为锁的粒度细一点比较好。加锁的部分的代码,并不能并发执行的。锁粒度越细,能并发的代码就越多。反之则越少。
但是有些情况下,锁的粒度反而粗一点更好。
比如:两次加锁解锁之间,间隙非常小,此时,就用一把大锁来解决。
举个🌰: 麻麻👩要我去买菜菜。要包玉米猪肉饺子。
我买了猪肉,给麻麻打电话汇报买了猪肉。
我买了玉米,给麻麻打电话汇报买了玉米。
我买了饺子皮,给麻麻打电话汇报买了饺子皮。
当我的第三个电话汇报完,就挨骂了。理由是一根筋,为什么不全部买完再汇报?
当我第二次要我买菜菜的时候,我就一次买完给麻麻汇报。
显然第二次的方法更为高效。