Java多线程进阶:
引言
多线程编程是Java开发中绕不开的核心命题。从最初的Thread类起步,到理解synchronized背后的JVM锁升级机制,再到熟练运用JUC并发工具包中的各种利器------这条进阶之路充满了"看似简单实则精妙"的设计哲学。
本文基于系统性的课程学习,结合我个人对并发编程的理解与思考,从锁策略 这一底层概念出发,逐层递进地剖析CAS机制、synchronized原理、JUC工具类、线程安全集合以及死锁等核心话题。我的核心体会是:并发编程的本质,是在"正确性"与"性能"之间寻找最优平衡点。每一种锁策略、每一个并发工具的设计,都体现了这一权衡。
一、常见的锁策略
锁策略不仅仅是Java的概念,任何涉及"锁"的编程语言和系统都会涉及这些分类。理解这些策略,既能帮助我们在应用层正确选择锁,也能帮助我们理解底层框架的实现原理。
1.1 乐观锁 vs 悲观锁
这是两种根本性的设计哲学 差异,核心分歧在于:对并发冲突概率的预判不同。
悲观锁:总是假设最坏的情况,每次取数据时都认为别人会修改,所以每次都会先上锁。这样其他线程想拿这个数据就会阻塞,直到它拿到锁。
乐观锁:假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才正式检测是否产生了并发冲突。如果发现冲突,则返回错误信息,由调用方决定如何处理。
生活中的类比:同学A和同学B都想请教老师问题。
- 同学A(悲观锁)认为"老师比较忙",所以先发微信确认:"老师您下午两点有空吗?"得到肯定答复后才去。这就是先加锁再访问资源。
- 同学B(乐观锁)认为"老师比较闲",直接跑去办公室。如果老师确实在,问题就解决了;如果老师不在,下次再来。这就是不加锁直接访问,遇到冲突再处理。
我的深刻理解 :这两种策略没有绝对的优劣,关键在于场景适配 。如果冲突频繁(老师确实很忙),用乐观锁会导致"白跑很多趟"(CAS重试次数过多),反而浪费资源;如果冲突很少(老师确实很闲),用悲观锁会让本来不需要等待的线程白白阻塞,降低了并发度。这告诉我们:架构设计没有银弹,只有权衡。
实现方式:
- 悲观锁:
synchronized、ReentrantLock等,先真正加锁再操作数据。 - 乐观锁:借助CAS机制或版本号机制,在提交时检测冲突。
1.2 重量级锁 vs 轻量级锁
要理解这个区别,需要先理解锁的底层实现链路:
CPU 原子指令 → 操作系统 mutex → JVM synchronized/ReentrantLock
重量级锁:加锁机制重度依赖操作系统提供的mutex。
- 大量的内核态与用户态切换
- 很容易引发线程调度
这两个操作成本极高。一旦涉及用户态和内核态的切换,就意味着时间上"沧海桑田"。
轻量级锁:加锁机制尽可能不使用mutex,而是尽量在用户态代码中完成。实在搞不定了,再降级使用mutex。
- 少量的内核态用户态切换
- 不太容易引发线程调度
用户态 vs 内核态的直观理解:想象去银行办业务。在窗口外自己准备材料,这是用户态,时间成本可控;在窗口内让工作人员操作,这是内核态,时间成本不太可控。如果反复沟通还需要重新排队,效率极低。
synchronized开始是一个轻量级锁,如果锁冲突比较严重,才升级为重量级锁。
我的深刻理解:这种"先尝试低成本方案,不行再升级"的渐进式设计,是性能优化的经典范式。它体现了"大部分情况下不需要最重手段"的乐观假设,与乐观锁的哲学一脉相承。
1.3 自旋锁(Spin Lock)
传统的挂起等待方式中,线程抢锁失败后进入阻塞状态、放弃CPU,需要过很久才能再次被调度。但实际上,大部分情况下锁不会持有太久------没必要就这样放弃CPU。
自旋锁的做法:获取锁失败后,立即再次尝试获取,无限循环,直到获取到锁为止。
java
while (抢锁(lock) == 失败) { }
生动类比:去追求一个女神。
- 挂起等待锁:表白被拒后陷入沉沦不能自拔,过了很久突然女神发来消息"咱俩要不试试?"(注意这个很长的时间间隔里,女神可能已经换了好几个对象了)。
- 自旋锁:死皮赖脸坚韧不拔,每天持续说早安晚安。一旦女神恢复单身,立刻抓住机会上位。
自旋锁的本质:它是一种典型的轻量级锁实现方式。
优点:
- 没有放弃CPU,不涉及线程阻塞和调度
- 一旦锁被释放,就能第一时间获取到锁
- 在锁持有时间较短的场景下非常高效
缺点:
- 如果锁的持有时间较长,就会白白浪费CPU资源(空转)
我的深刻理解 :自旋锁的代价本质上是"用CPU时间换响应延迟"。这提醒我们,在锁持有时间不确定的场景中,纯自旋是危险的。因此实际工程中的自旋锁都有"自适应"机制(后面 synchronized部分会详述),即自旋到一定次数后放弃,转为阻塞等待。
1.4 公平锁 vs 非公平锁
假设有三个线程A、B、C依次尝试获取锁:A获取成功,B和C获取失败后等待。当A释放锁时:
- 公平锁:遵守"先来后到",B比C先来,所以B先于C获取锁。
- 非公平锁:不遵守先来后到,B和C都有可能获取到锁。
类比:一群男生追同一个女神。女神分手后,先来追的男生上位------这是公平锁;女神不按顺序挑一个自己看得顺眼的------这是非公平锁。
关键注意:
- 操作系统内部的线程调度本身就是随机的。如果不做任何额外限制,锁就是非公平锁。要实现公平锁,需要依赖额外的数据结构(如队列)来记录线程的先后顺序。
- 公平锁和非公平锁没有好坏之分,关键看适用场景。
synchronized是非公平锁。
我的深刻理解:非公平锁性能通常更好,因为公平锁需要维护等待队列,每次切换都要做"入队/出队"操作。非公平锁允许新来的线程"插队",减少了线程上下文切换,吞吐量更高。公平锁适合对响应时间有严格一致性要求的场景(如实时系统),非公平锁适合追求最大吞吐量的场景。
1.5 可重入锁 vs 不可重入锁
可重入锁字面意思是"可以重新进入的锁",即允许同一个线程多次获取同一把锁。
为什么需要可重入? 考虑递归函数中有加锁操作的场景------递归过程中这个锁会阻塞自己吗?如果不会,说明它是可重入锁(因此也叫递归锁)。
不可重入锁的致命问题------"把自己锁死":
java
// 第一次加锁,加锁成功
lock();
// 第二次加锁,锁已经被占用(被自己占用),阻塞等待
lock(); // 永远等不到自己释放锁 → 死锁!
释放锁的操作本来应该由持有锁的线程完成,但这个线程已经阻塞了,啥都干不了------这就是死锁。
Java中的情况:
synchronized是可重入锁ReentrantLock是可重入锁(类名中"Reentrant"就是"可重入"的意思)- JDK提供的所有现成Lock实现类都是可重入的
- Linux系统的mutex是不可重入锁
可重入锁的实现原理:在锁内部记录当前持有锁的线程身份,以及一个计数器记录加锁次数。如果发现当前加锁的线程就是持有锁的线程,则计数器自增;解锁时计数器自减,减到0才真正释放。
1.6 读写锁
核心思想:多线程之间,数据的读取方之间不会产生线程安全问题,但写入方互相之间以及写入方与读取方之间都需要互斥。如果两种场景都用同一把锁,会产生极大的性能损耗。
读写锁(Readers-Writer Lock)的规则:
- 读锁和读锁之间:不互斥(可以并发读)
- 写锁和写锁之间:互斥
- 读锁和写锁之间:互斥
适用场景:特别适合于"频繁读、不频繁写"的场景(这样的场景在实际中非常广泛)。
典型案例:教务系统------每节课老师都要点名(查看同学列表,读操作),一周可能执行好几次;而修改同学列表(写操作),可能一个月都不必一次。同学们查看作业(读操作)一天可能几十上百次,但老师布置作业(写操作)一节课只有一次。
Java实现 :ReentrantReadWriteLock类提供了ReadLock和WriteLock,各自有lock()和unlock()方法。
注意 :synchronized不是读写锁。
我的深刻理解 :读写锁的本质是将锁的粒度从"操作类型"维度进行了拆分。它背后的洞察是:大多数读操作之间不会互相冲突。这是降低锁冲突概率的经典手段------在能确定不会冲突的地方就不要加锁。
二、CAS机制------乐观锁的基石
2.1 什么是CAS
CAS全称Compare And Swap(比较并交换),是乐观锁思想的核心实现方式。
假设内存中的原数据为V,旧的预期值为A,需要修改的新值为B:
- 比较:判断A与V是否相等
- 交换:如果相等,将B写入V
- 返回:操作是否成功
CAS伪代码(注意:下面代码本身不是原子的,真实CAS由CPU硬件指令原子完成):
java
boolean CAS(address, expectValue, swapValue) {
if (&address == expectValue) {
&address = swapValue;
return true;
}
return false;
}
两种典型的非原子操作:
- check-then-set:if判断然后设定值(如上CAS伪代码形式)
- read-then-update:
i++(读旧值、计算、写回)
当多个线程同时对某个资源进行CAS操作时,只有一个线程操作成功,但不会阻塞其他线程------其他线程只会收到操作失败的信号。
2.2 CAS是怎么实现的
CAS的底层实现遵循硬件→OS→JVM→Java的层次:
- Java的CAS利用
Unsafe类提供的CAS操作 Unsafe的CAS依赖JVM针对不同操作系统实现的Atomic::cmpxchgAtomic::cmpxchg使用汇编的CAS指令,并通过CPU硬件提供的lock前缀指令保证其原子性
简而言之:因为硬件予以支持,软件层面才能做到原子性。 这是并发编程中"自底向上"的根基。
2.3 CAS的应用------实现原子类
标准库中java.util.concurrent.atomic包下的类都是基于CAS实现的。最典型的是AtomicInteger:
java
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
伪代码实现:
java
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while (!CAS(value, oldValue, oldValue + 1)) {
oldValue = value; // CAS失败,重新读取最新值
}
return oldValue;
}
}
两个线程并发执行的流程:
- 两个线程都读取value的值到各自的oldValue(局部变量在栈上,每个线程有自己的栈)
- 线程1先执行CAS:oldValue和value相同,赋值成功
- 线程2执行CAS:发现oldValue和value不相等(线程1已改),CAS失败,进入循环
- 线程2重新读取value赋值给oldValue,再次CAS成功
- 两个线程各返回自己的oldValue值
关键注意:CAS是直接读写主内存的(不是寄存器),且读内存→比较→写内存是一条硬件指令,是原子的。
2.4 CAS的应用------实现自旋锁
基于CAS可以实现更灵活、更轻量的锁:
java
public class SpinLock {
private Thread owner = null;
public void lock() {
// CAS尝试将owner从null设为当前线程
// 成功则加锁成功;失败说明锁被别人持有,自旋等待
while (!CAS(this.owner, null, Thread.currentThread())) { }
}
public void unlock() {
this.owner = null;
}
}
2.5 CAS的ABA问题
问题描述:假设变量num初始值为A。线程t1想CAS把num从A改成Z。但在t1执行两个步骤之间,t2线程把num从A改成了B,又从B改回了A。此时t1的CAS操作会"误判"数据未被修改------因为当前值仍然是A。
类比:买手机时,无法判定这个手机是刚出厂的新手机,还是别人用旧了又翻新过的。
ABA问题的实际危害------取款案例:
正常过程:滑稽有100存款,两个线程并发执行-50操作。线程1扣款成功(余额变50),线程2发现余额为50≠100,操作失败。正确!
异常过程:线程1扣款成功(余额变50)。此时朋友正好转了50(余额变回100)。线程2发现余额为100==100,再次扣款成功。扣了两次!
解决方案:引入版本号
CAS操作在比较数据的同时,也比较版本号:
- 版本号一致:修改数据,版本号+1
- 版本号不一致:认为数据已被修改过,操作失败
对应上面取款例子:线程1扣款成功(余额50,版本号变2)。朋友转账50(余额100,版本号变3)。线程2发现版本号为3≠1,判定操作失败。
Java解决方案 :AtomicStampedReference<E>------对引用类型包装,内部维护版本号管理。
我的深刻理解:ABA问题的本质是CAS只关心"值是否变化",不关心"值的变化轨迹"。引入版本号相当于把"值比较"升级为"状态比较",这是从一维到二维的升维思考。
三、synchronized原理------JVM锁升级全解
3.1 基本特点总结
综合前面的锁策略知识,synchronized在JDK 1.8中具有以下特性:
| 特性维度 | 表现 |
|---|---|
| 乐观/悲观 | 开始是乐观锁,锁冲突频繁时转为悲观锁 |
| 重量级/轻量级 | 开始是轻量级锁,持锁时间长时转为重量级锁 |
| 自旋 | 实现轻量级锁时大概率用到自旋策略 |
| 公平性 | 非公平锁 |
| 可重入性 | 可重入锁 |
| 读写分离 | 不是读写锁 |
3.2 锁升级机制
JVM将synchronized锁分为无锁→偏向锁→轻量级锁→重量级锁四种状态,会根据竞争程度依次升级。
3.2.1 偏向锁
核心思想:第一个尝试加锁的线程优先进入偏向锁状态。偏向锁不是真的"加锁",只是给对象头做一个"偏向锁标记",记录这个锁属于哪个线程。
- 如果后续没有其他线程竞争:不用进行任何同步操作,避免了加锁解锁开销
- 如果有其他线程来竞争:取消偏向锁,进入轻量级锁状态
偏向锁本质是"延迟加锁"------能不加就不加,但标记还是要打的,否则无法识别何时需要真正加锁。
类比:假设男主是一个锁,女主是一个线程。只有这一个线程使用时,即使不领证结婚(避免高成本操作),也能幸福生活。但女配出现了(第二个线程来竞争),此时不管领证成本多高,都必须完成这个动作让女配死心。
3.2.2 轻量级锁
随着其他线程进入竞争,偏向锁被消除,进入轻量级锁状态(自适应的自旋锁)。
- 通过CAS尝试将锁对象头中的Mark Word改为指向当前线程栈帧中的Lock Record的指针
- CAS成功:加锁成功
- CAS失败:锁被别人占用,继续自旋等待(不放弃CPU)
"自适应"的含义:自旋操作一直让CPU空转比较浪费。因此自适应自旋不会一直进行,而是根据历史成功率动态调整自旋次数------成功率高就多转几圈,成功率低就少转或放弃。
3.2.3 重量级锁
如果竞争进一步激烈,自旋无法快速获取锁,就膨胀为重量级锁。
重量级锁直接使用操作系统提供的mutex:
- 执行加锁操作,进入内核态
- 在内核态判定锁是否已被占用
- 未占用:加锁成功,切换回用户态
- 已占用:线程进入等待队列挂起,等待操作系统唤醒
- 经过漫长等待,锁被释放,操作系统唤醒该线程尝试重新获取
我的深刻理解 :锁升级机制是JVM对"自适应优化"的最佳实践。大部分锁在大部分时间内只被一个线程使用(偏向锁就够了);少数情况下有少量竞争但不激烈(轻量级锁足够);极少数激烈竞争才需要重量级锁。这种"按需付费"的设计思想,让synchronized在低竞争场景下性能极高,而在高竞争场景下也能正确工作。
3.3 锁消除
编译器+JVM在运行时判断锁是否可以消除。如果可以,就直接消除。
典型案例 :StringBuffer在单线程中的使用------每个append都涉及加锁解锁,但变量只在方法内部使用,不可能被其他线程访问,所以JIT编译器会消除这些锁。
java
StringBuffer sb = new StringBuffer(); // 局部变量
sb.append("a");
sb.append("b");
// 单线程场景下,这些锁都可以被消除
3.4 锁粗化
一段逻辑中如果频繁加锁解锁,编译器+JVM会自动合并为一次加锁。
类比:领导给下属安排任务:
- 方式一(细粒度锁):打电话交代任务1挂电话 → 打电话交代任务2挂电话 → 打电话交代任务3挂电话
- 方式二(粗化后):打电话一次交代完所有任务再挂电话
显然方式二更高效。频繁加锁解锁如果没有其他线程竞争,就是纯粹的浪费。
我的深刻理解 :synchronized背后有如此复杂的优化策略(偏向、自适应自旋、锁消除、锁粗化),体现了JVM团队的设计理念------让程序员哪怕对性能一无所知,也不至于写出特别慢的并发程序 。"傻瓜式"的安全保证是 synchronized存在的核心价值。
四、JUC工具包:并发编程的瑞士军刀
4.1 Callable接口与FutureTask
Callable和Runnable是相对的------都是描述一个"任务",但Callable描述的是带返回值 的任务,Runnable描述的是不带返回值的任务。
对比代码:计算1+2+3+...+1000
不用Callable(复杂、易出错):需要辅助类Result + wait/notify + 手动同步。
用Callable + FutureTask(简洁优雅):
java
Callable<Integer> callable = () -> {
int sum = 0;
for (int i = 1; i <= 1000; i++) sum += i;
return sum;
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
new Thread(futureTask).start();
System.out.println(futureTask.get()); // 阻塞等待结果
FutureTask的理解:想象去吃麻辣烫。餐点好后后厨开始做,同时前台给你一张"小票"------这个小票就是FutureTask。你可以随时凭小票查看麻辣烫做好了没。
4.2 ReentrantLock
可重入互斥锁,和synchronized定位类似,都是实现互斥效果保证线程安全。
基本用法:
java
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock(); // 必须在finally中释放!
}
ReentrantLock vs synchronized 全面对比:
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 本质 | JVM关键字(C++实现) | 标准库类(Java实现) |
| 释放锁 | 自动释放(离开同步块) | 必须手动unlock() |
| 获取锁失败 | 死等 | 支持tryLock超时放弃 |
| 公平性 | 非公平锁 | 默认非公平,可设公平 |
| 等待唤醒 | wait/notify(随机唤醒) | Condition(可精确唤醒指定线程) |
如何选择:
- 锁竞争不激烈:用
synchronized,效率更高,自动释放更方便 - 锁竞争激烈:用
ReentrantLock,tryLock更灵活 - 需要公平锁:用
ReentrantLock - 需要精确唤醒特定线程:用
ReentrantLock+Condition
4.3 原子类
原子类内部使用CAS实现,性能远高于加锁方案。常见类:
AtomicBoolean、AtomicInteger、AtomicIntegerArrayAtomicLong、AtomicReference、AtomicStampedReference
AtomicInteger常用方法:addAndGet(delta)、incrementAndGet()(++i)、getAndIncrement()(i++)、decrementAndGet()(--i)、getAndDecrement()(i--)。
4.4 线程池
虽然创建销毁线程比进程轻量,但频繁创建销毁仍然低效。线程池的核心思想:用空间换时间------线程用完不销毁,放到池子里复用。
Executors工具类提供的四种线程池:
| 方法 | 说明 | 适用场景 |
|---|---|---|
newFixedThreadPool(n) |
固定线程数 | 负载稳定的服务 |
newCachedThreadPool |
线程数动态增长 | 大量短期异步任务 |
newSingleThreadExecutor |
单线程 | 需要串行执行 |
newScheduledThreadPool |
支持延时/周期执行 | 定时任务 |
ThreadPoolExecutor构造参数------把线程池想象成开公司:
corePoolSize:正式员工数量(一旦录用永不辞退)maximumPoolSize:正式员工+临时工的最大数目keepAliveTime:临时工允许的空闲时间(超时辞退)unit:时间单位workQueue:任务队列(待处理任务暂存处)threadFactory:线程工厂RejectedExecutionHandler:拒绝策略(公司超负荷怎么办)
四种拒绝策略:
AbortPolicy:直接抛异常CallerRunsPolicy:让调用者自己执行DiscardOldestPolicy:丢弃队列中最老的任务DiscardPolicy:丢弃新来的任务
线程池工作流程:提交任务 → 核心线程未满则创建核心线程 → 核心线程满了入队列 → 队列满了创建临时工 → 临时工也满了触发拒绝策略。
4.5 信号量 Semaphore
信号量用来表示"可用资源的个数",本质上是一个计数器。
类比:停车场展示牌------当前有100个车位。车开进去-1(P操作),车开出来+1(V操作)。计数为0时新的车必须等待。
java
Semaphore semaphore = new Semaphore(4); // 4个可用资源
semaphore.acquire(); // P操作:申请资源(可能阻塞)
// 使用资源...
semaphore.release(); // V操作:释放资源
Semaphore的PV操作中加减计数器都是原子操作,天然线程安全。
应用场景:
- 限流:限制同时访问某资源的线程数
- 共享锁:允许N个线程同时持有锁
4.6 CountDownLatch
一句话:同时等待N个任务执行结束。
类比:跑步比赛------10个选手依次就位,哨声响同时出发;所有选手都通过终点,才能公布成绩。
java
CountDownLatch latch = new CountDownLatch(10);
// 每个任务完成时调用 latch.countDown()(计数器-1)
// 主线程 latch.await()(阻塞等待计数器归零)
五、线程安全的集合类
5.1 多线程环境使用List
原始集合类中只有Vector、Stack、Hashtable是线程安全的(但不建议使用)。在多线程环境下使用ArrayList有三种方案:
- 手动同步 :用
synchronized或ReentrantLock包裹 - Collections.synchronizedList():标准库提供的基于synchronized的线程安全List包装
- CopyOnWriteArrayList:写时复制
CopyOnWriteArrayList的核心思想:写时复制。往容器添加元素时,不直接添加,而是先复制出新容器,在新容器中修改,完成后将引用指向新容器。
优点 :读操作无需加锁,读多写少场景下性能很高。
缺点:
- 占用内存较多(复制了一份)
- 新写入的数据不能被第一时间读取到(读写分离的代价)
本质:这也是读写分离思想的体现------读和写在不同的容器上进行。
5.2 多线程环境使用队列(阻塞队列)
| 实现类 | 底层结构 | 特点 |
|---|---|---|
ArrayBlockingQueue |
数组 | 有界阻塞队列 |
LinkedBlockingQueue |
链表 | 可选有界/无界 |
PriorityBlockingQueue |
堆 | 带优先级 |
TransferQueue |
- | 最多只包含一个元素 |
阻塞队列在队列为空时take()阻塞等待,队列为满时put()阻塞等待------天然的线程同步机制。
5.3 多线程环境使用哈希表
Hashtable的三大性能问题:
- 直接锁整个Hashtable对象,所有线程都竞争同一把锁
- size属性也是通过synchronized控制,效率低
- 扩容由触发线程独立完成,涉及大量元素拷贝
ConcurrentHashMap(JDK 1.8)的优化:
- 读操作无锁 :使用
volatile保证可见性,只对写操作加锁 - 锁粒度细化:不是锁整个对象,而是"锁桶"------用每个链表头结点作为锁对象,大幅降低锁冲突概率
- 充分利用CAS:size属性通过CAS更新,避免重量级锁
- 优化扩容(化整为零):发现需要扩容的线程只需创建新数组并搬几个元素;后续每个来操作的线程都参与"搬家";搬完最后一个元素才删除老数组。期间插入只往新数组加,查找需要同时查新老数组
JDK 1.8的其他优化:
- 取消了分段锁(Segment),直接给每个哈希桶分配锁
- 数据结构从"数组+链表"升级为"数组+链表/红黑树"------链表长度≥8时转红黑树
Hashtable vs HashMap vs ConcurrentHashMap 对比:
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全 | 否 | 是 | 是 |
| key允许null | 是 | 否 | 否 |
| 锁机制 | 无 | synchronized锁整个对象 | synchronized锁链表头+CAS |
| 性能 | 单线程最快 | 多线程最差 | 多线程最优 |
六、死锁------并发编程的头号大敌
6.1 什么是死锁
死锁是多个线程同时被阻塞,它们中的一个或全部都在等待某个资源被释放。由于线程被无限期地阻塞,程序不可能正常终止。
生活类比:滑稽和女神去饺子馆。滑稽拿了酱油瓶,女神拿了醋瓶。滑稽说"你把醋给我我再把酱油给你",女神说"你把酱油给我我再把醋给你"。俩人互不相让------死锁!
哲学家就餐问题:五个哲学家围坐一桌,每人左右各一根筷子。所有人同时拿起左手筷子,再试图拿右手筷子------发现都被占用了,所有人都阻塞等待。
6.2 死锁产生的四个必要条件
- 互斥使用:资源被一个线程占有时,别的线程不能使用
- 不可抢占:资源请求者不能强制从占有者手中夺取资源
- 请求和保持:线程在请求新资源的同时保持对原有资源的占有
- 循环等待:存在等待环路------P1等P2的资源,P2等P3的资源,P3等P1的资源
四个条件同时满足才会产生死锁。打破其中任何一个,死锁就不会发生。
6.3 如何避免死锁
最常用、最实用的方法是破坏"循环等待"条件------锁排序:
假设有N个线程尝试获取M把锁,对M把锁进行编号(1, 2, 3...M),所有线程都按照编号从小到大的顺序获取锁,就不会产生环路等待。
可能产生死锁的代码:
java
// 线程1: 先lock1再lock2
synchronized (lock1) {
synchronized (lock2) { /* ... */ }
}
// 线程2: 先lock2再lock1 ← 顺序不一致,可能死锁
synchronized (lock2) {
synchronized (lock1) { /* ... */ }
}
不会产生死锁的代码:
java
// 所有线程统一顺序:先lock1再lock2
synchronized (lock1) {
synchronized (lock2) { /* ... */ }
}
我的深刻理解 :死锁的预防策略本质上是在便利性 和安全性之间做取舍。锁排序是最简单的方案,但它要求开发者全局了解所有锁的依赖关系。实际工程中,还可以用超时获取锁(tryLock)来打破"不可抢占"条件,用一次性申请所有锁来打破"请求和保持"条件。选择哪种方案取决于系统的复杂度和对响应性的要求。
七、高频面试要点汇总
7.1 volatile关键字
volatile保证内存可见性:强制从主内存读取数据。如果其他线程修改了被volatile修饰的变量,当前线程可以第一时间读取到最新值。注意:volatile不保证原子性。
7.2 Java线程的六种状态
| 状态 | 说明 |
|---|---|
| NEW | 新创建,还没调用start |
| RUNNABLE | 可运行(正在CPU上运行或准备运行) |
| BLOCKED | synchronized获取锁失败,阻塞等待 |
| WAITING | 调用wait()后等待被唤醒 |
| TIMED_WAITING | 调用sleep(n)或wait(n),超时等待 |
| TERMINATED | run方法执行完毕,线程终止 |
7.3 进程和线程的区别
- 进程包含线程,每个进程至少有一个主线程
- 进程间不共享内存空间,同进程内的线程共享同一内存空间
- 进程是资源分配的最小单位,线程是CPU调度的最小单位
7.4 多线程下对一个数进行叠加
- 使用
synchronized/ReentrantLock加锁 - 使用
AtomicInteger原子操作(推荐,CAS实现,性能更高)
7.5 Servlet是否线程安全
Servlet本身工作在多线程环境下。如果在Servlet中创建了成员变量,多个请求到达服务器时多线程操作,可能出现线程不安全的情况。最佳实践:Servlet不使用成员变量,或者将成员变量设为线程安全类型。
总结
回顾整个多线程进阶的知识体系,我想分享三个层面的理解:
第一,从"是什么"到"为什么" :不仅要记住synchronized是可重入的非公平锁,更要理解JVM为什么这样设计------因为"默认安全、默认高效"是对开发者最友好的选择。不仅要会用CAS,更要理解它为什么需要CPU硬件支持。
第二,没有银弹,只有权衡:乐观锁vs悲观锁、公平锁vs非公平锁、细粒度锁vs粗粒度锁------每一个选择背后都是"正确性"与"性能"、"公平性"与"吞吐量"、"便利性"与"灵活性"之间的权衡。优秀的并发设计不是选"最好的",而是选"最合适的"。
第三,分层抽象的力量:从CPU原子指令到OS mutex,到JVM synchronized,到JUC工具包,再到线程安全集合------每一层都在向上提供更高层次的抽象,同时向下隐藏实现的复杂性。理解这个分层结构,才能在出问题时精准定位是哪一层的坑,在优化时知道往哪一层发力。
并发编程的学习是一个从"知道怎么做"到"理解为什么这样做"再到"知道什么时候该换一种做法"的渐进过程。希望本文能帮助你构建起多线程进阶的完整知识地图。