Java(七):7.0 Java并发编程(上)
Java(七):7.0 Java并发编程(上)
Prorise7.0 Java并发编程
欢迎来到Java并发编程的世界。在正式踏上这段旅程之前,我们首先需要回答一个根本性的问题:在已经能够编写出功能完善的单线程程序的情况下,我们为什么还要投入精力去学习和使用看似复杂得多的并发编程呢,注意,该案例里的所有代码都不建议手敲,我们学过的Hutool中有提供并发相关的方法,我们会着重讲解那部分的语法点,对于前面的知识,我们可以理解里面的API,以后再来查阅即可!
7.1 [基础] 并发编程入门
7.1.1 为什么需要并发编程?
简单来说,驱动我们使用并发编程的根本原因,是对效率的极致追求。
充分利用硬件性能
随着硬件技术的飞速发展,现代计算机早已进入了多核CPU
时代。这意味着我们的设备拥有多个能够独立执行计算任务的“大脑”。如果程序依旧是传统的单线程模型,那么在任何时刻,它最多只能利用其中一个“大脑”。无论硬件多么强大,其他的核心都将处于空闲状态,这无疑是对计算资源的巨大浪费。
并发编程允许我们将一个大任务拆分成多个子任务,并将这些子任务交给不同的线程,这些线程可以被操作系统调度到不同的CPU
核心上实现真正的并行执行。
对于计算密集的任务(例如,大规模数据分析、图像渲染),并发能够成倍地缩短处理时间,带来性能的质变。
优化程序响应能力
程序在运行过程中,并非所有时间都在进行计算。很多时候,线程需要等待某些操作完成,例如读取磁盘文件、调用网络接口、等待数据库返回结果等。这些操作统称为I/O
操作。
在单线程模型中,一旦线程发起I/O
请求,它就会进入阻塞
状态,必须等到操作完成后才能继续。如果这是一个图形界面的主线程,那么在阻塞期间,整个应用界面会卡住,无法响应用户操作,体验将非常糟糕。
并发编程可以完美解决这个问题。当一个线程因为I/O
操作而阻塞时,CPU
可以立即切换去执行其他线程,从而让应用保持流畅。
在需要与用户交互或处理网络请求的场景下,并发能够避免因等待而产生的“假死”现象,极大地提升应用的响应速度和吞吐量。
总而言之,我们学习和使用并发编程,并非为了炫技,而是解决实际工程问题的必要手段。它是通往高性能、高响应能力软件架构的必经之路。
7.1.2 [基础] 核心概念辨析
在深入学习之前,理清并发世界中的几个基础概念至关重要,它们是我们后续讨论的基石。
进程与线程
我们可以将进程
想象成一个正在运行的应用程序,比如你打开的音乐播放器或浏览器。操作系统会为每个进程
分配一块独立的内存空间,它们之间的数据默认是隔离的,互不干扰。
而线程
则是进程
内部的一条执行路径。一个进程
可以包含一个或多个线程
,它们共享进程
的内存资源(如代码、数据)。可以把进程
比作一个工厂,而线程
就是工厂里的流水线,多条流水线可以同时工作,共享工厂的资源来完成生产任务。
[面试题]
进程
是操作系统进行资源分配的基本单位,而线程
是CPU
进行任务调度的基本单位。一个进程
至少拥有一个线程,即主线程。
并发与并行
这两个词在日常交流中经常被混用,但在技术领域它们有明确的区别。
并发 :指的是在一个时间段内,多个任务都在向前推进。在单核
CPU
上,操作系统通过快速地在多个任务之间切换来实现并发,宏观上看,感觉像是所有任务在同时执行,但微观上任意时刻只有一个任务在被执行。这好比一个人在同时做饭、接电话和看孩子,他需要在几件事情之间不停切换。并行:指的是在同一时刻,有多个任务在真地同时执行。这必须依赖多核
CPU
才能实现,每个核心在同一瞬间处理一个独立的任务。这好比一个团队里有多个人,每个人都在同时炒自己面前的一盘菜。
并行是并发的一种更理想、更高效的实现形式。并发是逻辑层面的概念,而并行是物理层面的实现。我们的目标是编写并发程序,并利用多核硬件让它尽可能地并行执行。
同步与异步
这两个概念描述的是方法调用的行为模式。
- 同步:同步调用就像去餐厅点餐,你点完菜后,必须在座位上一直等待,直到服务员把菜端上来,你才能去做别的事情(比如吃饭)。
- 在程序中,一个同步方法调用,调用方必须等待该方法执行完毕并返回结果,才能继续执行下一步。
- 异步:异步调用则像是点外卖,你下完单就可以立刻去做别的事情了,比如看电视、打游戏。外卖送到后,你会收到一个通知(比如电话或门铃)
- 在程序中,一个异步方法调用会立即返回,调用方无需等待方法执行完成,可以在未来的某个时刻通过回调函数、状态查询或通知等方式获取结果。
7.1.3 [基础] 线程的生命周期与状态
一个线程从被创建到最终消亡,会经历一系列不同的状态,理解这些状态是诊断和调试并发问题的基础。在Java中,线程的生命周期被明确地定义为六种状态,它们都封装在Thread.State
这个枚举类中。
线程的六种状态
新建 (NEW)
当一个Thread
对象被创建出来,但还没有调用其start()
方法时,它就处于新建状态。此时,它仅仅是一个普通的Java对象,操作系统还没有为它分配任何线程资源。可运行 (RUNNABLE)
这是一个复合状态。一旦我们调用了线程的start()
方法,它就进入了可运行状态。此时,它可能正在CPU
上执行,也可能正在等待操作系统的CPU
时间片分配。Java虚拟机将这两种情况(Ready
和Running
)统一归为RUNNABLE
状态。阻塞 (BLOCKED)
当一个线程试图进入一个synchronized
同步代码块或方法,但该代码块的锁已经被其他线程持有时,该线程就会进入阻塞状态。它会暂停执行,直到成功获取到锁,才会转为可运行
状态。等待 (WAITING)
当线程调用了没有设置超时时间的Object.wait()
方法、Thread.join()
方法或LockSupport.park()
方法时,会进入该状态。处于等待状态的线程需要等待一个明确的外部信号才能被唤醒。例如,另一个线程调用了Object.notify()
或Object.notifyAll()
。计时等待 (TIMED_WAITING)
与等待
状态类似,但这个状态是有时间限制的。当线程调用了带有超时参数的方法,如Thread.sleep(long millis)
、Object.wait(long timeout)
或Thread.join(long millis)
时,就会进入此状态。线程会等待指定的时间,时间结束后会自动唤醒,或者在等待期间被其他线程提前唤醒。终止 (TERMINATED)
当线程的run()
方法执行完毕,或者因未捕获的异常而退出时,线程就进入了终止状态。这标志着线程的整个生命周期已经结束。
核心要点:线程的状态反映了它在特定时刻的活动情况。通过
Thread.getState()
方法,我们可以获取到线程的当前状态,这对于监控和分析多线程程序的行为非常有帮助。例如,如果发现大量线程处于阻塞
状态,可能意味着存在严重的锁竞争。
7.1.4 [基础] 创建与管理线程
了解了线程的状态后,我们来看看如何实际地创建并控制一个线程。
两种核心方式的对比
在Java中,我们主要通过两种方式来定义一个线程所要执行的任务。
实现
Runnable
接口
这是最推荐的方式。我们创建一个类并实现Runnable
接口,然后重写其run()
方法。这个Runnable
实例代表了一个“任务”,它可以被提交给一个Thread
对象来执行。继承
Thread
类
我们也可以创建一个类并直接继承Thread
类,然后重写其run()
方法。
[面试题] 为什么推荐使用实现
Runnable
接口的方式?
- 解耦:实现
Runnable
接口的方式将“任务”(Runnable
)与“执行者”(Thread
)分离开来,结构更清晰。一个任务可以被多个不同的线程执行。- 避免单继承局限:Java类只支持单继承。如果我们的类继承了
Thread
,就无法再继承其他任何类,这在复杂的系统设计中限制了扩展性。实现接口则没有这个限制。- 资源共享:多个线程可以共享同一个
Runnable
实例,从而方便地共享数据。
线程常用操作方法解析
下表简要概述了几个最核心的线程操作方法,以便快速查阅。
方法 | 核心作用 | 简要说明 |
---|---|---|
start() | 启动新线程 | 异步执行run() 方法,这是正确的启动方式。 |
run() | 定义任务逻辑 | 直接调用等于普通方法,不会创建新线程。 |
sleep(long) | 暂停当前线程 | 静态方法,暂停期间不释放锁。 |
join() | 等待目标线程结束 | 调用t.join() 会使当前线程等待t 线程执行完毕。 |
interrupt() | 发送中断信号 | 协作式中断,设置中断标志位或抛出InterruptedException 。 |
interrupt()
机制取代了已被废弃的stop()
方法,因为stop()
方法会立即终止线程并释放所有锁,这可能导致对象状态不一致,是极其不安全的。
1 | package com.example; |
守护线程
在Java中,线程分为用户线程和守护线程。守护线程是一种特殊的线程,其使命是为其他用户线程服务,典型的例子是Java垃圾回收(GC
)线程。
核心特性:当一个Java虚拟机中不存在任何存活的用户线程时,虚拟机就会退出。这意味着,如果只剩下守护线程,它们会被立即终止,无论其任务是否执行完毕。我们可以通过
thread.setDaemon(true)
方法在线程启动前将其设置为守护线程。
1 | package com.example; |
7.2 [核心] 线程安全与同步控制
Java并发安全问题的根源,均可归结于JMM(Java内存模型)在屏蔽硬件底层差异时,未能天然保证的三大特性。理解它们是掌握并发编程的前提。
7.2.1 [核心] 线程安全问题的根源
三大特性速查表
为了快速定位和理解并发问题,我们可以将这三大特性总结如下:
特性 | 核心定义 | 问题的根源 | 典型场景 / 面试题 |
---|---|---|---|
原子性 | 操作不可分割,要么全做完,要么不做。 | 复合操作(如 i++ )在CPU层面由多条指令构成。 | i++ 在多线程下的计数错误。 |
**可见性 ** | 一个线程对共享变量的修改,对其他线程立即可见。 | CPU多级缓存导致各线程工作内存数据不一致。 | while(!stop) 循环中,stop 标志的修改无法被读取线程看到。 |
有序性 | 程序按代码的书写顺序执行。 | 编译器和处理器的指令重排序优化。 | 双重检查锁定(DCL)单例模式中获取到半初始化的对象。 |
特性详解与代码示例
下面我们结合代码,对每个特性进行深入剖析。
1. 原子性
[高频面试点] 讲解一下
i++
的线程不安全问题。
- 代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13// 示例:一个非线程安全的计数器
public class UnsafeCounter {
private int count = 0;
// 该方法在多线程环境下调用会产生问题
public void increment() {
count++; // 这是一个典型的非原子操作
}
public int getCount() {
return count;
}
} - 讲解:
count++
看似一步,实则在底层包含了读取-修改-写入
三个独立的步骤。- 问题: 在多线程环境下,两个线程可能同时读取到旧值(例如
5
),各自计算得到6
,然后先后将6
写回。我们期望的结果是7
,但实际结果却是6
,造成了数据更新的丢失。
2. 可见性
[高频面试点] 什么是可见性问题?举例说明。
- 代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// 示例:一个可能无法停止的线程
public class VisibilityProblem {
// 若没有可见性保障,stop的修改对子线程可能永远不可见
private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
int i = 0;
// 子线程从自己的工作内存中读取stop,可能一直是false
while (!stop) {
i++;
}
System.out.println("Loop finished, i = " + i);
}).start();
Thread.sleep(1000); // 确保子线程已启动并运行
stop = true; // 主线程修改stop,但子线程可能看不到
System.out.println("Main thread set stop to true.");
}
} - 讲解:
- 为了提高效率,执行循环的子线程可能会将
stop
变量的值false
缓存到自己的高速工作内存中。 - 问题: 即使主线程在
1
秒后将主内存中的stop
修改为true
,子线程也可能因为一直从自己的缓存读取数据而无法感知到这一变化,从而导致无限循环。
- 为了提高效率,执行循环的子线程可能会将
3. 有序性
[高频面试点] 请解释指令重排序,并说明它如何导致DCL(双重检查锁定)单例模式失效。
- 代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 示例:线程不安全的双重检查锁定(DCL)
public class UnsafeDCLSingleton {
// instance 必须使用 volatile 修饰才能保证线程安全
private static UnsafeDCLSingleton instance;
private UnsafeDCLSingleton() {} // 私有构造
public static UnsafeDCLSingleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (UnsafeDCLSingleton.class) {
if (instance == null) { // 第二次检查
// 问题根源:该操作可能被重排序
instance = new UnsafeDCLSingleton();
}
}
}
return instance;
}
} - 讲解:
instance = new UnsafeDCLSingleton()
并非原子操作,它大致可分为三步:- A. 为对象分配内存空间。
- B. 调用构造函数,初始化对象。
- C. 将
instance
引用指向分配的内存地址。
- 正常的执行顺序是 A -> B -> C。但由于指令重排序,实际执行顺序可能是 A -> C -> B。
- 问题: 如果线程T1按 A->C->B 顺序执行,在完成C(
instance
引用已不为null
)但还未执行B(对象未初始化)时,线程T2调用getInstance()
。T2会发现instance
不为null
,从而直接返回一个尚未初始化的“半成品”对象,后续使用将引发严重错误。
7.2.2 [高频] synchronized 关键字
synchronized
是Java提供的内置锁机制,它是一种悲观锁、可重入锁。其核心目标是为代码块或方法提供互斥访问,从而一次性解决原子性、可见性和有序性问题。
**悲观锁:**悲观锁认为并发访问总会发生冲突,因此在访问资源前就加锁,阻止其他线程访问,直到锁被释放。
可重入:可重入是指函数在执行过程中,允许被同一个线程再次调用而不会产生副作用或数据损坏,就像从未被调用过一样。
核心用法与锁对象(Monitor)
正确使用synchronized
的关键在于理解**“锁住的是哪个对象”**。不同的使用方式,其锁定的对象(即Monitor)也不同。
使用方式 (Usage) | 锁定的对象 (Locked Object / Monitor) | 讲解 |
---|---|---|
修饰实例方法 | 当前类的实例对象 (this ) | 当一个线程进入该方法时,它锁定了这个对象实例。其他线程无法同时访问该实例的任何其他synchronized 方法。 |
修饰静态方法 | 当前类的 Class 对象 (Xxx.class ) | 它锁定的是整个类,这是一个全局锁。无论有多少个实例,任何线程进入该方法都会阻止其他线程进入这个类的任何synchronized 静态方法。 |
修饰代码块 | 括号内 () 指定的任意对象 | 这是最灵活的方式,可以精确控制锁的范围和粒度。锁定的就是括号里指定的那个对象实例。 |
代码示例与场景分析
1. 修饰实例方法
- 代码示例:
1
2
3
4
5
6
7
8
9
10// 锁住的是 bankAccount 这个实例
public class BankAccount {
private double balance;
// 锁是 this,即 BankAccount 的实例对象
public synchronized void deposit(double amount) {
// 这里的操作是线程安全的
balance += amount;
}
} - 场景分析: 如果有两个线程同时操作
accountA.deposit(100)
,它们会因为争抢accountA
这个对象的锁而串行执行。但如果一个线程操作accountA.deposit(100)
,另一个线程操作accountB.deposit(100)
,它们之间不会有任何影响,因为它们获取的是不同实例(accountA
和accountB
)的锁。
2. 修饰静态方法
- 代码示例:
1
2
3
4
5
6
7// 锁住的是整个 Logger class
public class Logger {
// 锁是 Logger.class 这个 Class 对象
public static synchronized void log(String message) {
// ... 写入日志文件
}
} - 场景分析: 无论有多少个地方调用
Logger.log("...")
,由于锁是Logger.class
这个全局唯一的对象,所有调用都会串行执行,确保了日志写入操作不会交叉混乱。
3. 修饰代码块
- 代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public class FineGrainedLock {
// 推荐做法:使用一个私有的、final的、不可变的对象作为锁
private final Object lock = new Object();
public void performAction() {
// ... 非线程安全的代码 ...
// 只对关键部分加锁,减小锁的粒度,提高性能
synchronized (lock) {
// 这里的操作是线程安全的
}
// ... 其他非线程安全的代码 ...
}
} - 场景分析: 这是推荐的用法,因为它将锁的范围限制在最小的必要代码片段上。使用一个专门的
lock
对象(而不是this
)可以避免外部代码无意中获取了this
的锁而导致死锁或性能问题,这是一种良好的封装实践。
[高频面试点] 深入理解 synchronized
Q1:
synchronized
是可重入锁吗?如何实现的?A: 是的,它是可重入的。
- 表现: 一个线程在持有锁的情况下,可以自由进入由同一个锁保护的其他同步代码区,不会自己把自己锁死。
- 实现原理: 每个对象监视器(Monitor)内部都有一个计数器。当一个线程首次获取锁时,计数器变为1,此后该线程每重入一次,计数器就加1。每次退出同步代码区,计数器减1。当计数器归零时,锁被完全释放。
Q2:
synchronized
的底层是如何实现的?A: 它依赖于Java对象头和对象的监视器(Monitor)实现。
- 同步代码块: JVM通过编译后的字节码指令
monitorenter
和monitorexit
来支持。当执行monitorenter
时,线程尝试获取对象的Monitor所有权。monitorexit
则释放所有权。- 同步方法: 方法的实现则更为隐蔽,它依赖于方法访问标志位
ACC_SYNCHRONIZED
。当JVM调用一个有此标志的方法时,会自动执行获取锁、执行方法体、释放锁的操作。- 锁优化 (Java 6+): 为了提升性能,
synchronized
引入了锁升级机制,演进路径为:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。锁会根据竞争情况自动升级,但通常不能降级。这使得synchronized
在无竞争或低竞争场景下的性能得到极大提升。
7.2.3 [高频] volatile 关键字
如果说 synchronized
是重量级的“保险箱”,那么 volatile
就是轻量级的“公示牌”。它是一个变量修饰符,不提供互斥锁定,但能以更低的成本确保多线程环境下的可见性和有序性。
核心要点: volatile
不保证原子性。这是理解和使用它的关键前提。
两大核心作用
1. 保证可见性
- 实现原理: 当一个线程写入
volatile
变量时,JMM会强制将该线程工作内存中的值立即刷新回主内存。当另一个线程读取volatile
变量时,JMM会强制其使本地工作内存的缓存失效,并从主内存中重新加载最新值。 - 效果: 通过这种机制,
volatile
变量的任何修改,都能对其他线程立即可见,有效解决了CPU缓存导致的数据不一致问题。
2. 禁止指令重排
- 实现原理: JMM会为
volatile
变量的读写操作插入特定的内存屏障。- 在写操作前插入屏障,保证之前的任何操作都已完成。
- 在写操作后插入屏障,保证其结果对其他线程可见。
- 在读操作前插入屏障,保证本地缓存失效,从主存读取。
- 在读操作后插入屏障,保证后续操作能看到
volatile
变量的值。
- 效果: 这些屏障确保了
volatile
变量相关的操作不会被编译器或处理器随意重排序,从而维护了多线程环境下的程序逻辑。最经典的应用就是修复了双重检查锁定(DCL)单例模式的隐患。
[高频面试点] volatile vs. synchronized
这是Java并发面试中最经典的问题之一。下表清晰地展示了它们的区别:
对比维度 | volatile | synchronized |
---|---|---|
作用级别 | 变量级别(Variable Level) | 代码块 / 方法级别(Block / Method Level) |
保证的特性 | ✅ 可见性<br>✅ 有序性<br>❌ 不保证原子性 | ✅ 原子性<br>✅ 可见性<br>✅ 有序性 |
是否阻塞 | 否,是一种非阻塞的同步机制 | 是,未获取到锁的线程会进入阻塞状态 |
底层实现 | 内存屏障(Memory Barrier) | 对象监视器(Monitor),涉及锁的获取与释放 |
性能开销 | 较低,不涉及线程上下文切换 | 较高,涉及锁竞争、上下文切换和调度 |
适用场景 | 读多写少,且写入不依赖变量原值的场景 | 需要保证原子性的复合操作场景 |
适用场景与避坑指南
Q: 既然
volatile
这么轻量,为什么不都用它呢?因为它不保证原子性,解释一下?A:
volatile
的“可见性”仅保证你每次拿到的都是最新值,但无法保护“读取-修改-写入”这个完整的过程不被打断。
- 代码示例:
1
2
3
4
5
6
7
8public class VolatileAtomicityTest {
// 使用 volatile 也无法保证 ++ 操作的原子性
public volatile int count = 0;
public void increment() {
count++;
}
} - 避坑指南:
- 线程A读取
volatile count
的值为5
。 - 此时线程B也来读取
volatile count
的值,由于可见性保证,它也读到5
。 - 线程A在自己的工作内存中执行
+1
操作,得到6
,并立即写回主存。 - 线程B也在自己的工作内存中执行
+1
操作,得到6
,并立即写回主存。 - 最终结果: 主存中的
count
值为6
,而不是期望的7
。volatile
确保了每次读/写都是最新的,但无法阻止两个线程基于同一个旧值进行计算。
- 线程A读取
正确的应用场景:
状态标志位: 当一个线程修改状态,而其他线程依赖此状态来决定是否继续执行时,这是
volatile
的完美应用。1
2volatile boolean shutdownRequested;
public void shutdown()
public void doWork() {
while (!shutdownRequested) {
// ... do stuff
}
}
1
2
3
4
5
2. **双重检查锁定(DCL)**: 在单例模式中,为 `instance` 变量加上 `volatile` 是必不可少的,它可以防止因指令重排而导致其他线程获取到未完全初始化的对象。
```java
// 正确的DCL实现
private static volatile Singleton instance;
7.2.4 [进阶] JUC 锁机制:Lock 接口
synchronized
关键字虽然简单易用,但其功能相对单一,无法满足所有复杂的并发场景。为此,JUC提供了一套全新的锁机制——Lock
接口,它将锁的获取和释放操作对象化,提供了比synchronized
更丰富的控制能力。
核心实现:ReentrantLock
ReentrantLock
是 Lock
接口最主要的实现类,它提供了与 synchronized
相同的可重入性和互斥性,但功能更为强大。
[避坑指南] 标准使用范式
使用Lock
必须手动释放锁,且为了保证在任何情况下(即使发生异常)锁都能被释放,必须将unlock()
操作放在finally
块中。这是铁律。代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private final Lock lock = new ReentrantLock();
public void performAction() {
// 1. 获取锁
lock.lock();
try {
// 2. 将需要同步控制的代码放入 try 块
System.out.println(Thread.currentThread().getName() + " 获取了锁,执行关键操作...");
Thread.sleep(500); // 模拟业务耗时
} catch (InterruptedException e) {
// 处理中断
} finally {
// 3. 必须在 finally 块中释放锁
lock.unlock();
System.out.println(Thread.currentThread().getName() + " 释放了锁。");
}
}
}
ReentrantLock 的高级特性
ReentrantLock
之所以被称为 synchronized
的增强版,在于其提供了以下独有功能:
- 可中断获取锁 (
lockInterruptibly
): 线程在等待获取锁的过程中,可以响应中断信号,从而避免长时间无意义的等待。而等待synchronized
锁的线程是无法被中断的。 - 可超时获取锁 (
tryLock
):tryLock(long time, TimeUnit unit)
允许线程在指定时间内尝试获取锁,如果超时仍未获取到,则会返回false
,线程可以继续执行其他逻辑,这在处理死锁问题时非常有用。 - 公平性选择 (Fairness):
ReentrantLock
的构造函数new ReentrantLock(boolean fair)
允许我们创建公平锁或非公平锁。- 公平锁: 严格按照线程请求的先后顺序(FIFO)分配锁。优点是不会产生饥饿现象,缺点是吞吐量较低。
- 非公平锁 (默认): 允许新来的线程“插队”,直接尝试获取锁。优点是吞吐量更高,缺点是可能导致某些线程长时间获取不到锁(饥饿)。
synchronized
关键字则一直是非公平的。
读写锁:ReadWriteLock
在“读多写少”的场景下,如果依然使用ReentrantLock
或synchronized
这样的排他锁,会让所有读操作也串行执行,极大地限制了并发性能。ReadWriteLock
正是为了解决这个问题而生。
- 核心思想: 读写分离。
- 读锁(共享锁): 多
个线程可以同时
持有读锁,并发读取数据。 - 写锁(排他锁): 一次只能有一个线程持有写锁,且在持有期间,其他任何读、写线程都必须等待。
- 读锁(共享锁): 多
- 代码示例 (
ReentrantReadWriteLock
是其标准实现):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
// 使用读写锁实现一个线程安全的缓存
public class Cache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock rLock = rwLock.readLock(); // 获取读锁
private final Lock wLock = rwLock.writeLock(); // 获取写锁
public V get(K key) {
rLock.lock(); // 加读锁,允许多个读线程并发
try {
return map.get(key);
} finally {
rLock.unlock();
}
}
public void put(K key, V value) {
wLock.lock(); // 加写锁,一次只允许一个写线程
try {
map.put(key, value);
} finally {
wLock.unlock();
}
}
}
[高频][面试题] ReentrantLock 与 synchronized 的全方位对比
对比维度 | ReentrantLock (JUC Lock) | synchronized (JVM Keyword) |
---|---|---|
实现层面 | JDK 层面实现的 API | JVM 内置的 关键字 |
锁的释放 | 必须手动在 finally 块中调用unlock() 释放 | JVM 自动释放(代码块执行完毕或异常退出) |
功能特性 | 功能丰富:可中断、可超时、可选择公平性、可绑定多个Condition | 功能单一:仅有非公平的可重入锁 |
性能 | Java 6后两者性能基本持平,官方更推荐synchronized | Java 6后经过锁升级等大量优化,性能不再是短板 |
使用便利性 | 相对复杂,需要try-finally 模板代码 | 简单直观,不易出错 |
底层原理 | 基于 AQS (AbstractQueuedSynchronizer) 框架 | 基于对象头的 Monitor 和 monitorenter /exit 指令 |
总结: 在现代Java开发中,synchronized
因其简单、不易出错且性能优异,依然是绝大多数场景下的首选。只有在需要ReentrantLock
提供的高级特性(如可中断、超时、公平锁)时,才考虑使用它。
7.2.5 [进阶] 原子类(Atomic Classes)
当我们需要对单个变量进行线程安全的操作时,使用synchronized
或ReentrantLock
虽然可行,但显得过于“重”,因为它们会引起线程的阻塞和唤醒,开销较大。为此,JUC提供了一套“无锁”的解决方案——原子类,它们位于java.util.concurrent.atomic
包下。
无锁编程的利器:CAS (Compare-And-Swap)
原子类的神奇之处在于,它们不使用锁,却能实现线程安全,其背后的核心技术就是CAS(比较并交换)。
- CAS思想: 这是一种乐观锁的实现。它假设在操作期间数据不会被其他线程修改,因此不会加锁。在真正要更新数据时,它会执行以下三个步骤:
- 读取内存中的当前值 V。
2. 计算出要更新的新值 N。
3. 在写入新值前,再次读取内存中的值,检查它是否依然是V。如果是,说明没有其他线程修改过,就将值更新为N。如果不是,说明在此期间数据已被修改,本次更新失败,然后通常会进行重试(自旋),直到成功为止。
- 硬件支持: CAS操作并非由Java实现,而是依赖于CPU提供的一条原子指令(如
cmpxchg
),这保证了“比较并交换”这个过程本身是不可分割的,从而实现了高效的无锁并发控制。
常用原子类一览
原子类 (Atomic Class) | 作用 | 常用方法 |
---|---|---|
AtomicInteger | 以原子方式更新 int 值 | get() , set() , incrementAndGet() , getAndIncrement() , compareAndSet() |
AtomicLong | 以原子方式更新 long 值 | 与AtomicInteger 类似 |
AtomicBoolean | 以原子方式更新 boolean 值 | get() , set() , compareAndSet() |
AtomicReference<V> | 以原子方式更新对象引用 | get() , set() , compareAndSet() |
代码示例:原子计数器
让我们用AtomicInteger
来重写之前那个线程不安全的计数器。
1 | import java.util.concurrent.atomic.AtomicInteger; |
- 讲解:
count.incrementAndGet()
方法内部封装了CAS操作。它会循环尝试“获取当前值,加1,然后比较并设置新值”,直到成功为止。整个过程没有使用任何锁,但却能保证在多线程环境下计数的准确性,性能远高于使用synchronized
的版本。
[高频面试点] CAS 的 ABA 问题
Q: CAS 有什么缺点?什么是 ABA 问题?
A: CAS 的主要缺点是 ABA 问题。
- 定义: 一个变量的值原来是A,被另一个线程改成了B,然后又被改回了A。CAS检查时会发现它的值仍然是A,于是认为它没有被修改过,从而执行更新操作。但在某些业务场景下,这个“失而复得”的过程可能已经破坏了数据的一致性。
- 比喻: 你去取款机取钱,余额是100元。你操作时卡了一下,此时你老婆用手机银行给你转了50元,余额变为150元,但她马上又消费了50元,余额又变回100元。当你恢复操作时,CAS检查发现余额还是100元,就认为没问题,继续你的取款操作。但实际上,账户的流水已经发生了变化。
Q: 如何解决 ABA 问题?
A: JUC 提供了
AtomicStampedReference
类来解决。
- 原理: 它在CAS的基础上,额外增加了一个版本号(Stamp)。每次变量更新时,不仅要比较值,还要比较版本号。当值被修改时,版本号也会随之改变。这样,即使值从A变B再变回A,版本号也已经不同了,CAS操作就会失败。
1
2
3
4 // AtomicStampedReference<V> 构造时需要一个初始引用和一个初始版本号
AtomicStampedReference<String> stampedRef = new AtomicStampedReference<>("A", 1);
// 比较并设置时,需要同时提供期望的引用、新引用、期望的版本号、新版本号
boolean success = stampedRef.compareAndSet("A", "B", 1, 2);
7.2.6 [高频] ThreadLocal 的妙用与陷阱
ThreadLocal
提供了一种与众不同的解决并发问题思路。它不加锁,而是通过为每个线程提供一个独立的变量副本,来实现数据的线程隔离。这是一种“以空间换时间”的策略:为每个线程都分配一块内存,从而避免了因共享资源而产生的同步等待时间。
核心思想与底层原理
- 一句话概括:
ThreadLocal
使得访问某个变量的每个线程都有自己的、独立的初始值,线程之间互不影响。 - 底层原理:
- 每个
Thread
对象内部都有一个名为threadLocals
的成员变量,它的类型是ThreadLocal.ThreadLocalMap
。 - 当我们调用
threadLocal.set(value)
时,实际上是获取当前线程的ThreadLocalMap
,然后以**threadLocal
对象自身为Key**,以**value
为Value**,存入这个Map中。 - 调用
threadLocal.get()
时,也是先获取当前线程的ThreadLocalMap
,再以threadLocal
对象为Key,从中取出对应的Value。 - 关键: 数据实际上是存储在线程自己的
Map
里,ThreadLocal
对象只是一个用来从中存取数据的“钥匙”。
- 每个
妙用:实际应用场景
数据库连接管理: 在Web应用中,一个请求通常由一个线程处理。我们可以将该请求生命周期内使用的数据库连接存入
ThreadLocal
,这样该线程中的所有方法(无论调用链路多深)都能方便地获取到同一个连接,避免了频繁创建销毁连接的开销,也便于事务控制。用户身份信息传递: 在处理用户请求时,可以将用户信息(如User对象)存入
ThreadLocal
。这样,业务逻辑的各个层面(Controller, Service, DAO)都能直接获取当前用户,无需在方法参数中层层传递。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class UserContextHolder {
// 创建一个ThreadLocal来持有用户信息
private static final ThreadLocal<User> holder = new ThreadLocal<>();
public static void setUser(User user) {
holder.set(user);
}
public static User getUser() {
return holder.get();
}
public static void clear() {
holder.remove();
}
}保障线程不安全的工具类:
SimpleDateFormat
是一个典型的线程不安全类。通过ThreadLocal
为每个线程创建一个独立的实例,就可以在不加锁的情况下安全地使用它。(注:Java 8及以后版本,推荐使用线程安全的java.time.format.DateTimeFormatter
)。
[高频][面试题] ThreadLocal 内存泄漏问题
这是ThreadLocal
最核心,也是最高频的面试题。
Q: ThreadLocal 为什么会发生内存泄漏?
A: 这个问题的根源在于
ThreadLocalMap
的特殊设计和线程池的生命周期。
- 弱引用作Key:
ThreadLocalMap
中的每个Entry
,其Key(即ThreadLocal
对象)是一个弱引用(WeakReference),而其Value是强引用。- Key被回收: 当外部不再有对
ThreadLocal
对象的强引用时(例如myThreadLocal = null
),在下一次GC时,这个弱引用的Key就会被回收,导致ThreadLocalMap
中出现Key为null
的Entry
。- Value无法回收: 尽管Key变成了
null
,但这个Entry
中的Value仍然被Entry
对象本身强引用着,而Entry
又被ThreadLocalMap
强引用,ThreadLocalMap
又被线程对象强引用。- 内存泄漏: 如果这个线程是一个长生命周期的线程(比如线程池中的核心线程),它会一直存活。那么这个Key为
null
、但Value不为null
的Entry
就会一直存在于线程的Map
中,无法被GC回收,从而造成内存泄漏。
避坑指南:如何正确使用 ThreadLocal
Q: 如何防止 ThreadLocal 内存泄漏?
A: 核心原则是:确保在线程结束前,手动清理掉
ThreadLocal
变量。最佳实践: 在使用完
ThreadLocal
后,务必在finally
块中调用其remove()
方法。
- 正确使用范式:
1
2
3
4
5
6
7
8
9
10
11
12
13public void processBusiness() {
// 获取一个数据库连接
Connection conn = ConnectionManager.getConnection();
// 将连接存入ThreadLocal
threadLocalConnection.set(conn);
try {
// ... 执行业务逻辑,随时可以通过 threadLocalConnection.get() 获取连接
} finally {
// 最关键的一步:无论如何都要在finally中清理
threadLocalConnection.remove();
ConnectionManager.releaseConnection(conn);
}
} - 补充说明: 虽然
ThreadLocalMap
在调用get()
,set()
,remove()
时,会“顺便”清理一些Key为null
的Entry
(所谓的“启发式清理”),但这并非100%可靠。我们绝不能依赖这种机制,必须养成手动调用remove()
的习惯。
7.3 [核心] 线程间协作与通信
之前的章节,我们聚焦于如何通过锁等机制,防止多个线程在访问共享资源时互相干扰。这好比是为马路设置红绿灯,避免撞车。而本章我们将学习如何让线程之间互相通知、协调步调,共同完成一项任务。这就像是建立一套物流系统,让生产线和包装线能够无缝对接。
这个领域最经典、最基础的模型,就是“生产者-消费者”模型。
7.3.1 经典的“生产者-消费者”模型
想象一个场景:一个线程(生产者)负责生产数据(比如爬取网页、处理数据),另一个线程(消费者)负责处理这些数据(比如存入数据库、进行分析)。它们之间通过一个共享的缓冲区(比如一个队列)来传递数据。
- 当缓冲区满了,生产者就应该停止生产,并等待消费者消费掉一些数据。
- 当缓冲区空了,消费者就应该停止消费,并等待生产者生产出新的数据。
要实现这种精确的等待和唤醒,就需要用到Object
类提供的三个基础方法:wait()
, notify()
, notifyAll()
。
传统线程通信方式:wait() / notify() / notifyAll()
这三个方法是Java底层线程通信的基石,定义在Object
类中,意味着任何Java对象都可以充当“通信信箱”。
方法 (Method) | 核心作用 (Core Function) | 详细说明 |
---|---|---|
wait() | 使当前线程进入等待状态,并立即释放它所持有的锁。 | 线程会进入该对象的“等待队列”(Wait Set),直到被notify /notifyAll 唤醒或被中断。 |
notify() | 从“等待队列”中随机唤醒一个正在等待的线程。 | 被唤醒的线程不会立即执行,而是进入“锁竞争队列”,只有重新获取到锁后才能继续执行。 |
notifyAll() | 唤醒“等待队列”中所有正在等待的线程。 | 所有被唤醒的线程会一起去竞争锁。这是更推荐、更安全的方式,能有效避免“信号丢失”问题。 |
[核心规则] 这三个方法都必须在synchronized
同步块中调用,并且synchronized
锁定的对象必须与调用wait/notify
的对象是同一个。否则会抛出IllegalMonitorStateException
。
代码示例
下面是一个使用wait/notifyAll
实现的、固定容量的生产者-消费者模型的完整示例:
1 | import java.util.LinkedList; |
[高频][避坑指南] 为什么要用 while
循环判断条件而不是 if
?
这是使用wait/notify
机制时最关键、最容易出错的地方。
Q: 在调用
wait()
之前,为什么必须用while
循环检查条件,而不能用if
?A: 主要原因是为了防止 “虚假唤醒”。
- 什么是虚假唤醒: JVM规范允许,一个线程在没有被任何
notify/notifyAll
调用的情况下,也可能从wait()
状态中被“意外”唤醒。虽然这种情况非常罕见,但我们的代码必须具备防御能力。if
的隐患: 如果使用if (condition)
,当一个线程被虚假唤醒时,它会跳过if
判断,直接执行后续代码,此时condition
(如buffer.isEmpty()
)很可能仍然不满足,从而导致程序出错(比如从空队列中取元素)。while
的健壮性: 使用while (condition)
,当线程(无论是被正常唤醒还是虚假唤醒)醒来后,它会重新检查循环条件。如果条件仍然不满足,它会再次调用wait()
,重新进入等待状态。这确保了只有在条件真正满足时,线程才会继续执行。另一个重要原因:当有多个消费者时,
notifyAll()
会唤醒所有消费者。第一个抢到锁的消费者会消费掉数据。当其他消费者随后获得锁时,如果它们用的是if
,就会直接尝试消费(但此时缓冲区已空),导致错误。而while
会迫使它们重新检查缓冲区状态,发现已空,于是继续等待。
结论: while
循环是保证wait/notify
模式正确性的“安全网”,必须严格遵守。
7.3.2 [进阶] Condition 接口
如果说ReentrantLock
是synchronized
的增强版,那么Condition
就是Object.wait/notify
的增强版。它与Lock
紧密绑定,通常被称**Lock
的黄金搭档**,提供了更精细、更强大的线程等待与唤醒控制能力。
核心概念与方法
Condition
对象必须通过一个Lock
对象来创建,调用lock.newCondition()
即可。它的核心方法与Object
的方法有着清晰的对应关系:
Object 方法 | Condition 对应方法 | 核心作用 |
---|---|---|
wait() | await() | 使当前线程等待,并释放当前Condition 关联的Lock 。 |
notify() | signal() | 唤醒一个在当前Condition 上等待的线程。 |
notifyAll() | signalAll() | 唤醒所有在当前Condition 上等待的线程。 |
关键区别:Object
的等待/通知机制是与对象监视器(锁)绑定的,一个锁只能有一个等待队列。而一个Lock
对象可以创建多个Condition
实例,每个Condition
都拥有自己独立的等待队列。
核心优势:实现分组唤醒与精准通知
synchronized
+ notifyAll()
的一个痛点是,它无法区分等待的线程类型。在生产者-消费者模型中,当一个生产者生产完产品后调用notifyAll()
,它会唤醒所有在等待的线程,这其中可能包含了其他生产者。这些被唤醒的生产者发现缓冲区依然是满的,只好再次进入等待,这造成了不必要的CPU开销和上下文切换。
Condition
完美地解决了这个问题。我们可以为不同的条件创建不同的Condition
对象:
- 创建一个
notFull
条件,专门给因缓冲区已满而等待的生产者使用。 - 创建一个
notEmpty
条件,专门给因缓冲区为空而等待的消费者使用。
这样,当消费者消费了一个产品后,它只需要调用notFull.signal()
,精准地唤醒一个生产者,而不会打扰到其他正在等待的消费者。反之亦然。
代码示例:使用 Condition 改造生产者-消费者模型
下面我们将使用ReentrantLock
和两个Condition
来重构之前的生产者-消费者代码,体验其精准通知的优雅。
1 | import java.util.LinkedList; |
总结: Condition
将线程的等待队列从锁中分离出来,使得我们可以根据不同的业务条件对线程进行分组管理和精准唤醒,从而实现更高效、更复杂的线程协作逻辑。在需要精细控制线程通信的场景下,Lock
+ Condition
的组合是比synchronized
+ wait/notify
更优越的选择。
7.3.3 [进阶] JUC 并发工具类
除了锁和条件变量,JUC还提供了一系列用于控制和同步线程的辅助类,我们称之为同步器(Synchronizers)。它们可以帮助我们轻松实现如“倒计时”、“循环栅栏”、“资源限流”等常见并发模式。
1. CountDownLatch (倒计时门闩)
核心思想:
CountDownLatch
允许一个或多个线程等待其他一组线程完成操作。它就像一个倒计时器,只有当计时器归零时,等待的线程才能继续执行。应用场景: 主任务需要等待所有前置的子任务(如初始化、数据加载)全部完成后,才能开始执行。
核心方法:
CountDownLatch(int count)
: 构造函数,设置初始计数值。countDown()
: 将计数值减 1。通常由完成任务的子线程调用。await()
: 阻塞当前线程,直到计数值变为 0。通常由主线程调用。
特性: 一次性使用,计数值归零后无法重置。
代码示例: 模拟一场赛跑,裁判(主线程)必须等待所有选手(子线程)都准备就绪后才能鸣枪发令。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
int runnerCount = 5;
// 裁判需要等待5名选手准备好
CountDownLatch readyLatch = new CountDownLatch(runnerCount);
ExecutorService executor = Executors.newFixedThreadPool(runnerCount);
System.out.println("裁判:各位选手请准备...");
for (int i = 1; i <= runnerCount; i++) {
final int runnerId = i;
executor.execute(() -> {
try {
System.out.println("选手 " + runnerId + " 正在准备...");
Thread.sleep((long) (Math.random() * 2000) + 1000);
System.out.println("选手 " + runnerId + " 准备就绪!");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
readyLatch.countDown(); // 准备好了,计数值减1
}
});
}
// 裁判等待所有选手准备就绪
readyLatch.await();
System.out.println("所有选手准备完毕,比赛开始!");
executor.shutdown();
}
}
2. CyclicBarrier (循环屏障)
核心思想:
CyclicBarrier
让一组线程能够相互等待,直到所有线程都到达某个公共的屏障点(Rendezvous Point),然后才能一起继续执行。应用场景: 多线程分块处理数据,需要等待所有线程处理完当前阶段后,再统一进入下一阶段。
核心方法:
CyclicBarrier(int parties, Runnable barrierAction)
: 构造函数,parties
是需要等待的线程数,barrierAction
是一个可选的回调任务,在所有线程到达屏障后、释放它们之前,由最后一个到达的线程执行。await()
: 线程调用此方法表示已到达屏障,并开始等待其他线程。
特性: 可循环使用。当所有线程越过屏障后,屏障会自动重置,可用于下一轮等待。
代码示例: 模拟公司团建,所有员工(线程)必须先集合到公司门口(屏障),然后大巴车(
barrierAction
)才能出发。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CyclicBarrierExample {
public static void main(String[] args) {
int employeeCount = 4;
// 创建一个需要4个线程到达的屏障,并在到达后执行一个任务
CyclicBarrier barrier = new CyclicBarrier(employeeCount, () -> {
System.out.println("所有人都到齐了,大巴车出发!");
});
ExecutorService executor = Executors.newFixedThreadPool(employeeCount);
for (int i = 1; i <= employeeCount; i++) {
final int empId = i;
executor.execute(() -> {
try {
System.out.println("员工 " + empId + " 从家出发了...");
Thread.sleep((long) (Math.random() * 3000) + 1000);
System.out.println("员工 " + empId + " 到达公司门口,开始等待其他人...");
barrier.await(); // 等待其他线程
System.out.println("员工 " + empId + " 上车,一起出发!");
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
});
}
executor.shutdown();
}
}
3. Semaphore (信号量)
核心思想:
Semaphore
用于控制同时访问特定资源的线程数量。它内部维护了一组“许可证”。应用场景: 流量控制、限流,比如控制数据库连接池的最大连接数,或者限制同时下载文件的线程数。
核心方法:
Semaphore(int permits)
: 构造函数,设置许可证的总数。acquire()
: 获取一个许可证,如果许可证已发完,则线程阻塞等待。release()
: 释放一个许可证,将其归还给信号量。
特性: 必须保证
release()
被调用,通常放在finally
块中。代码示例: 模拟一个只有3个停车位的停车场。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
public static void main(String[] args) {
// 停车场只有3个车位
Semaphore parkingLot = new Semaphore(3);
ExecutorService executor = Executors.newFixedThreadPool(10); // 10辆车
for (int i = 1; i <= 10; i++) {
final int carId = i;
executor.execute(() -> {
try {
System.out.println("汽车 " + carId + " 到达停车场,试图停车。");
parkingLot.acquire(); // 尝试获取一个许可证(车位)
System.out.println("汽车 " + carId + " 成功停车!");
Thread.sleep((long) (Math.random() * 4000) + 2000); // 停车时长
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("汽车 " + carId + " 离开停车场。");
parkingLot.release(); // 释放许可证(车位)
}
});
}
executor.shutdown();
}
}
[高频][面试题] CountDownLatch 与 CyclicBarrier 的区别
对比维度 | CountDownLatch | CyclicBarrier |
---|---|---|
作用机制 | 减法计数:一个或多个线程等待其他线程完成任务,计数值从 N->0。 | 加法计数:一组固定数量的线程相互等待,直到所有线程都到达屏障点。 |
可重用性 | 不可重用。计数值归零后,CountDownLatch 就失效了。 | 可循环使用。所有线程越过屏障后,屏障会自动重置,可用于下一轮。 |
使用场景 | 一个主任务等待多个子任务完成(启动门)。 | 多个子任务之间需要同步,分阶段执行(同步点)。 |
工作模式 | 非对称:有await() 的等待线程和countDown() 的工作线程之分。 | 对称:所有线程角色相同,都调用await() 等待彼此。 |
回调函数 | 无内置回调函数。 | 可在构造时传入一个Runnable ,在屏障触发时执行。 |
7.3.4 [面试题] 如何理解和避免死锁?
死锁是多线程环境下一个经典且危险的问题。它指的是两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的僵局。若无外力干涉,这些线程都将无法继续推进,导致程序“卡死”。
死锁的四个必要条件
一个死锁的发生,必须同时满足以下四个条件。只要破坏其中任意一个,就能有效预防死锁。
- 互斥条件
- 一个资源在同一时刻只能被一个线程持有。如果其他线程请求该资源,则必须等待,直到资源被释放。这是产生死锁的根本前提。
- 请求与保持条件
- 一个线程在已经持有了至少一个资源的情况下,又去请求其他线程正持有的资源,但因请求不到而阻塞,同时对自己已有的资源保持不放。
- 不可剥夺条件
- 线程已获得的资源,在未使用完毕之前,不能被其他线程强行剥夺,只能由持有者自愿释放。
- 循环等待条件
- 存在一个线程的资源等待环路。即线程T1等待线程T2的资源,线程T2等待线程T3的资源,…,而线程Tn又在等待线程T1的资源,形成一个闭环。
死锁的代码示例
下面是一个最经典的、由锁顺序不当导致的死锁场景:
1 | public class DeadlockExample { |
- 执行过程分析:
- 线程1获取
lockA
。 - 与此同时,线程2获取
lockB
。 - 线程1在持有
lockA
的情况下,尝试获取lockB
,但lockB
被线程2持有,于是线程1进入等待。 - 线程2在持有
lockB
的情况下,尝试获取lockA
,但lockA
被线程1持有,于是线程2也进入等待。 - 此时,线程1等待线程2释放
lockB
,线程2等待线程1释放lockA
,形成循环等待,死锁发生。
- 线程1获取
如何排查死锁
如果线上应用发生了死锁,我们可以使用JDK自带的工具进行诊断。
jps
+jstack
命令 (推荐)第一步: 找到Java进程ID (PID)
1
jps -l
该命令会列出所有Java进程及其PID。
第二步: 分析线程堆栈
1
jstack -l <PID>
jstack
会打印出指定Java进程的全部线程堆栈信息。如果存在死锁,它会在输出的末尾明确指出,并打印出死锁涉及的线程、它们持有的锁以及正在等待的锁。1
2
3
4
5
6
7
8
9
10
11Found one Java-level deadlock:
=============================
"线程2":
waiting to lock monitor 0x00007f... (a java.lang.Object),
which is held by "线程1"
"线程1":
waiting to lock monitor 0x00007f... (a java.lang.Object),
which is held by "线程2"
Java stack information for the threads listed above:
... (详细堆栈) ...
图形化工具 (JConsole, VisualVM)
- 连接到目标Java进程后,这些工具的“线程”选项卡通常有“检测死锁”的功能,可以一键发现并可视化展示死锁信息。
如何预防死锁
预防死锁的核心思想是破坏其四个必要条件之一。在实际开发中,最常用、最有效的是破坏“循环等待”条件。
策略 | 破坏的条件 | 具体做法 |
---|---|---|
按序申请锁 | 循环等待 | (最推荐) 规定所有线程必须按照一个固定的、全局的顺序来申请锁。例如,规定必须先申请lockA 再申请lockB 。 |
使用带超时的锁 | 不可剥夺 | 使用Lock 接口的tryLock(timeout) 方法。线程在尝试获取锁时,如果超过了指定时间还未成功,就主动放弃已经持有的锁,然后等待一会再重试。 |
一次性申请所有资源 | 请求与保持 | 在进入同步代码前,一次性地获取所有需要的锁。但这在很多复杂场景下难以实现。 |
最佳实践: 在绝大多数情况下,保证所有线程以相同的顺序获取锁,是预防死锁最简单、最有效的策略。