多线程-高级版

1.常⻅的锁策略

1.1乐观锁 vs 悲观锁---加锁时遇到的问题

不是针对某一种具体的锁,而是具体的某个锁具有" 悲观 " 和" 乐观 "的特性~~

. 悲观锁

默认认为并发冲突一定会发生 ,操作数据前先加锁,锁住资源,别人不能改,自己操作完再释放锁,强隔离、防冲突。

✅ 优点:

  • 数据强一致性,杜绝并发脏写
  • 逻辑简单,不用处理重试逻辑

❌ 缺点:

  • 锁竞争严重,并发能力差
  • 容易产生死锁、锁等待、超时问题
  • 长事务会长期占用锁,影响业务

. 乐观锁

默认认为并发冲突很少发生 ,操作数据不加锁 ,只在提交更新时校验数据是否被别人修改过,没被改就更新,被改了就放弃 / 重试。

✅ 优点:

  • 无阻塞、高并发友好
  • 无死锁,资源利用率高

❌ 缺点:

  • 高并发写冲突时,大量重试,CPU 飙升

1.2重量级锁 vs 轻量级锁---遇到问题之后的解决方法

.重量级锁

当悲观场景下,此时就要付出更大的代价--->更低效

.轻量级锁

当乐观场景下,此时就要付出相对较小的代价--->更高效

1.3⾃旋锁 vs 挂起等待锁

.挂起等待锁

是重量级锁的典型表现,也是操作系统内核级别的,加锁的时候如果发现有竞争,就会使该线程进入阻塞状态,后续需要时在进行唤醒。

.⾃旋锁

是轻量级锁的典型表现,也是应用程序级别的,加锁的时候如果发现有竞争,一般是不会进入阻塞,而是通过忙等的形式来进行等待

💡 悲观锁 ==> 重量级锁 ==> 挂起等待锁

🎯乐观锁 ==> 轻量级锁 ==> ⾃旋锁

1.4公平锁 vs ⾮公平锁

假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后 C也尝试获取锁, C 也获取失败, 也阻塞等待.当线程 A 释放锁的时候, 会发⽣啥呢?

.公平锁:

遵守 "先来后到". B ⽐ C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.

.⾮公平锁:

不遵守 "先来后到". B 和 C 都有可能获取到锁.synchronized 是⾮公平锁.

注意:

synchronized 是⾮公平锁.

操作系统内部的线程调度就可以视为是随机的 . 如果不做任何额外的限制, 锁就是⾮公平锁. 如果要

想实现公平锁, 就需要依赖额外的数据结构 , 来记录线程们的先后顺序.

• 公平锁和⾮公平锁没有好坏之分, 关键还是看适⽤场景.

1.5可重⼊锁 vs 不可重⼊锁

可重⼊锁的字⾯意思是"可以重新进⼊的锁",即允许同⼀个线程多次获取同⼀把锁。
synchronized 是可重⼊锁

核心重点:

1.锁要记录当前是那个线程拿到的这把锁。

2.使用计数器,记录当前加锁了多少次,在合适的时候进行解锁唤醒。

1.6读写锁vs普通互斥锁

  • 普通互斥锁(Mutex)同一时间只允许一个线程访问,不管是读还是写。
  • 读写锁(RWMutex)读可以共享,写必须独占
    • 读锁共享:多个线程可以同时加读锁(并发读)
    • 写锁独占:加写锁时,其他读写都阻塞

⼀个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.

• 两个线程都只是读⼀个数据, 此时并没有线程安全问题. 直接并发的读取即可.

• 两个线程都要写⼀个数据, 有线程安全问题.

• ⼀个线程读另外⼀个线程写, 也有线程安全问题.

💡读写锁规则(非常重要)

  1. 读 + 读:可以同时进行(共享)
  2. 读 + 写:互斥阻塞
  3. 写 + 写:互斥阻塞
  4. 写的时候,所有读都要等
    注意 ,Synchronized 不是读写锁.
    只要是涉及到 "互斥", 就会产⽣线程的挂起等待. ⼀旦线程挂起, 再次被唤醒就不知道隔了多久
    了.因此尽可能减少 "互斥" 的机会, 就是提⾼效率的重要途径.读写锁特别适合于 "频繁读, 不频繁写" 的场景中.

