目录
一、常见的锁策略:
1.1 悲观锁 | 乐观锁:
1.2 重量级锁 | 轻量级锁:
1.3 自旋锁 | 挂起等待锁:
1.4 公平锁 | 非公平锁:
1.5 可重入锁 | 不可重入锁:
1.6 互斥锁 | 读写锁:
1.7 面试题:
二、CAS
2.1 CAS 的概念:
2.2 CAS 的实现的:
2.3 CAS 的应用:
2.3.1 实现原子类:
2.3.2 实现自旋锁:
2.4 CAS 的 ABA 问题:
2.4.1 ABA 问题的概述:
2.4.2 ABA 问题引来的 BUG:
2.5 解决方案:
2.6 面试题:
终于进入到多线程的进阶了,这里面涉及到的内容面试容易考,但是工作中很少直接用到。
一、常见的锁策略:
注意:接下来讲解的锁策略不仅仅是局限于 Java 。任何和 "锁" 相关的话题,都可能会涉及到以下内容。这些特性主要是给锁的实现者来参考的。我们了解一些,也能更加合理的使用锁。
1.1 悲观锁 | 乐观锁:
加锁的时候,预测当前锁冲突的概率是大还是小。
• 悲观锁:
预测当前锁的冲突概率大,后续要做的工作往往就会更多。加锁的开销就会更大(时间,系统资源)。
• 乐观锁:
预测当前锁的冲突概率不大,后续要做的工作往往就会更少。加锁的开销就会更小(时间,系统资源)。
synchronized 初始使用乐观锁策略。当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。所以 synchronized 既是乐观锁也是悲观锁,支持自适应。
1.2 重量级锁 | 轻量级锁:
一般来说,悲观锁往往就是重量级锁(加锁过程做的事情多),乐观锁往往就是轻量级锁(加锁过程做的事情少)。
锁的核心特性 "原子性",这样的机制追根溯源是 CPU 这样的硬件设备提供的。硬件有提供,软件层面才能实现。
• CPU 提供了 "原子操作指令"。
• 操作系统基于 CPU 的原子指令,实现了 mutex 互斥锁。
• JVM 基于操作系统提供的互斥锁,实现了 synchronized 和 ReentrantLock 等关键字和类。
注意:synchronized 并不仅仅是对 mutex 进行封装,在 synchronized 内部还做了很多其他的工作。
• 重量级锁:
加锁机制重度依赖了 OS 提供了 mutex。
这样做的特点有:1. 大量的内核态用户态切换。2. 很容易引发线程的调度。
这两个操作,成本比较高,一旦涉及到用户态和内核态的切换,就意味着 “沧海桑田”。
• 轻量级锁:
加锁机制尽可能不使用 mutex,而是尽量在用户态代码完成,实在搞不定了,再使用 mutex。
这样做的特点有:1. 少量的内核态用户态切换。2. 不太容易引发线程调度。
为什么会有这样的好处呢?举个栗子:
想象去银行办业务。在窗口外,自己做,这是用户态,用户态的时间成本是比较可控的。在窗口内让工作人员做,这是内核态,内核态的时间成本是不太可控的(可能人家处理一半,去做别的事情了)。如果办业务的时候反复和工作人员沟通,还需要重新排队,这时效率是很低的。
重量级锁、轻量级锁和悲观锁、乐观锁的概念有重合的地方,面试的时候要能转的过来。
synchronized 开始是一个轻量级锁。如果锁冲突严重,就会变成重量级锁。
1.3 自旋锁 | 挂起等待锁:
• 自旋锁:
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度。但实际上,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要放弃 CPU,这个时候就可以使用自旋锁来处理这样的问题。
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
⼀旦锁被其他线程释放,就能第⼀时间获取到锁(线程没有被调度)。
自旋锁是一种典型的轻量级锁的实现方式。
优点:没有放弃 CPU,不涉及线程阻塞和调度,⼀旦锁被释放,就能第一时间获取到锁。
缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源,CPU 在空转。
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的。
• 挂起等待锁:
是重量级锁的一种典型的实现方式,借助系统中的线程调度机制,当尝试加锁,并且锁被占用了,出现锁冲突,就会让当前这个尝试加锁的线程,被挂起(阻塞状态)。此时这个线程就不会参与线程调度了。知道这个锁被释放,然后系统才能唤醒这个线程,去尝试重新获取锁。
1.4 公平锁 | 非公平锁:
• 公平锁:遵守 "先来后到"。B 比 C 先来的。当 A 释放锁的之后,B 就能先于 C 获取到锁。
• 非公平锁:不遵守 "先来后到"。B 和 C 都有可能获取到锁。
其实这两个策略都挺公平的,只是最初的 Java 大佬把先来后到定义成公平,均等机会定义成不公平。
注意:
• 操作系统内部的线程调度就可以视为是随机的。如果不做任何额外的限制,锁就是非公平锁。如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序。
• 公平锁和非公平锁没有好坏之分,关键还是看业务场景。
synchronized 非公平锁。
1.5 可重入锁 | 不可重入锁:
可重入锁的字面意思是 “可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
例如:一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以 Reentrant 开头命名的锁都是可重入锁,而且 JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。而 Linux 系统提供的 mutex 是不可重入锁。synchronized 是可重入锁。这个前面几篇文章有涉及到,这里就不再赘述。
1.6 互斥锁 | 读写锁:
我们平时见到的 synchronized 是普通的互斥锁,读写锁是更加特殊的存在。
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。
Java 的读写锁是这样设定的:
• 读锁和读锁之间,不会产生互斥。
• 写锁和写锁之间,会产生互斥。
• 读锁和写锁之间,会产生互斥。
突出体现的是 “读操作和读操作” 之间是共享的(不会互斥),有利于降低锁冲突的概率,提高并发能力。
注意:和之前谈到的数据库中的事务,给读操作加锁:读的时候不能写。给写操作加锁:写的时候不能读。不是一回事。这是在减低并发能力。
读写锁就是把读操作和写操作区分对待。Java 标准库提供了 ReentrantReadWriteLock 类,实现了读写锁。
• ReentrantReadWriteLock.ReadLock 类表示一个读锁。这个对象提供了 lock / unlock 方法进行加锁解锁。
• ReentrantReadWriteLock.WriteLock 类表示一个写锁。这个对象也提供了 lock / unlock 方法进行加锁解锁。
读写锁特别适合于 "频繁读,不频繁写" 的业务中。(这样的场景其实也是非常广泛存在的)。
1.7 面试题:
1. 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
答:悲观锁认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加锁。乐观锁认为多个线程访问同⼀个共享变量冲突的概率不大,并不会真的加锁,而是直接尝试访问数据。在访问的同时识别当前的数据是否出现访问冲突。悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex)获取到锁再操作数据。获取不到锁就等待。乐观锁的实现可以引入一个版本号。借助版本号识别出当前的数据访问是否冲突。
2. 介绍下读写锁?
答: 读写锁就是把读操作和写操作分别进行加锁。读锁和读锁之间不互斥。写锁和写锁之间互斥,写锁和读锁之间互斥,读写锁最主要用在 "频繁读,不频繁写" 的场景中。
3. 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
答:如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止,第一次获取锁失败,第二次的尝试,会在极短的时间内到来。一旦锁被其他线程释放,就能第一时间获取到锁。
相比于挂起等待锁:
• 优点:没有放弃 CPU 资源,一旦锁被释放就能第一时间获取到锁,更高效。在锁持有时间比较短的场景下非常有用。
• 缺点:如果锁的持有时间较长,就会浪费 CPU 资源。
4. synchronized 是可重入锁么?
答:是可重入锁。可重入锁指的就是连续两次加锁不会导致死锁。实现的方式是在锁中记录该锁持有的线程身份,以及⼀个计数器(记录加锁次数),如果发现当前加锁的线程就是持有锁的线程,则直接计数自增。
二、CAS
2.1 CAS 的概念:
CAS:全称Compare and swap,字面意思:”比较并交换“。一个 CAS 涉及到以下操作:我们假设内存中的原数据V,旧的预期值A,需要修改成的新值B。
1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
3. 返回操作是否成功。
这是一条 CPU 指令(原子的),可以完成比较和交换。这给我们编写线程安全的代码,打开了新世界的大门。
• CAS 伪代码:
注意:下面写的代码不是原子的,真实的 CAS 是⼀个原子的硬件指令完成的。这个伪代码只是辅助理解 CAS 的工作流程。
address 是内存地址,expectValue 和 swapValue 都是寄存器的值(CPU)。
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
当多个线程同时对某个资源进行 CAS 操作,只能有一个线程能操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
CAS 可以视为是一种乐观锁。(或者可以理解成 CAS 是乐观锁的一种实现方式)
2.2 CAS 的实现的:
针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:
• java 的 CAS 利用的是 unsafe 这个类提供的 CAS 操作。
• unsafe 的 CAS 依赖的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg。
• Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 CPU 硬件提供的 lock 机制保证其原原性。
简而言之,是因为硬件予以了支持,软件层面才能做到。
2.3 CAS 的应用:
基本涉及到锁,程序就和高性能无缘了。这里可以为无锁编程提供一些思路(当然大部分情况下,只有加锁才行)。
2.3.1 实现原子类:
标准库中提供了 java.util.concurrent.atomic 包,里面的类都是基于这种方式来实现的。典型的就是 AtomicInteger 类。
如下案例:
import java.util.concurrent.atomic.*;
public class demo1 {
static AtomicInteger count = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
for(int i = 0;i < 50000;i++){
count.getAndIncrement();
}
}
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
案例演示结果如下:
可以发现是线程安全的。因为这里的 ++ 操作是原子的。
• 伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
• 对上面代码执行过程的刨析:
假设两个线程同时调用 getAndIncrement:
1. 两个线程都读取 value 的值到 oldValue 中。(oldValue 是⼀个局部变量,在栈上,每个线程有自己的栈)。
2. 线程 1 先执行 CAS 操作。由于 oldValue 和 value 的值相同,直接进行对 value 赋值。
注意:CAS 是直接读写内存的,而不是操作寄存器。 CAS 的读内存,比较,写内存操作是⼀条硬件指令,是原子的。
3. 线程 2 再执行 CAS 操作,第一次 CAS 的时候发现 oldValue 和 value 不相等,不能进行赋值。因此需要进入循环。在循环里重新读取 value 的值赋给 oldValue。
4. 线程 2 接下来第二次执行 CAS,此时 oldValue 和 value 相同,于是直接执行赋值操作。
5. 线程 1 和线程 2 返回各自的 oldValue 的值即可。
通过形如上述代码就可以实现⼀个原子类。不需要使用重量级锁,就可以高效的完成多线程的自增操作。
2.3.2 实现自旋锁:
• 自旋锁伪代码:
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;
}
}
2.4 CAS 的 ABA 问题:
2.4.1 ABA 问题的概述:
假设存在两个线程 t1 和 t2。有一个共享变量 num,初始值为 A。接下来,线程 t1 想使用CAS 把 num 值改成 Z,那么就需要先读取 num 的值,记录到 oldNum 变量中。使用 CAS 判定当前 num 的值是否为 A,如果为 A,就修改成 Z。
但是,在 t1 执行这两个操作之间,t2 线程可能把 num 的值从 A 改成了 B,又从 B 改成了 A。
到了这里就有个问题:线程 t1 的 CAS 是期望 num 不变就修改。但是 num 的值已经被 t2 给改了。只不过又改成 A 了。这个时候 t1 究竟是否要更新 num 的值为 Z ?
这就好比,我们买一个手机,无法判定这个手机是刚出厂的新手机,还是别人用旧了,又翻新过的手机。
2.4.2 ABA 问题引来的 BUG:
大部分的情况下,t2 线程这样的一个反复横跳改动,对于 t1 是否修改 num 是没有影响的。但是不排除一些特殊情况。
案例:假设滑稽有 100 存款。滑稽想从 ATM 取 50 块钱。取款机创建了两个线程,并发的来执行 -50 操作。我们期望一个线程执行 -50 成功,另一个线程 -50 失败。如果使用 CAS 的方式来完成这个扣款过程就可能出现问题。
• 正常的过程:
存款 100,线程 1 获取到当前存款值为 100,期望更新为 50。线程 2 获取到当前存款为 100,期望更新为 50。 线程 1 执行扣款成功,存款被改成 50。线程 2 阻塞等待中。轮到线程 2 执行,发现当前存款为 50,和之前读到的 100 不相同,执行失败。
• 异常的过程:
存款 100。线程 1 获取到当前存款值为 100,期望更新为 50。线程 2 获取到当前存款为 100,期望更新为 50。线程 1 执行扣款成功,存款被改成 50。线程 2 阻塞等待中。在线程 2 执行之前,滑稽的朋友正好给滑稽转账 50,账户余额变成 100。轮到线程 2 执行了,发现当前存款为 100,和之前读到的 100 相同,再次执行扣款操作,这个时候,扣款操作被执行了两次。这就是是 ABA 问题搞的鬼。
2.5 解决方案:
给要修改的值,引入版本号(约定版本号只能加,不能减,每次操作一次余额,版本号都要 + 1)。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。CAS 操作在读取旧值的同时,也要读取版本号。真正修改的时候,如果当前版本号和读到的版本号相同,则修改数据,并把版本号 +1。如果当前版本号高于读到的版本号。就操作失败(认为数据已经被修改过了)。
可以看到:如果数据本身属于 ”能加也能减“,就容易出现 ABA 问题。
2.6 面试题:
1. 讲解下你自己理解的 CAS 机制:
全称 Compare and swap,即"比较并交换"。相当于通过⼀个原子的操作,同时完成 "读取内存,比较是否相等,修改内存" 这三个步骤。本质上需要 CPU 指令的支撑。
2. ABA问题怎么解决?
给要修改的数据引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。如果发现当前版本号和之前读到的版本号一致,就真正执行修改操作,并让版本号自增。如果发现当前版本号比之前读到的版本号大,就认为操作失败。
结语:
其实写博客不仅仅是为了教大家,同时这也有利于我巩固知识点,和做一个学习的总结,由于作者水平有限,对文章有任何问题还请指出,非常感谢。如果大家有所收获的话还请不要吝啬你们的点赞收藏和关注,这可以激励我写出更加优秀的文章。