缘博客
发布日期

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);
    }
}

这里有三个角色:

  1. Callable<Integer> 表示一个能够返回整数结果的任务。
  2. FutureTask<Integer> 管理任务的执行状态和最终结果,同时实现了 Runnable
  3. 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,因此 RunnableCallable 与线程池的组合会更加常见。线程池将在本系列最后一篇详细介绍。

八、本篇小结

多线程的核心不是让所有代码同时执行,而是把可以独立推进的任务拆成多个执行单元。进程负责承载资源,线程负责执行任务;并发强调一段时间内多个任务都在推进,并行强调同一时刻真正同时执行。

Java 可以通过继承 Thread、实现 Runnable、实现 Callable 并配合 FutureTask 来创建线程任务。无论采用哪种方式,都要记住:run() 定义任务内容,start() 才负责启动新的线程。

下一篇将继续介绍线程名称、休眠、优先级、守护线程、礼让、插入执行以及线程的生命周期。

参考资料