Java 并发编程核心知识:线程、锁与死锁全解析

一、线程与进程

(1)进程的概念

  1. 进程是操作系统分配资源的基本单位。当进程启动时,操作系统会为其分配内存空间、文件描述符等一系列系统资源(通常操作系统是以线程为单位分配时间片)。
  2. 进程与进程之间实现了资源隔离,各自拥有独立的资源集合,这种隔离保证了系统的稳定性和安全性,一个进程无法直接访问另一个进程的资源。
  3. 现代计算机通常配备多个 CPU,每个 CPU 又包含多个核心,CPU 核心以线程为基本单位执行任务,使得进程呈现并发运行效果,多个进程的线程能在不同核心上并行执行。
  4. 一般打开一个软件会启动一个进程,但部分基于多实例或单例模式设计的软件,无论打开多少次都只启动一个进程。对于 Java 程序,main 函数开始运行就启动了一个 Java 进程

(2)线程的概念

  1. 线程是进程的基本执行单元。以 Java 程序为例,main 函数运行开启主线程,主线程在 Java 虚拟机栈中创建 main 方法的栈帧。
  2. 若 main 方法里开启其他线程,这些线程共享 Java 虚拟机的堆(存储对象实例)和方法区(存储类信息、常量等)资源,但各自拥有独立的程序计数器、虚拟机栈、本地方法栈。其中,程序计数器记录线程执行的字节码指令地址,虚拟机栈存储方法调用栈帧信息,本地方法栈为 Native 方法服务。
  3. 线程由操作系统的线程调度器决定执行时机和顺序,是 CPU 核心运行的基本单位。一个进程内的线程共享进程的文件描述符、打开的网络连接等资源,方便通信和数据共享。

(3)进程与线程的区别

  1. 资源占用:进程拥有独立且完整的资源集合,资源占用多;线程本身不拥有系统资源(仅有程序计数器等少量运行时数据区),共享所属进程资源,资源占用少。
  2. 上下文切换:进程和线程都会涉及上下文切换,进程切换时需保存和恢复内存管理信息等大量资源,开销大;线程切换主要保存和恢复程序计数器等少量私有数据,根据程序计数器确定下次执行指令位置,开销小。
  3. 通信机制:进程间相互隔离,通信复杂,需借助管道、消息队列等进程间通信(IPC)机制;线程共享进程内存空间,通过共享内存(如堆内存)通信,相对简单高效,可通过共享变量实现数据传递和同步。

二、线程生命周期与状态

(1)NEW(新建状态)

  1. 线程通过 new 关键字创建 Thread 对象后,便处于 NEW 状态
  2. 此时线程仅仅是一个对象实例,尚未与操作系统线程建立关联,也不会被线程调度器调度执行。
  3. 例如 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(阻塞状态)

  1. 当线程尝试获取一个被其他线程持有的同步锁(如 synchronized 修饰的代码块或方法)失败时,会进入 BLOCKED 状态
  2. 处于该状态的线程不会占用 CPU 资源,CPU 会执行其他可运行线程,直到获取到锁才会重新进入 RUNNABLE 状态。
  3. 例如多个线程竞争访问同一 synchronized 方法时,未获取到锁的线程将被阻塞。

(4)WAITING(无限期等待状态)

  1. 线程调用 Objectwait 方法、Threadjoin 方法,或 LockSupportpark 方法等,会进入 WAITING 状态
  2. 此时线程会释放持有的锁资源,进入等待队列,不会占用 CPU 资源。
  3. 直到被其他线程通过 notifynotifyAllunpark 等方法唤醒,才会重新进入 RUNNABLE 的 READY 状态。

(5)TIMED_WAITING(限期等待状态)

  1. 与 WAITING 类似,但线程会在指定时间到期后自动唤醒,也可被其他线程提前唤醒。
  2. 例如调用 Thread.sleep(long)Object.wait(long)Thread.join(long) 等带时间参数的方法时,线程进入 TIMED_WAITING 状态
  3. 到期或被唤醒后进入 RUNNABLE 的 READY 状态。

