文章目录
-
- [📕1. 常见的锁策略](#📕1. 常见的锁策略)
-
-
- [✏️1.1 乐观锁VS悲观锁](#✏️1.1 乐观锁VS悲观锁)
- [✏️1.2 轻量级锁VS重量级锁](#✏️1.2 轻量级锁VS重量级锁)
- [✏️1.3 自旋锁](#✏️1.3 自旋锁)
- [✏️1.4 公平锁VS非公平锁](#✏️1.4 公平锁VS非公平锁)
- [✏️1.5 可重入锁和不可重入锁](#✏️1.5 可重入锁和不可重入锁)
- [✏️1.6 读写锁](#✏️1.6 读写锁)
-
- [📕2. 死锁](#📕2. 死锁)
-
-
- [✏️2.1 哲学家就餐问题](#✏️2.1 哲学家就餐问题)
- [✏️2.2 形成死锁的必要条件](#✏️2.2 形成死锁的必要条件)
- [✏️2.3 如何避免死锁](#✏️2.3 如何避免死锁)
-
- [📕3. JUC(java.util.concurrent) 的常见类](#📕3. JUC(java.util.concurrent) 的常见类)
-
-
- [✏️3.1 Callable 接口](#✏️3.1 Callable 接口)
- [✏️3.2 ReentrantLock](#✏️3.2 ReentrantLock)
- [✏️3.3 原子类](#✏️3.3 原子类)
- [✏️3.4 信号量 Semaphore](#✏️3.4 信号量 Semaphore)
- [✏️3.5 CountDownLatch](#✏️3.5 CountDownLatch)
-
- [📕4. synchronized 原理](#📕4. synchronized 原理)
-
-
- [✏️4.1 加锁工作过程](#✏️4.1 加锁工作过程)
- [✏️4.2 其他的优化操作](#✏️4.2 其他的优化操作)
-
- [📕5. CAS](#📕5. CAS)
-
-
- [✏️5.1 CAS的ABA问题](#✏️5.1 CAS的ABA问题)
-
📕1. 常见的锁策略
注意 : 以下所介绍的锁策略, 不仅仅局限于Java这一种语言 , 这些性质通常也是给锁的实现者参考的.当然我们普通人也是可以了解一下的 , 这或许对使用锁也有帮助.
✏️1.1 乐观锁VS悲观锁
悲观锁: 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观锁: 假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
🌰举个栗子: 同学 A 和 同学 B 想请教老师一个问题.
同学 A 认为 "老师是比较忙的, 我来问问题, 老师不一定有空解答". 因此同学 A 会先给老师发消息: "老师你忙嘛? 我下午两点能来找你问个问题嘛?" (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题.如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.
同学 B 认为 "老师是比较闲的, 我来问问题, 老师大概率是有空解答的". 因此同学 B 直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.
Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
就好比同学 C 开始认为 "老师比较闲的", 问问题都会直接去找老师.但是直接来找两次老师之后, 发现老师都挺忙的, 于是下次再来问问题, 就先发个消息问问老师忙不忙,再决定是否来问问题.
✏️1.2 轻量级锁VS重量级锁
轻量级锁: 当一个线程尝试获取某个对象的锁时,如果该对象没有被其他线程锁定,则当前线程会将对象头中的Mark Word设置为指向当前线程栈帧的一个指针,这个过程称为"偏向锁"。如果多个线程同时竞争同一个锁,那么JVM会升级锁的状态,从偏向锁升级到轻量级锁。此时,每个线程都会尝试使用CAS操作来获取锁,如果成功则获得锁并进入临界区;如果失败,则自旋等待一段时间后再次尝试。
特定:
- 减少了操作系统上下文切换的开销。
- 在线程间竞争不激烈的情况下表现良好。
- 如果竞争过于激烈,可能会导致频繁的自旋,浪费CPU资源。
重量级锁: 传统的Java锁机制,如synchronized关键字所实现的锁,通常被称为重量级锁。当一个线程获取了某个对象的锁后,其他试图获取同一对象锁的线程会被阻塞,直到第一个线程释放锁为止。被阻塞的线程将进入等待队列,由操作系统负责管理这些线程的调度。
特点:
- 线程阻塞和唤醒的代价较高。
- 更适用于线程竞争激烈的场景,因为它可以避免CPU空转浪费资源。
- 相比轻量级锁,重量级锁的实现更加简单直接。
✏️1.3 自旋锁
按之前的方式,线程在抢锁失败后进⼊阻塞状态,放弃 CPU,需要过很久才能再次被调度 . 但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题 , 如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止.
java
理解⾃旋锁 vs 挂起等待锁
想象⼀下, 去追求⼀个⼥神. 当男⽣向⼥神表⽩后, ⼥神说: 你是个好⼈, 但是我有男朋友了~~
挂起等待锁: 陷⼊沉沦不能⾃拔.... 过了很久很久之后, 突然⼥神发来消息, '咱俩要不试试?' (注意, 这
个很⻓的时间间隔⾥, ⼥神可能已经换了好⼏个男朋友了).
⾃旋锁: 死⽪赖脸坚韧不拔. 仍然每天持续的和⼥神说早安晚安. ⼀旦⼥神和上⼀任分⼿, 那么就能⽴刻
抓住机会上位.
优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).
✏️1.4 公平锁VS非公平锁
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后 C也尝试获取锁, C 也获取失败, 也阻塞等待. 当线程 A 释放锁的时候, 会发生啥呢?
公平锁 : 遵守 "先来后到". B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁 : 不遵守 "先来后到". B 和 C 都有可能获取到锁
java
这就好⽐⼀群男⽣追同⼀个⼥神. 当⼥神和前任分⼿之后, 先来追⼥神的男⽣上位, 这就是公平锁;
如果是⼥神不按先后顺序挑⼀个⾃⼰看的顺眼的, 就是⾮公平锁.
公平锁
非公平锁
💡💡💡注意 :
- 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
- 公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
- synchronized 是非公平锁
✏️1.5 可重入锁和不可重入锁
可重入锁: 简单来说就是一个线程如果抢占到了互斥锁资源,在锁释放之前再去该竞争同一把锁的时候,不需要等待,只需要记录重入次数。在多线程并发编程里面,绝大部分锁都是可重入的,比如Synchronized、ReentrantLock等,但是也有不支持重入的锁,比如JDK8里面提供的读写锁StampedLock。锁的可重入性,主要解决的问题是避免线程死锁的问题。
✏️1.6 读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同⼀个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。
⼀个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
两个线程都只是读⼀个数据, 此时并没有线程安全问题. 直接并发的读取即可.
两个线程都要写⼀个数据, 有线程安全问题.
⼀个线程读另外⼀个线程写, 也有线程安全问题
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁
ReentrantReadWriteLock.ReadLock 类表示⼀个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示⼀个写锁. 这个对象也提供了 lock / unlock方法进行加锁解锁.
其中,
读加锁和读加锁之间, 不互斥.
写加锁和写加锁之间, 互斥.
读加锁和写加锁之间, 互斥
读写锁特别适合于 "频繁读, 不频繁写" 的场景中.
📕2. 死锁
什么是死锁?
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
🌰举个栗子理解死锁 :
当我和我对象(当然现在还没有😭)⼀起去饺子馆吃饺子时 , 吃饺子需要酱油和醋.
我拿起了酱油瓶, 我对象拿起了醋瓶.
我 : 你先把醋瓶给我, 我用完了就把酱油瓶给你.
我对象 : 你先把酱油瓶给我, 我用完了就把醋瓶给你.
如果我们俩彼此之间互不相让, 就构成了死锁.
酱油和醋相当于是两把锁, 我们两个人就是两个线程.
✏️2.1 哲学家就餐问题
有个桌子 , 围着一圈哲学家 , 桌子中间放着一盘意大利面 . 每个哲学家两两之间, 放着一根筷子.
每个哲学家只做两件事 : 思考人生或者吃面条. 思考人生的时候就会放下筷子. 吃⾯条就会拿起左右两边的筷子(先拿起左边, 再拿起右边).
如果哲学家发现筷子拿不起来了 , 就会阻塞等待
关键点 : 如果5位哲学家同时拿起左手边的筷子时 , 然后再尝试拿右手的筷子, 就会发现右手的筷子都被占用了. 由于哲学家们互不相让, 这个时候就形成了死锁
死锁是一种严重的 BUG!! 导致一个程序的线程 "卡死", 无法正常工作!
✏️2.2 形成死锁的必要条件
死锁产生的四个必要条件:
-
互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
-
不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
-
请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
-
循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
当上述四个条件都成⽴的时候,便形成死锁。当然,死锁的情况下如果打破上述任何⼀个条件,便可让死锁消失。
✏️2.3 如何避免死锁
破坏循环等待
最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号 (1, 2, 3...M).
N 个线程尝试获取锁的时候, 都按照固定的按编号由小到⼤顺序来获取锁. 这样就可以避免环路等待
可能产生循环等待死锁的代码
java
//产生环路等待不是100%发生的,这只是概率问题,哲学家就餐产生死锁也是概率问题
public class Test {
private static Object locker1 = new Object();
private static Object lockre2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (locker1) {
synchronized (lockre2) {
System.out.println("this is thread t1");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lockre2) {
synchronized (locker1) {
System.out.println("this is thread t2");
}
}
});
t1.start();
t2.start();
}
}
不会产生循环等待的代码
java
public class Test {
private static Object locker1 = new Object();
private static Object lockre2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (locker1) {
synchronized (lockre2) {
System.out.println("this is thread t1");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker1) {
synchronized (lockre2) {
System.out.println("this is thread t2");
}
}
});
t1.start();
t2.start();
}
}
📕3. JUC(java.util.concurrent) 的常见类
✏️3.1 Callable 接口
Callable 是一个 interface . 相当于把线程封装了一个 "返回值". 方便我们借助多线程的方式计算结果
🌰代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000, 使用 Callable 版本
- 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
- 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
- 把 callable 实例使用 FutureTask 包装一下
- 创建线程, 线程的构造方法传入FutureTask . 此时新线程就会执行FutureTask 内部的 Callable 的call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
- 在主线程中调用futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果.
java
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Test {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result = 0;
for (int i = 0; i <= 1_000; i++) {
result+=i;
}
return result;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
System.out.println(futureTask.get());
}
}
//500500
理解Callable
Callable 和 Runnable 相对, 都是描述一个 "任务". Callable 描述的是带有返回值的任务, Runnable描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定. FutureTask 就可以负责这个等待结果出来的工作.
理解 FutureTask
想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 "小票" . 这个小票就是FutureTask. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没.
✏️3.2 ReentrantLock
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock 的用法:
lock(): 加锁, 如果获取不到锁就死等.
trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
unlock(): 解锁
ReentrantLock 和 synchronized 的区别:
- synchronized 是一个关键字, 是 JVM 内部实现的 , ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现).
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.
- synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
- 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
如何选择使用哪个锁?
- 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便
- 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等
- 如果需要使用公平锁, 使用 ReentrantLock
✏️3.3 原子类
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ ⾼很多。原子类有以下几个:
- AtomicBoolean
- AtomicInteger
- AtomicIntegerArray
- AtomicLong
- AtomicReference
- AtomicStampedReference
虽然原子类有多 , 但是很抱歉 , 有很多原子类我也并没有使用过 , 所提具体在什么场景下使用什么原子类 , 也是我的知识盲区😭
以 AtomicInteger 举例,常见方法有:
java
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;
✏️3.4 信号量 Semaphore
信号量, 用来表示 "可用资源的个数". 本质上就是一个计数器.
🌰举个栗子理解一下:
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作(P是荷兰语单词首字母))
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作(V是荷兰语单词首字母))
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.
Semaphore 的 PV 操作中的加减计数器操作都是原⼦的, 可以在多线程环境下直接使⽤.
代码示例:
java
//创建 Semaphore ⽰例, 初始化为 4, 表⽰有 4 个可⽤资源.
//acquire ⽅法表⽰申请资源(P操作), release ⽅法表⽰释放资源(V操作)
//创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执⾏效果.
import java.util.concurrent.Semaphore;
public class Test {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("apply");
try {
semaphore.acquire();
System.out.println("accussful");
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
semaphore.release();
System.out.println("release");
}
};
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start();
}
}
}
✏️3.5 CountDownLatch
同时等待 N 个任务执行结束
🌰例如 : 好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。
代码示例:
- 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
- 每个任务执行完毕, 都调⽤用countDown() . 在 CountDownLatch 内部的计数器同时自减.
- 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
java
import java.util.Random;
import java.util.concurrent.CountDownLatch;
public class Test {
public static void main(String[] args) throws InterruptedException {
CountDownLatch count = new CountDownLatch(10);
Random random = new Random();
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
Thread.sleep(random.nextInt(5000));
count.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 10; i++) {
Thread t = new Thread(runnable);
t.start();
}
// 必须等到 10 ⼈全部回来
count.await();
System.out.println("game is over");
}
}
📕4. synchronized 原理
首先 , 我们总结一下synchronized锁的基本特定 :
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
✏️4.1 加锁工作过程
JVM 将 synchronized 锁分为无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
- 偏向锁
第一个尝试加锁的线程, 优先进入偏向锁状态.
偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程.如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销) , 如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进⼊一般的轻量级锁状态.
偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销 . 但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
🌰举个栗子理解偏向锁 :
假设男主是一个锁, 女主是一个线程. 如果只有这一个线程来使用这个锁, 那么男主女主即使不领证结婚(避免了高成本操作), 也可以一直幸福的生活下去.
但是女配出现了, 也尝试竞争男主, 此时不管领证结婚这个操作成本多高, 女主也势必要把这个动作完成了, 让女配死心
- 轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁)
- 重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
✏️4.2 其他的优化操作
- 锁消除
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下 , 编译器+JVM 会判断锁是否可消除. 如果可以, 就直接消除 , 避免白白浪费了一些资源开销.
- 锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会⾃动进行锁的粗化
🌰举个例子理解锁粗化
滑稽老哥当了领导, 给下属交代工作任务:
⽅式⼀:
打电话, 交代任务1, 挂电话.
打电话, 交代任务2, 挂电话.
打电话, 交代任务3, 挂电话
⽅式二:
打电话, 交代任务1, 任务2, 任务3, 挂电话.
明显方式二的效率更高
📕5. CAS
CAS: 全称Compare and swap,字面意思:"比较并交换",一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功
✏️5.1 CAS的ABA问题

ABA问题就好比 , 我们买⼀个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机.