从零开始:一文读懂Java并发编程核心基础
-
- [一. 为什么需要并发编程?](#一. 为什么需要并发编程?)
- [二. 并发编程的"另一面":挑战与代价](#二. 并发编程的“另一面”:挑战与代价)
-
- [2.1 频繁的上下文切换](#2.1 频繁的上下文切换)
- [2.2 线程安全问题(如:死锁)](#2.2 线程安全问题(如:死锁))
- [三. 夯实基础:必须掌握的核心概念与操作](#三. 夯实基础:必须掌握的核心概念与操作)
-
- [3.1 厘清基本概念](#3.1 厘清基本概念)
- [3.2 创建你的第一个线程](#3.2 创建你的第一个线程)
-
- [方式一:继承 `Thread` 类](#方式一:继承
Thread
类) - [方式二:实现 `Runnable` 接口 (推荐)](#方式二:实现
Runnable
接口 (推荐)) - [方式三:实现 `Callable` 接口 (可获取返回值)](#方式三:实现
Callable
接口 (可获取返回值))
- [方式一:继承 `Thread` 类](#方式一:继承
- [3.3 线程的生命周期:状态转换](#3.3 线程的生命周期:状态转换)
- [3.4 线程间的"对话":基本操作](#3.4 线程间的“对话”:基本操作)
-
- [`sleep()` 与 `wait()` 的经典对比](#
sleep()
与wait()
的经典对比) - [`join()` - 线程的协作](#
join()
- 线程的协作) - [`interrupt()` - 优雅的通知机制](#
interrupt()
- 优雅的通知机制) - [`yield()` - 主动的让步](#
yield()
- 主动的让步)
- [`sleep()` 与 `wait()` 的经典对比](#
- [3.5 默默的守护者:Daemon线程](#3.5 默默的守护者:Daemon线程)
为什么需要用到并发?凡事总有好坏两面,这其中的权衡(trade-off)是什么,也就是说并发编程具有哪些缺点?以及在进行并发编程时,我们应该了解和掌握的核心概念又是什么?这篇文章将主要围绕这三个问题,为你揭开Java并发编程的神秘面纱。
一. 为什么需要并发编程?
你可能会奇怪,我们讨论的是软件编程,为什么会扯到硬件的发展?这要从著名的"摩尔定律"说起。
在很长一段时间里,摩尔定律预示着单核处理器的计算能力会呈指数级增长。然而,大约在2004年,物理极限的瓶颈开始显现,单纯提升单核频率变得异常困难。聪明的硬件工程师们转变了思路:不再追求单个计算单元的极致速度,而是将多个计算单元整合到一颗CPU中。这就是"多核CPU"时代的到来。
如今,家用的i7处理器拥有8个甚至更多的核心已是常态,服务器级别的CPU核心数则更为庞大。硬件已经铺好了路,但如何才能榨干这些核心的性能呢?
答案就是并发编程。
顶级计算机科学家Donald Ervin Knuth曾半开玩笑地评价:"在我看来,并发这种现象或多或少是由于硬件设计者无计可施了,他们将摩尔定律的责任推给了软件开发者。"
这句评价一语中的。正是多核CPU的普及,催生了并发编程的浪潮。通过并发编程,我们可以:
- 充分利用多核CPU的计算能力:将复杂的计算任务分解,让多个核心同时工作,从而大幅提升程序性能。想象一下处理一张高清图片,如果串行处理数百万个像素点会非常耗时,但如果将图片分成几块,交由不同的核心并行处理,速度就会成倍提升。
- 方便进行业务拆分,提升应用响应速度:在很多业务场景中,并发是天生的需求。例如,在网上购物下单时,系统需要同时完成检查库存、生成订单、扣减优惠券、通知物流等多个操作。如果这些操作串行执行,用户需要等待很长时间。而通过并发技术,这些操作可以被拆分到不同的线程中"同时"进行,极大地缩短了用户的等待时间,提升了体验。
正是这些显著的优点,使得并发编程成为现代软件开发者必须掌握的关键技能。
二. 并发编程的"另一面":挑战与代价
既然并发编程如此强大,我们是否应该在所有场景下都使用它呢?答案显然是否定的。它是一把双刃剑,在带来性能提升的同时,也引入了新的复杂性和挑战。
2.1 频繁的上下文切换
在我们看来,多个线程似乎是"同时"执行的,但这在单核CPU上只是一种宏观上的错觉。CPU会为每个线程分配一个极短的时间片(通常是几十毫秒),然后快速地在不同线程间轮换。这个切换过程,被称为上下文切换。
切换时,系统需要保存当前线程的运行状态(如程序计数器、寄存器值等),以便下次轮到它时能恢复现场。这个保存和恢复的过程本身是有性能开销的。如果线程数量过多,或者切换过于频繁,上下文切换消耗的时间甚至可能超过线程真正执行任务的时间,导致程序性能不升反降。
如何减少上下文切换?
- 无锁并发编程 :例如
ConcurrentHashMap
的分段锁思想,让不同线程处理不同数据段,减少锁竞争。 - CAS算法 :Java的
Atomic
包使用了CAS(比较并交换)这种乐观锁机制,它在很多场景下能避免加锁带来的阻塞和上下文切换。 - 使用最少线程:创建适量的线程,避免大量线程处于空闲等待状态。
- 使用协程:在单线程内实现多任务调度,这是更轻量级的"线程"。
2.2 线程安全问题(如:死锁)
这是并发编程中最棘手、也最容易出错的地方。当多个线程访问共享资源(也称为"临界区")时,如果没有恰当的同步机制,就可能导致数据错乱、状态不一致,甚至出现死锁。
死锁是指两个或多个线程无限期地互相等待对方释放资源,导致所有相关的线程都无法继续执行。
来看一个经典的死锁示例:
java
public class DeadLockDemo {
private static final String resource_a = "资源A";
private static final String resource_b = "资源B";
public static void main(String[] args) {
Thread threadA = new Thread(() -> {
synchronized (resource_a) {
System.out.println(Thread.currentThread().getName() + " 获得了 " + resource_a);
try {
// 等待一会儿,确保threadB能获得resource_b
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 尝试获得 " + resource_b + "...");
synchronized (resource_b) {
System.out.println(Thread.currentThread().getName() + " 获得了 " + resource_b);
}
}
}, "线程A");
Thread threadB = new Thread(() -> {
synchronized (resource_b) {
System.out.println(Thread.currentThread().getName() + " 获得了 " + resource_b);
System.out.println(Thread.currentThread().getName() + " 尝试获得 " + resource_a + "...");
synchronized (resource_a) {
System.out.println(Thread.currentThread().getName() + " 获得了 " + resource_a);
}
}
}, "线程B");
threadA.start();
threadB.start();
}
}
在这个例子中,线程A获得了resource_a
的锁,然后尝试去获得resource_b
的锁;而线程B同时获得了resource_b
的锁,并尝试获得resource_a
的锁。两者互相持有对方需要的锁,并等待对方释放,从而陷入了永久的等待,形成了死锁。
我们可以使用JDK自带的jps
和jstack
命令来诊断这种情况,jstack
会明确地告诉你"Found 1 deadlock"。
如何避免死锁?
- 避免一个线程同时获取多个锁:尽量减少锁的持有范围和时间。
- 保证加锁顺序:确保所有线程都按照相同的顺序来获取锁。
- 使用定时锁 :使用
lock.tryLock(timeout)
,当等待超时后线程可以主动放弃,而不是无限期阻塞。 - 将锁和资源隔离:对于数据库锁,确保加锁和解锁在同一个数据库连接中完成。
三. 夯实基础:必须掌握的核心概念与操作
了解了并发的优缺点后,让我们深入到实践层面,看看在Java中到底该如何使用和操作线程。
3.1 厘清基本概念
-
同步 vs 异步 (Synchronous vs Asynchronous):这通常用来描述一次方法调用。
- 同步:调用方发起调用后,必须原地等待被调用方法执行完毕并返回结果,才能继续执行后续代码。就像你去实体店买东西,必须排队、付款、拿到商品后才能离开。
- 异步:调用方发起调用后,不等待结果,立即返回并继续执行后续代码。被调用的方法在后台执行,完成后通过回调、通知等方式告诉调用方。就像网购,你下单后就可以去做别的事了,快递到了会通知你去取。
-
并发 vs 并行 (Concurrency vs Parallelism):
- 并发 :指多个任务在一段时间内都得到了执行,它们在宏观上是"同时"发生的,但在微观上可能是通过时间片快速交替执行的。好比一个人在同时处理做饭、接电话、看孩子三件事,他需要不停地在任务间切换。
- 并行 :指多个任务在同一时刻真正地同时执行。这必须在多核CPU上才能实现。好比三个人,一人做饭,一人接电话,一人看孩子,他们是真正在同一时间做着不同的事。
-
阻塞 vs 非阻塞 (Blocking vs Non-blocking):这通常用来形容线程间的相互影响。
- 阻塞:一个线程的操作导致它自身被挂起,等待某个条件满足(如等待I/O完成、等待获取锁)。在此期间,它不会占用CPU。
- 非阻塞:一个线程的操作不会导致自身被挂起,无论操作是否成功都会立即返回。
3.2 创建你的第一个线程
一个Java程序从main()
方法启动时,JVM就已经创建了多个线程(如主线程、垃圾回收线程等)。要在我们自己的程序中创建线程,主要有以下三种方式:
方式一:继承 Thread
类
这是最直接的方式,通过继承Thread
并重写run()
方法来定义任务。
java
class MyThread extends Thread {
@Override
public void run() {
System.out.println("通过继承Thread类创建线程");
}
}
// 使用
MyThread thread = new MyThread();
thread.start(); // 必须调用start()来启动新线程
- 优点:实现简单,易于理解。
- 缺点 :Java是单继承的,如果你的类已经继承了其他类,就无法再继承
Thread
,这极大地限制了其灵活性。
方式二:实现 Runnable
接口 (推荐)
这是更常用、也更受推荐的方式。它将"任务"(Runnable
)和"执行任务的载体"(Thread
)解耦开来。
java
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("通过实现Runnable接口创建线程");
}
}
// 使用
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
- 优点 :
- 任务与线程解耦,结构更清晰。
- 避免了单继承的限制,你的任务类还可以继承其他类。
- 多个线程可以共享同一个
Runnable
实例,方便实现资源共享。
- 缺点:代码比方式一稍微多一点。
方式三:实现 Callable
接口 (可获取返回值)
Runnable
的run()
方法没有返回值,也不能抛出受检异常。如果你的任务需要一个执行结果或可能抛出异常,Callable
是更好的选择。
java
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
Thread.sleep(2000); // 模拟耗时任务
return "通过实现Callable接口返回的结果";
}
}
// 使用
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(new MyCallable());
System.out.println("主线程继续做其他事情...");
// 在需要结果时,调用get()方法阻塞等待
String result = future.get();
System.out.println(result);
executor.shutdown();
- 优点 :
- 可以获得任务的返回值。
- 可以向外抛出异常。
- 说明 :
Callable
通常与线程池(ExecutorService
)配合使用,submit()
方法返回一个Future
对象,你可以用它来跟踪任务状态并获取结果。
3.3 线程的生命周期:状态转换
一个Java线程在其生命周期中,会经历多种状态的变迁。这些状态定义在java.lang.Thread.State
枚举中。

NEW
(新建):new Thread()
之后,但还未调用start()
。RUNNABLE
(可运行): 调用start()
后,线程进入就绪队列,等待CPU调度。它可能正在运行,也可能在等待运行。BLOCKED
(阻塞): 线程等待获取一个synchronized
监视器锁。WAITING
(无限等待): 线程调用Object.wait()
、Thread.join()
等方法后进入此状态,需要被其他线程显式唤醒。TIMED_WAITING
(计时等待): 与WAITING
类似,但有超时限制,时间到了会自动返回RUNNABLE
状态。TERMINATED
(终止):run()
方法执行完毕或因异常退出。
3.4 线程间的"对话":基本操作
sleep()
与 wait()
的经典对比
sleep()
是让线程"睡一会",而wait()
是让线程"等通知"。这是面试高频题,也是理解线程协作的关键。
特性 | Thread.sleep(long millis) |
Object.wait() |
---|---|---|
所属类 | Thread (静态方法) |
Object (实例方法) |
锁的释放 | 不释放对象锁 | 释放对象锁 |
使用前提 | 任何地方都可以调用 | 必须在synchronized 代码块或方法中 |
唤醒方式 | 时间到期后自动唤醒 | 需要其他线程调用notify() 或notifyAll() |
join()
- 线程的协作
join()
方法允许一个线程等待另一个线程执行完成。就像接力赛跑,你必须等前一个队友跑完把接力棒交给你,你才能开始跑。
java
// 在main线程中
Thread worker = new Thread(() -> {
System.out.println("工作线程正在处理任务...");
try { Thread.sleep(3000); } catch (InterruptedException e) {}
});
worker.start();
worker.join(); // main线程会在这里暂停,直到worker线程执行完毕
System.out.println("工作线程已结束,主线程继续执行。");
interrupt()
- 优雅的通知机制
interrupt()
并非强制中断线程,而是一种协作式的"打招呼"机制。它会设置目标线程的中断标志位。
- 如果线程正在
sleep
、wait
或join
,它会立即被唤醒并抛出InterruptedException
,同时清除中断标志位。 - 如果线程正在正常运行,它需要自己通过
Thread.currentThread().isInterrupted()
来检查这个标志,并决定如何响应。
java
final Thread busyThread = new Thread(() -> {
while (true) {} // 死循环,消耗CPU
}, "busyThread");
busyThread.start();
busyThread.interrupt(); // 设置中断标志
// 等待片刻后检查
System.out.println("busyThread isInterrupted: " + busyThread.isInterrupted()); // 输出: true
yield()
- 主动的让步
yield()
是一个静态方法,它会向线程调度器暗示:当前线程愿意让出CPU,给其他同等优先级的线程一个执行机会。但这仅仅是一个建议,调度器可能会忽略它,所以它不保证当前线程一定会暂停。
3.5 默默的守护者:Daemon线程
Java中的线程分为两类:用户线程(User Thread)和守护线程(Daemon Thread)。
- 用户线程:是我们平时创建的普通线程,执行系统的业务逻辑。
- 守护线程:在后台运行,为其他线程(主要是用户线程)提供服务。最典型的例子就是垃圾回收(GC)线程。
当JVM中所有的用户线程都执行完毕后,无论是否还有守护线程在运行,JVM都会退出。
java
Thread daemonThread = new Thread(() -> {
while (true) {
System.out.println("我是守护线程,正在后台守护...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {}
}
});
daemonThread.setDaemon(true); // 必须在start()之前设置
daemonThread.start();
System.out.println("Main线程即将结束...");
// Main线程(用户线程)结束后,JVM会退出,daemonThread也会随之终止
一个重要的注意事项 :守护线程在JVM退出时会被强制终止,其finally
代码块不保证一定会被执行。因此,不要在守护线程的finally
中执行关键的资源释放操作。