💡💡2. 重点面试题

2.1 是怎么理解乐观锁和悲观锁的,具体怎么实现呢?

悲观锁是 多个线程访问同⼀个变量冲突的概率较⼤, 会在每次访问变量之前都去真正加锁.

乐观锁认为多个线程访问同⼀个变量冲突的概率不⼤. 并不会真的加锁, ⽽是直接尝试访问数据.

在访问的同时识别当前的数据是否出现访问冲突

悲观锁的实现就是先加锁,获取到锁再操作数据. 获取不到锁就等待.

2.2 介绍下读写锁?

读写锁就是把读操作写操作 分别进⾏加锁. 读锁和读锁之间不互斥.

写锁和写锁之间互斥. 写锁和读锁之间互斥.
读写锁最主要⽤在 "频繁读, 不频繁写" 的场景中.

2.3什么是⾃旋锁,为什么要使⽤⾃旋锁策略呢,缺点是什么?

如果获取锁失败, ⽴即再尝试获取锁, ⽆限循环, 直到获取到锁为⽌. 第⼀次获取锁失败, 第⼆次的尝试

会在极短的时间内到来. ⼀旦锁被其他线程释放, 就能第⼀时间获取到锁.

相⽐于挂起等待锁,
优点 : 没有放弃 CPU 资源, ⼀旦锁被释放就能第⼀时间获取到锁, 更⾼效. 在锁持有时间⽐较短的场景下⾮常有⽤.
缺点: 如果锁的持有时间较⻓, 就会浪费 CPU 资源

2.4synchronized是什么?

synchronized 是:

悲观锁、可重入锁、非公平锁、普通互斥排他锁

无竞争轻量级 / 自旋,竞争激烈变为重量级 / 挂起等待

3.CAS

3.1什么是CAS?

全称 :Compare And Swap,比较并交换

乐观锁 的核心实现,无锁、非阻塞、基于硬件指令。

3.2核心执行逻辑

  1. 拿到内存旧值 V

  2. 比较:当前工作内存值 预期值 A 是否等于 内存值 V

  3. 相等 → 交换:把新值 B 写入内存

    不相等 → 失败,重试 / 放弃

    CAS伪代码

    if (内存值 == 预期值) {
    内存值 = 新值;
    }

整个过程是 CPU 硬件原子指令,不加锁。

3.3CAS 归类

  1. 悲观锁 / 乐观锁:乐观锁
  2. 重量级 / 轻量级锁:轻量级、无锁
  3. 挂起等待 / 自旋锁:自旋锁(失败就循环重试)
  4. 公平 / 非公平:无锁机制,无公平概念
  5. 可重入 / 不可重入:不可重入
  6. 读写锁 / 互斥锁:既不是读写锁,也不是互斥锁,无锁并发

3.4优缺点

优点
  1. 全程用户态,无内核切换,性能高
  2. 不死锁、不阻塞线程
  3. 适合竞争不激烈的并发场景
缺点
  1. ABA 问题(致命)
  2. 循环自旋,竞争激烈时CPU 空转消耗高
  3. 只能保证单个变量原子性,不能保证代码块

3.4三大经典问题

1. ABA 问题
  • 现象:原值 A → 被改成 B → 又改回 A
    CAS 只看最终值,误以为没被修改,导致数据错乱
  • 解决:版本号 / 时间戳
2. 自旋消耗 CPU
  • 解决:自适应自旋、限制重试次数、退化为锁
3. 只能原子操作单个变量
  • 解决:AtomicReference 封装对象

3.5相关⾯试题

  1. 讲解下你⾃⼰理解的 CAS 机制

    全称 Compare and swap, 即 "⽐较并交换". 相当于通过⼀个原⼦的操作, 同时完成 "读取内存, ⽐较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的⽀撑.

  2. ABA问题怎么解决?

给要修改的数据引⼊版本号. 在 CAS ⽐较数据当前值和旧值的同时, 也要⽐较版本号是否符合预期. 如果发现当前版本号和之前读到的版本号⼀致, 就真正执⾏修改操作, 并让版本号⾃增; 如果发现当前版本号⽐之前读到的版本号⼤, 就认为操作失败