(6)TERMINATED(终止状态)

  1. 正常结束:当线程的执行体(run 方法)正常执行完毕,线程进入 TERMINATED 状态。
  2. 异常终止:若线程执行过程中抛出未捕获的异常,且该异常未在程序内被妥善处理而直接抛给虚拟机,线程也会进入 TERMINATED 状态。
  3. 处于该状态的线程已结束生命周期,无法再被调度执行。
  4. 当线程处于 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)线程上下文的组成部分

  1. 程序计数器:记录线程即将执行的下一条字节码指令地址,确保线程恢复执行时能从正确位置继续。
  2. 虚拟机栈和本地方法栈:保存线程执行过程中方法调用的栈帧信息,包括局部变量表、操作数栈、动态链接等。上下文切换时,需完整保存和恢复整个栈结构,保证方法调用的正确性。
  3. 线程局部存储:为线程提供私有数据存储区域。线程在执行过程中,通常会从 JVM 主内存读取数据到本地内存进行操作,修改后再同步回主内存,线程局部存储保证了线程操作数据的独立性和可见性。

四、线程安全与锁

(1)线程不安全的定义

  1. 线程不安全是指当多个线程并发访问和操作共享数据时,可能出现数据不一致或错误的情况
  2. 共享数据存储在堆(如对象实例变量)或方法区(如类的静态变量)中。
  3. 由于线程执行的不确定性(如线程切换时机不可预测),当多个线程同时对共享数据进行读写操作,可能会导致数据覆盖、读取到中间状态数据等问题,最终使数据未达到预期值。
  4. 例如,多个线程同时对一个共享的计数器变量进行自增操作,可能会出现丢失更新的情况,导致最终结果与预期不一致。

(2)线程安全的实现方式

1. 悲观锁:

  • 持有悲观锁的线程认为在自己操作共享数据时,大概率会有其他线程也尝试进行操作,因此在访问共享数据前预先加锁,将其他线程暂时阻挡在外,确保同一时刻只有一个线程能操作共享数据。
  • 在 Java 中,synchronized关键字是内置的悲观锁实现方式,它可以修饰方法或代码块,当一个线程进入被synchronized修饰的区域时,会自动获取锁,其他线程只能等待锁释放后才能尝试获取并进入。
  • ReentrantLock是 Java 并发包中提供的可重入锁,也是一种悲观锁实现,相比synchronized,它提供了更灵活的加锁、解锁方式以及一些高级功能,如公平锁与非公平锁的选择、可中断的锁获取等。

2. 乐观锁:

  • 乐观锁的线程认为自己在操作共享数据时,不太可能会有其他线程同时修改数据,因此不会在操作前加锁。
  • 它通常依赖版本号机制或 CAS(Compare - And - Set,比较并交换)机制来保证数据的一致性。
  • 基于版本号的实现方式是,每次对共享数据进行修改时,版本号递增。
  • 当线程尝试修改数据时,会先检查数据当前版本号与自己开始操作时记录的版本号是否一致
    • 如果一致则进行修改,并更新版本号。
    • 若不一致则说明数据已被其他线程修改,放弃本次操作或进行重试。
  • CAS 机制是由 CPU 硬件支持的原子操作,它包含三个操作数:内存位置、预期值和新值。
  • CAS 操作会将内存位置的值与预期值进行比较,如果相等则将内存位置的值更新为新值,否则不做任何操作,并返回是否更新成功的结果。
  • Java 中的Atomic类(如AtomicIntegerAtomicReference等)就是基于 CAS 机制实现的,通过这种无锁方式实现线程安全,避免了传统锁机制可能带来的线程阻塞和上下文切换开销。

五、死锁

(1)死锁的定义

  1. 死锁是指多个线程在执行过程中,因争夺资源而造成的一种互相等待的僵局状态
  2. 此时,线程之间互相持有对方继续运行所需的资源,且都在等待对方释放资源,导致所有线程都无法继续执行,从而陷入无限阻塞。

(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();
    }
}
posted @ 2025-05-01 16:05  qwqw75  阅读(40)  评论(0)    收藏  举报