万字解析Java多线程创建——现代并发到基础原理

引言:为什么现代开发优选线程池?

在探讨具体技术前,必须明确一个核心思想:在生产级应用中,我们几乎总是使用线程池来管理线程,而非手动new Thread()。原因在于:

  • 性能开销:手动创建和销毁线程涉及操作系统层面的资源调度,成本高昂。

  • 资源管理:无限制地创建线程会迅速耗尽系统内存和CPU资源,导致应用崩溃。

  • 管理复杂性:缺乏统一的管理、监控和流量控制机制,代码难以维护。

因此,我们的学习路径将从解决这些问题的Executor框架开始,再深入到底层实现和基础概念。

1.现代并发编程的基石 —— Executor 框架

Executor框架是java.util.concurrent包的核心,它将任务的提交任务的执行完全解耦。

1.1 核心设计:ExecutorService 接口

ExecutorService是整个框架的“规范”和“契约”。它是一个接口,定义了管理异步任务的标准行为。

  • 定位:线程池的统一操作标准

  • 核心方法

    • void execute(Runnable command): 提交一个不需要返回值的任务,“执行后不理”。

    • Future submit(Callable task): 提交一个需要返回值的任务,并返回一个Future对象用于后续获取结果。

    • void shutdown(): 平滑关闭。不再接受新任务,但会执行完队列中已有的任务。

    • List shutdownNow(): 立即关闭。尝试停止所有正在执行的任务,并返回队列中未执行的任务列表。

1.2 核心实现:ThreadPoolExecutor

ThreadPoolExecutorExecutorService最重要、最灵活的实现类。我们所说的“线程池”通常就是指它的实例。

  • 定位:线程池的具体实现,内部包含了完整的线程管理和任务调度机制。

  • 七大构造参数(理解这些是理解线程池工作原理的关键):

    1. int corePoolSize: 核心线程数。线程池中保持存活的线程数量。

    2. int maximumPoolSize: 最大线程数。线程池能容纳的最大线程数量。

    3. long keepAliveTime: 存活时间。当线程数超过corePoolSize时,多余的空闲线程能存活的时间。

    4. TimeUnit unit: 时间单位。为keepAliveTime指定单位(秒、毫秒等)。

    5. BlockingQueue workQueue: 任务队列。用于存放等待执行的任务的阻塞队列。

    6. ThreadFactory threadFactory: 线程工厂。用于创建新线程,可以自定义线程名、优先级等。

    7. RejectedExecutionHandler handler: 拒绝策略。当队列和线程池都满了之后,用于处理新任务的策略。

  • 核心工作流程(当一个新任务被execute()时):

    1. 检查当前线程数是否小于corePoolSize,如果是,则创建新线程执行任务。

    2. 如果不是,尝试将任务添加到workQueue任务队列。

    3. 如果队列已满,检查当前线程数是否小于maximumPoolSize,如果是,则创建“非核心”线程执行任务。

    4. 如果线程数也已达到最大,则执行拒绝策略RejectedExecutionHandler

  • 生产实践建议

    • 强烈推荐手动通过ThreadPoolExecutor的构造函数来创建线程池,而不是使用Executors工具类。

    • 原因Executors提供的方法(如newFixedThreadPoolnewCachedThreadPool)可能使用无界队列,在任务堆积时有OutOfMemoryError的风险。手动创建能让你对线程池的行为有最精确的控制。

上面的可能过于理论有些晦涩难懂,下面结合具体的示例再来理解一下吧:


2.任务的抽象 —— RunnableCallable

线程池负责“执行”,而RunnableCallable则负责定义“被执行的任务”。

创建线程有三种主流方式:选对姿势,事半功倍

我们来看看怎么“创造”一个线程。在实际开发中,我们主要有以下三种方式,各有优劣,要根据场景来选择。

方式一:继承 Thread

这是最直观的一种方式,但也是最不推荐的一种。

怎么做?

  1. 创建一个类,让它继承Thread类。

  2. 重写run()方法,把你的业务逻辑写在里面。

  3. 创建这个子类的实例,然后调用start()方法。

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("你看,通过继承Thread类跑起来了!当前线程:" + Thread.currentThread().getName());
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
    }
}