4.加锁⼯作过程

JVM 将 synchronized 锁分为 ⽆锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进⾏依次升级。(锁升级)

  1. 偏向锁--第⼀个尝试加锁的线程, 优先进⼊偏向锁状态

  2. 轻量级锁 --随着其他线程进⼊竞争, 偏向锁状态被消除, 进⼊轻量级锁状态(⾃适应的⾃旋锁).

    此处的轻量级锁就是通过 CAS 来实现

  3. 重量级锁 --如果竞争进⼀步激烈, ⾃旋不能快速获取到锁状态, 就会膨胀为重量级锁
    注意:(当前JVM中,只有锁升级没有锁降级)

无锁 =>偏向锁:代码进入synchronized 的代码块

偏向锁 =>轻量级锁:拿到偏向锁的线程运行过程中,遇到其他线程尝试竞争这个锁

轻量级锁 => 重量级锁:JVM发现,当前竞争锁的情况非常激烈

5.其他的优化操作

5.1锁消除--是编译器优化的体现

编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.
有些应⽤程序的代码中, 写了 synchronized, 但其实在多线程环境下没有用到,就会自动把synchronized给去掉。

5.2锁粗化--锁的粒度

⼀段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会⾃动进⾏锁的粗化
加锁与解锁之间,包含的代码越多,锁的粗粒度就越粗,包含的代码量越少,锁的粗粒度就越细 (不是代码行数,是实际执行的指令/时间)
一个代码中,反复对细粒度的代码进行加锁,就可能被优化成更粗粒度的加锁了

6.JUC 的常⻅类--java.util.concurrent--就是和线程相关的一下工具

6.1Callable 接⼝

Callable 是⼀个 interface . 相当于把线程封装了⼀个 "返回值"

复制代码
import java.util.concurrent.Callable;  
import java.util.concurrent.ExecutionException;  
import java.util.concurrent.FutureTask;  
  
public class demo8 {  
    public static void main(String[] args) {  
        Callable<Integer> callable = new Callable<Integer>() {  
            @Override  
            public Integer call() throws Exception {  
                int sum = 0;  
                for (int i = 1; i <= 100; i++) {  
                    sum += i;  
                }  
                return sum;  
            }  
        };  
  
        FutureTask<Integer> futureTask = new FutureTask<>(callable);  
        Thread t = new Thread(futureTask);  
        t.start();  
  
        try {  
            // 等待任务执行完成并获取结果  
            int result = futureTask.get();  
            System.out.println(result);  
        } catch (InterruptedException e) {  
            // 线程被中断时的处理  
            System.err.println("线程被中断");  
            e.printStackTrace();  
        } catch (ExecutionException e) {  
            // 任务执行过程中发生异常  
            System.err.println("任务执行出错");  
            e.printStackTrace();  
        }  
    }  
}

可以看到, 使⽤ Callable 和 FutureTask 之后, 代码简化了很多, 也不必⼿动写线程同步代码了.

理解 Callable

Callable 和 Runnable 相对, 都是描述⼀个 "任务". Callable 描述的是带有返回值的任务, Runnable描述的是不带返回值的任务

.

Callable 通常需要搭配 FutureTask 来使⽤. FutureTask ⽤来保存 Callable 的返回结果. 因为

Callable 往往是在另⼀个线程中执⾏的, 啥时候执⾏完并不确定.

FutureTask 就可以负责这个等待结果出来的⼯作

理解 FutureTask

想象去吃⿇辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你⼀张 "⼩票" . 这个⼩票就是

FutureTask. 后⾯我们可以随时凭这张⼩票去查看⾃⼰的这份⿇辣烫做出来了没.

6.2ReentrantLock

可重⼊互斥锁. 和 synchronized 定位类似, 都是⽤来实现互斥效果, 保证线程安全.

ReentrantLock 也是可重⼊锁. Reentrant 这个单词的原意就是 "可重⼊"

ReentrantLock 的⽤法:

• lock(): 加锁, 如果获取不到锁就死等.

