在 Java 并发编程领域,多线程是构建高性能服务的基础能力。从简单的异步任务处理,到高并发的后端业务系统,多线程的身影无处不在。然而很多开发者对多线程的认知停留在 “创建线程执行任务” 的表层,对线程状态流转、关键字底层原理、死锁规避等核心知识一知半解,最终导致线上出现难以排查的并发故障。

本文将系统梳理 Java 多线程的完整知识体系,从线程与进程的本质区别出发,深入讲解线程状态转换、并发关键字底层实现、线程间通信机制,以及死锁、资源约束等工程痛点,帮你构建扎实的并发编程底层认知。

一、线程的本质:与进程的核心区别

1.1 基础定义

运行一个程序时,操作系统会首先创建一个进程。进程是操作系统进行资源分配的最小单位,每个进程拥有独立的内存地址空间、文件句柄、网络端口等资源,进程之间相互隔离。

在一个进程内部,可以创建多个线程。线程是操作系统进行CPU 调度的最小单位,同一个进程内的所有线程共享进程的堆内存、方法区资源,但每个线程拥有独立的程序计数器、虚拟机栈和本地方法栈。

我们常说的 “多线程并发执行”,本质是 CPU 通过时间片轮转算法,在多个线程之间快速切换执行,让用户感知到多个任务在同时运行。

1.2 进程与线程的核心差异

对比维度 进程 线程
核心定位 资源分配的最小单位 CPU 调度的最小单位
资源归属 拥有独立完整的资源空间 共享进程资源,仅持有少量私有栈数据
切换开销 开销大,需切换页表、刷新 CPU 缓存 开销小,仅切换栈指针、寄存器等上下文
通信成本 成本高,需通过管道、消息队列、共享内存等机制 成本低,直接读写进程共享内存空间
故障影响 进程间隔离,单个进程崩溃不影响其他进程 线程共享资源,单个线程异常可能导致整个进程崩溃
并发粒度 粗粒度,进程级并行 细粒度,任务级并行

二、多线程一定更快吗?上下文切换的代价

线程越多程序越快” 是并发编程最常见的认知误区。多线程的性能提升建立在任务可并行拆分的基础上,而线程切换本身会带来不可忽视的性能开销。

2.1 上下文切换的成本

当 CPU 从一个线程切换到另一个线程时,需要保存当前线程的执行上下文(寄存器状态、程序计数器、栈帧信息等),再加载下一个线程的上下文,这个过程就是上下文切换

切换的代价主要来自两方面:

  1. CPU 时间消耗:切换操作需要执行内核态指令,直接占用 CPU 时间,切换越频繁,有效计算时间占比越低。
  2. 缓存失效开销:线程切换会导致 CPU 缓存、TLB(转译后备缓冲器)失效,后续内存访问速度大幅下降。

对于计算密集型任务,当线程数超过 CPU 核心数时,频繁的上下文切换甚至会让多线程程序的执行速度慢于单线程。

2.2 减少上下文切换的常用方案

  1. 无锁并发编程 通过业务设计规避锁竞争,比如按照 ID 对数据分段,不同线程处理不同分段的数据,典型代表是 ConcurrentHashMap 的分段锁思想;同时尽量缩小锁的持有范围,减少锁冲突的概率。
  2. CAS 原子操作 利用 CPU 硬件级别的原子指令实现无锁更新,Java 中的 Atomic 原子类、AQS 同步器均基于 CAS 实现。不需要加锁阻塞线程,避免了锁争抢和线程挂起唤醒的开销。
  3. 协程(虚拟线程) 协程是用户态的轻量级线程,由 JVM 而非操作系统调度,切换成本远低于系统线程。Java 21 正式引入的虚拟线程(Virtual Thread)就是协程的工业级实现,大幅降低了 IO 密集型场景的线程切换开销。

三、死锁:成因与工程化规避方案

死锁是多线程编程中最严重的故障之一:多个线程互相持有对方需要的资源,且都不释放自身持有的资源,最终导致所有线程永久阻塞,程序无法继续运行。

3.1 死锁的四个必要条件

