一、引言:为什么必须深入理解多线程

在现代软件开发中,多线程已经不再是一个“可选加分项”,而是高性能系统的底层基础设施。无论是 Web 服务同时处理成千上万个请求,还是移动端保持界面流畅不卡顿,无论是数据库连接池的并发调度,还是大数据平台的并行计算,多线程都无处不在。可以毫不夸张地说,一个合格的后端工程师、客户端工程师或基础架构工程师,都无法绕开对多线程的深入理解。

然而,多线程也是一把典型的双刃剑。它在带来吞吐量提升、资源利用率提高和响应速度改善的同时,也引入了一系列让人头疼的问题:数据竞争、内存可见性陷阱、指令重排序、死锁、活锁、饥饿、上下文切换开销、伪共享、线程泄漏……这些问题往往具有隐蔽性、偶发性和难以复现的特点。一个在测试环境跑了上百次都没问题的代码,上线后却可能在高并发下突然崩溃或产出错误数据,让开发者陷入“白天改 Bug,深夜查日志”的困境。

从“会写代码”到“写好代码”,从“能跑通功能”到“能在生产环境稳定运行”,中间隔着的正是对多线程原理和常见问题的深刻理解。很多开发者虽然每天都在使用线程池、锁和并发集合,却对线程安全三大特性、锁升级机制、缓存一致性等底层知识一知半解。当系统真正出现性能瓶颈或偶发数据错误时,只能靠猜测和尝试,无法快速定位根因。

本文将以 Java 为主要演示语言,系统梳理多线程的底层概念、优点、缺点与代价,深入剖析原子性、可见性、有序性、竞态条件、死锁、活锁、饥饿、上下文切换、伪共享、线程泄漏等高频问题,并给出大量可运行的代码示例和可落地的解决方案。全文预计超过两万字,适合希望系统补齐多线程知识体系、提升并发编程能力的开发者阅读。

之所以选择 Java 作为示例语言,是因为 Java 拥有清晰的内存模型(JMM)、成熟的虚拟机线程状态管理,以及功能完备的 java.util.concurrent 并发工具包,是学习和讲解多线程的经典语言。但必须强调的是,本文讨论的绝大多数原理——原子性、可见性、有序性、死锁的四个必要条件、上下文切换开销、缓存伪共享等——都是语言无关的,同样适用于 C++、Python、Go、C#、Rust 等其他语言。理解了这些底层规律,无论后续切换哪种语言,你都能更快地识别并解决并发问题。

本文适合已经掌握基本编程能力、希望系统补齐多线程知识的开发者阅读。建议读者在阅读过程中动手运行文中的示例代码,亲自观察并发 Bug 的产生与修复过程,这样记忆才会更深刻。

二、多线程基础概念:先把地基打牢

很多开发者在学习多线程时急于上手锁和线程池,却忽略了一些最基础的概念。然而大量疑难 Bug 的根源,恰恰藏在这些看似简单的概念里。本章先把进程与线程、并发与并行、线程生命周期和线程创建方式讲清楚,为后续理解优缺点和常见问题打下坚实基础。

2.1 进程与线程:资源分配与执行调度的两个视角

进程(Process)是操作系统进行资源分配和调度的基本单位。每运行一个程序,操作系统就会为它创建一个或多个进程。进程拥有独立的内存地址空间、文件描述符、信号处理表等资源。进程之间的隔离性非常强,一个进程的崩溃通常不会直接影响其他进程,但这也意味着进程间通信(IPC)需要借助管道、消息队列、共享内存、Socket 等机制,开发和运行开销都相对较大。

线程(Thread)是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程内部的实际执行单元。同一个进程内的多个线程共享该进程的内存空间(堆、方法区、常量池等),但每个线程又拥有自己独立的程序计数器、虚拟机栈和本地方法栈。由于线程之间共享内存,线程间通信变得非常方便,只需要读写共享变量即可。但也正是因为“共享”,才引出了后文要反复讨论的线程安全问题——共享数据一旦被并发读写,就必须谨慎处理同步。

可以打一个比方:进程像一家公司,拥有独立的办公楼、设备和资金;线程像公司里的员工,他们共享公司的办公资源,各自负责不同的任务。员工之间协作方便、沟通成本低,但如果不对公共资源的使用加以管理,就很容易在会议室、打印机、共享文档上产生冲突。这个类比能帮助理解后文几乎所有问题的来源。

2.2 并发与并行:两个经常被混用的概念

“并发”和“并行”在日常表达中经常被混用,但它们在计算机领域有本质区别。理解这一点,是理解后文“上下文切换开销”和“线程数配置”的关键:并发并不天然带来性能提升,切换本身也有成本;并行才可能真正缩短总执行时间。

并发(Concurrency)指多个任务在同一时间段内交替执行。在单核 CPU 上,操作系统通过时间片轮转,让多个线程快速切换执行。从宏观上看,多个任务像是在“同时”运行;但从微观上看,任意时刻只有一个线程真正占用 CPU。并发强调的是“任务调度”和“逻辑上的同时”。

并行(Parallelism)指多个任务在同一时刻真正同时执行。这需要多核 CPU 的支持,每个核心在同一时刻可以运行不同的线程。并行强调的是“物理上的同时执行”。

一个通俗的类比:并发是“一个人同时处理多件事,通过快速切换来实现”——比如厨师一边煮汤一边切菜,表面上看同时在做两件事,实际是来回切换;并行是“多个人同时处理多件事”——比如厨房里有多个厨师,各自独立负责不同的菜品。对于 CPU 密集型计算任务,只有真正的并行才能减少总耗时;而单纯的并发切换,甚至可能因为切换开销而让总耗时变长。

2.3 线程的生命周期:排查“线程卡住”问题的基础

在 Java 中,线程的生命周期可以用 Thread.State 枚举表示,共包含六种状态:

  • NEW(新建):线程对象已经被创建,但尚未调用 start() 方法。此时线程还未真正启动。
  • RUNNABLE(可运行):线程已经调用了 start(),正在等待 CPU 时间片或正在执行。Java 把操作系统层面的“就绪”和“运行中”两种状态统一称为 RUNNABLE。
  • BLOCKED(阻塞):线程正在等待获取一个监视器锁(synchronized 锁),试图进入同步代码块或同步方法,但锁被其他线程持有。
  • WAITING(无限等待):线程无限期等待另一个线程执行特定操作,例如调用了 Object.wait()、Thread.join() 或 LockSupport.park()。
  • TIMED_WAITING(限时等待):与 WAITING 类似,但带有超时时间,例如调用了 Thread.sleep(long)、Object.wait(long)、Thread.join(long) 等。
  • TERMINATED(终止):线程的 run() 方法执行完毕,或者因未捕获的异常退出。

理解线程状态迁移,对排查“线程卡住”“线程不结束”“系统响应变慢”等问题非常重要。例如,后文讨论死锁时会看到,死锁的一个典型特征就是多个线程都长时间处于 BLOCKED 状态,并且互相持有对方需要的锁。再比如,线程池中大量线程处于 WAITING 状态,通常是它们在等待任务到来,这是正常现象;但如果所有线程都处于 BLOCKED,就需要警惕是否发生了锁竞争或死锁。

2.4 线程的创建方式:三种写法的取舍

在 Java 中创建线程主要有三种方式,下面分别给出示例并分析优缺点。

方式一:继承 Thread 类

public class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " 正在执行");
    }
public static void main(String[] args) {
    MyThread t1 = new MyThread();
    MyThread t2 = new MyThread();
    t1.start();
    t2.start();
}
}

这种方式简单直接,适合线程逻辑非常简单的场景。但由于 Java 是单继承,继承了 Thread 之后就无法再继承其他类,灵活性和扩展性较差。同时,任务和线程耦合在一起,不利于后续使用线程池。

方式二:实现 Runnable 接口

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " 正在执行");
    }
public static void main(String[] args) {
    Thread t1 = new Thread(new MyRunnable(), "线程A");
    Thread t2 = new Thread(new MyRunnable(), "线程B");
    t1.start();
    t2.start();
}
}

实现 Runnable 接口是更推荐的写法。它把“任务”和“线程”分离:Runnable 描述要做什么,Thread 负责怎么执行。这符合面向对象设计原则,也天然适合交给线程池管理。实际开发中,除非确实需要扩展 Thread 类的行为,否则应优先使用 Runnable。

方式三:实现 Callable 接口(支持返回值和异常)

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 100; i++) {
sum += i;
}
return sum;
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
    FutureTask&lt;Integer&gt; futureTask = new FutureTask&lt;&gt;(new MyCallable());
    Thread thread = new Thread(futureTask);
    thread.start();
    Integer result = futureTask.get();
    System.out.println("计算结果: " + result);
}
}

Callable 与 Runnable 的核心区别在于:call() 方法可以返回结果,并且允许抛出受检异常。配合 FutureTask 和线程池,Callable 是处理“有返回值并发任务”的首选,也是后续 CompletableFuture 等异步编程工具的基础。

三、多线程的优点:它为什么值得用

多线程之所以被广泛应用,是因为它能够带来一系列实实在在的收益。本章从资源利用率、响应速度、多核计算能力、异步模型和程序结构五个维度展开。理解这些优点之后,也就能理解为什么在高并发服务、图形界面、大数据计算等场景中,多线程几乎是必然选择。