• trylock(超时时间): 加锁, 如果获取不到锁, 等待⼀定的时间之后就放弃加锁.

• unlock(): 解锁

复制代码
ReentrantLock lock = new ReentrantLock(); 
-----------------------------------------
lock.lock(); 
try { 
	 // working 
	} finally { 
	 lock.unlock() 
} 

6.3ReentrantLock 和 synchronized 的区别:

• synchronized 是⼀个关键字, 是 JVM 内部实现的(是基于 C++ 实现)

ReentrantLock 是标准库的⼀个类, 在 JVM 外实现的(基于 Java 实现).

• synchronized 使⽤时不需要⼿动释放锁.

ReentrantLock 使⽤时需要⼿动释放. 使⽤起来更灵活, 但是也容易遗漏 unlock.

•synchronized 在申请锁失败时, 会死等.

ReentrantLock 可以通过 trylock 的⽅式等待⼀段时间就放弃.

• synchronized 是⾮公平锁, ReentrantLock 默认是⾮公平锁. 可以通过构造⽅法传⼊⼀个 true 开启公平锁模式.

• synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是⼀个随机等待的线程.

ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.

6.4如何选择使⽤哪个锁?

• 锁竞争不激烈的时候, 使⽤ synchronized, 效率更⾼, ⾃动释放更⽅便.

• 锁竞争激烈的时候, 使⽤ ReentrantLock, 搭配 trylock 更灵活控制加锁的⾏为, ⽽不是死等.

• 如果需要使⽤公平锁, 使⽤ ReentrantLock

7.原子类

是 Java java.util.concurrent.atomic 包下的一组 无锁、线程安全工具类,核心用 CAS(Compare-And-Swap)+ volatile 实现,保证单个变量的 "读 - 改 - 写" 全程原子、不可分割 ,多线程并发无竞态,性能远超 synchronized

作用
AtomicInteger 原子更新 int(常用)
AtomicLong 原子更新 long
AtomicBoolean 原子更新 boolean
AtomicReference<V> 原子更新对象引用
AtomicStampedReference<V> 版本号更新引用
AtomicMarkableReference<V> 标记位更新引用
复制代码
 AtomicInteger cnt = new AtomicInteger(0);

 cnt.incrementAndGet(); // ++i,返回新值 
 cnt.getAndIncrement(); // i++,返回旧值 
 cnt.addAndGet(5); // +=5,返回新值 
 cnt.compareAndSet(0,1);// CAS:预期0则设为1,成功返回true

原子类 vs synchronized

对比 原子类 synchronized
实现 无锁(CAS + 自旋) 悲观锁(阻塞 + 唤醒)
粒度 变量级(极细) 代码块 / 方法级
性能 高并发下极高 竞争激烈时阻塞、上下文切换开销大
适用 单变量简单原子操作 多变量复合操作、复杂临界区
原子类是无锁并发 的基石,适合单变量、高并发、简单原子操作 场景,性能碾压 synchronized;复杂多变量同步仍用锁或 java.util.concurrent 高级工具。

8.线程池

虽然创建销毁线程⽐创建销毁进程更轻量, 但是在频繁创建销毁线程的时候还是会⽐较低效.线程池就是为了解决这个问题. 如果某个线程不再使⽤了, 并不是真正把线程释放, ⽽是放到⼀个 "池⼦" 中, 下次如果需要⽤到线程就直接从池⼦中取, 不必通过系统来创建了.

8.1ExecutorService 和 Executors

代码⽰例:

• ExecutorService 表⽰⼀个线程池实例.

• Executors 是⼀个⼯⼚类, 能够创建出⼏种不同⻛格的线程池.

• ExecutorService 的 submit ⽅法能够向线程池中提交若⼲个任务.

复制代码
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
	 @Override
	 public void run() {
	 System.out.println("hello");
	 }
});

8.2Executors 创建线程池的⼏种⽅式

  • newFixedThreadPool(n):固定核心线程,队列无界 → OOM 风险
  • newSingleThreadExecutor():单线程池,串行执行
  • newCachedThreadPool():无核心线程、最大线程无限 → 线程爆炸
  • newScheduledThreadPool():定时任务线程池

