- 发布日期
Java多线程(一):认识多线程与三种创建方式
- 作者
- 姓名
- 缘
- 社交账号
文章目录
前言
我们每天使用的软件几乎都离不开多线程:音乐软件可以一边播放歌曲一边下载文件,聊天程序可以同时接收消息和上传图片,Web 服务器也需要同时处理许多用户的请求。如果一个程序从头到尾只能按顺序做一件事,任何耗时操作都可能让整个程序停在那里等待。
本篇从最基本的进程、线程、并发和并行讲起,再完整介绍 Java 创建线程的三种常见方式。学完后,你不仅能把线程“跑起来”,还应该能说清楚 start() 与 run() 的区别,以及三种写法分别适合什么场景。
一、进程与线程
1. 什么是进程
进程可以理解为正在运行的程序实例。启动一次 IDEA、浏览器或音乐播放器,操作系统通常就会为它创建一个进程,并分配内存、文件句柄等运行资源。
程序与进程并不完全相同:程序是磁盘上的静态代码,进程则是程序运行起来后的动态状态。同一个程序也可以产生多个进程,例如打开多个彼此独立的应用实例。
2. 什么是线程
线程是操作系统能够调度的执行单元,一个进程可以包含一个或多个线程。线程依附于进程,同一进程中的多个线程会共享进程的堆内存和其他资源,但每个线程又拥有自己的程序计数器、虚拟机栈和本地方法栈。
Java 程序启动后并不是只有我们编写的 main 方法。至少会存在执行 main 方法的主线程,JVM 还可能启动垃圾回收等后台线程。因此,即使没有主动创建线程,一个普通 Java 程序通常也已经是多线程程序。
3. 为什么要使用多线程
多线程主要有三类价值:
- 提升响应性:耗时任务在后台执行,前台界面不必一直卡住。
- 提高吞吐量:不同任务可以交替执行,在多核 CPU 上还可能真正同时执行。
- 更自然地描述业务:下载任务、日志任务、请求处理任务可以由不同线程负责。
不过,多线程并不意味着程序必然更快。线程的创建、销毁和上下文切换都有成本;多个线程同时修改共享数据,还会引入线程安全问题。是否使用多线程,要看任务是否可以拆分、计算资源是否足够,以及并发带来的收益能否覆盖管理成本。
二、并发与并行
这两个概念经常一起出现,但侧重点不同。
1. 并发
并发是指在同一时间段内,有多个任务都在向前推进。单核 CPU 在某一时刻只能执行一个任务,但它可以快速地在多个线程之间切换:线程 A 执行一小段,切换到线程 B,再切回线程 A。从人的感受来看,多个任务像是在一起运行。
2. 并行
并行是指在同一时刻,有多个任务真正同时执行。它通常需要多核 CPU,例如一个核心执行线程 A,另一个核心执行线程 B。
可以把并发比作一位厨师交替照看多口锅,把并行比作多位厨师各自负责一口锅。现实中的程序往往同时具有并发和并行:任务数量多于 CPU 核心数量时,一部分任务并行执行,各核心内部又会发生并发切换。
三、创建线程的第一种方式:继承 Thread
最直观的做法是定义一个类继承 Thread,重写 run(),再创建对象并调用 start()。
public class DownloadThread extends Thread {
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println(getName() + " 正在下载第 " + i + " 个文件");
}
}
}
测试类:
public class ThreadDemo1 {
public static void main(String[] args) {
DownloadThread t1 = new DownloadThread();
DownloadThread t2 = new DownloadThread();
t1.setName("下载线程A");
t2.setName("下载线程B");
t1.start();
t2.start();
}
}
每次运行时,两组输出的交错顺序都可能不同,因为线程获得 CPU 执行权的时机不是由业务代码精确控制的。多线程程序中的输出顺序不固定,是正常现象。
这种方式代码简单,适合初学和功能较小的线程类。缺点也很明显:Java 只支持单继承,如果一个类已经继承了其他父类,就不能再继承 Thread;同时,“任务”与“执行任务的线程”耦合在了同一个类中,不利于复用。
四、创建线程的第二种方式:实现 Runnable
更常见的方式是把任务定义为 Runnable,再交给 Thread 执行。
public class PrintTask implements Runnable {
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println(
Thread.currentThread().getName() + " 打印第 " + i + " 页"
);
}
}
}
public class ThreadDemo2 {
public static void main(String[] args) {
PrintTask task = new PrintTask();
Thread t1 = new Thread(task, "打印机A");
Thread t2 = new Thread(task, "打印机B");
t1.start();
t2.start();
}
}
Runnable 方式把任务和线程分开了:PrintTask 只描述“做什么”,Thread 负责“由哪个线程执行”。任务类仍然可以继承其他类,而且同一个任务对象可以交给多个线程,这使得共享数据也更加直观。
如果任务逻辑较短,还可以使用 Lambda 表达式:
public class ThreadDemo3 {
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 1; i <= 3; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
};
new Thread(task, "线程一").start();
new Thread(task, "线程二").start();
}
}
需要注意,Runnable.run() 的返回类型是 void。如果线程执行完后需要把计算结果交回主线程,就要使用下一种方式。
五、创建线程的第三种方式:Callable 与 FutureTask
Callable<V> 的 call() 方法可以返回一个 V 类型的结果,也允许抛出异常。由于 Thread 构造方法不能直接接收 Callable,需要使用 FutureTask 作为中间适配器。
import java.util.concurrent.Callable;
public class SumTask implements Callable<Integer> {
private final int start;
private final int end;
public SumTask(int start, int end) {
this.start = start;
this.end = end;
}
@Override
public Integer call() {
int sum = 0;
for (int i = start; i <= end; i++) {
sum += i;
}
return sum;
}
}
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ThreadDemo4 {
public static void main(String[] args)
throws ExecutionException, InterruptedException {
FutureTask<Integer> futureTask =
new FutureTask<>(new SumTask(1, 100));
Thread thread = new Thread(futureTask, "求和线程");
thread.start();
Integer result = futureTask.get();
System.out.println("1 到 100 的和为:" + result);
}
}
这里有三个角色:
Callable<Integer>表示一个能够返回整数结果的任务。FutureTask<Integer>管理任务的执行状态和最终结果,同时实现了Runnable。Thread真正启动新的执行路径。
get() 会等待任务完成。如果调用 get() 时结果还没有产生,当前线程会被阻塞。因此不要把 get() 理解成“立即读取一个普通变量”,它同时也是一个等待点。
六、start() 与 run() 到底有什么区别
这是初学多线程时最容易混淆的问题。
thread.run(); // 普通方法调用
thread.start(); // 启动新线程
直接调用 run(),只是让当前线程执行一个普通成员方法,不会创建新的执行路径。如果这行代码写在 main 方法中,那么 run() 仍由主线程执行。
调用 start() 才是在告诉 JVM:这个线程已经准备好,可以参与调度。新线程获得执行机会后,JVM 会让它执行 run() 中的任务。一个 Thread 对象只能成功启动一次,重复调用 start() 会抛出 IllegalThreadStateException。
可以用下面的程序验证:
public class StartAndRunDemo {
public static void main(String[] args) {
Thread thread = new Thread(() ->
System.out.println(Thread.currentThread().getName())
);
thread.run();
thread.start();
}
}
第一次通常打印 main,第二次打印类似 Thread-0。这正好说明两种调用方式的本质差异。
七、三种实现方式如何选择
| 实现方式 | 是否有返回值 | 主要优点 | 主要限制 |
|---|---|---|---|
继承 Thread | 否 | 结构直观,便于理解 | 占用继承名额,任务与线程耦合 |
实现 Runnable | 否 | 解耦任务与线程,容易共享任务 | 不能直接返回结果 |
Callable + FutureTask | 是 | 能获取结果,也能传递异常 | 结构相对复杂,get() 可能阻塞 |
学习阶段三种方式都要掌握。实际开发中通常更强调“提交任务”而不是手动创建大量 Thread,因此 Runnable、Callable 与线程池的组合会更加常见。线程池将在本系列最后一篇详细介绍。
八、本篇小结
多线程的核心不是让所有代码同时执行,而是把可以独立推进的任务拆成多个执行单元。进程负责承载资源,线程负责执行任务;并发强调一段时间内多个任务都在推进,并行强调同一时刻真正同时执行。
Java 可以通过继承 Thread、实现 Runnable、实现 Callable 并配合 FutureTask 来创建线程任务。无论采用哪种方式,都要记住:run() 定义任务内容,start() 才负责启动新的线程。
下一篇将继续介绍线程名称、休眠、优先级、守护线程、礼让、插入执行以及线程的生命周期。