死锁的产生必须同时满足四个条件,破坏任意一个即可避免死锁:

  • 互斥条件:资源同一时间只能被一个线程持有
  • 持有并等待:线程持有一个资源的同时,等待另一个被其他线程持有的资源
  • 不可剥夺:资源只能由持有者主动释放,无法被其他线程强行抢占
  • 循环等待:线程之间形成首尾相接的循环等待资源链路

3.2 死锁的规避方案

  1. 避免单个线程同时获取多个锁 尽量减少单一线程持有的锁数量,能锁代码块就不锁整个方法,能用一个锁解决就不引入多锁,从根源降低锁冲突的概率。
  2. 避免在锁中占用过多资源 不要在同步代码块内执行耗时 IO、远程调用等长耗时逻辑,尽量缩小锁的持有时间,减少其他线程的等待时长。
  3. 使用定时锁替代永久阻塞 使用ReentrantLocktryLock(long timeout)方法尝试获取锁,超过等待时间就主动放弃,避免线程无限期阻塞,破坏 “不可剥夺” 条件。
  4. 统一加锁顺序 所有线程按照固定的顺序获取锁(例如按资源 ID 从小到大加锁),避免出现循环等待的链路,直接破坏循环等待条件。
  5. 数据库场景特殊约束 在数据库事务场景中,加锁和释放锁必须在同一个数据库连接内完成;同时严格控制事务范围,避免长事务长时间持有数据库锁,引发数据库层面的死锁。

四、多线程的边界:资源限制

多线程的性能上限永远受限于系统的瓶颈资源,这就是资源限制问题。盲目增加线程数不仅不会提升性能,反而会因为资源争抢导致整体效率下降。

4.1 两类核心资源限制

  1. 硬件资源限制 包括 CPU 核心数、网络带宽、磁盘 IO 读写速度、内存大小等。例如文件下载任务,瓶颈是网络带宽,即使开启上百个线程,下载速度也无法突破带宽上限,反而会因为线程切换和资源争抢降低效率。
  2. 软件资源限制 包括数据库连接数、Redis 连接数、线程池最大容量、操作系统文件句柄数等。例如数据库连接池只有 10 个连接,开启 100 个线程执行 SQL,绝大多数线程都会阻塞等待连接,整体吞吐量不升反降。

4.2 资源限制的解决方案

  • 硬件资源瓶颈:采用分布式集群架构,将任务拆分到多台机器并行处理,突破单台机器的硬件资源上限。
  • 软件资源瓶颈:使用资源池化技术,比如数据库连接池、Redis 连接池、线程池,通过复用资源减少创建销毁开销,同时控制并发访问量,避免资源耗尽。

五、Java 线程的 6 种状态与转换

Java 线程在整个生命周期中共有 6 种状态,明确定义在Thread.State枚举中。理解状态流转是排查并发问题的基础。

5.1 六种线程状态详解

  1. NEW(新建) 线程对象刚被创建,还未调用start()方法。此时只是一个普通的 Java 对象,还不是操作系统层面的真实线程。
  2. RUNNABLE(可运行) 调用start()方法后进入该状态。Java 层面的 RUNNABLE 对应操作系统的就绪(Ready)运行中(Running)两种状态:线程可能正在被 CPU 执行,也可能在等待 CPU 分配时间片。
  3. BLOCKED(阻塞) 线程等待获取 synchronized 监视器锁时进入该状态。例如一个线程正在执行同步代码块,其他尝试进入同步块的线程就会处于 BLOCKED 状态。
  4. WAITING(无限等待) 线程调用无超时的等待方法后进入该状态,必须由其他线程主动唤醒才能继续。触发方法包括:Object.wait()Thread.join()LockSupport.park()
  5. TIMED_WAITING(超时等待) 线程调用带超时时间的等待方法后进入该状态,超时时间结束后自动唤醒。触发方法包括:Thread.sleep(long)Object.wait(long)Thread.join(long)LockSupport.parkNanos()等。
  6. TERMINATED(终止) 线程正常执行完run()方法,或者因未捕获的异常退出,线程生命周期结束。