3.1 提高资源利用率:让 CPU 不再空转

在单线程程序中,当一个线程执行 I/O 操作(读文件、访问数据库、调用远程接口)时,CPU 会处于空闲等待状态。由于 I/O 的速度远慢于 CPU 的运算速度,这种等待会造成巨大的资源浪费。举例来说,一次远程 HTTP 请求可能需要 100 毫秒,而现代 CPU 在这 100 毫秒内本可以执行数以亿计的指令。

在多线程环境下,当一个线程因 I/O 阻塞时,操作系统会调度其他线程使用 CPU,从而让 CPU 尽可能地保持忙碌,大幅提升系统整体吞吐量。这正是 Web 服务器普遍采用线程池模型的根本原因:每个请求由一个工作线程处理,当某个线程因为等待数据库响应而阻塞时,其他线程可以继续处理新请求,避免 CPU 空转。

3.2 提升响应速度:避免界面卡顿和 ANR

在图形界面(GUI)应用和移动应用开发中,如果主线程执行耗时操作(如网络请求、文件读写、复杂计算),界面就会出现“卡顿”,甚至触发系统“应用无响应”(ANR)提示。通过把耗时任务放到后台线程执行,主线程(UI 线程)可以及时响应用户的点击、滑动、输入等操作,显著提升用户体验。

以 Android 开发为例,系统强制要求网络请求必须在子线程中执行,否则会抛出 NetworkOnMainThreadException 异常。这就是多线程提升响应速度的典型应用。

下面是一个简单的 Java Swing 示例,演示如何通过后台线程避免界面卡顿:

import javax.swing.*;
import java.awt.event.ActionEvent;
public class ResponsiveUI {
public static void main(String[] args) {
JFrame frame = new JFrame("多线程响应示例");
JButton button = new JButton("开始耗时任务");
JTextArea textArea = new JTextArea(10, 40);
    button.addActionListener((ActionEvent e) -&gt; {
        // 在后台线程执行耗时任务,避免阻塞 UI
        new Thread(() -&gt; {
            StringBuilder result = new StringBuilder();
            for (int i = 1; i &lt;= 50; i++) {
                result.append("正在处理第 ").append(i).append(" 个任务\n");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ex) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
            // 回到 UI 线程更新界面
            SwingUtilities.invokeLater(() -&gt; textArea.setText(result.toString()));
        }).start();
    });
frame.setLayout(new java.awt.FlowLayout());
frame.add(button);
frame.add(new JScrollPane(textArea));
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.pack();
frame.setVisible(true);
}
}

在这个例子中,耗时任务在后台线程执行,主线程可以继续响应用户操作;任务完成后通过 SwingUtilities.invokeLater 切回 UI 线程更新界面,保证了界面流畅和线程安全。

3.3 充分利用多核 CPU 的计算能力

现代 CPU 普遍拥有 4 核、8 核甚至更多核心。单线程程序只能使用其中一个核心,其他核心处于闲置状态,这无疑是巨大的浪费。多线程能够让任务分布到不同核心上并行执行,充分释放多核 CPU 的计算潜力。

对于 CPU 密集型任务,理论上最理想的线程数通常接近 CPU 核心数。例如,对于一个在 8 核 CPU 上运行的图像处理程序,把图像分割成 8 块交给 8 个线程并行处理,理论上处理速度可以接近原来的 8 倍。下面使用 ForkJoinPool 演示并行求和:

import java.util.concurrent.RecursiveTask;
import java.util.concurrent.ForkJoinPool;
public class ParallelSum extends RecursiveTask<Long> {
private static final int THRESHOLD = 100_000;
private final long[] numbers;
private final int start;
private final int end;
public ParallelSum(long[] numbers, int start, int end) {
    this.numbers = numbers;
    this.start = start;
    this.end = end;
}
@Override
protected Long compute() {
int length = end - start;
if (length &lt;= THRESHOLD) {
long sum = 0;
for (int i = start; i &lt; end; i++) {
sum += numbers[i];
}
return sum;
}
int mid = start + length / 2;
ParallelSum leftTask = new ParallelSum(numbers, start, mid);
ParallelSum rightTask = new ParallelSum(numbers, mid, end);
leftTask.fork();
Long rightResult = rightTask.compute();
Long leftResult = leftTask.join();
return leftResult + rightResult;
}
public static void main(String[] args) {
long[] numbers = new long[20_000_000];
for (int i = 0; i &lt; numbers.length; i++) {
numbers[i] = i + 1;
}
ForkJoinPool pool = new ForkJoinPool();
ParallelSum task = new ParallelSum(numbers, 0, numbers.length);
long startTime = System.currentTimeMillis();
long result = pool.invoke(task);
long endTime = System.currentTimeMillis();
System.out.println("并行求和结果: " + result);
System.out.println("耗时: " + (endTime - startTime) + " ms");
}
}

ForkJoinPool 采用“分而治之”的思路,将大任务不断拆分为小任务并行执行,再合并结果。对于大规模计算任务,这种并行化能够带来显著的性能提升。需要注意的是,任务拆分的粒度要适中:拆得太细,任务调度和合并的开销会上升。

3.4 简化异步编程模型

在没有多线程的年代,处理多个“同时进行”的任务往往需要借助复杂的状态机、回调嵌套或者多进程通信。多线程提供了一种更自然的异步编程模型:每个任务可以拥有独立的执行流程,开发者可以用近似串行的思维编写每个线程的逻辑,而线程调度和切换由操作系统负责。

例如,一个电商下单流程可能包含“校验库存”“扣减库存”“生成订单”“发送通知”等步骤。使用多线程后,发送通知这类非关键路径的操作可以放到异步线程中执行,主流程无需等待,整个下单接口的响应时间大大缩短。

3.5 改善程序结构

多线程还可以让程序结构更清晰。把不同职责的任务放在不同线程中执行,符合“单一职责”原则。例如,一个网络聊天程序可以设计为:一个线程负责接收网络消息、一个线程负责发送消息、一个线程负责更新界面,每个线程各司其职,代码的可读性和可维护性都得到提升。

此外,使用线程池统一管理线程,可以避免在业务代码中到处手动创建线程,让资源管理和任务执行解耦,进一步改善代码结构。

3.6 优点小结:先分析任务类型再决定是否多线程

综合来看,多线程的核心优势可以归纳为“提高资源利用率”“提升系统吞吐量”和“提升响应速度”。但必须注意,多线程并非在所有场景下都能带来收益:对于 CPU 密集型任务,线程数过多反而会产生额外开销;对于简单小任务,创建和销毁线程的成本可能超过任务本身的执行成本。因此,在使用多线程之前,应先分析任务类型(CPU 密集型还是 I/O 密集型)、任务规模以及系统资源情况,避免盲目“为了多线程而多线程”。

四、多线程的缺点:每个收益背后都有代价

任何技术都有代价,多线程也不例外。如果不理解这些代价,就可能无意间引入严重的性能问题或难以排查的 Bug。本章详细拆解多线程的主要成本,帮助读者在架构选型和编码时做出更合理的权衡。

4.1 系统复杂度显著上升

多线程程序的行为具有不确定性。由于线程调度由操作系统决定,同一段代码在不同时间、不同机器上运行,线程的执行顺序和交错方式都可能不同。这使得多线程程序难以预测、难以调试、难以测试。一个在开发环境运行了数百次都没问题的程序,上线后可能在特定并发压力下才暴露 Bug。

多线程带来的复杂度体现在几个方面:

  • 时序依赖:程序结果可能依赖于线程执行的先后顺序,而这种顺序是不确定的。
  • 状态共享:多个线程访问共享数据时,需要仔细设计同步策略,稍有不慎就会出错。
  • 难以复现:很多并发 Bug 只有在特定时序下才会出现,难以稳定复现,给排查带来巨大困难。
  • 代码可读性下降:锁、volatile、synchronized、CountDownLatch 等同步机制会让代码逻辑变复杂,增加理解和维护成本。

4.2 上下文切换开销:线程不是越多越好

当线程数超过 CPU 核心数时,操作系统需要在多个线程之间频繁切换。每次切换都需要保存当前线程的执行现场(寄存器、程序计数器、栈指针等),并恢复另一个线程的执行现场,这个过程称为“上下文切换”。上下文切换单次耗时虽然很短(通常几微秒到几十微秒),但在高并发场景下,频繁切换累积起来会构成可观的 CPU 开销。

更重要的是,上下文切换会带来缓存失效。线程在当前 CPU 核心上运行时会使用该核心的 L1、L2 缓存;当线程被切换到另一个核心上执行时,原本的缓存数据无法直接利用,需要重新从内存甚至更低层级的存储加载数据,导致性能进一步下降。

下面通过一个实验直观感受“线程数过多反而更慢”:

