多线程(面试

3. 多线程-进阶
1. 常见的锁策略
1.1 乐观锁 vs 悲观锁(心态划分)
乐观锁
假设数据⼀般情况下不会产⽣并发冲突,所以在数据进⾏提交更新的时候,才会正式对数据是否产⽣ 并发冲突进⾏检测,如果发现并发冲突了,则让返回⽤⼾错误的信息,让⽤⼾决定如何去做
即假设不会冲突的乐观 心态
悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别⼈会修改,所以每次在拿数据的时候都会上锁, 这样别⼈想拿这个数据就会阻塞直到它拿到锁。
即假设下一秒就会冲突的悲观心态
乐观锁与悲观锁是用来预测加锁之后的遇到锁竞争的概率
维度 |
悲观锁 |
乐观锁 |
|---|---|---|
| 开销 | 大(涉及上下文切换) | 小(无阻塞) |
| 适用场景 | 写多读少 | 读多写少 |
| 安全性 | 高 | 需要处理好重试逻辑 |
1.2 重量级锁 vs 轻量级锁(加锁开销划分)
轻量级锁
完全在用户态通过 CAS 操作实现的锁,不依赖操作系统。
缺点:如果锁被长时间占着,线程会一直循环(自旋),就会浪费CPU
重量级锁
依赖操作系统的 Mutex(互斥量 来实现的锁。
缺点:好处虽然是当线程拿不到锁的时候,会被挂起(Blocked)放入等待队列,但是这需要从用户态到内核态的切换,开销成本大(大概需要几千个cpu周期
1.3 自旋锁 (Spin Lock)VS挂起等待锁(等待策略划分)
自旋锁
线程获取锁失败后,不放弃 CPU,而是执行一个空循环(while循环),不断尝试获取锁。也就是忙等,
优点: 不涉及线程上下文切换,响应快,适合临界区极短的场景
缺点:浪费CPU资源
挂起等待锁
线程获取锁失败时,主动放弃 CPU,由操作系统将其挂起(阻塞),放入等待队列;当锁释放时,再由调度器唤醒。
优点: 不浪费CPU,适合长时间持锁
缺点:涉及上下文切换,开销大,相应会延迟
结合等待队列和唤醒机制
对比维度 |
自旋锁 |
挂起等待锁 |
|---|---|---|
| 等待方式 | 忙等待(while) | 阻塞等待(sleep) |
| 是否占用 CPU | ✅ 是 | ❌ 否 |
| 是否上下文切换 | ❌ 无 | ✅ 有 |
| 响应速度 | 快 | 较慢 |
| 实现复杂度 | 简单 | 较复杂 |
| 适用锁持有时间 | 极短 | 较长 |
| 是否可用于中断上下文 | ✅ 可 | ❌ 不可 |
| 典型实现 | spinlock_t |
pthread_mutex_t |
举个例子
自旋类似原地等待,等到到他了
而挂起等待类似还没到他,就先走了过段时间再看有没有到他
1.4 公平锁 vs 非公平锁(排队规则划分)
公平锁(先来后到)
- 规则:先来后到。线程在队列里排队,只有队首的线程能拿到锁。
- 优点:不会“饥饿”,每个线程迟早能执行。
- 缺点:性能低。因为要维护队列,且唤醒线程需要时间。
- Java:
ReentrantLock(true)。
非公平锁(概率公平)
- 规则:允许插队。线程来了不管队列里有没有人,先尝试抢锁,抢不到再进队列。
- 优点:性能高。如果刚释放锁,刚好一个新线程来了,直接抢到,就不用唤醒队列里的老线程了。
- 缺点:可能导致队列里的线程一直抢不到(饥饿)。
- Java:
synchronized和ReentrantLock(false)默认都是非公平锁
1.5 可重入锁 vs 不可重入锁(死锁防护角度)
不可重入锁
lock.lock();
// ... 业务逻辑
lock.lock(); // 第二次加锁,死锁!
可重入锁
- 原理:锁内部记录持有者线程 ID和计数器。
- 第一次加锁:计数器=1。
- 第二次加锁(同一线程):计数器=2。
- 解锁:计数器-1。计数器归0,锁才真正释放。
- Java:
synchronized和ReentrantLock都是可重入的。
1.6 读写锁VS普通锁(多读的角度)
读写锁(可以多读,但写要排队
- 核心规则:读读不互斥,读写互斥,写写互斥。
- 原理:将读操作和写操作区分对待。多个线程同时读数据时不需要加锁互斥,只有当有线程要写数据时,才会与其他读写线程产生互斥。
- 适用场景:非常适合“频繁读,不频繁写”的业务场景(例如:电商商品详情页、教务系统查成绩等)。
- 注意点:Java标准库中的
synchronized不是读写锁,它无法区分读写操作进行优化。 - 在synchronized中,不管读还是写都是互斥的,都需要排队
场景 |
能不能同时进行 |
原因 |
|---|---|---|
| 读 + 读 | ✅ 可以 | 数据不会被改 |
| 读 + 写 | ❌ 不行 | 防止读到半成品 |
| 写 + 写 | ❌ 不行 | 防止数据覆盖 |
1.7 synchronized原理
特性
- 开始时是乐观锁,当发生锁冲突频繁时,转为悲观锁
- 开始是轻量级锁,当锁被持有的时间长了就转换成重量级锁
- 实现轻量级锁的时候是用到自旋锁的策略,重量级锁的时候大概率是挂起等待锁
- 不是公平的锁,线程不排队,是随机的
- 是可重入锁,不会死锁
- 不是读写锁,不能同时读
加锁的过程(一步一步加深,自适应的过程)
当锁升级后不会降级
- 无锁(没有进入代码块)
Object obj = new Object();
// 还没进入 synchronized
- 偏向锁(进入synchronized代码块)
此时没有真正加锁只是标记(开销很小),
-
当没有线程竞争这把锁,就没有关系
-
当有线程竞争这把锁,就会升级为自旋锁,就不会竞争而是等待(忙等)
-
如果后续更多的锁等待,就升级为重量级锁,就不是等待而是挂起
-
(一个阈值,竞争线程的数量决定是升级为自旋锁还是重量级锁)
- 自旋锁(轻量级锁)
当另一个线程 B 尝试进入 synchronized:
撤销偏向锁
使用 CAS + 自旋 抢锁
在栈帧中创建 Lock Record
这就是自旋锁阶段
- 线程 不挂起
- 在用户态忙等
- 适合锁持有时间极短
- 重量级锁
当
- 自旋失败
- 竞争线程太多
- 自旋时间过长
升级为 重量级锁(Monitor) - 依赖 OS 的 互斥量(mutex)
- 线程进入 阻塞 / 挂起状态
- 涉及 上下文切换
稳定、可靠,但是性能开销大
无锁
↓
偏向锁(只有一个线程)
↓(发生竞争)
轻量级锁(自旋 CAS)
↓(竞争激烈 / 自旋失败)
重量级锁(OS 阻塞)
1.8锁的粗化
锁粗化就是 JVM 帮我们把细碎的锁合并成一把大的锁,目的是减少加锁/解锁的开销。
// 没优化前:锁在循环里,每次循环都加锁解锁,非常耗时
for (int i = 0; i < 1000; i++) {
synchronized(this) {
list.add(i);
}
}
// 锁粗化后:JVM 会自动把锁移到循环外面,只加锁一次
synchronized(this) {
for (int i = 0; i < 1000; i++) {
list.add(i);
}
}
2. CAS
2.1 什么是 CAS(循环等待精髓)
CAS的全称Compare and swap,即比较并交换
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
但这不是函数,这是一条指令,原子的,是最小单位,发生在内存和两个寄存器身上
CPU提供了CAS指令
由操作系统对CAS指令进行封装
操作系统提供了API通过API使用CAS机制
JVM就可以封装调用操作系统的API
IDEA的代码就可以使用jvm的API
通过CAS不是在禁止穿插,而是识别穿插,如果被穿插了就会进入循环,也就是自旋
所以自旋锁(轻量级锁)内部靠的就是CAS,这样子就不必加重量级锁
因为是原子性的,大不了循环几次
2.2 CAS 是怎么实现的
针对不同的操作系统,JVM⽤到了不同的CAS实现原理,简单来讲: • java的CAS利⽤的的是unsafe这个类提供的CAS操作;
• unsafe的CAS依赖了的是jvm针对不同的操作系统实现的Atomic::cmpxchg;
• Atomic::cmpxchg的实现使⽤了汇编的CAS操作,并使⽤cpu硬件提供的lock机制保证其原⼦ 性
2.3 CAS 有哪些应用
- 实现原⼦类
package thread;
import java.util.concurrent.atomic.AtomicInteger;
public class Demo36 {
//不使用int使用原子类
//private static int count=0;
private static AtomicInteger count = new AtomicInteger(0);
static void main(String[] args)throws InterruptedException {
Thread t1=new Thread(()->{
for(int i=0;i<100;i++){
//count++;
count.getAndIncrement();
//++count;
// count.incrementAndGet(); //count--; // count.getAndDecrement(); //--count; //count.decrementAndGet(); //count+=2; //count.addAndGet(2); }
});
Thread t2=new Thread(()->{
for(int i=0;i<100;i++){
//count++;
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count=" +count.get());
}
}
- 实现⾃旋锁
2.4 CAS 的 ABA 问题
CAS不算特备的严禁,因为只是检查值首尾没有改变,不考虑中途的改变,即A->B->A
问题的核心
通过A的值相同判定是否插队
于是约定了版本号的概念
2.5 相关面试题
CAS是什么
定义:
线程 A 读取到一个值 A,此时线程 B 把值从 A 改成了 B,随后又改回了 A。当线程 A 进行 CAS 检查时,发现值还是 A,误以为“期间没人动过”,于是 CAS 操作成功。
关键点:
CAS 只关心值变没变,不关心值在中间过程经历了什么。
有什么应用场景(CAS如何实现的)
CAS的ABA问题是什么样的
提出版本号的概念
3. synchronized 原理
3.1 基本特点
3.2 加锁工作过程
3.3 其他的优化操作
3.4 相关面试题
4. JUC(java.util.concurrent) 的常见类
4.1 Callable 接口
类似Runnable,void run表示任务没有返回值
package thread;
public class Demo37 {
private static int result = 0;
static void main(String[] args)throws InterruptedException {
//创建线程,让这个线程计算1+到1000
//主线程在这个计算线程执行完毕后,输出线程计算的结果
Thread t1=new Thread(new Runnable(){
@Override
public void run() {
int sum=0;
for(int i=0;i<1000;i++){
sum+=i;
}
result = sum;
}
});
t1.start();
t1.join();
System.out.println(result);
}
}
Callable接口,是call方法,有一个返回值
callable避免出现成员变量,尽可能不蒙圈,这是和Runnable的区别
callable搭配FutureTask使用
package thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Demo38 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for(int i=0;i<1000;i++){
sum+=i;
}
return sum;
}
};
//对于Thread来说,无法接受Callable对象作为参数
//此处搭配FutureTask使用
//可以理解成,通过这个对象获取到未来的结果,call方法可以认为是加工麻辣烫的过程,煮之前会发号码牌
//其实这是Thread的run方法就会调用FutureTask的run方法
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();
//get方法拿到call的返回值
//如果call没有执行完,get会阻塞等待!!
System.out.println(futureTask.get());
}
}
4.2 ReentrantLock
是可重入锁,可以说是sychronized的老版本,上古时期用的,现如今synchronized代替ReentrantLock
可以说是锁的经典用法了
lock和unlock之间可能有returne,throws
此外,
- 还支持trylock
lock行为加锁不成就会阻塞的等待
trylock行为加锁不成们可以直接放回(放弃)也支持指定超时时间 - 支持公平锁
按照先来后到方式,锁内部记录各个线程的加锁顺序,按照顺序从队列中取确保拿到的锁的线程是最早申请的线程 - 等待通知机制
synchronized是搭配wait notify 使用 notify唤醒的是随机一个
ReetrantLock搭配Condition类使用,可以指定唤醒
package thread;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.locks.ReentrantLock;
public class Demo39 {
private static int count=0;
public static void main(String[] args) throws InterruptedException, ExecutionException {
ReentrantLock lock = new ReentrantLock();
Thread t1=new Thread(()->{
for(int i=0;i<1000;i++){
try{
if( lock.tryLock()){
count++;
}else{
//放弃
}
}finally{
lock.unlock();}
}
});
Thread t2=new Thread(()->{
for(int i=0;i<1000;i++){
lock.lock();
count++;
lock.unlock();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
//解决的是同一个问题(count++),但“拿不到资源时的态度”完全不同。
// 方式1:CAS(自旋,不睡觉)
while (!atomicCount.compareAndSet(old, old + 1));
// 方式2:lock()(睡觉排队)
lock.lock(); count++; lock.unlock();
// 方式3:tryLock()(不排队,直接走)
if (lock.tryLock()) {
try { count++; } finally { lock.unlock(); }
}
技术 |
拿不到资源怎么办? |
形象比喻 |
|---|---|---|
| CAS / AtomicInteger | 原地转圈圈重试(自旋) | 你站在奶茶店门口一直问:“好了没?好了没?” |
| lock() | 去旁边睡觉排队(阻塞) | 你拿个号,去座位上玩手机,叫号再回来 |
| tryLock() | 直接走人 | 看一眼人多,扭头去喝咖啡了 |
4.3信号量Semaphore
在操作系统会提到,这是Java把操作系统提供的机制进行进一步的封装
信号量就是技术器,描述可用资源,类似商场停车位的显示信号
P操作是申请资源
V操作是释放资源
当计数器为零,还申请资源P操作,就会触发阻塞
本质上操作计数器的值是原子的过程
信号量和锁
P操作1->0,加锁
V操作,0->,解锁
package thread;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Semaphore;
public class Demo40 {
public static void main(String[] args) throws InterruptedException, ExecutionException {
Semaphore semaphore = new Semaphore(4);
semaphore.acquire();
System.out.println("进行P操作用的aquire方法");
semaphore.acquire();
System.out.println("进行P操作用的aquire方法");
semaphore.acquire();
System.out.println("进行P操作用的aquire方法");
semaphore.acquire();
System.out.println("进行P操作用的aquire方法");
semaphore.release();
System.out.println("V操作用的release");
semaphore.release();
System.out.println("V操作");
}
}
//这样子也可以解决线程安全问题
package thread;
import java.util.concurrent.Semaphore;
public class Demo41 {
private static int count=0;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(1);
Thread t1 = new Thread(()->{
for(int i=0;i<10;i++){
try {
semaphore.acquire();
count++;
semaphore.release();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread t2 = new Thread(()->{
for(int i=0;i<10;i++){
try {
semaphore.acquire();
count++;
semaphore.release();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
解决线程安全问题的方式多种
- 调整代码结构,避免多个线程修改同一个变量
- synchronized
- ReentrantLock
- Semaphore
- CAS/原子类
4.4countdownlatch
闭锁,原子变量,怎么翻译都不对
大概就是大的任务=》拆成多个小任务=》所有小的任务执行完,整个任务才算完
用百米跑步的例子
package thread;
import java.util.concurrent.CountDownLatch;
public class Demo42 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(8);
//这个跑道八个人跑
for (int i = 0; i < 8; i++) {
int id = i;
Thread thread = new Thread(() -> {
System.out.println("运动员"+id+"开始");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("运动员"+id+"到达终点");
//“撞线操作”latch中保存的计数值-1
countDownLatch.countDown();
});
thread.start();
}
//主线程中,如何判定所有的运动员到终点
//通过await进行等待,等待过程中,当前线程阻塞
//await==all wait
countDownLatch.await();
System.out.println("比赛结束");
}
}
5. 线程安全的集合类
Vector,Hashtable ,Stack,StringBuffer,核心方法都有synchronized关键字,即线程安全
5.1 多线程环境使用 ArrayList
线程不安全
5.2 多线程环境使用队列
线程安全
使用时拷贝
这是一种解决线程安全的方法,不推荐
因为只适合小数据,在修改数据的时候是先复制一份,然后再副本修改,副本盖住原来的数据
5.3 多线程环境使用哈希表
1.ConcurrentHashMap
将每个链表的头节点作为锁对象,hash是挂着多个链表
多个线程竞争同意把锁才会冲突,就是在同一个链表才会冲突
2.ConcurrentHashMap使用CAS的方式对size进行修改(避免加锁)
3.ConcurrentHashMap扩容场景
hash表扩容是创建更大的数组,原hash表的每个元素都查到新的hash表数组中,这会花大量时间
所以是分成多次完成,所以ConcurrentHashMap会同时维护两个数组
5.4 相关面试题
6. 死锁
6.1 死锁是什么
6.2 如何避免死锁
6.3 相关面试题
7. 其他常见问题
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)