5.2 核心方法对比与状态变更

wait、sleep、join 等方法是并发编程的常用 API,很多开发者容易混淆其特性,下表从多个维度对比核心方法:

方法 所属类 是否释放锁 进入状态 唤醒条件
wait() Object 类 是,释放 monitor 锁 WAITING 其他线程调用 notify/notifyAll
sleep(long) Thread 类 否,不释放任何锁 TIMED_WAITING 睡眠时间结束
join() Thread 类 是(底层基于 wait 实现) WAITING 目标线程执行完毕
yield() Thread 类 保持 RUNNABLE 立刻重新参与 CPU 时间片竞争
park() LockSupport 类 WAITING 其他线程调用 unpark / 线程被中断
notify() Object 类 - 唤醒一个等待线程到 BLOCKED 状态 -
notifyAll() Object 类 - 唤醒所有等待线程到 BLOCKED 状态 -

补充说明:

  • yield()只是主动让出 CPU 时间片,线程依然处于 RUNNABLE 状态,会立即重新参与 CPU 调度,不会进入等待队列。
  • park()/unpark()基于许可机制实现,比 wait/notify 更灵活:可以先调用unpark()发放许可,后续调用park()时就不会阻塞。

六、线程的初始化与中断机制

6.1 线程的构造与初始化

创建 Thread 对象时,所有构造方法最终都会调用内部的init()方法完成初始化,主要执行以下逻辑:

  • 绑定线程组,设置线程名称、优先级、守护线程标记
  • 保存待执行的 Runnable 任务对象
  • 分配线程 ID,设置线程堆栈大小
  • 继承父线程的上下文类加载器、可继承 ThreadLocal 等属性

注意:new Thread()只是创建了 Java 堆中的对象,只有调用start()方法后,JVM 才会向操作系统申请创建真实的内核线程。

6.2 线程中断机制

Java 线程中断是协作式的,它不会强制终止线程,只是给线程发送一个中断信号,由线程自身决定如何响应中断。

三个核心中断方法:

  1. interrupt():实例方法,给目标线程设置中断标记为 true。如果线程正处于 WAITING/TIMED_WAITING 状态,会抛出InterruptedException并清除中断标记。
  2. isInterrupted():实例方法,检查当前线程的中断标记,不会改变标记的状态。
  3. Thread.interrupted():静态方法,检查当前线程的中断标记,检查后会清除中断标记(重置为 false)。

工程最佳实践:捕获InterruptedException后,不要生吞异常,要么向上层抛出,要么在 catch 块中重新调用interrupt()设置中断标记,保证上层逻辑能感知到中断请求。

七、线程间通信的核心机制

Java 线程间通信主要有两种实现思路:基于共享内存的同步机制,以及基于等待通知的协作机制;此外 ThreadLocal 实现了线程间的数据隔离,从另一个维度解决并发问题。

7.1 共享内存模型:三大特性与解决手段

Java 内存模型(JMM)规定:所有共享变量存储在主内存,每个线程有自己独立的工作内存,线程操作变量必须先从主内存拷贝到工作内存,操作完成后再写回主内存。这一模型带来了三大并发问题:

  • 可见性:一个线程修改了变量,其他线程无法立刻感知
  • 原子性:一个操作执行到一半可能被其他线程打断
  • 有序性:编译器和 CPU 可能对指令重排序,导致多线程下执行结果异常
7.1.1 volatile 关键字

volatile 是轻量级同步机制,解决可见性和有序性问题,不保证原子性

底层实现原理

  • 可见性:通过内存屏障实现。对 volatile 变量写操作后插入写屏障,强制将工作内存数据刷新回主内存;读操作前插入读屏障,强制从主内存读取最新数据。
  • 有序性:禁止指令重排序。内存屏障会保证屏障之前的所有指令一定在屏障之后的指令之前执行,避免重排序导致的并发问题。

经典使用场景

  1. 状态标记:例如用volatile boolean标记服务运行状态,一个线程修改标记,其他线程立刻可见。
  2. 双重校验锁(DCL)单例:禁止对象创建过程中的指令重排序,避免其他线程拿到半初始化的对象