8.3线程池的⼯作流程

8.4信号量 Semaphore--能够协调多个线程之间的资源分配

信号量, ⽤来表⽰ "可⽤资源的个数". 本质上就是⼀个计数器

8.5CountDownLatch--同时等待 N 个任务执⾏结束

8.6相关⾯试题

1. 线程同步的⽅式有哪些?

synchronized, ReentrantLock, Semaphore 等都可以⽤于线程同步.

2. 为什么有了 synchronized 还需要 juc 下的 lock?

以 juc 的 ReentrantLock 为例,

• synchronized 使⽤时不需要⼿动释放锁. ReentrantLock 使⽤时需要⼿动释放. 使⽤起来更灵活,

• synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的⽅式等待⼀段时间就放弃.

• synchronized 是⾮公平锁, ReentrantLock 默认是⾮公平锁. 可以通过构造⽅法传⼊⼀个 true 开启公平锁模式.

• synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是⼀个随机等待的线程.

ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.

9.线程安全的集合类

9.1多线程环境使⽤ ArrayList

  1. ⾃⼰使⽤同步机制 (synchronized 或者 ReentrantLock)
  2. Collections.synchronizedList(new ArrayList)
  3. 使⽤ CopyOnWriteArrayList

9.2多线程环境使⽤队列

  1. ArrayBlockingQueue---基于数组实现的阻塞队列
  2. LinkedBlockingQueue---基于链表实现的阻塞队列
  3. PriorityBlockingQueue---基于堆实现的带优先级的阻塞队列
  4. TransferQueue---最多只包含⼀个元素的阻塞队列

9.3多线程环境使⽤哈希表

HashMap 本⾝不是线程安全的.

在多线程环境下使⽤哈希表可以使⽤:

• Hashtable

• ConcurrentHashMap

10.死锁

死锁是什么

死锁是这样⼀种情形:多个线程同时被阻塞,它们中的⼀个或者全部都在等待某个资源被释放。由于线程被⽆限期地阻塞,因此程序不可能正常终⽌。
死锁是⼀种严重的 BUG!! 导致⼀个程序的线程 "卡死", ⽆法正常⼯作!
如何避免死锁

死锁产⽣的四个必要条件:

互斥使⽤ ,即当资源被⼀个线程使⽤(占有)时,别的线程不能使⽤

不可抢占 ,资源请求者不能强制从资源占有者⼿中夺取资源,资源只能由资源占有者主动释放。

请求和保持 ,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。

循环等待,即存在⼀个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了⼀个等待环路。当上述四个条件都成⽴的时候,便形成死锁。当然,死锁的情况下如果打破上述任何⼀个条件,便可让死锁消失。

相关推荐
随便做点啥1 小时前
Agent 后台 - Token工场-集群设备配置建议
服务器·经验分享
SXJR2 小时前
spring boot + langchain4j +milvus实现向量存储
java·spring boot·后端·大模型·milvus·rag·langchain4j
武子康2 小时前
Java-27 深入浅出 Spring - 实现简易Ioc-03 在上节的业务下手动实现IoC 从 XML 配置到 BeanFactory 反射注入
java·后端·mybatis
二哈赛车手2 小时前
新人笔记---idea索引失效问题解决方案
java·笔记·spring·elasticsearch·intellij-idea
A.零点2 小时前
【2个月 C 语言从入门到精通:零基础系统教程】第十二讲:深入了解指针(五)
c语言·开发语言·网络·笔记·visual studio
飞天狗1112 小时前
零基础JavaWeb入门——第五课第一小节:九大内置对象 · 第1个:request(请求对象)
java·开发语言·前端·后端·servlet
志栋智能2 小时前
从固定周期到动态触发:超自动化巡检的智能调度
运维·网络·自动化
a15108416932 小时前
记一次大模型探索
java·服务器·前端
c++之路2 小时前
Bazel C++ 构建系列文档(五):多目标与多包项目
java·开发语言·c++
中云DDoS CC防护蔡蔡2 小时前
游戏杀手- ACCN
运维·服务器·经验分享·网络安全·ddos