为什么不推荐?

  • 单继承局限:Java是单继承的,你的类如果继承了Thread,就不能再继承其他任何类了。在复杂的业务场景下,这会极大地限制你的代码设计。

  • 职责不单一:继承Thread的方式,将“线程”这个概念和“任务(业务逻辑)”耦合在了一起。一个类既是线程,又干业务的活,不符合面向对象设计中的“单一职责原则”。

方式二:实现 Runnable 接口 (推荐)

这是我们最常用、最推荐的方式。

  • 定义:代表一个没有返回值的异步任务。

  • 核心方法void run()

  • 特点

    • 方法没有返回值。

    • 方法签名无法直接throws受检异常(Checked Exception),必须在run()方法内部try-catch

怎么做?

  1. 创建一个类,实现Runnable接口。

  2. 实现接口中唯一的run()方法。

  3. 创建这个实现类的实例,然后把它作为参数传给new Thread()来创建Thread对象。

  4. 调用Thread对象的start()方法。

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("通过实现Runnable接口,我也跑起来啦!当前线程:" + Thread.currentThread().getName());
    }
}

public class Main {
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();

        // Java 8 之后,可以用 Lambda 表达式,代码更简洁
        new Thread(() -> System.out.println("Lambda表达式让代码更清爽!")).start();
    }
}

为什么推荐?

  • 解耦合:它完美地将“任务”(Runnable)和“执行任务的线程”(Thread)分离开来。Runnable只负责定义要做什么事,Thread负责怎么去执行这件事。

  • 无继承限制:你的任务类可以自由地继承其他类,代码结构更灵活。

  • 资源共享:多个线程可以共享同一个Runnable实例,非常方便地实现资源共享。

方式三:实现 Callable 接口 (处理有返回值的任务)

当你的任务执行完后,需要一个返回结果,或者任务执行过程中可能会抛出异常需要处理时,Runnable就有点力不从心了。这时候,Callable就闪亮登场了。

怎么做?

  1. 创建一个类,实现Callable接口,V就是你希望返回结果的类型。

  2. 重写call()方法(注意不是run()),在这个方法里实现你的业务逻辑,并返回结果。

  3. Callable不能直接被Thread类使用,它需要和线程池ExecutorService)一起配合。

  4. 通过线程池的submit()方法提交Callable任务,这个方法会返回一个Future对象。

  5. 通过Future对象的get()方法,可以阻塞地获取任务执行的最终结果。

import java.util.concurrent.*;

class MyCallable implements Callable {
    @Override
    public String call() throws Exception {
        // 模拟一个耗时计算
        Thread.sleep(2000);
        return "这是Callable返回的结果!";
    }
}

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 1. 创建线程池
        ExecutorService executorService = Executors.newSingleThreadExecutor();

        // 2. 创建Callable任务
        MyCallable myCallable = new MyCallable();

        // 3. 提交任务并获取Future对象
        Future future = executorService.submit(myCallable);

        System.out.println("主线程先干点别的...");

        // 4. 获取任务执行结果 (get()方法会阻塞,直到拿到结果)
        String result = future.get();
        System.out.println("拿到了异步任务的结果:" + result);

        // 5. 关闭线程池
        executorService.shutdown();
    }
}

Runnable vs Callable 总结一下:

特性

Runnable

Callable

方法

void run()

V call() throws Exception

返回值

无 (void)

有,类型为泛型 V

异常

只能内部try-catch,不能向上抛出

