多线程进阶(八股文版)
先赞后看,养成习惯!!! ^ _ ^ ❤️ ❤️ ❤️
码字不易,大家的支持就是我坚持下去的动力,点赞后不要忘了 关注我哦!
如有错误,还请您批评改正^ _ ^
1. 常见的锁策略
1.1 乐观锁和悲观锁
1.1.1 悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别⼈会修改,所以每次在拿数据的时候都会上锁,这样别⼈想拿这个数据就会阻塞直到它拿到锁(锁竞争激烈)
1.1.2 乐观锁
假设数据⼀般情况下不会产⽣并发冲突,所以在数据进⾏提交更新的时候,才会正式对数据是否产⽣并发冲突进⾏检测,如果发现并发冲突了,则让返回⽤⼾错误的信息,让⽤⼾决定如何去做。
加锁的时候锁竞争不激烈,不需要做额外的工作
Synchronized 初始使⽤乐观锁策略. 当发现锁竞争⽐较频繁的时候, 就会⾃动切换成悲观锁策略
1.2 重量级锁 VS 轻量级锁
锁的核⼼特性 “原⼦性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的。
- CPU 提供了 “原⼦操作指令”.
- 操作系统基于 CPU 的原⼦指令, 实现了 mutex 互斥锁.
- JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类
![[Pasted image 20250712104219.png]]
1.2.1 重量级锁
加锁机制重度依赖了操作系统 OS 提供了 mutex
⼤量的内核态⽤⼾态切换
很容易引发线程的调度
synchronized是自适应
JVM内部会统计每个锁竞争的激烈程度
如果竞争不激烈,此时synchronized就会按照轻量级锁(自旋)
如果竞争激烈,此时synchronized就会按照重量级锁(挂起等待)
悲观锁-重量级锁-挂起等待锁
乐观锁-轻量级锁-自旋锁
1.2.2 轻量级锁
加锁机制尽可能不使⽤ mutex, ⽽是尽量在⽤⼾态代码完成. 实在搞不定了, 再使⽤ mutex
少量的内核态⽤⼾态切换.
不太容易引发线程调度
synchronized 开始是⼀个轻量级锁. 如果锁冲突⽐较严重, 就会变成重量级锁
1.3 ⾃旋锁
按之前的⽅式,线程在抢锁失败后进⼊阻塞状态,放弃 CPU,需要过很久才能再次被调度.
但实际上, ⼤部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使⽤⾃旋锁来处理这样的问题
⾃旋锁是⼀种典型的 轻量级锁 的实现⽅式
- 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, ⼀旦锁被释放, 就能第⼀时间获取到锁.
- 缺点: 如果锁被其他线程持有的时间⽐较久, 那么就会持续的消耗 CPU 资源. (⽽挂起等待的时候是不消耗 CPU 的)
synchronized 中的轻量级锁策略⼤概率就是通过⾃旋锁的⽅式实现的.
1.4 挂起等待锁
挂起等待锁:当线程获取锁是失败后会无限的阻塞等待,直到锁被释放,再与其他线程竞争锁
- 优点:不会持续消耗cpu的资源
- 缺点:当锁释放后还要与其他线程竞争,并不会第一时间获取锁
1.5 读写锁
多线程之间,数据的读取⽅之间不会产⽣线程安全问题,但数据的写⼊⽅互相之间以及和读者之间都
需要进⾏互斥。如果两种场景下都⽤同⼀个锁,就会产⽣极⼤的性能损耗。所以读写锁因此⽽产⽣。
⼀个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
两个线程都只是读⼀个数据, 此时并没有线程安全问题. 直接并发的读取即可.
两个线程都要写⼀个数据, 有线程安全问题.
⼀个线程读另外⼀个线程写, 也有线程安全问
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现
了读写锁
读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是⾮常⼴泛存在的).
读加锁和读加锁之间, 不互斥.
写加锁和写加锁之间, 互斥.
读加锁和写加锁之间, 互斥
1.6 可重入锁和不可重入锁
一个线程,一把锁,把连续加锁多次,是否会死锁
核心要点:
- 锁要记录当前是哪个线程拿到这把锁
- 使用计数器,记录当前加锁多少次,在合适的时候解锁
Java⾥只要以Reentrant开头命名的锁都是可重⼊锁,⽽且JDK提供的所有现成的Lock实现类,包括
synchronized关键字锁都是可重⼊的。
1.7公平锁和非公平锁
公平锁: 遵守 “先来后到”. B ⽐ C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
⾮公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁
1.8 加锁过程
JVM 将 synchronized 锁分为 ⽆锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进⾏依次升
级。
- 偏向锁: 第⼀个尝试加锁的线程, 优先进⼊偏向锁状态,做标记,并非真加锁
- 轻量级锁:随着其他线程进⼊竞争, 偏向锁状态被消除, 进⼊轻量级锁状态(⾃适应的⾃旋锁)
- 重量级锁:如果竞争进⼀步激烈, ⾃旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指⽤到内核提供的 mutex
2. 其他优化操作
2.1 锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除
什么是 “锁消除”
有些应⽤程序的代码中, ⽤到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
2.2锁粗化
段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会⾃动进⾏锁的粗化.
实际开发过程中, 使⽤细粒度锁, 是期望释放锁的时候其他线程能使⽤锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会⾃动把锁粗化, 避免频繁申请释放
锁.
3.CAS
CAS: 全称Compare and swap,字⾯意思:”⽐较并交换“,⼀个 CAS 涉及到以下操作:
-
⽐较 A 与 V 是否相等。(⽐较)
-
如果⽐较相等,将 B 写⼊ V。(交换)
-
返回操作是否成功。
当多个线程同时对某个资源进⾏CAS操作,只能有⼀个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
CAS 可以视为是⼀种乐观锁
3.1 CAS 有哪些应⽤
- 实现原⼦类:
`
boolean CAS(address, expectValue, swapValue) {`
`if (&address == expectedValue) {`
`&address = swapValue;`
`return true;`
`}`
`return false;`
`}`
- 实现⾃旋锁
**
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;**
**}**
**}**
3.2 CAS 的 ABA 问题
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 呢?
到这⼀步, t1 线程⽆法区分当前这个变量始终是 A, 还是经历了⼀个变化过程.
3.2.1 解决⽅案
给要修改的值, 引⼊版本号. 在 CAS ⽐较数据当前值和旧值的同时, 也要⽐较版本号是否符合预期.
CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候,
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
如果当前版本号⾼于读到的版本号. 就操作失败(认为数据已经被修改过了).
4. Callable 接⼝
Callable 是⼀个 interface . 相当于把线程封装了⼀个 “返回值”. ⽅便程序猿借助多线程的⽅式计算结
果
`
public class Demo29 {`
`public static void main(String[] args) throws ExecutionException, InterruptedException {`
`//此时callable不能执行,需要借助Thread`
`Callable<Integer> callable=new Callable<Integer>() {`
`@Override`
`public Integer call() throws Exception {`
`int result=0;`
`for (int i=1;i<=100;i++){`
`result+=i;`
`}`
`return result;`
`}`
`};`
`FutureTask<Integer> futureTask=new FutureTask<>(callable);`
`Thread t=new Thread(futureTask);`
`t.start();`
`//get操作就是获取FutureTask的返回值,这个返回值就是来自于Callable的call方法`
`//get可能会阻塞,如果当前线程执行完毕,get会拿到结果`
`//如果当前线程还没执行完毕,get会一直阻塞`
`System.out.println(futureTask.get());`
`}`
`}`
创建⼀个匿名内部类, 实现 Callable 接⼝. Callable 带有泛型参数. 泛型参数表⽰返回值的类型.
重写 Callable 的 call ⽅法, 完成累加的过程. 直接通过返回值返回计算结果.
把 callable 实例使⽤ FutureTask 包装⼀下.
创建线程, 线程的构造⽅法传⼊ FutureTask . 此时新线程就会执⾏ FutureTask 内部的 Callable 的
call ⽅法, 完成计算. 计算结果就放到了 FutureTask 对象中.
在主线程中调⽤ futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的
结果
4.1 理解 Callable
Callable 和 Runnable 相对, 都是描述⼀个 “任务”. Callable 描述的是带有返回值的任务, Runnable
描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使⽤. FutureTask ⽤来保存 Callable 的返回结果. 因为
Callable 往往是在另⼀个线程中执⾏的, 啥时候执⾏完并不确定.
FutureTask 就可以负责这个等待结果出来的⼯作
4.2 理解 FutureTask
想象去吃⿇辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你⼀张 “⼩票” . 这个⼩票就是
FutureTask. 后⾯我们可以随时凭这张⼩票去查看⾃⼰的这份⿇辣烫做出来了没.
5. ReentrantLock
可重⼊互斥锁. 和 synchronized 定位类似, 都是⽤来实现互斥效果, 保证线程安全
ReentrantLock 的⽤法:
lock(): 加锁, 如果获取不到锁就死等.
trylock(超时时间): 加锁, 如果获取不到锁, 等待⼀定的时间之后就放弃加锁.
unlock(): 解锁
`
ReentrantLock lock = new ReentrantLock();`
`-----------------------------------------`
`lock.lock();`
`try {`
`// working`
`} finally {`
`lock.unlock()`
`}`
6. ReentrantLock 和 synchronized 的区别:
- synchronized 是⼀个关键字, 是 JVM 内部实现的(⼤概率是基于 C++ 实现). ReentrantLock 是标准
库的⼀个类, 在 JVM 外实现的(基于 Java 实现). - synchronized 使⽤时不需要⼿动释放锁. ReentrantLock 使⽤时需要⼿动释放. 使⽤起来更灵活, 但
是也容易遗漏 unlock. - synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的⽅式等待⼀段时间就放
弃. - synchronized 是⾮公平锁, ReentrantLock 默认是⾮公平锁. 可以通过构造⽅法传⼊⼀个 true 开启
公平锁模式 - 更强⼤的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是⼀个
- 随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定
的线程.
6.1 如何选择使⽤哪个锁?
锁竞争不激烈的时候, 使⽤ synchronized, 效率更⾼, ⾃动释放更⽅便.
锁竞争激烈的时候, 使⽤ ReentrantLock, 搭配 trylock 更灵活控制加锁的⾏为, ⽽不是死等.
如果需要使⽤公平锁, 使⽤ ReentrantLock
7. 原⼦类
原⼦类内部⽤的是 CAS 实现,所以性能要⽐加锁实现 i++ ⾼很多。原⼦类有以下⼏个
-
AtomicBoolean
-
AtomicInteger
-
AtomicIntegerArray
-
AtomicLong
-
AtomicReference
-
AtomicStampedReference
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i–;
incrementAndGet(); ++i;
getAndIncrement(); i++;
8. 信号量 Semaphore
信号量, ⽤来表⽰ “可⽤资源的个数”. 本质上就是⼀个计数器
理解信号量
可以把信号量想象成是停⻋场的展⽰牌: 当前有⻋位 100 个. 表⽰有 100 个可⽤资源.
当有⻋开进去的时候, 就相当于申请⼀个可⽤资源, 可⽤⻋位就 -1 (这个称为信号量的 P 操作)
当有⻋开出来的时候, 就相当于释放⼀个可⽤资源, 可⽤⻋位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.
Semaphore 的 PV 操作中的加减计数器操作都是原⼦的, 可以在多线程环境下直接使用
也可以当锁用
`
public class Demo32 {`
`private static int count=0;`
`public static void main(String[] args) throws InterruptedException {`
`Semaphore semaphore=new Semaphore(1);`
`Thread t1=new Thread(new Runnable() {`
`@Override`
`public void run() {`
`for(int i=0;i<50000;i++){`
`try {`
`semaphore.acquire();`
`} catch (InterruptedException e) {`
`throw new RuntimeException(e);`
`}`
`count++;`
`semaphore.release();`
`}`
`}`
`});`
`Thread t2=new Thread(new Runnable() {`
`@Override`
`public void run() {`
`for(int i=0;i<50000;i++){`
`try {`
`semaphore.acquire();`
`} catch (InterruptedException e) {`
`throw new RuntimeException(e);`
`}`
`count++;`
`semaphore.release();`
`}`
`}`
`});`
`t1.start();`
`t2.start();`
`t1.join();`
`t2.join();`
`System.out.println("Fina count = "+count);`
`}`
`}`
9. CountDownLatch
同时等待 N 个任务执⾏结束
好像跑步⽐赛,10个选⼿依次就位,哨声响才同时出发;所有选⼿都通过终点,才能公布成绩。
构造 CountDownLatch 实例, 初始化 10 表⽰有 10 个任务需要完成.
每个任务执⾏完毕, 都调⽤ latch.countDown() . 在 CountDownLatch 内部的计数器同时⾃减
主线程使用latch.await()阻塞等待所有任务执行完成,相当于计数器为0了
**`public class Demo33 {**`
`**public static void main(String[] args) throws InterruptedException {**`
`**//构造方法中的参数表示任务的个数**`
`**CountDownLatch latch=new CountDownLatch(10);**`
`**ExecutorService executorService= Executors.newFixedThreadPool(4);**`
`**for (int i=0;i<10;i++){**`
`**int id=i;**`
`**executorService.submit(()->{**`
`**System.out.println("子任务开始执行:"+id );**`
`**try {**`
`**Thread.sleep(1000);**`
`**} catch (InterruptedException e) {**`
`**throw new RuntimeException(e);**`
`**}**`
`**System.out.println("子任务结束执行:"+id);**`
`**latch.countDown();**`
`**});**`
`**}**`
`**latch.await();**`
`**System.out.println("所有任务都执行完毕");**`
`**executorService.shutdown();**`
`**}**`
`**}`**
10. 线程安全的集合类
原来的集合类, ⼤部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(不建议⽤), 其他的集合类不是线程安全的
10.1 多线程环境使⽤ ArrayList
- ⾃⼰使⽤同步机制 (synchronized 或者 ReentrantLock)
前⾯做过很多相关的讨论了. 此处不再展开. - Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的⼀个基于 synchronized 进⾏线程同步的 List. synchronizedList
的关键操作上都带有 synchronized(代价较高) - 使⽤ CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器。
当我们往⼀个容器添加元素的时候,不直接往当前容器添加,⽽是先将当前容器进⾏Copy,复制
出⼀个新的容器,然后新的容器⾥添加元素,
添加完元素之后,再将原容器的引⽤指向新的容器
这样做的好处是我们可以对CopyOnWrite容器进⾏并发的读,⽽不需要加锁,因为当前容器不会添
加任何元素。
所以CopyOnWrite容器也是⼀种读写分离的思想,读和写不同的容器。
优点:
在读多写少的场景下, 性能很⾼, 不需要加锁竞争.
缺点:
-
占⽤内存较多.
-
新写的数据不能被第⼀时间读取到
10.2 多线程环境使⽤队列
- ArrayBlockingQueue
比特就业课基于数组实现的阻塞队列
- LinkedBlockingQueue
基于链表实现的阻塞队列
- PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
- TransferQueue
最多只包含⼀个元素的阻塞队列
10.3多线程环境使⽤哈希表
HashMap 本⾝不是线程安全的.
在多线程环境下使⽤哈希表可以使⽤:
Hashtable
ConcurrentHashMap
- Hashtable
只是简单的把关键⽅法加上了 synchronized 关键字
这相当于直接针对 Hashtable 对象本⾝加锁.
如果多线程访问同⼀个 Hashtable 就会直接造成锁冲突.
size 属性也是通过 synchronized 来控制同步, 也是⽐较慢的.
⼀旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到⼤量的元素拷⻉, 效率会⾮常低
2. ConcurrentHashMap
读操作没有加锁(但是使⽤了 volatile 保证从内存读取结果), 只对写操作进⾏加锁. 加锁的⽅式仍然
是是⽤ synchronized, 但是不是锁整个对象, ⽽是 “锁桶” (⽤每个链表的头结点作为锁对象), ⼤⼤降
低了锁冲突的概率.
- 充分利⽤ CAS 特性. ⽐如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况.
- 优化了扩容⽅式: 化整为零
- 发现需要扩容的线程, 只需要创建⼀个新的数组, 同时只搬⼏个元素过去.
- 扩容期间, 新⽼数组同时存在.
- 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运⼀⼩部
分元素
更多推荐
所有评论(0)