Java 并发编程核心知识:线程、锁与死锁全解析
一、线程与进程
(1)进程的概念
- 进程是操作系统分配资源的基本单位。当进程启动时,操作系统会为其分配内存空间、文件描述符等一系列系统资源(通常操作系统是以线程为单位分配时间片)。
- 进程与进程之间实现了资源隔离,各自拥有独立的资源集合,这种隔离保证了系统的稳定性和安全性,一个进程无法直接访问另一个进程的资源。
- 现代计算机通常配备多个 CPU,每个 CPU 又包含多个核心,CPU 核心以线程为基本单位执行任务,使得进程呈现并发运行效果,多个进程的线程能在不同核心上并行执行。
- 一般打开一个软件会启动一个进程,但部分基于多实例或单例模式设计的软件,无论打开多少次都只启动一个进程。对于 Java 程序,main 函数开始运行就启动了一个 Java 进程。
(2)线程的概念
- 线程是进程的基本执行单元。以 Java 程序为例,main 函数运行开启主线程,主线程在 Java 虚拟机栈中创建 main 方法的栈帧。
- 若 main 方法里开启其他线程,这些线程共享 Java 虚拟机的堆(存储对象实例)和方法区(存储类信息、常量等)资源,但各自拥有独立的程序计数器、虚拟机栈、本地方法栈。其中,程序计数器记录线程执行的字节码指令地址,虚拟机栈存储方法调用栈帧信息,本地方法栈为 Native 方法服务。
- 线程由操作系统的线程调度器决定执行时机和顺序,是 CPU 核心运行的基本单位。一个进程内的线程共享进程的文件描述符、打开的网络连接等资源,方便通信和数据共享。
(3)进程与线程的区别
- 资源占用:进程拥有独立且完整的资源集合,资源占用多;线程本身不拥有系统资源(仅有程序计数器等少量运行时数据区),共享所属进程资源,资源占用少。
- 上下文切换:进程和线程都会涉及上下文切换,进程切换时需保存和恢复内存管理信息等大量资源,开销大;线程切换主要保存和恢复程序计数器等少量私有数据,根据程序计数器确定下次执行指令位置,开销小。
- 通信机制:进程间相互隔离,通信复杂,需借助管道、消息队列等进程间通信(IPC)机制;线程共享进程内存空间,通过共享内存(如堆内存)通信,相对简单高效,可通过共享变量实现数据传递和同步。
二、线程生命周期与状态
(1)NEW(新建状态)
- 线程通过 new 关键字创建 Thread 对象后,便处于 NEW 状态。
- 此时线程仅仅是一个对象实例,尚未与操作系统线程建立关联,也不会被线程调度器调度执行。
- 例如
Thread thread = new Thread(() -> System.out.println("Hello"));执行后,thread就处于该状态。
(2)RUNNABLE(可运行状态)
1. READY(就绪):
- 当线程对象调用 start 方法后,它会开始与操作系统线程绑定,并进入操作系统的线程队列等待线程调度器调度。
- 例如
thread.start();调用后,线程进入就绪状态,随时可能被调度执行。
2. RUNNING(运行):
- 当线程被 CPU 调度选中并执行时,处于 RUNNING 状态。
- 在多核心 CPU 环境下,多个线程可能同时处于 RUNNING 状态,但在单核心 CPU 中,同一时刻仅有一个线程处于该状态,其余处于 READY 状态。
(3)BLOCKED(阻塞状态)
- 当线程尝试获取一个被其他线程持有的同步锁(如
synchronized修饰的代码块或方法)失败时,会进入 BLOCKED 状态。 - 处于该状态的线程不会占用 CPU 资源,CPU 会执行其他可运行线程,直到获取到锁才会重新进入 RUNNABLE 状态。
- 例如多个线程竞争访问同一
synchronized方法时,未获取到锁的线程将被阻塞。
(4)WAITING(无限期等待状态)
- 线程调用
Object的wait方法、Thread的join方法,或LockSupport的park方法等,会进入 WAITING 状态。 - 此时线程会释放持有的锁资源,进入等待队列,不会占用 CPU 资源。
- 直到被其他线程通过
notify、notifyAll或unpark等方法唤醒,才会重新进入 RUNNABLE 的 READY 状态。
(5)TIMED_WAITING(限期等待状态)
- 与 WAITING 类似,但线程会在指定时间到期后自动唤醒,也可被其他线程提前唤醒。
- 例如调用
Thread.sleep(long)、Object.wait(long)、Thread.join(long)等带时间参数的方法时,线程进入 TIMED_WAITING 状态。 - 到期或被唤醒后进入 RUNNABLE 的 READY 状态。
(6)TERMINATED(终止状态)
- 正常结束:当线程的执行体(run 方法)正常执行完毕,线程进入 TERMINATED 状态。
- 异常终止:若线程执行过程中抛出未捕获的异常,且该异常未在程序内被妥善处理而直接抛给虚拟机,线程也会进入 TERMINATED 状态。
- 处于该状态的线程已结束生命周期,无法再被调度执行。
- 当线程处于 TERMINATED 状态时,对应的操作系统层面的线程资源通常会被回收销毁,不过具体的销毁时机和方式由操作系统的线程管理机制决定。
三、线程上下文切换
(1)线程上下文切换的触发情况
1. 时间片耗尽:
- 操作系统为线程分配的时间片用完,调度器会暂停当前线程,将 CPU 资源分配给其他线程,触发上下文切换。
2. 主动让出资源:
- 线程主动调用
sleep方法、Object.wait()方法(进入等待锁状态),或者通过yield方法(主动放弃 CPU 使用权,提示调度器切换线程,但不保证一定会切换)等,让出 CPU 运行资源,从而引发上下文切换。
3. 阻塞事件:
- 当线程获取锁失败进入阻塞状态(例如
synchronized同步块竞争失败),或执行到 IO 相关的耗时操作时,由于无法继续执行,线程会被阻塞并让出 CPU,等待事件完成(如 IO 操作结束)后再重新进入可运行状态,这期间会发生线程上下文切换。 - 对于 IO 操作,因其耗时较长,若由 CPU 持续等待其完成会造成资源浪费,所以在等待 IO 操作完成的过程中,CPU 会转而执行其他线程。
- IO 操作通常由操作系统的内核来执行。当线程执行到 IO 相关的代码时,例如读取文件或者网络数据,它会向操作系统发送 IO 请求。
- 操作系统会将这个 IO 任务分配给相应的设备驱动程序,由设备驱动程序来控制硬件设备完成实际的 IO 操作。
(2)线程上下文切换的开销
线程上下文切换需要保存当前线程的上下文信息(包括程序计数器、寄存器、栈等状态),然后将下一个要运行线程的上下文信息加载进来,这些操作涉及内存访问和数据处理,会带来一定的性能损耗。
(3)使用线程池优化线程上下文切换开销
1. 减少线程创建开销:
- 线程池初始不会立即创建指定数量的操作系统线程,而是根据任务量逐步创建。当线程数量达到指定上限后,核心线程会被复用执行后续任务。
- 常规代码创建线程时,新线程需要与操作系统线程绑定,并由操作系统分配时间片,这个过程会触发线程上下文切换,同时也会影响正在运行线程的执行(在一个线程 A 里创建新的线程 B 通常会导致线程 A 的上下文切换)。
- 而线程池通过复用线程,避免了频繁创建新线程带来的上下文切换开销。
2. 降低调度开销:
- 若创建大量线程,CPU 为众多线程分配时间片时,每个线程获得的执行时间会相应减少,导致 CPU 频繁在不同线程间切换,增加调度开销。
- 线程池可控制线程数量在合理范围内,减少 CPU 调度的复杂度,使 CPU 能更高效地分配时间片,降低线程上下文切换的频率。
(4)线程上下文的组成部分
- 程序计数器:记录线程即将执行的下一条字节码指令地址,确保线程恢复执行时能从正确位置继续。
- 虚拟机栈和本地方法栈:保存线程执行过程中方法调用的栈帧信息,包括局部变量表、操作数栈、动态链接等。上下文切换时,需完整保存和恢复整个栈结构,保证方法调用的正确性。
- 线程局部存储:为线程提供私有数据存储区域。线程在执行过程中,通常会从 JVM 主内存读取数据到本地内存进行操作,修改后再同步回主内存,线程局部存储保证了线程操作数据的独立性和可见性。
四、线程安全与锁
(1)线程不安全的定义
- 线程不安全是指当多个线程并发访问和操作共享数据时,可能出现数据不一致或错误的情况。
- 共享数据存储在堆(如对象实例变量)或方法区(如类的静态变量)中。
- 由于线程执行的不确定性(如线程切换时机不可预测),当多个线程同时对共享数据进行读写操作,可能会导致数据覆盖、读取到中间状态数据等问题,最终使数据未达到预期值。
- 例如,多个线程同时对一个共享的计数器变量进行自增操作,可能会出现丢失更新的情况,导致最终结果与预期不一致。
(2)线程安全的实现方式
1. 悲观锁:
- 持有悲观锁的线程认为在自己操作共享数据时,大概率会有其他线程也尝试进行操作,因此在访问共享数据前预先加锁,将其他线程暂时阻挡在外,确保同一时刻只有一个线程能操作共享数据。
- 在 Java 中,
synchronized关键字是内置的悲观锁实现方式,它可以修饰方法或代码块,当一个线程进入被synchronized修饰的区域时,会自动获取锁,其他线程只能等待锁释放后才能尝试获取并进入。 ReentrantLock是 Java 并发包中提供的可重入锁,也是一种悲观锁实现,相比synchronized,它提供了更灵活的加锁、解锁方式以及一些高级功能,如公平锁与非公平锁的选择、可中断的锁获取等。
2. 乐观锁:
- 乐观锁的线程认为自己在操作共享数据时,不太可能会有其他线程同时修改数据,因此不会在操作前加锁。
- 它通常依赖版本号机制或 CAS(Compare - And - Set,比较并交换)机制来保证数据的一致性。
- 基于版本号的实现方式是,每次对共享数据进行修改时,版本号递增。
- 当线程尝试修改数据时,会先检查数据当前版本号与自己开始操作时记录的版本号是否一致
- 如果一致则进行修改,并更新版本号。
- 若不一致则说明数据已被其他线程修改,放弃本次操作或进行重试。
- CAS 机制是由 CPU 硬件支持的原子操作,它包含三个操作数:内存位置、预期值和新值。
- CAS 操作会将内存位置的值与预期值进行比较,如果相等则将内存位置的值更新为新值,否则不做任何操作,并返回是否更新成功的结果。
- Java 中的
Atomic类(如AtomicInteger、AtomicReference等)就是基于 CAS 机制实现的,通过这种无锁方式实现线程安全,避免了传统锁机制可能带来的线程阻塞和上下文切换开销。
五、死锁
(1)死锁的定义
- 死锁是指多个线程在执行过程中,因争夺资源而造成的一种互相等待的僵局状态。
- 此时,线程之间互相持有对方继续运行所需的资源,且都在等待对方释放资源,导致所有线程都无法继续执行,从而陷入无限阻塞。
(2)形成死锁的必要条件
1. 互斥条件:
- 资源具有独占性,即一个资源在同一时刻只能被一个线程占用,其他线程若要访问该资源,必须等待资源被释放。
- 例如,打印机资源在某一时刻只能被一个进程使用,其他进程需要排队等待。
2. 请求与保持条件:
- 线程在持有资源的同时,又去请求其他线程已占有的资源,且不释放自己已持有的资源。
- 即形成死锁的线程之间,互相持有对方继续运行所必需的资源,且在获取新资源前不会主动放弃已持有的资源。
3. 不可剥夺条件:
- 资源一旦被某个线程获取,其他线程不能强行夺取,只能由持有资源的线程自行释放。
- 例如,一个线程获取到数据库连接资源后,其他线程不能直接将其夺走。
4. 循环等待条件:
- 存在一个线程 - 资源的循环链,链中每个线程都在等待下一个线程所持有的资源,形成一个闭环。
- 例如,线程 A 等待线程 B 持有的资源,线程 B 等待线程 C 持有的资源,线程 C 又等待线程 A 持有的资源,从而构成循环等待。
(3)解决死锁的方法
1. 预防死锁:
- 在程序设计阶段,通过破坏死锁产生的必要条件来预防死锁。
- 例如,避免资源独占,采用资源共享策略。
- 或者要求线程一次性申请所需的全部资源,若无法满足则不分配任何资源,从而避免请求与保持条件的出现。
2. 避免死锁:
- 在程序运行过程中,采用合理的资源分配算法,动态监测资源分配情况,确保系统始终处于安全状态。
- 银行家算法就是一种经典的避免死锁算法,它通过模拟银行系统的贷款审批过程,在每次资源分配前先判断此次分配是否会导致系统进入不安全状态,若会则拒绝分配,从而避免死锁的发生。
3. 检测与解除死锁:
- 利用工具对运行中的程序进行监测,当检测到死锁时,采取措施解除死锁。
- 例如,使用
jstack命令(用于打印 Java 线程堆栈信息)、JConsole(Java 的可视化监控工具)等定位发生死锁的线程。 - 一旦确定死锁线程,可以通过终止死锁线程、剥夺死锁线程持有的资源等方式来解除死锁。
(4)死锁示例
// 线程1持有resource1,请求resource2
// 线程2持有resource2,请求resource1
public class DeadLockDemo {
private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
// 请求resource2
}
}, "线程1").start();
new Thread(() -> {
synchronized (resource2) {
// 请求resource1
}
}, "线程2").start();
}
}
浙公网安备 33010602011771号