public class ContextSwitchOverhead {
    private static final long COUNT = 10_000_000_000L;
public static void main(String[] args) throws InterruptedException {
    long start1 = System.currentTimeMillis();
    long sum1 = serialSum();
    long end1 = System.currentTimeMillis();
    System.out.println("单线程耗时: " + (end1 - start1) + " ms, 结果: " + sum1);
int threadNum = 40;
Thread[] threads = new Thread[threadNum];
long[] partialSums = new long[threadNum];
long perThread = COUNT / threadNum;
long start2 = System.currentTimeMillis();
for (int i = 0; i &amp;lt; threadNum; i++) {
final int index = i;
threads[i] = new Thread(() -&amp;gt; {
long sum = 0;
for (long j = 0; j &amp;lt; perThread; j++) {
sum++;
}
partialSums[index] = sum;
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
long end2 = System.currentTimeMillis();
long total = 0;
for (long s : partialSums) {
total += s;
}
System.out.println("40线程耗时: " + (end2 - start2) + " ms, 结果: " + total);
}
private static long serialSum() {
long sum = 0;
for (long i = 0; i &lt; COUNT; i++) {
sum++;
}
return sum;
}
}

在核心数较少的机器上运行这段代码,很可能发现“40 个线程”的版本反而比单线程更慢,或者提升远不如预期。原因就是大量线程争抢有限的 CPU 核心,频繁的上下文切换消耗了大量时间。这个实验清楚地说明:线程并非越多越好,当线程数超过合理范围后,上下文切换开销会抵消甚至超过并行带来的收益。

4.3 数据一致性与线程安全问题

多线程共享内存是一把双刃剑:它让线程间通信变得简单,但也让数据一致性维护变得困难。当多个线程同时读写共享变量时,如果没有适当的同步机制,就会出现数据竞争(Data Race),导致计算结果错误、数据丢失或程序状态异常。这就是著名的“线程安全”问题,后文将深入剖析。

一个经典例子是“i++ 不是原子操作”。下面的代码看似简单,实际会产生错误结果:

public class CounterProblem {
    private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
    Thread[] threads = new Thread[1000];
    for (int i = 0; i &lt; threads.length; i++) {
        threads[i] = new Thread(() -&gt; {
            for (int j = 0; j &lt; 1000; j++) {
                counter++;
            }
        });
        threads[i].start();
    }
    for (Thread t : threads) {
        t.join();
    }
    System.out.println("期望结果: 1000000");
    System.out.println("实际结果: " + counter);
}
}

理论上 1000 个线程各执行 1000 次自增,结果应该是 1,000,000。但实际运行结果几乎总是小于这个值,且每次运行都可能不同。原因在于 counter++ 实际上包含“读取、加一、写回”三个步骤,在多线程环境下这些步骤可能交错执行,导致部分自增操作丢失。这就是典型的数据一致性问题。

4.4 可能产生死锁和饥饿

多线程程序如果不当使用锁,可能产生死锁(Deadlock)和饥饿(Starvation)等严重问题。

死锁指两个或多个线程互相持有对方所需的资源,并且都在等待对方释放资源,导致所有相关线程都无法继续推进。死锁一旦发生,相关线程会永久阻塞,系统资源被持续占用,可能导致整个服务不可用。

饥饿指某个线程因为无法获得所需资源(如 CPU 时间片、锁)而长期无法执行,而其他线程持续占用资源。饥饿不一定是永久性的,但会严重影响系统公平性和响应性。例如,如果某个锁被部分线程持续抢占,另一个低优先级线程可能一直得不到执行机会。

死锁和饥饿的具体成因、示例与解决方案将在第五章详细讨论。

4.5 资源消耗:线程本身也占内存和系统资源

在 JVM 中,每个线程都会分配独立的虚拟机栈,默认大小通常在 256KB 到 1MB 之间(可通过 -Xss 参数调整)。如果一个进程创建了成千上万个线程,仅栈内存就会占用数 GB,可能导致 OutOfMemoryError。同时,操作系统对线程数量也有上限,过多的线程会耗尽系统资源。

除了内存,线程的创建和销毁本身也有开销。频繁创建和销毁线程会带来时间成本,因此实际开发中通常使用线程池复用线程。下面是一段可能导致内存溢出的反面示例,实际项目中切勿这样做:

public class ThreadResourceExhaustion {
    public static void main(String[] args) {
        int count = 0;
        try {
            while (true) {
                Thread thread = new Thread(() -> {
                    try {
                        Thread.sleep(60_000);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
                thread.start();
                count++;
                if (count % 1000 == 0) {
                    System.out.println("已创建线程数: " + count);
                }
            }
        } catch (OutOfMemoryError e) {
            System.out.println("发生内存溢出,此时线程总数约为: " + count);
        }
    }
}

这段代码不断创建永远不会结束的线程,最终会抛出 OutOfMemoryError,直观展示了无节制创建线程的严重后果。

4.6 测试和调试更加困难

多线程程序的执行路径呈指数级增长,测试时很难覆盖所有可能的线程交错情况。一个并发 Bug 可能只在特定线程调度顺序下出现,而在通常在测试中难以复现。此外,传统断点调试在多线程环境中也会受到干扰——当你在一个线程中下断点时,其他线程仍在运行,观察到的程序状态只是“某一瞬间的快照”,难以还原完整执行时序。

针对这些问题,开发者通常需要借助专门的并发分析工具(如 JConsole、VisualVM、jstack、Arthas)以及更谨慎的代码审查来辅助排查。这也从侧面反映了多线程带来的工程复杂度。

4.7 缺点的本质:两个根本矛盾

归根结底,多线程的缺点源于两个核心矛盾:共享与竞争并发与顺序。共享数据带来了竞争,竞争需要同步,同步带来了复杂度和性能开销;并发的本质是执行顺序的不确定,而这种不确定性与人类习惯的顺序思维产生了冲突。理解这两个矛盾,就能更深刻地理解后文关于线程安全和锁机制的讨论。

五、多线程常见问题及解决方案:核心攻坚

本章是全文的重点。我们将系统剖析多线程编程中最常见的几类问题:线程安全三大特性(原子性、可见性、有序性)、竞态条件、死锁、活锁与饥饿、上下文切换与伪共享、线程泄漏等。每一类问题都会先给出描述和成因,再通过可运行代码复现,最后给出解决方案。

5.1 线程安全的三大核心问题

要理解所有多线程数据问题,必须掌握三个核心概念:原子性(Atomicity)可见性(Visibility)有序性(Ordering)。几乎所有线程安全问题都可以归结为这三者之一或它们的组合。

5.1.1 原子性问题:操作被打断导致的更新丢失

原子性指一个或多个操作在执行过程中不会被任何因素中断,要么全部执行成功,要么全部不执行。在单线程环境下,代码按编写顺序执行,不存在“执行到一半被打断”的情况;但在多线程环境下,多个线程的操作会交错执行,一个线程的“读—改—写”操作可能在中间被打断。

最典型的例子就是 counter++。它在 Java 字节码层面被拆分为多个指令:读取 counter 的值、将值加一、将新值写回。假设 counter 初始值为 0,线程 A 和线程 B 同时执行 counter++,可能出现如下交错:

  1. 线程 A 读取 counter 的值,得到 0。
  2. 线程 A 还没来得及加一和写回,时间片耗尽,暂停执行。
  3. 线程 B 读取 counter 的值,得到 0(因为 A 还没有写回)。
  4. 线程 B 完成加一,将 1 写回 counter。
  5. 线程 A 恢复执行,在之前读取的 0 基础上加一,将 1 写回 counter。

最终 counter 的值是 1,而正确结果应该是 2。两个线程各执行了一次自增,但由于操作不具备原子性,丢失了一次更新。

解决方案有三种:

  • 使用 synchronized 关键字保证代码块在同一时刻只有一个线程执行。
  • 使用 java.util.concurrent.atomic 包下的原子类(如 AtomicInteger、AtomicLong),它们基于 CAS 实现无锁原子操作。
  • 使用显式锁 Lock(如 ReentrantLock)。

下面是使用 AtomicInteger 解决自增问题的示例:

import java.util.concurrent.atomic.AtomicInteger;
public class AtomicSolution {
private static final AtomicInteger counter = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
    Thread[] threads = new Thread[1000];
    for (int i = 0; i &lt; threads.length; i++) {
        threads[i] = new Thread(() -&gt; {
            for (int j = 0; j &lt; 1000; j++) {
                counter.incrementAndGet();
            }
        });
        threads[i].start();
    }
    for (Thread t : threads) {
        t.join();
    }
    System.out.println("期望结果: 1000000");
    System.out.println("实际结果: " + counter.get());
}
}

AtomicInteger 的 incrementAndGet() 方法通过 CAS 保证“读取—加一—写回”的原子性,从而得到正确结果。

5.1.2 可见性问题:修改了却看不到

可见性指当一个线程修改了共享变量的值后,其他线程能够立即看到这个修改。多线程环境下的可见性问题源于两方面:一是 CPU 缓存,二是 JVM 的指令优化。

现代 CPU 通常拥有多级缓存(L1、L2、L3)。当线程 A 修改变量时,新值可能先写入线程 A 所在 CPU 核心的缓存,尚未刷新到主内存;此时如果线程 B 在另一个核心上读取该变量,读取到的可能是自己核心缓存中的旧值,而不是线程 A 写入的新值。Java 内存模型(JMM)规定,线程对共享变量的操作都在自己的工作内存中进行,何时刷新到主内存、何时从主内存读取,在没有同步机制的情况下都是不确定的。

下面通过经典示例演示可见性问题:

public class VisibilityProblem {
    private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
    Thread reader = new Thread(() -&gt; {
        int loopCount = 0;
        while (!flag) {
            loopCount++;
        }
        System.out.println("读线程观察到了 flag 变为 true,循环次数: " + loopCount);
    });
reader.start();
Thread.sleep(1000);
new Thread(() -&amp;gt; {
flag = true;
System.out.println("写线程已将 flag 置为 true");
}).start();
reader.join(5000);
if (reader.isAlive()) {
System.out.println("读线程 5 秒后仍未退出,出现了可见性问题");
reader.interrupt();
}
}
}

在这段代码中,主线程启动一个读线程,该线程在 flag 为 false 时持续循环;1 秒后另一个线程将 flag 置为 true。理想情况下读线程应立即退出循环。但由于没有同步机制,flag 的修改可能只存在于写线程所在核心的缓存中,读线程可能永远看不到 flag=true,从而陷入死循环。在 Server 模式下,JIT 编译器还可能把 while(!flag) 优化成类似 if(!flag) 的无限循环,进一步加剧问题。

解决方案

  • 将共享变量声明为 volatile。volatile 保证变量的可见性:一个线程对 volatile 变量的修改会立即刷新到主内存,其他线程读取时也能立即看到最新值。
  • 使用 synchronized 或 Lock。锁的获取和释放会强制刷新缓存,保证可见性。
  • 使用原子类。原子类内部也保证了可见性。

下面是使用 volatile 修复可见性问题的代码:

public class VisibilityFixed {
    private static volatile boolean flag = false;
public static void main(String[] args) throws InterruptedException {
    Thread reader = new Thread(() -&gt; {
        int loopCount = 0;
        while (!flag) {
            loopCount++;
        }
        System.out.println("读线程观察到了 flag 变为 true,循环次数: " + loopCount);
    });
reader.start();
Thread.sleep(1000);
new Thread(() -&amp;gt; {
flag = true;
System.out.println("写线程已将 flag 置为 true");
}).start();
reader.join(5000);
if (reader.isAlive()) {
System.out.println("读线程仍未退出,出现意外情况");
reader.interrupt();
} else {
System.out.println("读线程已正常退出,可见性问题已解决");
}
}
}

需要特别强调:volatile 只能保证可见性,不能保证原子性。对于 counter++ 这种“读—改—写”操作,仅仅加 volatile 是不够的,仍然会丢失更新。volatile 适用于“一个线程写、多个线程读”或“状态标志位”这类场景。这个误区在面试和实际开发中都很常见,务必牢记。

5.1.3 有序性问题:指令重排序带来的诡异 Bug

有序性指程序执行的顺序与代码编写的顺序一致。在单线程环境下,虽然编译器、JVM 和 CPU 可能会进行指令重排序,但这种重排序受到“as-if-serial”语义的约束,保证单线程环境的最终结果与顺序执行一致。然而在多线程环境下,指令重排序可能破坏跨线程的预期顺序,导致程序行为异常。

最经典的例子是“双重检查锁定(DCL)”的单例模式,在早期 Java 版本中存在严重的有序性问题。先看有问题的写法:

public class DclSingletonProblem {
    private static DclSingletonProblem instance;
private DclSingletonProblem() {}
public static DclSingletonProblem getInstance() {
if (instance == null) {                    // 第一次检查
synchronized (DclSingletonProblem.class) {
if (instance == null) {            // 第二次检查
instance = new DclSingletonProblem();  // 可能发生重排序
}
}
}
return instance;
}
}

问题出在 instance = new DclSingletonProblem() 这一行。它实际上包含三个步骤:

  1. 分配内存空间。
  2. 初始化对象(调用构造方法)。
  3. 将 instance 引用指向分配的内存地址。

在没有同步保证的情况下,步骤 2 和步骤 3 可能被重排序:先执行步骤 3(instance 指向内存地址,但对象尚未初始化),再执行步骤 2。此时另一个线程进入 getInstance(),在第一次检查时发现 instance 不为 null,直接返回了一个尚未完成初始化的对象,使用该对象时就会出错。这就是有序性问题。

解决方案是使用 volatile 修饰 instance,禁止步骤 2 和步骤 3 的重排序。volatile 除了保证可见性,还通过在读写位置插入内存屏障来禁止特定类型的指令重排序。

public class DclSingletonFixed {
    private static volatile DclSingletonFixed instance;
private DclSingletonFixed() {
    System.out.println("单例对象已创建");
}
public static DclSingletonFixed getInstance() {
if (instance == null) {
synchronized (DclSingletonFixed.class) {
if (instance == null) {
instance = new DclSingletonFixed();
}
}
}
return instance;
}
public static void main(String[] args) {
for (int i = 0; i &lt; 50; i++) {
new Thread(() -&gt; DclSingletonFixed.getInstance()).start();
}
}
}

实际上,更简洁可靠的方案是直接使用枚举实现单例。枚举天然具备序列化安全和反射安全,是《Effective Java》推荐的方式。这里使用 DCL 主要是为了讲解有序性问题。

5.2 竞态条件:结果依赖不可控的执行顺序

竞态条件(Race Condition)指程序的执行结果依赖于多个线程执行的相对顺序或交错方式,而这个顺序是不受控的。竞态条件与原子性问题密切相关,但侧重点不同:原子性关注“操作是否会被打断”,竞态条件关注“结果是否依赖于不正确的执行顺序”。

竞态条件的经典模式是“检查—然后—行动(Check-Then-Act)”:先检查一个条件,再根据检查结果采取行动,但在检查与行动之间,另一个线程可能改变了条件,导致行动基于过期信息。下面是一个延迟初始化的竞态条件示例:

public class LazyInitRace {
    private static ExpensiveObject instance = null;
public static ExpensiveObject getInstance() {
    if (instance == null) {   // 检查
        instance = new ExpensiveObject();  // 行动
    }
    return instance;
}
static class ExpensiveObject {
private int id;
ExpensiveObject() {
id = (int) (Math.random() * 1000);
try {
Thread.sleep(50);  // 模拟初始化耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("创建新实例, id=" + id);
}
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[20];
for (int i = 0; i &lt; threads.length; i++) {
threads[i] = new Thread(() -&gt; {
ExpensiveObject obj = LazyInitRace.getInstance();
// 使用 obj
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
}
}

运行这段代码,很可能看到多个“创建新实例”的输出,说明 instance 被创建了多次。原因在于多个线程同时通过了 instance == null 的检查,然后各自创建了新的对象。这就是典型竞态条件。解决方案是使用同步(如 synchronized 方法、双重检查加锁配合 volatile),或者直接采用线程安全的单例实现。

5.3 死锁:最令人头疼的并发问题

死锁是多线程编程中最棘手的问题之一。下面从定义、产生条件、代码复现、排查方法和解决方案几个层面详细剖析。

5.3.1 死锁的定义与四个必要条件

死锁指两个或多个线程在执行过程中,因争夺共享资源而互相等待,导致所有相关线程都无法继续推进的状态。死锁发生时,相关线程会永久阻塞,不会自动解除,除非外部干预(如杀死进程、强制释放资源)。

根据操作系统理论,死锁的产生必须同时满足以下四个必要条件:

  1. 互斥条件(Mutual Exclusion):资源在同一时刻只能被一个线程持有,其他线程若要使用该资源必须等待。
  2. 持有并等待(Hold and Wait):线程已经持有一个资源,同时又在等待获取其他线程持有的资源,且在等待期间不释放已持有的资源。
  3. 不可剥夺(No Preemption):线程持有的资源不能被其他线程强制抢占,只能由持有者主动释放。
  4. 循环等待(Circular Wait):存在一个线程集合,其中每个线程都在等待下一个线程持有的资源,形成一个环形的等待链。

只要破坏这四个必要条件中的任意一个,就可以避免死锁。这为死锁解决方案提供了理论基础。

5.3.2 死锁代码复现

下面是一段经典的双资源死锁代码:

public class DeadlockDemo {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();
public static void main(String[] args) {
    Thread thread1 = new Thread(() -&gt; {
        synchronized (lockA) {
            System.out.println(Thread.currentThread().getName() + " 获取了锁 A");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            synchronized (lockB) {
                System.out.println(Thread.currentThread().getName() + " 获取了锁 B");
            }
        }
    }, "线程1");
Thread thread2 = new Thread(() -&amp;gt; {
    synchronized (lockB) {
        System.out.println(Thread.currentThread().getName() + " 获取了锁 B");
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        synchronized (lockA) {
            System.out.println(Thread.currentThread().getName() + " 获取了锁 A");
        }
    }
}, "线程2");
thread1.start();
thread2.start();
}
}

运行这段代码,控制台会先打印“线程1 获取了锁 A”和“线程2 获取了锁 B”,然后程序就卡住。原因是:线程1 持有锁 A、等待锁 B;线程2 持有锁 B、等待锁 A;两者互相等待对方释放锁,形成循环等待,产生死锁。

5.3.3 死锁的排查方法

当线上服务疑似发生死锁时,常用排查手段包括:

  • jstack:使用 jstack 命令打印 JVM 线程快照。如果检测到死锁,jstack 输出中会明确显示“Found one Java-level deadlock”,并列出发生死锁的线程以及它们各自持有的锁和等待的锁。
  • jconsole 和 VisualVM:通过图形化界面查看线程状态,通常有“检测死锁”按钮,能直观展示死锁线程信息。
  • Arthas:阿里开源的 Java 诊断工具,使用 thread -b 命令可以找出当前阻塞其他线程的线程,有助于定位死锁源头。
  • 日志与线程 dump 监控:如果系统没有挂掉但响应变慢,可以周期性采集线程 dump,分析是否存在大量 BLOCKED 状态的线程。
5.3.4 死锁的解决方案

根据死锁的四个必要条件,可以设计多种预防策略。

方案一:破坏循环等待——按固定顺序加锁。让所有线程都按相同顺序获取锁(例如先锁 A 再锁 B),就不会形成循环等待。修复后的代码如下:

public class DeadlockFixedByOrder {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();
private static void acquireBoth(Object first, Object second) {
    synchronized (first) {
        System.out.println(Thread.currentThread().getName() + " 获取了第一把锁");
        synchronized (second) {
            System.out.println(Thread.currentThread().getName() + " 获取了第二把锁");
        }
    }
}
public static void main(String[] args) {
Thread thread1 = new Thread(() -&gt; acquireBoth(lockA, lockB), "线程1");
Thread thread2 = new Thread(() -&gt; acquireBoth(lockA, lockB), "线程2");
thread1.start();
thread2.start();
}
}

两个线程都先获取 lockA 再获取 lockB,锁的获取顺序一致,从根本上消除了循环等待。

方案二:破坏不可剥夺——使用 tryLock 设定超时。ReentrantLock 提供 tryLock(long timeout, TimeUnit unit) 方法,允许线程在指定时间内尝试获取锁,超时未获取到则主动放弃已持有的锁,从而打破死锁僵局。示例:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class DeadlockFixedByTryLock {
private static final ReentrantLock lockA = new ReentrantLock();
private static final ReentrantLock lockB = new ReentrantLock();
private static void acquireBoth(ReentrantLock first, ReentrantLock second) {
    while (true) {
        boolean gotFirst = false;
        boolean gotSecond = false;
        try {
            gotFirst = first.tryLock(1, TimeUnit.SECONDS);
            gotSecond = second.tryLock(1, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return;
        } finally {
            if (gotFirst &amp;&amp; gotSecond) {
                try {
                    System.out.println(Thread.currentThread().getName() + " 成功获取两把锁,执行业务逻辑");
                    return;
                } finally {
                    second.unlock();
                    first.unlock();
                }
            }
            if (gotFirst) first.unlock();
            if (gotSecond) second.unlock();
        }
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return;
        }
    }
}
public static void main(String[] args) {
new Thread(() -&gt; acquireBoth(lockA, lockB), "线程1").start();
new Thread(() -&gt; acquireBoth(lockB, lockA), "线程2").start();
}
}

即使线程以相反顺序请求锁,tryLock 也会在获取失败时释放已有锁并重试,避免永久阻塞。

方案三:破坏持有并等待——一次性申请所有资源。通过一个粗粒度锁或其他机制,让线程在开始执行前一次性获取所有需要的锁,如果获取不全就不持有任何锁地等待。这种方法实现较复杂,实践中使用相对较少。

5.4 活锁与饥饿:没有阻塞也可能出问题

5.4.1 活锁

活锁指线程虽然没有被阻塞,但由于某种原因始终无法推进,表现为线程“忙碌”地重复执行某些操作,却没有任何实质性进展。活锁与死锁的区别在于:死锁中的线程处于阻塞等待状态,不消耗 CPU;活锁中的线程处于活动状态,不断执行操作,但始终无法完成目标。

一个形象类比:两人在狭窄走廊迎面相遇,都礼貌地侧身让路,结果两人始终保持互相让路的姿态,谁也无法通过。下面用代码模拟活锁:

public class LivelockDemo {
    static class Diner {
        private final String name;
        private boolean isHungry = true;
    Diner(String name) {
        this.name = name;
    }
public void eatWith(Diner other) {
    int attempts = 0;
    while (isHungry) {
        if (other.isHungry) {
            System.out.println(name + ": 对方也饿了,我先让对方吃");
            attempts++;
            if (attempts &amp;gt; 5) {
                // 增加随机延迟打破对称,避免活锁
                isHungry = false;
                System.out.println(name + ": 不再等待,开始吃饭");
                break;
            }
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        } else {
            isHungry = false;
            System.out.println(name + ": 开始吃饭");
        }
    }
}
}
public static void main(String[] args) {
Diner husband = new Diner("丈夫");
Diner wife = new Diner("妻子");
Thread t1 = new Thread(() -&amp;gt; husband.eatWith(wife));
Thread t2 = new Thread(() -&amp;gt; wife.eatWith(husband));
t1.start();
t2.start();
}
}

在这个例子中,两位就餐者都表现得很礼貌:只要对方还没吃,自己就不断让先。如果没有打破对称的机制,他们可能永远互相谦让下去,形成活锁。解决方案通常是引入随机延迟或设置重试上限,打破对称性,使至少一方能优先完成。

5.4.2 饥饿

饥饿指某个线程因为无法获得所需资源(CPU 时间片、锁等)而长期无法执行。饥饿可能由以下原因引起:

  • 线程优先级设置不当:Thread.setPriority() 可以设置线程优先级,低优先级线程在资源竞争激烈时可能被高优先级线程持续抢占。需要注意的是,线程优先级对调度的影响在不同操作系统上表现不一致,依赖优先级做关键逻辑是不可靠的。
  • 非公平锁:synchronized 和默认的 ReentrantLock 都是非公平的。在非公平锁下,新到达的线程可能先于等待队列中等待已久的线程获得锁。如果锁竞争非常激烈,某些等待线程可能长时间抢不到锁,形成饥饿。ReentrantLock 支持传入 true 创建公平锁,公平锁按线程等待时间顺序分配锁,从而避免饥饿,但公平锁的吞吐量通常低于非公平锁。
  • 无限循环中的资源占用:如果某个线程持锁时间过长或不释放资源,其他线程就会持续等待。

下面演示公平锁与非公平锁的区别:

import java.util.concurrent.locks.ReentrantLock;
public class FairLockDemo {
public static void main(String[] args) {
// 修改 true/false 观察公平锁与非公平锁的行为差异
ReentrantLock fairLock = new ReentrantLock(true);
    for (int i = 1; i &lt;= 5; i++) {
        final int threadId = i;
        new Thread(() -&gt; {
            for (int j = 0; j &lt; 3; j++) {
                fairLock.lock();
                try {
                    System.out.println("线程 " + threadId + " 第 " + (j + 1) + " 次获得锁");
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    fairLock.unlock();
                }
            }
        }, "线程" + i).start();
    }
}
}

在公平锁模式下,线程获得锁的顺序基本与申请顺序一致;在非公平锁模式下,线程获得锁的顺序则可能比较随机,某些线程可能明显频繁获得锁,而另一些线程较少获得。

5.5 上下文切换与伪共享:看不见的性能陷阱

5.5.1 上下文切换的量化与优化

上文已经介绍过上下文切换的基本概念,这里补充量化和优化思路。在 Linux 系统中,可以使用 vmstat 命令观察上下文切换次数,其中 cs(context switch)列表示每秒上下文切换次数。也可以通过 perf 工具进行更精细的性能分析。在 Java 应用中,如果发现系统 CPU 使用率很高但吞吐量提升不明显,且 vmstat 显示 cs 数值很高,那么上下文切换开销就很可能是性能瓶颈。

优化思路

  • 控制线程数量:线程数不宜远超 CPU 核心数。对于 CPU 密集型任务,线程数约为 CPU 核心数(可参考 N 或 N+1 的经验值);对于 I/O 密集型任务,可适当增加线程数,但也不宜无限制。
  • 使用无锁数据结构:减少锁竞争可以降低线程阻塞和切换。例如使用 ConcurrentHashMap 替代 Hashtable、使用原子类替代 synchronized、使用 CAS 算法。
  • 减少锁的粒度:将大锁拆分为多个小锁,降低线程竞争同一把锁的概率。
  • 优化锁的持有时间:尽量不要在锁内执行耗时操作(如 I/O),缩短锁持有时间,减少其他线程等待。
5.5.2 伪共享

伪共享(False Sharing)是多核 CPU 缓存体系下的一个隐蔽性能问题。CPU 缓存与内存之间的数据交换以“缓存行(Cache Line)”为单位,通常为 64 字节。当两个线程分别修改两个不同变量,而这两个变量恰好位于同一个缓存行时,CPU 会认为它们相互干扰——即使它们在逻辑上毫无关联。当一个线程修改了自己的变量后,会使另一个线程所在核心的该缓存行失效,另一个线程再次访问自己的变量时需要从主内存重新加载,导致频繁缓存失效和性能下降。

下面演示伪共享问题,并使用填充(Padding)技术解决:

public class FalseSharingDemo {
    // 普通对象,两个变量可能在同一个缓存行
    static class PlainCounter {
        public volatile long value1;
        public volatile long value2;
    }
// 通过填充使 value1 和 value2 位于不同缓存行
static class PaddedCounter {
    public volatile long value1;
    // 填充 7 个 long,每个 8 字节,加上 value1 共 64 字节,隔离 value2
    public long p1, p2, p3, p4, p5, p6, p7;
    public volatile long value2;
}
private static final long ITERATIONS = 100_000_000L;
public static void main(String[] args) throws InterruptedException {
long plainTime = test(new PlainCounter());
long paddedTime = test(new PaddedCounter());
System.out.println("普通计数器耗时: " + plainTime + " ms");
System.out.println("填充计数器耗时: " + paddedTime + " ms");
}
private static long test(Object counter) throws InterruptedException {
Thread t1 = new Thread(() -&gt; {
for (long i = 0; i &lt; ITERATIONS; i++) {
if (counter instanceof PlainCounter) {
((PlainCounter) counter).value1 = i;
} else {
((PaddedCounter) counter).value1 = i;
}
}
});
Thread t2 = new Thread(() -&gt; {
for (long i = 0; i &lt; ITERATIONS; i++) {
if (counter instanceof PlainCounter) {
((PlainCounter) counter).value2 = i;
} else {
((PaddedCounter) counter).value2 = i;
}
}
});
long start = System.currentTimeMillis();
t1.start();
t2.start();
t1.join();
t2.join();
return System.currentTimeMillis() - start;
}
}

在多核机器上运行,填充版本通常明显更快。原因在于普通版本中 value1 和 value2 位于同一缓存行,两个线程的写入相互干扰,导致缓存行频繁失效;而填充版本通过插入无用的 long 字段将这两个变量推到不同缓存行,消除了伪共享。

在 Java 8 及以上版本,还可以使用 @sun.misc.Contended 注解实现更优雅的缓存行隔离,但需要添加 JVM 参数 -XX:-RestrictContended。JDK 内部的一些类(如 ConcurrentHashMap 的 CounterCell)就使用了这一机制。需要注意,@Contended 属于 JDK 内部 API,生产环境使用需谨慎评估。

5.6 线程泄漏:资源被悄悄耗尽

线程泄漏指程序中创建的线程没有被正确回收,导致线程对象持续占用内存和其他资源,最终可能耗尽系统资源、影响程序稳定性。

线程泄漏的常见原因包括:

  • 未关闭线程池:使用 Executors 创建线程池后,如果没有调用 shutdown() 或 shutdownNow(),核心线程会一直存活,即使所有任务都已执行完毕。
  • 非守护线程无限等待:线程在执行完任务后进入无限等待状态(例如在一个永不返回的阻塞队列上 take()),又没有设置为守护线程,JVM 不会退出,线程资源无法释放。
  • 循环创建线程未复用:在循环中不断 new Thread 并 start,而没有使用线程池或复用机制,每次都产生新线程,旧线程可能因等待任务而迟迟不结束。
  • ThreadLocal 使用不当:将大量对象放入 ThreadLocal 却没有及时调用 remove(),线程池中的线程被复用时,这些 ThreadLocal 值会一直存在,导致内存泄漏。

下面是一个 ThreadLocal 内存泄漏的示例和修复方案:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadLocalLeak {
private static final ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
    ExecutorService pool = Executors.newFixedThreadPool(2);
for (int i = 0; i &amp;lt; 10; i++) {
    final int taskId = i;
    pool.execute(() -&amp;gt; {
        // 模拟向 ThreadLocal 放入大对象
        threadLocal.set(new byte[10 * 1024 * 1024]);  // 10MB
        System.out.println("任务 " + taskId + " 执行完毕,线程: "
                + Thread.currentThread().getName());
        // 忘记调用 threadLocal.remove()
    });
    Thread.sleep(200);
}
System.out.println("主线程结束(线程池未关闭,可能导致资源泄漏)");
pool.shutdown();
}
}

如果忘记调用 threadLocal.remove(),线程池中的 2 个核心线程会一直持有 ThreadLocal 中大对象的引用,即使后续任务不再使用这些对象,它们也无法被垃圾回收,造成内存泄漏。修复方法是在任务结束前(通常在 finally 块中)调用 threadLocal.remove():

pool.execute(() -> {
    try {
        threadLocal.set(new byte[10 * 1024 * 1024]);
        System.out.println("任务执行完毕,线程: " + Thread.currentThread().getName());
    } finally {
        threadLocal.remove();  // 关键:及时清理,避免内存泄漏
    }
});

5.7 其他常见问题

5.7.1 线程池参数配置不当

线程池的七个参数——核心线程数、最大线程数、空闲线程存活时间、时间单位、任务队列、线程工厂、拒绝策略——如果配置不当,会引发一系列问题:

  • 任务堆积:如果使用无界队列(如 LinkedBlockingQueue 不指定容量),高并发下任务会无限堆积,最终导致内存溢出。
  • 拒绝策略选择错误:默认 AbortPolicy 会直接抛异常,若不处理可能导致任务丢失;CallerRunsPolicy 让提交任务的线程执行任务,可能反过来拖慢主流程。
  • 核心线程数设置过大:核心线程即使空闲也不会被回收,过多核心线程浪费内存和线程资源。

推荐根据任务类型(CPU 密集型还是 I/O 密集型)和系统资源,通过压测确定合理参数,并使用有界队列和合适的拒绝策略。下面是使用 ThreadPoolExecutor 手动创建线程池的规范示例:

import java.util.concurrent.*;
public class ThreadPoolBestPractice {
public static void main(String[] args) {
int corePoolSize = 4;
int maxPoolSize = 8;
long keepAliveTime = 60L;
int queueCapacity = 100;
    ThreadPoolExecutor pool = new ThreadPoolExecutor(
            corePoolSize,
            maxPoolSize,
            keepAliveTime,
            TimeUnit.SECONDS,
            new ArrayBlockingQueue&lt;&gt;(queueCapacity),
            new ThreadFactory() {
                private int count = 0;
                @Override
                public Thread newThread(Runnable r) {
                    return new Thread(r, "业务线程-" + (++count));
                }
            },
            new ThreadPoolExecutor.CallerRunsPolicy()
    );
try {
    for (int i = 0; i &amp;lt; 200; i++) {
        final int taskId = i;
        pool.execute(() -&amp;gt; {
            System.out.println("执行任务 " + taskId + ",线程: "
                    + Thread.currentThread().getName());
        });
    }
} finally {
    pool.shutdown();
}
}
}

这个示例中,线程工厂为线程取了有业务含义的名字(方便排查问题),使用有界队列防止任务无限堆积,使用 CallerRunsPolicy 避免任务被静默丢弃,同时符合《阿里巴巴 Java 开发手册》中“线程池不允许使用 Executors 创建,而是通过 ThreadPoolExecutor 的方式”的建议。

5.7.2 使用 Stop 方法强杀线程的风险

Thread.stop() 已被标记为 @Deprecated,因为它会立即终止线程,可能导致锁未释放、数据处于不一致状态等严重问题。正确做法是通过协作式方式停止线程:设置共享标志位(配合 volatile 保证可见性),让线程在合适时机自行退出,或者使用 interrupt() 配合中断检查。下面是协作式停止线程示例:

public class StopThreadCooperatively {
    private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
    Thread worker = new Thread(() -&gt; {
        while (running) {
            System.out.println("线程正在工作...");
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;   // 响应中断,退出循环
            }
        }
        System.out.println("线程已安全退出");
    });
worker.start();
Thread.sleep(3000);
running = false;          // 方式一:设置标志位
worker.interrupt();       // 方式二:发送中断信号
worker.join();
System.out.println("主线程结束");
}
}

通过标志位和中断信号,线程能够在处理完当前操作后安全退出,避免资源泄漏和数据不一致。

5.7.3 使用 wait/notify 的常见错误

Object 类的 wait()、notify()、notifyAll() 是 Java 中最基础的线程协作机制,但使用不当会引发问题:

  • 应在循环中检查条件:wait() 被唤醒后,应使用 while 循环重新检查条件,而不是 if。因为线程可能被“虚假唤醒”,或者唤醒后发现条件仍不满足。
  • 必须在 synchronized 块内调用:在 synchronized 块外调用 wait/notify 会抛出 IllegalMonitorStateException。
  • 优先使用 notifyAll:如果多个线程等待同一条件,notify 只随机唤醒一个线程,某些线程可能一直得不到唤醒,造成饥饿。无法确定唤醒哪个线程合适时,应优先使用 notifyAll。

下面是使用 while 循环和 notifyAll 的规范写法:

public class WaitNotifyBestPractice {
    private static final Object lock = new Object();
    private static boolean ready = false;
public static void main(String[] args) throws InterruptedException {
    Thread consumer1 = new Thread(() -&gt; waitForReady(), "消费者1");
    Thread consumer2 = new Thread(() -&gt; waitForReady(), "消费者2");
    consumer1.start();
    consumer2.start();
Thread.sleep(1000);
synchronized (lock) {
    ready = true;
    lock.notifyAll();  // 唤醒所有等待线程
}
}
private static void waitForReady() {
synchronized (lock) {
while (!ready) {   // while 循环,而非 if
try {
System.out.println(Thread.currentThread().getName() + " 等待条件满足...");
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
System.out.println(Thread.currentThread().getName() + " 条件满足,继续执行");
}
}
}

六、并发工具与最佳实践

6.1 常用并发工具类

Java 在 java.util.concurrent 包中提供了大量成熟的并发工具类,能帮助开发者避免重复造轮子,显著降低并发编程出错概率。下面挑选几个最常用的进行介绍。

6.1.1 CountDownLatch:等待一组任务完成

CountDownLatch 允许一个或多个线程等待其他线程完成操作。它内部维护一个计数器,每次调用 countDown() 计数器减一,当计数器归零时,所有等待线程被唤醒。典型场景是“主线程等待多个子任务完成后再继续执行”。

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
int taskCount = 5;
CountDownLatch latch = new CountDownLatch(taskCount);
ExecutorService pool = Executors.newFixedThreadPool(taskCount);
    for (int i = 0; i &lt; taskCount; i++) {
        final int taskId = i + 1;
        pool.execute(() -&gt; {
            try {
                System.out.println("子任务 " + taskId + " 开始执行");
                Thread.sleep((long) (Math.random() * 2000));
                System.out.println("子任务 " + taskId + " 执行完成");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                latch.countDown();
            }
        });
    }
System.out.println("主线程等待所有子任务完成...");
latch.await();
System.out.println("所有子任务已完成,主线程继续执行");
pool.shutdown();
}
}
6.1.2 CyclicBarrier:让一组线程在屏障点汇合

CyclicBarrier 允许一组线程在到达某个屏障点时互相等待,直到所有线程都到达后才能继续执行。与 CountDownLatch 不同,CyclicBarrier 的计数器可以重置复用。典型场景是多个线程分阶段协作处理。

import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierDemo {
public static void main(String[] args) {
int participantCount = 3;
CyclicBarrier barrier = new CyclicBarrier(participantCount, () ->
System.out.println("所有参与者已到达屏障,开始下一阶段"));
    for (int i = 0; i &lt; participantCount; i++) {
        final int id = i + 1;
        new Thread(() -&gt; {
            try {
                for (int stage = 1; stage &lt;= 3; stage++) {
                    System.out.println("参与者 " + id + " 完成第 " + stage + " 阶段");
                    Thread.sleep((long) (Math.random() * 1000));
                    barrier.await();
                }
            } catch (InterruptedException | BrokenBarrierException e) {
                Thread.currentThread().interrupt();
            }
        }).start();
    }
}
}
6.1.3 Semaphore:控制同时访问资源的线程数

Semaphore 信号量用于控制同时访问某个资源的线程数量,可以理解为“限流”工具。它维护一组许可证,线程访问资源前必须先获取许可证,访问完毕后释放。典型场景包括数据库连接池、限流器等。

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class SemaphoreDemo {
public static void main(String[] args) {
int permitCount = 3;   // 同时最多 3 个线程访问资源
Semaphore semaphore = new Semaphore(permitCount);
ExecutorService pool = Executors.newFixedThreadPool(10);
    for (int i = 1; i &lt;= 10; i++) {
        final int taskId = i;
        pool.execute(() -&gt; {
            try {
                semaphore.acquire();
                System.out.println("任务 " + taskId + " 获得许可证,开始执行");
                Thread.sleep(2000);
                System.out.println("任务 " + taskId + " 执行完毕,释放许可证");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                semaphore.release();
            }
        });
    }
    pool.shutdown();
}
}
6.1.4 CompletableFuture:现代异步编程利器

CompletableFuture 是 Java 8 引入的强大异步编程工具,支持链式调用、任务组合、异常处理等能力,极大简化了异步编程。下面演示多个异步任务的编排:

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
CompletableFuture<String> fetchUser = CompletableFuture.supplyAsync(() -> {
sleep(1000);
return "用户信息";
});
    CompletableFuture&lt;String&gt; fetchOrder = CompletableFuture.supplyAsync(() -&gt; {
        sleep(800);
        return "订单信息";
    });
CompletableFuture&amp;lt;String&amp;gt; fetchCoupon = CompletableFuture.supplyAsync(() -&amp;gt; {
    sleep(1200);
    return "优惠券信息";
});
CompletableFuture&amp;lt;Void&amp;gt; allDone = CompletableFuture.allOf(fetchUser, fetchOrder, fetchCoupon);
CompletableFuture&amp;lt;String&amp;gt; result = allDone.thenApply(v -&amp;gt;
fetchUser.join() + " + " + fetchOrder.join() + " + " + fetchCoupon.join()
);
System.out.println("最终结果: " + result.get());
}
private static void sleep(long millis) {
try {
TimeUnit.MILLISECONDS.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

这段代码同时发起三个异步请求(获取用户信息、订单信息、优惠券信息),当三者全部完成后合并结果。整个过程不需要手动管理线程,结构清晰,可读性强。

6.2 多线程编程最佳实践清单

综合前文讨论,以下是多线程编程的实用最佳实践清单,建议在开发中逐条对照检查:

  1. 优先使用并发工具包:能用 java.util.concurrent 中的现成类(ConcurrentHashMap、原子类、线程池、CountDownLatch 等),就不要手写基于 wait/notify 或 synchronized 的低级同步逻辑。
  2. 最小化锁的粒度:锁的范围越小、持有时间越短,竞争越小,吞吐量越高。避免在锁内执行 I/O 或耗时计算。
  3. 使用不可变对象:不可变对象天然线程安全,无需同步。尽量将共享数据设计为不可变(final 字段、不可变集合等)。
  4. 使用不可变集合和并发集合:如 Collections.unmodifiableList、CopyOnWriteArrayList、ConcurrentHashMap 等,而不是在业务代码中手动加锁保护普通集合。
  5. 正确使用 volatile:volatile 适用于“一写多读”的标志位场景,不能替代锁来保证复合操作原子性。
  6. 用 while 循环检查等待条件:避免虚假唤醒导致逻辑错误,wait 醒来后必须重新检查条件。
  7. 优先使用 notifyAll:除非能严格保证只应唤醒某个特定线程,否则用 notifyAll 避免饥饿。
  8. 通过 ThreadPoolExecutor 创建线程池:避免使用 Executors 快捷方法(可能隐藏无界队列或过大线程数的风险),合理设置核心线程数、最大线程数、队列容量和拒绝策略。
  9. 给线程命名:为线程池中的线程设置有意义的名字,方便通过日志和线程 dump 排查问题。
  10. 及时清理 ThreadLocal:在线程池场景下,务必在 finally 块中调用 ThreadLocal.remove(),防止内存泄漏。
  11. 正确处理中断:捕获 InterruptedException 后,要么恢复中断状态(Thread.currentThread().interrupt()),要么向上抛出,不要吞掉中断信号,否则会破坏线程的协作式取消机制。
  12. 避免使用已废弃的 API:如 Thread.stop()、Thread.suspend()、Thread.resume(),这些方法存在严重安全隐患。
  13. 编写并发测试:对关键并发逻辑进行压力测试,借助 jstack、Arthas、VisualVM 等工具排查死锁和性能问题。
  14. 谨慎使用线程优先级:线程优先级在不同操作系统上的语义不一致,不应作为核心逻辑的依赖。

七、案例实战:模拟银行转账系统

为了让读者更好理解前述理论,本章通过一个银行转账实战案例,展示如何规避多线程问题并验证正确性。

7.1 问题描述

银行系统中,账户 A 和账户 B 之间的转账必须满足以下要求:从 A 扣款和向 B 加款必须是原子的,不能出现只扣不加或只加不扣;多个账户并发转账时不能产生死锁;系统整体余额必须保持不变。

首先看一个有问题的实现:没有加锁,并发下可能破坏一致性。运行下面的错误版本:

public class BadBankTransfer {
    static class Account {
        private String name;
        private int balance;
    Account(String name, int balance) {
        this.name = name;
        this.balance = balance;
    }
void add(int amount) {
    balance += amount;
}
void subtract(int amount) {
balance -= amount;
}
int getBalance() {
return balance;
}
}
private static void transfer(Account from, Account to, int amount) {
// 错误的做法:没有加锁,并发下可能破坏一致性
from.subtract(amount);
to.add(amount);
}
public static void main(String[] args) throws InterruptedException {
Account a = new Account("账户A", 100000);
Account b = new Account("账户B", 100000);
Thread t1 = new Thread(() -&amp;gt; {
for (int i = 0; i &amp;lt; 5000; i++) {
transfer(a, b, 10);
}
});
Thread t2 = new Thread(() -&amp;gt; {
for (int i = 0; i &amp;lt; 5000; i++) {
transfer(b, a, 10);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
int total = a.getBalance() + b.getBalance();
System.out.println("账户A余额: " + a.getBalance());
System.out.println("账户B余额: " + b.getBalance());
System.out.println("总余额: " + total);
System.out.println("总余额是否正确: " + (total == 200000));
}
}

运行这段代码,总余额很可能不等于 200000,说明转账过程中出现了数据不一致。因为 balance += amount 和 balance -= amount 都不是原子操作,并发执行时会丢失更新。

7.2 改进版本:按固定顺序加锁

改进方案是:每次转账时同时锁住转出账户和转入账户,并按照账户的某种稳定标识(如 id)的顺序加锁,避免死锁;同时在锁内完成扣款和加款,保证原子性。

public class SafeBankTransfer {
    static class Account {
        private final int id;
        private final String name;
        private int balance;
    Account(int id, String name, int balance) {
        this.id = id;
        this.name = name;
        this.balance = balance;
    }
int getId() {
    return id;
}
int getBalance() {
return balance;
}
void add(int amount) {
balance += amount;
}
void subtract(int amount) {
balance -= amount;
}
}
private static void transfer(Account from, Account to, int amount) {
Account first;
Account second;
if (from.getId() &lt; to.getId()) {
first = from;
second = to;
} else {
first = to;
second = from;
}
synchronized (first) {
synchronized (second) {
if (from.getBalance() &amp;lt; amount) {
System.out.println("余额不足,转账失败: " + from.name + " -&amp;gt; " + to.name);
return;
}
from.subtract(amount);
to.add(amount);
System.out.println("转账成功: " + from.name + " -&amp;gt; " + to.name
+ ",金额: " + amount);
}
}
}
public static void main(String[] args) throws InterruptedException {
Account a = new Account(1, "账户A", 100000);
Account b = new Account(2, "账户B", 100000);
Thread[] threads = new Thread[10];
for (int i = 0; i &amp;lt; threads.length; i++) {
final boolean reverse = (i % 2 == 0);
threads[i] = new Thread(() -&amp;gt; {
for (int j = 0; j &amp;lt; 2000; j++) {
if (reverse) {
transfer(a, b, 10);
} else {
transfer(b, a, 10);
}
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
int total = a.getBalance() + b.getBalance();
System.out.println("账户A余额: " + a.getBalance());
System.out.println("账户B余额: " + b.getBalance());
System.out.println("总余额: " + total);
System.out.println("总余额是否正确: " + (total == 200000));
}
}

在这个版本中,不同线程无论转账方向如何,都按照账户 id 从小到大依次加锁,消除了循环等待,不会产生死锁;扣款和加款都在锁内完成,保证了原子性;总余额始终保持不变。这个案例把“按顺序加锁避免死锁”和“锁内完成复合操作保证原子性”两个重要原则落到了实处。

7.3 使用 StampedLock 优化读多写少场景

在银行系统中,查询余额(读操作)的频率通常远高于转账(写操作)。对于“读多写少”的场景,直接使用 synchronized 或 ReentrantLock 会导致所有读操作也互斥,影响吞吐量。StampedLock 提供乐观读能力,下面使用它优化:

import java.util.concurrent.locks.StampedLock;
public class StampedLockDemo {
private final StampedLock lock = new StampedLock();
private int balance = 1000;
public int getBalance() {
    long stamp = lock.tryOptimisticRead();
    int current = balance;
    if (!lock.validate(stamp)) {
        // 乐观读失败,升级为悲观读
        stamp = lock.readLock();
        try {
            current = balance;
        } finally {
            lock.unlockRead(stamp);
        }
    }
    return current;
}
public void add(int amount) {
long stamp = lock.writeLock();
try {
balance += amount;
} finally {
lock.unlockWrite(stamp);
}
}
public static void main(String[] args) throws InterruptedException {
StampedLockDemo demo = new StampedLockDemo();
Thread[] readers = new Thread[50];
for (int i = 0; i &amp;lt; readers.length; i++) {
    readers[i] = new Thread(() -&amp;gt; {
        for (int j = 0; j &amp;lt; 100000; j++) {
            demo.getBalance();
        }
    });
}
Thread writer = new Thread(() -&amp;gt; {
    for (int j = 0; j &amp;lt; 1000; j++) {
        demo.add(1);
    }
});
long start = System.currentTimeMillis();
for (Thread r : readers) {
r.start();
}
writer.start();
for (Thread r : readers) {
r.join();
}
writer.join();
long end = System.currentTimeMillis();
System.out.println("最终余额: " + demo.getBalance());
System.out.println("总耗时: " + (end - start) + " ms");
}
}

StampedLock 的乐观读不需要加锁,只需在读之后通过 validate 验证数据没有被修改;如果验证失败,再退回悲观读。在读多写少场景下,乐观读能极大提升并发读取性能。需要注意 StampedLock 不是可重入锁,使用时要避免在锁内再次获取同一把锁。

八、常见面试问题与解析

多线程是技术面试的高频考点。下面整理一些经典面试题,并给出简明解析,帮助读者巩固知识、应对面试。

8.1 synchronized 和 Lock 的区别

  • 关键字与类:synchronized 是 Java 关键字,Lock(ReentrantLock)是 java.util.concurrent.locks 包下的接口和类。
  • 锁的释放:synchronized 会在代码块执行完或抛出异常时自动释放锁;Lock 需要手动在 finally 中 unlock,否则可能造成锁不释放。
  • 可中断性:synchronized 获取锁的过程不可中断;Lock 支持 lockInterruptibly(),可被其他线程中断。
  • 公平性:synchronized 是非公平锁;ReentrantLock 可以指定公平或非公平,公平锁按等待时间分配锁。
  • 条件变量:synchronized 配合 wait/notify 实现等待通知;Lock 可通过 newCondition() 创建多个 Condition,实现更精细的线程协调。
  • 性能:JDK 6 之后 synchronized 经过大量优化(偏向锁、轻量级锁、自旋锁等),与 Lock 的性能差距大幅缩小。功能需求简单时优先使用 synchronized,需要可中断、公平锁、多条件等高级特性时使用 ReentrantLock。

8.2 volatile 的作用及与 synchronized 的区别

volatile 的作用是保证变量可见性和禁止指令重排序(通过内存屏障实现),但它不能保证复合操作的原子性。synchronized 既能保证可见性,也能保证原子性,还保证有序性,但开销更大。核心区别在于:volatile 是轻量级、仅用于修饰变量的机制;synchronized 是重量级、用于修饰代码块或方法的锁机制。

8.3 什么是 CAS?它有什么优缺点?

CAS(Compare And Swap,比较并交换)是一种无锁原子操作。它包含三个操作数:内存位置 V、预期原值 A、新值 B。只有当 V 的值等于 A 时,才将 V 更新为 B,否则不做任何修改。Java 的原子类(AtomicInteger 等)底层就是基于 CAS 实现。

优点:避免线程阻塞和上下文切换,在竞争不激烈时性能优秀。

缺点:存在三个主要问题——ABA 问题(变量从 A 变成 B 又变回 A,CAS 无法察觉)、自旋开销(竞争激烈时循环尝试消耗 CPU)、只能保证一个共享变量的原子操作(多个变量需要锁或 AtomicReference 配合复杂逻辑)。

8.4 线程池的核心参数和执行流程

线程池的核心参数有七个:核心线程数(corePoolSize)、最大线程数(maximumPoolSize)、空闲线程存活时间(keepAliveTime)、时间单位(unit)、任务队列(workQueue)、线程工厂(threadFactory)、拒绝策略(handler)。

任务提交后的执行流程:

  1. 判断核心线程是否已满:未满则创建核心线程执行任务。
  2. 核心线程已满,判断任务队列是否已满:未满则将任务放入队列等待。
  3. 队列已满,判断线程池线程数是否达到最大线程数:未达到则创建非核心线程执行任务。
  4. 线程数达到最大且队列已满,执行拒绝策略。

四种拒绝策略分别是:AbortPolicy(抛异常)、CallerRunsPolicy(调用者线程执行)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢弃队列中最旧的任务)。

8.5 如何排查死锁?

可以使用 jstack 命令打印线程 dump,输出中会直接提示检测到死锁并列出相关线程;也可以使用 jconsole、VisualVM 的检测死锁功能;或者使用 Arthas 的 thread -b 命令找出阻塞者。定位到死锁后,根据死锁的四个必要条件设计解决方案,最常见的是统一加锁顺序和 tryLock 超时机制。

8.6 线程安全的三要素是什么?

原子性、可见性、有序性。原子性保证操作不可分割;可见性保证一个线程的修改对其他线程立即可见;有序性保证指令执行顺序符合预期(通过内存屏障禁止不安全的指令重排序)。Java 内存模型通过 synchronized、volatile、final 等机制保障这三大特性。

九、总结

多线程是一把强大的双刃剑。它能够显著提高 CPU 利用率和系统吞吐量、提升应用响应速度、充分利用多核硬件,是现代高性能系统的基石;但与此同时,它也带来了上下文切换开销、数据一致性风险、死锁、饥饿、线程泄漏等一系列棘手问题。

本文从进程与线程的基本概念出发,系统梳理了多线程的优点与缺点,随后重点剖析了线程安全的三大特性——原子性、可见性、有序性,以及竞态条件、死锁、活锁、饥饿、上下文切换、伪共享、线程泄漏等常见问题。每一类问题都结合具体代码展示了问题成因、复现方式和解决方案,并通过银行转账的实战案例,展示了如何在实际项目中综合运用加锁顺序、锁粒度控制和读写锁等策略规避并发风险。

最后给出几条核心建议:第一,优先使用成熟的并发工具包,不要重复造轮子;第二,最小化共享状态和锁的粒度,能用不可变对象就不用锁;第三,深入理解 JMM 和锁机制,遇到问题时从原子性、可见性、有序性三个维度分析;第四,重视并发测试和线上排查,合理使用 jstack、Arthas 等工具;第五,把线程数、线程池参数、锁策略建立在真实压测数据之上,而不是凭感觉配置。

多线程编程的功力绝非一日之功,它需要理论、实践和踩坑经验的长期积累。希望本文能帮助读者建立扎实的多线程知识框架,在实际工作中写出更正确、更高效、更健壮的并发程序。当你在深夜面对一个偶发的并发 Bug 时,请记住:耐心分析线程交错,从三大特性入手,答案终会浮现。

Logo

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

更多推荐