【多线程】——基础篇
深入理解Java多线程:从进程到并发控制
在多核处理器成为主流的今天,有效地利用多线程技术是构建高性能、高响应应用程序的关键。本文将系统地阐述Java多线程编程中的核心概念与区别。
一、线程和进程的区别
这是理解多线程的基础。我们可以用一个生动的比喻来理解:
- 进程: 一个独立的工厂。每个工厂都有自己独立的厂房、原材料仓库、电力系统(系统资源如内存、文件句柄等)。工厂之间相互隔离,一个工厂的火灾不会直接影响另一个工厂(进程间具有独立的内存空间,互不干扰)。切换工厂需要耗费很大成本(进程上下文切换开销大)。
- 线程: 工厂中的工人。同一个工厂里的多个工人共享厂房、仓库和电力(同一进程内的多个线程共享进程的内存空间和资源)。工人们协同工作,沟通成本低(线程间通信简单,共享内存即可)。让一个工人停下换另一个工人工作,代价较小(线程上下文切换开销小)。
核心区别总结表:
特性 | 进程 | 线程 |
---|---|---|
基本单位 | 资源分配和调度的基本单位 | CPU调度和执行的基本单位 |
资源开销 | 大(独立地址空间,创建、销毁、切换开销大) | 小(共享地址空间,创建、销毁、切换开销小) |
内存共享 | 进程间内存空间相互隔离,通信需要IPC机制 | 线程共享所属进程的内存和资源,通信简单但需同步 |
健壮性 | 高,一个进程崩溃不会影响其他进程 | 低,一个线程崩溃可能导致整个进程崩溃 |
包含关系 | 一个进程可以包含多个线程 | 线程是进程的一部分 |
二、并行与并发的区别
这两个概念密切相关,但侧重点不同:
并发: 逻辑上的同时发生。指在单核或多核CPU上,系统通过快速切换任务(时间片轮转),使得在一段时间内,多个任务都能得到推进。它解决的是“任务排队等待”的问题。
- 例子: 单核CPU同时运行微信和Chrome浏览器。CPU先执行一段微信的代码,再快速切换到执行一段Chrome的代码,用户感觉两个程序在“同时”运行。
并行: 物理上的同时执行。指在多核CPU上,多个任务在同一时刻真正地同时执行。它需要硬件支持,是并发的真子集。
- 例子: 四核CPU,一个核心在计算Excel公式,一个核心在播放音乐,一个核心在渲染网页,它们是真正同时进行的。
核心关系: 并发是并行的超集。 并行是并发的一种特殊情况。我们编写多线程程序是为了实现并发,而程序能否并行执行取决于硬件条件。并发关注的是程序的设计与结构,并行关注的是程序的执行状态。
三、线程创建的方式:Runnable vs. Callable,start vs. run
1. 创建方式 Java有四种创建线程的方式:
- 继承
Thread
类,重写run()
方法。
- 实现
Runnable
接口,实现run()
方法(最常用)。
- 实现
Callable
接口,实现call()
方法。
- 使用线程池(如
ExecutorService
)。
2. Runnable 与 Callable 区别
特性 | Runnable | Callable |
---|---|---|
方法签名 | void run() | V call() throws Exception |
返回值 | 无 | 有,返回泛型 V 类型的结果 |
异常处理 | 必须在 run() 内部消化所有 checked Exception | 可以向上抛出异常,方便外部获取 |
应用场景 | 简单的异步任务执行 | 需要获取任务执行结果或抛出异常的场景 |
提交方式 | 可提交给 Thread 或线程池 (execute ) | 只能提交给线程池 (submit ) |
3. start() 与 run() 区别
这是一个经典的面试题,关键在于理解线程的生命周期。
thread.start()
:- 作用:启动一个新线程。JVM会调用这个新线程的
run()
方法。 - 结果:两个线程并发运行:当前线程(调用start的线程)和另一个新线程(执行其run方法)。
- 作用:启动一个新线程。JVM会调用这个新线程的
thread.run()
:- 作用:普通的方法调用。它不会启动新线程。
- 结果:仍在当前线程中,同步地执行
run()
方法中的代码。失去了多线程的意义。
结论: 启动线程必须调用 start()
方法。
四、线程的状态及状态变化
Java线程在其生命周期中处于以下6种状态之一(java.lang.Thread.State
):
- NEW(新建):线程被创建,但尚未调用
start()
方法。 - RUNNABLE(可运行):调用
start()
后。注意:此状态包含了 正在CPU上运行(Running) 和 就绪(Ready,在等待CPU时间片) 两种子状态。 - BLOCKED(阻塞):线程等待获取一个监视器锁(synchronized锁) 而陷入阻塞。(其他线程释放锁后,JVM会随机选择一个此状态的线程变为RUNNABLE)
- WAITING(无限期等待):线程进入等待状态,需要被其他线程显式地唤醒。调用以下方法会进入此状态:
Object.wait()
(需被notify()
/notifyAll()
唤醒)Thread.join()
(等待目标线程执行完毕)LockSupport.park()
- TIMED_WAITING(限期等待):线程进入等待状态,但无需其他线程唤醒,时间一到自动唤醒。调用以下方法会进入此状态:
Thread.sleep(long millis)
Object.wait(int timeout)
Thread.join(int timeout)
LockSupport.parkNanos()
- TERMINATED(终止):线程执行完毕(
run()
方法执行结束)或因异常退出。
状态转换图:
NEW --start()--> RUNNABLE <--(CPU时间片到/或IO完成)--> RUNNING
RUNNABLE --获取synchronized锁--> RUNNING
RUNNABLE --竞争synchronized锁失败--> BLOCKED
RUNNING --synchronized锁被释放--> RUNNABLERUNNING --wait()/join()--> WAITING
WAITING --notify()/notifyAll()/目标线程结束--> RUNNABLERUNNING --sleep(time)/wait(time)--> TIMED_WAITING
TIMED_WAITING --时间到--> RUNNABLERUNNING --run()方法结束/异常退出--> TERMINATED
小结:
五、线程按顺序执行:join、notify和notifyAll区别
1. join() thread.join()
方法用于等待目标线程执行完毕。调用此方法的线程(如main线程)会从 RUNNABLE
进入 WAITING
或 TIMED_WAITING
状态,直到目标线程 thread
终止,它才会继续执行。
Thread t1 = new Thread(() -> System.out.println("T1"));
Thread t2 = new Thread(() -> {try {t1.join(); // 等T1执行完} catch (InterruptedException e) {e.printStackTrace();}System.out.println("T2");
});
t1.start();
t2.start();
// 输出顺序永远是:T1 -> T2
2. notify() 和 notifyAll() 区别 这两个方法都用于唤醒因调用 Object.wait()
而进入 WAITING
或 TIMED_WAITING
状态的线程,但策略不同:
notify()
:随机唤醒一个正在等待此对象锁的线程。我们无法控制唤醒哪一个。notifyAll()
:唤醒所有正在等待此对象锁的线程。这些被唤醒的线程会去竞争锁,抢到锁的线程会继续执行。
选择: 在绝大多数情况下,使用 notifyAll()
更安全。使用 notify()
可能导致“信号丢失”——如果唤醒的线程发现条件依然不满足而又自己wait了,而真正该被唤醒的线程却一直在等待,从而导致所有线程都永久等待(死锁)。
六、Java中wait和sleep方法的不同
特性 | Object.wait() | Thread.sleep() |
---|---|---|
所属类 | Object 类 | Thread 类 |
调用前提 | 必须在同步代码块(synchronized) 中调用 | 可以在任何地方调用 |
作用机制 | 释放当前持有的对象锁 | 不释放任何锁(包括synchronized和Lock) |
用途 | 用于线程间通信和协调 | 仅用于让当前线程暂停执行指定时间 |
唤醒方式 | 只能由其他线程通过 notify() /notifyAll() 唤醒,或自己 interrupt() | 时间到期后自动唤醒,或被 interrupt() |
异常 | 抛出 InterruptedException | 抛出 InterruptedException |
核心记忆点: wait()
会释放锁并用于线程间通信;sleep()
不释放锁,只是让线程睡觉。
七、如何停止一个正在运行的线程
停止一个线程不是一个简单的 stop()
操作(该方法已废弃,因为它强制停止,会导致数据不一致等严重后果)。正确的方式是协作式的,即通知目标线程“请你停下来”,由目标线程自己决定何时安全地结束。
核心机制:中断(Interruption)
标记中断位:每个线程都有一个 boolean 类型的中断标志。调用
thread.interrupt()
方法就是将该线程的中断标志设为true
。响应中断:被中断的线程需要周期性地检查自己的中断状态,并决定如何处理。
thread.isInterrupted()
:检查指定线程的中断状态,不清除标志位。Thread.interrupted()
:检查当前线程的中断状态,检查后会清除标志位(重置为false)。
处理中断异常:如果线程在
wait()
,sleep()
,join()
等过程中被中断,这些方法会立即抛出InterruptedException
,并且在抛出异常前,JVM会先将该线程的中断标志位清除(重置为false)。
正确停止线程的示例:
public class StoppableThread implements Runnable {@Overridepublic void run() {// 1. 定期检查中断标志位while (!Thread.currentThread().isInterrupted()) {try {// 2. 执行任务...System.out.println("Working...");// 模拟耗时操作,此时若被中断会抛出InterruptedExceptionThread.sleep(1000);} catch (InterruptedException e) {// 3. 捕获异常,收到中断信号System.out.println("Thread was interrupted, exiting...");// 恢复中断状态(可选,让上层调用者也能知道发生了中断)Thread.currentThread().interrupt();// break出循环,结束run方法,线程自然终止break;}}System.out.println("Thread stopped.");}public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(new StoppableThread());thread.start();// 主线程睡眠3秒后,请求中断子线程Thread.sleep(3000);thread.interrupt(); // 发送中断请求}
}
另一种方式:使用 volatile 标志位 通过一个共享的 volatile boolean 变量来控制线程的终止。
public class VolatileStopThread implements Runnable {private volatile boolean cancelled = false;@Overridepublic void run() {while (!cancelled) {// 执行任务...}System.out.println("Thread stopped by flag.");}public void cancel() {cancelled = true;}
}
小结:
选择: 对于阻塞在 wait()
, sleep()
等操作上的线程,中断机制是唯一能使其立即响应的方式。对于纯计算型的循环,两种方式都可以,但中断机制更为标准和强大。
希望这篇详尽的文章能帮助您彻底理解Java多线程的这些核心概念。理解这些区别和机制是编写正确、高效并发代码的基础。