在现代软件开发中,高性能和 responsiveness(响应灵敏度)是衡量应用程序质量的关键指标。你是否曾想过,为什么现代服务器能够同时处理成千上万个请求?或者为什么你的浏览器在下载大文件时仍然允许你浏览其他网页?这一切的背后,都离不开一项核心技术——多线程。
在这篇文章中,我们将深入探讨 Java 多线程的世界。我们将从基础概念出发,逐步解析线程的生命周期、创建方式,以及如何通过同步机制来保证线程安全。无论你是刚刚接触并发编程的新手,还是希望巩固基础的开发者,这篇指南都将为你提供实用的见解和最佳实践。让我们开始这段并发编程的旅程吧!
为什么我们需要多线程?
在单线程程序中,代码是按顺序执行的。如果一项任务被阻塞(例如等待用户输入或文件读取),整个程序就会停止。这显然无法满足现代复杂应用的需求。
让我们想象一个餐厅厨房的场景:
- 单线程模式: 只有一位厨师。他必须先切菜,然后等待水烧开,最后才开始炒菜。如果水开得慢,切好的菜就得等着,整个上菜速度极慢。
- 多线程模式: 厨房里有三位厨师。一位负责切菜,一位负责炖汤,一位负责炒菜。他们共享同一个厨房空间(内存空间),但独立完成各自的任务。这就是多线程的魅力:在同一程序内并发运行多个任务,从而极大提高资源利用率和程序吞吐量。
核心概念:进程 vs 线程
在深入代码之前,我们需要理清两个容易混淆的概念:
- 进程: 是操作系统分配资源的基本单位。一个正在运行的 Java 虚拟机(JVM)就是一个进程。它是重量级的,拥有独立的内存堆。
- 线程: 是 CPU 调度的基本单位,也被称为“轻量级进程”。同一个进程内的所有线程共享该进程的内存资源(堆和方法区),但拥有独立的程序计数器、栈和本地变量。
创建线程的两种核心方式
在 Java 中,我们主要有两种方式来创建一个线程:继承 INLINECODE66734286 类和实现 INLINECODE02c94ea1 接口。让我们逐一看看它们的实际应用。
#### 方式一:继承 Thread 类
这是最直观的方式。我们创建一个类继承 INLINECODE46be3ba3,并重写其 INLINECODE5c01f280 方法。
class MyThread extends Thread {
// 重写 run 方法,定义线程要执行的任务
@Override
public void run() {
System.out.println("线程 " + Thread.currentThread().getName() + " 正在运行。");
try {
// 模拟耗时任务
Thread.sleep(500);
} catch (InterruptedException e) {
System.out.println("线程被中断");
}
System.out.println("线程 " + Thread.currentThread().getName() + " 执行完毕。");
}
}
public class ThreadDemo {
public static void main(String[] args) {
// 实例化自定义线程
MyThread thread1 = new MyThread();
MyThread thread2 = new MyThread();
// 注意:必须调用 start() 方法来启动线程,而不是直接调用 run()
thread1.start();
thread2.start();
System.out.println("主线程继续执行...");
}
}
关键点解析:
- INLINECODE5ecabf33 vs INLINECODE929c9f7a: 这是最常见的面试陷阱。INLINECODEcd5ad1f7 方法会告诉 JVM 虚拟机创建一个新的系统级线程并调用 INLINECODE30be59af 方法。如果你直接调用
run(),它只是一个普通的方法调用,会在当前线程(主线程)中同步执行,而不会产生并发效果。 - 执行顺序: 由于线程调度是由操作系统决定的,INLINECODEe11febfe 和 INLINECODE5a98e8a2 的执行顺序是不确定的。这就是“异步”的含义。
#### 方式二:实现 Runnable 接口
由于 Java 只支持单继承,如果你的类已经继承了其他类(例如 INLINECODEc33999b8),就无法再继承 INLINECODE835dd527。这时,Runnable 接口就成了更好的选择。这也是我们在实际开发中更推荐的做法。
class MyRunnable implements Runnable {
private String taskName;
public MyRunnable(String name) {
this.taskName = name;
}
@Override
public void run() {
System.out.println("正在执行任务: " + taskName);
for (int i = 0; i < 5; i++) {
System.out.println(taskName + " - 计数: " + i);
try {
Thread.sleep(200); // 暂停 200 毫秒
} catch (InterruptedException e) {
System.err.println("任务被中断");
}
}
}
}
public class RunnableDemo {
public static void main(String[] args) {
// 创建任务对象
MyRunnable task1 = new MyRunnable("数据下载");
MyRunnable task2 = new MyRunnable("界面渲染");
// 将任务传递给 Thread 对象并启动
new Thread(task1).start();
new Thread(task2).start();
}
}
实战见解: 这种“任务”与“执行机制”分离的设计模式,使得代码逻辑更加清晰,也更容易被线程池复用。
深入理解线程生命周期
理解线程的状态对于调试并发问题至关重要。一个线程在其生命周期中会经历以下状态(这也是 JVM 管理线程的方式):
- 新建: 创建了 INLINECODE7cece3d3 对象,但 INLINECODEdf304d82 还没被调用。
- 可运行: 调用了
start(),线程准备好运行,正在等待 CPU 时间片。 - 运行中: 线程获得了 CPU 资源,正在执行
run()方法中的代码。 - 阻塞/等待: 线程因等待 I/O、锁或其他线程的信号而暂时停止执行。
- 终止:
run()方法执行完毕或抛出未捕获的异常。
掌握核心线程方法
Java 提供了丰富的内置方法来控制线程行为。让我们看看几个最常用的方法及其使用场景。
#### 1. sleep() 方法
Thread.sleep() 会让当前线程暂停执行指定的时间(毫秒),释放 CPU 资源给其他线程,但不释放锁。
使用场景: 模拟网络延迟、定期轮询或避免 CPU 占用过高(即“忙等待”)。
#### 2. join() 方法
如果你需要等待某个线程执行完成后才能继续主线程的工作,join() 就是你的救星。
class Worker extends Thread {
public Worker(String name) { super(name); }
public void run() {
System.out.println(getName() + " 开始工作...");
try { Thread.sleep(1000); } catch (InterruptedException e) {}
System.out.println(getName() + " 工作完成。");
}
}
public class JoinDemo {
public static void main(String[] args) {
Worker worker = new Worker("装修工");
worker.start();
try {
// 主线程等待装修工完成
worker.join();
System.out.println("验收装修结果...");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
#### 3. INLINECODEe3b27351 和 INLINECODEe933f697
这两个方法是 Object 类中的方法,用于线程间的通信。它们必须在同步代码块或同步方法中调用。
- INLINECODEab81bb4a: 释放锁并进入等待状态,直到其他线程调用 INLINECODEac892c7c 或
notifyAll()。 -
notify(): 唤醒在此对象监视器上等待的单个线程。
线程优先级与守护线程
并非所有线程都是平等的。我们可以通过 setPriority(int) 设置优先级(1-10),但这只是给调度器的一个“建议”,并不保证绝对的执行顺序。
守护线程 是一种特殊的后台线程(如垃圾回收器)。如果所有非守护线程(用户线程)都结束了,JVM 会直接退出,不管守护线程是否还在运行。我们可以通过 setDaemon(true) 将线程标记为守护线程。
并发编程的挑战:同步与线程安全
多线程虽然强大,但也带来了巨大的风险:数据竞态 和 线程安全 问题。
当多个线程同时修改共享数据时,如果不加控制,最终结果可能会不可预测。
#### 问题场景:银行账户取款
想象一下,你和你的配偶同时持有同一张银行卡(余额 1000 元)。你们在两台不同的 ATM 机上同时取款 1000 元。如果没有同步机制,系统可能会读取到相同的初始余额(1000),然后都执行减法操作,导致最终余额错误(例如变成了 0 或 -1000,而不是正确的“余额不足”提示)。
#### 解决方案:Synchronized 关键字
synchronized 关键字可以修饰方法或代码块,确保同一时刻只有一个线程能进入该区域。
class BankAccount {
private int balance = 1000;
// 同步方法:锁定的是当前对象
public synchronized void withdraw(int amount) {
if (balance >= amount) {
System.out.println(Thread.currentThread().getName() + " 准备取款...");
try { Thread.sleep(500); } // 模拟网络延迟,增加出错概率
catch (InterruptedException e) {}
balance -= amount;
System.out.println(Thread.currentThread().getName() + " 取款成功。余额: " + balance);
} else {
System.out.println(Thread.currentThread().getName() + " 取款失败,余额不足。");
}
}
}
public class SyncDemo {
public static void main(String[] args) {
BankAccount account = new BankAccount();
// 创建两个线程模拟并发取款
Runnable task = () -> account.withdraw(1000);
new Thread(task, "用户A").start();
new Thread(task, "用户B").start();
}
}
最佳实践: 尽量缩小同步的范围。锁住整个方法会降低性能,只锁住关键代码段(临界区)是更优的选择。
总结与进阶方向
通过这篇文章,我们掌握了 Java 多线程的核心基石。我们从简单的线程创建入手,了解了线程的生命周期,并重点解决了多线程并发带来的安全问题。
关键要点回顾:
- 使用 INLINECODE682e425c 接口通常比继承 INLINECODEf7eec1c4 类更灵活。
- 始终调用 INLINECODEbc4ed93f 来启动线程,而非 INLINECODE84f47cc8。
- 当多个线程访问共享可变状态时,必须使用 INLINECODE19b76a67 或其他锁机制(如 INLINECODE7ff09280)来保证线程安全。
接下来你可以探索:
现在的你,已经具备了处理基本并发任务的能力。但在更复杂的企业级应用中,频繁创建和销毁线程会消耗大量资源。下一步,我建议你深入研究 Java 线程池 以及 INLINECODE8710ede9 包下的高级工具类(如 INLINECODE764bc464, CyclicBarrier),它们将帮助你编写出更加高效、优雅的并发程序。
希望这篇指南能帮助你更好地理解 Java 多线程。祝你在编码之路上不断精进!