引言:为何并发编程如此重要?
在现代软件开发中,并发编程已不再是阳春白雪,而是开发者必备的核心技能之一。随着多核 CPU 的普及,以及业务系统对高性能、高吞吐量和快速响应的持续追求,充分利用计算资源、提升程序运行效率变得至关重要。并发编程正是实现这一目标的关键手段。
什么是并发 (Concurrency)?
并发指的是在同一个时间段内,能够处理多个任务的能力。注意,这并不意味着多个任务在同一时刻同时执行(那是并行),而是在宏观上看起来是同时在推进。例如,一个 Web 服务器需要同时处理来自多个用户的请求,一个 GUI 应用需要在响应用户操作的同时执行后台计算,这些都是并发的典型场景。
为何需要并发?
-
提升性能:
- 利用多核 CPU: 对于计算密集型任务,可以通过并发将其分解到多个核心上并行执行,缩短总处理时间。
- 提高资源利用率: 对于 I/O 密集型任务(如网络请求、磁盘读写),当一个任务等待 I/O 时,CPU 可以切换去执行其他任务,避免空闲浪费。
- 改善响应性: 在图形用户界面 (GUI) 或交互式应用中,将耗时操作(如文件下载)放到后台线程执行,可以保持主线程(UI 线程)的流畅响应,提升用户体验。
-
简化复杂问题: 某些问题天然适合分解为多个并发执行的独立单元,使得程序设计更模块化、更易于理解和管理(例如生产者-消费者模型)。
image.png
然而,并发编程并非银弹,它引入了复杂性,带来了诸多挑战,如线程安全问题、死锁、资源竞争等。掌握并发编程的基础知识,理解其核心概念、挑战以及 Java 提供的解决方案,是编写健壮、高效并发程序的基石。本文旨在梳理 Java 并发编程的基础通识,为读者构建一个清晰的知识框架。
一、核心基础概念
在深入探讨 Java 并发机制之前,需要先明确几个基本概念。
1. 进程 (Process) 与线程 (Thread)
- 进程: 操作系统进行资源分配和调度的基本单位。一个进程是内存中一个正在运行的程序实例,它拥有独立的内存空间(地址空间)、文件句柄、系统资源等。进程间通信 (IPC) 相对复杂且开销较大。
- 线程: 进程内的一个执行单元,是 CPU 调度的基本单位。一个进程可以包含多个线程,它们共享该进程的内存空间(堆内存、方法区等)和系统资源(如文件句柄),但每个线程拥有自己独立的程序计数器、虚拟机栈和本地方法栈。线程间的通信相对简单高效,但共享资源也带来了同步问题。
Java 程序启动时至少会创建一个主线程(执行 main
方法的线程)。开发者可以通过 new Thread()
或线程池等方式创建更多线程。
2. 线程的生命周期 (Java specific)
在 Java 中,一个线程从创建到消亡会经历不同的状态,定义在 Thread.State
枚举中:
-
NEW (新建): 使用
new Thread()
创建,但尚未调用start()
方法。 -
RUNNABLE (可运行): 调用
start()
方法后,线程进入可运行状态。它包含了操作系统线程状态中的 Running (运行中) 和 Ready (就绪)。处于此状态的线程可能正在 JVM 中执行,也可能在等待操作系统分配 CPU 时间片。
image.png -
BLOCKED (阻塞): 线程等待获取一个监视器锁 (Monitor Lock) 时进入此状态。通常发生在进入
synchronized
方法或代码块时,锁被其他线程持有。当获取到锁后,会转换回RUNNABLE
。 -
WAITING (无限期等待): 线程等待其他线程执行特定动作时进入此状态。常见触发条件:
- 调用无超时的
Object.wait()
。 - 调用无超时的
Thread.join()
。 - 调用
LockSupport.park()
。
需要被其他线程显式唤醒(如Object.notify()
,Object.notifyAll()
,LockSupport.unpark()
)才能回到RUNNABLE
。
- 调用无超时的
-
TIMED_WAITING (有限期等待): 与
WAITING
类似,但等待有时间限制。超时后会自动返回RUNNABLE
。常见触发条件:- 调用带超时的
Thread.sleep(long millis)
。 - 调用带超时的
Object.wait(long timeout)
。 - 调用带超时的
Thread.join(long millis)
。 - 调用带超时的
LockSupport.parkNanos()
,LockSupport.parkUntil()
。
- 调用带超时的
-
TERMINATED (终止): 线程的
run()
方法执行完毕或因未捕获的异常退出后,进入此状态。线程生命周期结束。
理解线程状态对于调试并发问题(如死锁、性能瓶颈)非常有帮助。
3. 并发 (Concurrency) 与并行 (Parallelism)
- 并发 (Concurrency): 指逻辑上同时处理多个任务的能力。在单核 CPU 上,通过时间片轮转快速切换任务,使得宏观上感觉任务在同时进行。
- 并行 (Parallelism): 指物理上同时执行多个任务的能力。这需要多核 CPU 或分布式系统的支持,多个任务在同一时刻真正地一起运行。
关系: 并行是并发的一种实现方式。并发的目标是充分利用资源、提高响应,可以在单核或多核上实现;而并行必须依赖多核或多机才能实现,是提升绝对速度的关键。Java 并发编程既可以用于实现并发(提高资源利用率和响应性),也可以用于实现并行(利用多核加速计算)。
二、并发编程的三大挑战
编写正确的并发程序之所以困难,主要源于以下三个核心问题:
1. 原子性 (Atomicity) 问题
原子性指一个或多个操作,要么全部执行且执行过程不被任何因素打断,要么就都不执行。
在并发环境中,如果一个操作不是原子的,那么在执行过程中可能被其他线程干扰,导致数据不一致,这就是竞争条件 (Race Condition)。
经典示例: count++
操作。
public class UnsafeCounter {
private int count = 0;
public void increment() {
count++; // 非原子操作
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
UnsafeCounter counter = new UnsafeCounter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join(); // 等待 t1 结束
t2.join(); // 等待 t2 结束
System.out.println("Final count: " + counter.getCount()); // 结果通常小于 20000
}
}
count++
看似简单,实际包含三个步骤:
- 读取
count
的当前值。 - 将值加 1。
- 将新值写回
count
。
如果两个线程同时执行 increment()
,可能发生以下情况:
- 线程 A 读取
count
(假设为 10)。 - 线程 B 读取
count
(也为 10)。 - 线程 A 计算 10 + 1 = 11。
- 线程 B 计算 10 + 1 = 11。
- 线程 A 将 11 写回
count
。 - 线程 B 将 11 写回
count
。
两次 increment()
操作后,count
预期应为 12,但结果却是 11。这就是原子性被破坏导致的竞争条件。
解决方案: 需要使用锁或其他原子操作机制(如 synchronized
, Lock
, AtomicInteger
)来保证 count++
的原子性。
2. 可见性 (Visibility) 问题
可见性指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
在现代多核 CPU 架构中,每个核心都有自己的高速缓存 (Cache)。线程对共享变量的操作可能先在自己的工作内存(CPU Cache 的抽象)中进行,然后再择机写回主内存。这会导致一个线程修改了变量值,但该值尚未写回主内存,或者其他线程仍然读取的是自己缓存中的旧值,从而产生可见性问题。
示例:
public class VisibilityProblem {
private boolean stopRequested = false; // 共享变量
public void requestStop() {
stopRequested = true; // 线程 A 修改
System.out.println("Stop requested.");
}
public void run() {
System.out.println("Worker thread started.");
while (!stopRequested) { // 线程 B 读取
// do work...
// 可能因为可见性问题,一直读取到 false,导致循环无法停止
}
System.out.println("Worker thread stopped.");
}
public static void main(String[] args) throws InterruptedException {
VisibilityProblem worker = new VisibilityProblem();
Thread workerThread = new Thread(worker::run);
workerThread.start();
Thread.sleep(1000); // 让 worker 线程跑一会儿
Thread stopperThread = new Thread(worker::requestStop);
stopperThread.start();
// 如果存在可见性问题,workerThread 可能永远不会结束
workerThread.join(5000); // 等待 worker 线程结束,设置超时
if (workerThread.isAlive()) {
System.out.println("Worker thread did not stop due to visibility issue!");
// 在实际情况中需要更健壮的停止机制
}
}
}
在这个例子中,stopperThread
修改 stopRequested
为 true
,但 workerThread
可能由于缓存原因一直读取到 false
,导致死循环。
解决方案: 使用 volatile
关键字修饰 stopRequested
,或使用锁 (synchronized
, Lock
) 来保证修改的可见性。