7.1.2 synchronized 关键字

synchronized 是重量级同步锁,保证原子性和可见性,但不能完全禁止指令重排序(仅保证锁内代码符合 as-if-serial 语义,单线程执行结果不受重排序影响)。

底层实现原理: synchronized 基于对象头的 Monitor 监视器实现,锁会按照竞争程度逐级升级:

  • 偏向锁:只有一个线程访问时,锁直接偏向该线程,几乎无额外开销
  • 轻量级锁:多线程交替访问时,通过 CAS 自旋获取锁,不阻塞线程
  • 重量级锁:多线程同时竞争时,升级为重量级锁,线程阻塞挂起,依赖操作系统调度

正因为 synchronized 无法禁止锁外的指令重排序,所以 DCL 单例模式中,即使加了 synchronized,也必须用 volatile 修饰单例对象。

7.2 等待通知机制

等待通知是线程协作的经典模式:一个线程因条件不满足而主动等待,另一个线程修改条件后通知唤醒等待线程。

等待通知是线程协作的经典模式:一个线程因条件不满足而主动等待,另一个线程修改条件后通知唤醒等待线程。

7.2.1 wait/notify/notifyAll

这三个方法都是 Object 类的原生方法,必须在 synchronized 同步块中调用,因为需要操作对象的监视器锁。

标准执行流程:

  1. 线程 A 获取锁,判断业务条件不满足,调用wait()方法,释放锁并进入 WAITING 状态
  2. 线程 B 获取锁,修改业务条件,调用notifyAll()通知所有等待线程
  3. 线程 A 被唤醒,重新竞争锁,获取锁后从wait()处继续执行

虚假唤醒问题: 操作系统层面,线程可能在没有被 notify 的情况下自发唤醒,称为虚假唤醒。因此判断条件时必须使用while循环而非if,每次被唤醒后都重新检查条件,不满足则继续等待。

7.2.2 join 方法的底层原理

thread.join()的作用是让当前线程等待目标线程执行完毕后再继续运行。

其底层实现非常简洁:join 方法内部调用wait()方法让当前线程进入等待;当目标线程执行完毕时,JVM 会自动调用该线程对象的notifyAll()方法,唤醒所有等待的线程。

7.3 ThreadLocal:线程隔离的数据存储

ThreadLocal 提供了线程私有的变量副本,每个线程只能访问自己的副本,线程之间互不干扰,通过数据隔离的方式规避了并发问题。

7.3.1 存储结构

每个 Thread 对象内部都持有一个ThreadLocalMap成员变量,ThreadLocalMap 是一个自定义的哈希表,以ThreadLocal 对象为 Key,以存储的业务值为 Value。

也就是说,ThreadLocal 本身不存储数据,它只是一个访问入口,真正的数据存储在每个线程自身的 ThreadLocalMap 中。

7.3.2 Key 为什么设计成弱引用?

ThreadLocalMap 中的 Entry 继承自WeakReference,Entry 的 Key(ThreadLocal 对象)是弱引用类型。

设计原因:如果 ThreadLocal 对象没有外部强引用,发生 GC 时弱引用的 Key 就会被回收,Entry 的 Key 变为 null。如果设计成强引用,即使 ThreadLocal 对象已经不再使用,只要线程还存活,ThreadLocalMap 中的 Entry 就永远无法回收,造成严重的内存泄漏。

7.3.3 存在的问题与 GC 时机

弱引用设计并不能完全解决内存泄漏问题:Key 被回收后变为 null,但 Value 是强引用,只要线程不终止,Value 就一直无法被回收;在线程池场景下,线程会长期复用,Value 会一直累积,最终导致内存泄漏。

GC 触发时机:当 ThreadLocal 对象没有外部强引用时,下一次 Young GC 或 Full GC 就会回收弱引用的 Key 对象。

避免内存泄漏的最佳实践: 每次使用完 ThreadLocal 后,主动调用remove()方法手动清除对应的 Entry,这是最可靠、最彻底的内存泄漏规避方案。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