可以向上抛出受检异常(throws Exception

使用

可直接用于new Thread()或线程池

通常配合线程池和Future使用

 上面的一些地方可能没有说的特别清楚,有疑问的继续看下面:


extends Thread 的单继承局限到底限制了什么?

举个咱们后端开发里非常实际的例子。

假设你在一个电商公司,需要写一个“订单支付成功后,异步发送短信通知”的功能。

你的业务逻辑类可能长这样:

// 这是一个模拟的短信发送服务的基类,可能包含了一些通用的配置,比如连接短信网关、处理签名等
abstract class AbstractSmsService {
    protected String gatewayUrl = "sms.aliyun.com"; // 假设这是短信网关地址

    public void connect() {
        System.out.println("连接到短信网关: " + gatewayUrl);
    }
    // 可能还有其他通用方法...
}

// 现在你要实现支付成功后发短信的逻辑
class PaymentSuccessSmsSender extends AbstractSmsService { // 这里需要继承公司的基础短信服务类
    public void sendSms(String phone, String content) {
        connect();
        System.out.println("向 " + phone + " 发送短信: " + content);
    }
}

看到没,为了复用公司提供的短信服务能力,你的PaymentSuccessSmsSender必须继承AbstractSmsService

现在,如果你想让这个发送短信的动作在一个新线程里执行(因为发送短信可能比较慢,不能阻塞主流程),并且你选择了extends Thread的方式:

// 错误示范
class PaymentSuccessSmsSender extends Thread, AbstractSmsService { // Java: ??? 你只能继承一个爹!
    // ...
}

Java会直接给你报错! 因为Java不允许一个类同时继承两个父类。这就是单继承局限。你继承了Thread,就没法再继承AbstractSmsService来复用已有的功能了。

但是,如果我们用Runnable接口,问题就迎刃而解了:

// 正确示范
class PaymentSuccessSmsTask extends AbstractSmsService implements Runnable {
    private String phone;
    private String content;

    public PaymentSuccessSmsTask(String phone, String content) {
        this.phone = phone;
        this.content = content;
    }

    @Override
    public void run() {
        // run方法里调用我们自己的业务逻辑
        sendSms(this.phone, this.content);
    }

    // sendSms方法是从父类 AbstractSmsService 继承来的
    public void sendSms(String phone, String content) {
        connect();
        System.out.println("向 " + phone + " 发送短信: " + content);
    }
}

// 使用时:
public class Main {
    public static void main(String[] args) {
        PaymentSuccessSmsTask task = new PaymentSuccessSmsTask("13888888888", "您的订单已支付成功!");
        new Thread(task).start();
        System.out.println("主线程:已经安排发短信了,我继续干别的活。");
    }
}

看,通过implements Runnable,我们的业务类PaymentSuccessSmsTask既能继承AbstractSmsService复用业务逻辑,又能作为一个“任务”被丢到任何一个线程里去执行。这才是灵活、可扩展的设计。


Runnable 代码的逐行解读

// 在 MyRunnable 的 run() 方法里
System.out.println("... 当前线程:" + Thread.currentThread().getName());
  • Thread.currentThread(): 这是一个Thread类的静态方法。无论在哪执行它,它都会返回当前正在执行这行代码的那个线程对象

  • .getName(): 这是线程对象的一个普通方法,用来获取这个线程的名字。线程的名字通常是给程序员调试时看的,比如默认名字可能是Thread-0, Thread-1...

  • 所以这整行代码的意思是:“嗨,CPU,不管现在是哪个线程在运行我这行代码,请把它的名字告诉我,然后我把它打印出来。” 这样我们就能在控制台清楚地看到到底是哪个线程在干活。

现在来看main方法里的三行核心代码:

// 1. MyRunnable myRunnable = new MyRunnable();
  • 解读:这里我们创建了一个MyRunnable类的实例(对象),我们叫它myRunnable此时此刻,它跟线程半毛钱关系都没有。它就是一个普通的Java对象,你可以把它想象成一张写着“待办事项”的便签纸,纸上记录了要在run()方法里执行的工作内容。

// 2. Thread thread = new Thread(myRunnable);
  • 解读:这里我们创建了一个真正的Thread对象,我们叫它thread。这是一个“工人”。关键在于构造函数里的myRunnable参数,这相当于我们把刚才那张“待办事项”便签纸交给了这个新来的工人。我们告诉他:“thread,你的工作任务就是myRunnable上写的内容”

// 3. thread.start();
  • 解读:这是“开工!”的命令。我们对工人thread说:“你可以开始干活了!”。JVM收到这个命令后,不会立刻执行,而是会为这个thread向操作系统申请一个新的执行路径,把它放到“就绪”队列里。一旦轮到它,它就会开始执行我们之前交给它的任务——也就是myRunnable对象里的run()方法。

至于Lambda表达式:

new Thread(() -> System.out.println("Lambda表达式让代码更清爽!")).start();
  • 它代替了哪几行? 它可以完全代替上面那三行代码,外加MyRunnable那个类的定义!

  • 为什么可以? Runnable接口只有一个抽象方法run(),这种接口被称为“函数式接口”。Java 8的Lambda表达式就是为简化这种接口的匿名实现而生的。() -> System.out.println(...) 这段代码,本质上就是动态地创建了一个实现了Runnable接口的匿名对象,并且把->后面的代码作为了run()方法的方法体。所以它一步到位,既定义了任务,又创建了线程,还启动了线程。

 

ExecutorService(线程池)是什么,为什么需要它?

这个问题非常关键

把它想象成一个“外包团队”

  • 没有线程池(自己new Thread():每次你需要有人干个活(比如发个短信),你就得上街现招一个人(new Thread()),告诉他干啥,干完就把他解雇了(线程销毁)。如果一瞬间有一万个短信要发,你就要瞬间招一万个人,干完再全解雇。这会造成:

    1. 成本极高:频繁创建和销毁线程是非常消耗系统资源的(CPU和内存)。

    2. 管理混乱:同时有一万个工人在街上跑,系统可能会因为资源耗尽而崩溃,你也没法控制到底同时有多少人开工。

  • 使用线程池:你不再上街招人,而是签约了一个有10个工人的外包团队(Executors.newFixedThreadPool(10))。这个团队就是线程池

    1. 什么是ExecutorService:它是一个接口,定义了管理和使用这个“外包团队”的规范。我们通常用Executors工厂类来创建它的实例。

    2. 有什么作用

      • 线程复用:团队里这10个工人是常驻的,不会被解雇。有活儿来了,就派一个闲着的工人去干。干完了,这个工人不清退,而是回到团队里待命,等下一个活儿。大大降低了资源开销。

      • 并发控制:你签约的就是10人团队,所以最多就只有10个任务能被同时执行。后面来的任务怎么办?排队等着。这样就有效避免了系统因瞬时请求过多而崩溃,让系统负载变得平滑可控。

      • 统一管理:这个“团队老大”(ExecutorService)提供了统一的接口来提交任务(submit, execute)、关闭团队(shutdown)等,让多线程编程变得更加规范和简单。

为什么Callable需要和它配合?

因为Callable的设计初衷就是为了处理有返回值的异步任务

  • Runnable是“交给你了,干完拉倒,别烦我”。

  • Callable是“交给你了,干完后记得把结果告诉我”。

那么问题来了,你怎么拿回这个结果呢?submit()方法就扮演了这个关键角色。

当你调用executorService.submit(myCallable)时,线程池做了两件事:

  1. 把你的Callable任务(一个需要产出结果的活)放进任务队列,等待团队里的工人来领取执行。

  2. 立刻返回给你一个“凭证”,这个凭证就是Future对象。它就像一张“取货单”。

你拿着这张“取货单”(Future),可以随时去查询任务状态,或者在你需要结果的时候,调用future.get()去“取货”。如果货还没好(任务还没执行完),get()方法就会让你在那等着,直到工人把结果生产出来为止。

所以,ExecutorService + Callable + Future 是一套优雅的组合拳,完美解决了“异步执行、获取结果”这一核心需求。

 


可能还有一个问题就是:线程池,ThreadPoolExecutor,ExecutorService这三个的关系:

对其理解可以串成一句话:

我们通过实例化 ThreadPoolExecutor 这个类来创建一个线程池对象,这个对象内部包含了各种核心参数来控制其行为,并且因为它实现了 ExecutorService 接口,所以它必须遵守该接口定义的统一规范,也使得我们可以通过 ExecutorService 这个接口类型来引用和操作它。

看下面这行最常见的代码:

//   接口类型         =       new         具体实现类的实例()
ExecutorService threadPool = new ThreadPoolExecutor(5, 10, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100));
  • 等号右边:我们创建了一个具体的、有血有肉的 ThreadPoolExecutor 实例。

  • 等号左边:我们用 ExecutorService 这个更抽象、更通用的接口类型来引用它。

这样做的好处,就是我们之前提到的“面向接口编程”,我们的代码只需要和 threadPool 这个 ExecutorService 接口打交道,非常灵活。


线程的生命周期

无论是通过哪种方式创建和启动,一个线程都有其明确的生命周期状态。

4.1 六大状态 (java.lang.Thread.State)
  1. NEW (新建)

    • new Thread()之后,但在调用start()之前。此时仅是一个Java对象。

  2. RUNNABLE (可运行)

    • 调用start()之后。线程进入就绪队列,等待CPU调度。它包含了操作系统层面的**就绪(Ready)运行中(Running)**两种状态。

  3. BLOCKED (阻塞)

    • 线程等待进入synchronized同步块或方法,但锁被其他线程持有时。

  4. WAITING (无限期等待)

    • 因调用Object.wait()Thread.join()LockSupport.park()而进入。必须等待其他线程的显式唤醒(notify()unpark())。

  5. TIMED_WAITING (计时等待)

    • WAITING类似,但有时间限制。由Thread.sleep(long)Object.wait(long)等方法触发。时间一到或被唤醒即可返回RUNNABLE状态。

  6. TERMINATED (终止)

    • run()方法正常执行完毕,或因未捕获的异常而退出。


补充(第一部分生产实践建议):

在对系统稳定性和资源控制有严格要求的生产环境中,手动通过ThreadPoolExecutor的构造函数来创建线程池,绝对是推荐的最佳实践。

著名的《阿里巴巴Java开发手册》中,就明确地将“不允许使用Executors去创建线程池”列为一条强制性规约。

现在,我们来分步看如何操作,以及为什么必须这么做。

一、如何操作:手动创建 ThreadPoolExecutor

手动创建的核心,就是自己去调用ThreadPoolExecutor那个最长的、有7个参数的构造函数,把每一个参数的含义都想清楚,并赋予一个合理的值。

1. 构造函数签名
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) { ... }

二、为什么必须这么做:Executors工具类的“陷阱”

Executors工具类提供的看似方便的静态方法,在生产环境中隐藏着巨大的风险,主要体现在对资源的不可控。

1. Executors.newFixedThreadPool(int n)
  • 表面行为:创建一个固定大小的线程池。

  • 隐藏陷阱:它内部使用的任务队列是new LinkedBlockingQueue<>()。这是一个无界队列(理论容量是Integer.MAX_VALUE,约21亿)。

  • 风险场景:如果任务生产的速度持续快于任务消耗的速度,任务就会在队列中无限堆积。最终,将耗尽服务器的所有内存,导致OutOfMemoryError(OOM),整个应用直接崩溃。而且因为任务只是在排队,你甚至收不到任何拒绝异常,应用在崩溃前看起来“一切正常”。

2. Executors.newCachedThreadPool()
  • 表面行为:创建一个可缓存的、线程数会根据需要自动扩缩的线程池。

  • 隐藏陷阱:它的maximumPoolSize被设置成了Integer.MAX_VALUE。虽然它使用SynchronousQueue导致任务不会堆积,但它会为每一个新任务创建一个新线程(如果没有空闲线程可用)。

  • 风险场景:如果瞬间有大量任务涌入(比如一次恶意请求或突发流量高峰),它会疯狂创建成千上万个线程。每个线程都需要消耗一定的内存(线程栈),最终会耗尽系统资源,同样可能导致OutOfMemoryError或操作系统因为无法创建更多线程而崩溃。

记住这个结论:Executors 是学习和简单测试时的“玩具”,而 new ThreadPoolExecutor(...) 才是生产环境的“工业级工具”。 在任何面试或实际工作中,能够清晰地阐述这一点,都会是你专业性的重要体现。

PS:本篇文章篇幅较长,可能在排版布局以及阅读体验上存在问题,请见谅,不过我认为这已经总结了学习以及面试中常见常考的问题了并且每一部分都做出了清楚的解释,如果还有不理解的地方欢迎继续探讨

你可能感兴趣的:(java,开发语言,多线程)