Skip to content

S05-01 多线程与并发-多线程

[TOC]

概述

进程与线程

  • 进程(Process):操作系统分配资源(如内存)的最小单位。一个独立运行的程序就是一个进程(比如你打开的一个浏览器)。
  • 线程(Thread):CPU 调度的最小单位。它是进程中的一个执行流程。一个进程可以包含多个线程,这些线程共享该进程的内存资源(如堆内存和方法区),但每个线程有自己独立的程序计数器(PC)和虚拟机栈

一句话总结: 进程就像是一辆火车,而线程就是火车上的一节节车厢。多线程就是让这列火车有多个车厢同时运作。

图示:JVM 中的线程

  • 不同的进程之间是不共享内存的。

  • 每个线程独立的拥有自己的:虚拟机栈、本地方法栈、程序计数器。一个进程中的多个线程,共享进程的:方法区、堆。

  • 进程之间的数据交换和通信的成本很高。

image-20220514175737426

并发与并行

这两个概念经常被混淆,但它们在计算机科学中有着明确的区别:

  • 并发 (Concurrency):指的是系统拥有处理多个任务的能力,但不一定是同时执行。在单核 CPU 中,操作系统通过快速切换时间片,让多个线程交替执行,使得宏观上看起来像是在“同时”运行。
  • 并行 (Parallelism):指的是系统拥有同时执行多个任务的能力。这通常需要多核 CPU 的支持,真正的在同一物理时刻,不同的核心在执行不同的线程。

比喻:

  • 并发是一个咖啡机,两个人排队交替接咖啡;
  • 并行是两台咖啡机,两个人同时各自接咖啡。

image-20260401164335096

线程调度

在 Java 多线程编程中,当有多个线程处于可运行(RUNNABLE)状态时,谁先执行、谁后执行、每个线程执行多久,这就涉及到了线程调度(Thread Scheduling)

理解线程调度,有助于我们明白为什么多线程程序的执行结果往往是“不可预测”的,以及我们能在多大程度上干预这种调度。

在计算机科学中,主要有两种线程调度模型:

协同式调度

方式一:协同式调度 (Cooperative Scheduling):

  • 机制: 线程的执行时间由线程本身控制。一个线程执行完自己的工作后,主动通知系统切换到另外一个线程。
  • 优点: 实现简单,没有线程同步的问题(因为什么时候切换是确定的)。
  • 缺点: 极其危险。如果一个线程编写有问题,一直不让出 CPU(比如死循环),会导致整个系统崩溃。

抢占式调度

方式二:抢占式调度 (Preemptive Scheduling) —— Java 的选择:

  • 机制: 线程的执行时间由**操作系统(调度器)**来分配。操作系统会给每个线程分配一个“时间片”(Time Slice,通常是几十毫秒)。时间片用完,或者发生阻塞时,操作系统会强制剥夺该线程的 CPU 执行权,并把 CPU 交给其他处于就绪状态的线程。
  • 优点: 一个线程的阻塞或死循环不会导致整个系统崩溃,多任务并发性好。
  • 缺点: 线程切换频繁会有上下文切换(Context Switch)开销;会导致线程安全问题,需要开发者手动进行同步(加锁等)。

核心结论: Java 的线程调度是抢占式的。这意味着 Java 程序无法绝对控制哪个线程在什么时刻执行,只能对调度器提出“建议”。

抢占式调度

线程优先级

线程优先级 (Thread Priority):

既然调度由 OS 决定,Java 提供了 setPriority(int newPriority) 方法,试图给调度器一些“建议”。

Java 线程优先级范围是 1 到 10:

  • Thread.MIN_PRIORITY (1)
  • Thread.NORM_PRIORITY (5) —— 默认优先级
  • Thread.MAX_PRIORITY (10)

优先级规则与致命陷阱:

  1. 高优先级不等于先执行: 优先级高的线程只是获取 CPU 时间片的概率更大,绝不意味着它一定会在低优先级线程之前执行完毕。

  2. OS 映射差异: Java 有 10 个优先级,但底层操作系统可能没有这么多。比如 Windows 有 7 个,Linux 的某些调度策略下优先级可能完全被忽略。多个 Java 优先级可能被映射到同一个 OS 优先级上。

  3. 饥饿 (Starvation): 如果你把某个线程优先级设得极低,在 CPU 繁忙时,它可能永远抢不到时间片,导致“饿死”。

实战建议: 在实际的业务开发中,绝对不要依赖线程优先级来控制程序的业务逻辑和执行顺序! 它极不可靠。通常我们都保持默认的优先级(5)即可。

单核和多核

单核CPU:在一个时间单元内,只能执行一个线程的任务。

例如,可以把CPU看成是医院的医生诊室,在一定时间内执行一行代码(给一个病人诊断治疗)。所以单核CPU就是,代码经过前面一系列的前导操作(类似于医院挂号),然后到cpu处执行时发现,就只有一个CPU,大家排队执行。(类似于10个挂号窗口挂号,结果跑到医生那只有一个医生,只能排队等)。

这时候想要提升系统性能,只有两个办法,要么提升CPU性能(让医生看病快点),要么多加几个CPU(多整几个医生)。后者即为提供多核CPU。如果是多核的话,才能更好的发挥多线程的效率。(现在的服务器都是多核的)。

问题:多核的效率是单核的倍数吗?

譬如4核A53的cpu,性能是单核A53的4倍吗?理论上是,但是实际不可能,至少有两方面的损耗。

  • 一个是多个核心的其他共用资源限制。譬如,4核CPU对应的内存、cache、寄存器并没有同步扩充4倍。这就好像医院一样,1个医生换4个医生,但是做B超检查的还是一台机器,性能瓶颈就从医生转到B超检查了。
  • 另一个是多核CPU之间的协调管理损耗。譬如多个核心同时运行两个相关的任务,需要考虑任务同步,这也需要消耗额外性能。好比公司工作一样,一个人的时候至少不用开会浪费时间,自己跟自己商量就行了。两个人就要开会同步工作,协调分配,所以工作效率绝对不可能达到2倍。

线程创建方式

严谨地说,Java 中创建线程本质上只有一种方式:构造一个 java.lang.Thread 类的实例,并调用其 start() 方法。所谓的“多种方式”,其实是指封装线程执行任务(Task)的方式不同

以下是 Java 中常见的四种创建并启动线程的方式详解:

方式1:继承 Thread

继承 Thread:

这是最直观、最古老的方式。你只需要创建一个类继承 Thread,并重写它的 run() 方法。

代码示例:

java
// 1. 自定义类继承 Thread
class MyThread extends Thread { 

  // 2. 重写 Thread 类的 run() 方法
  @Override
  public void run() {
    // 线程要执行的业务逻辑
    System.out.println(Thread.currentThread().getName() + " 正在执行...");
  }
}

public class ThreadDemo {
  public static void main(String[] args) {
    // 3. 创建线程对象
    MyThread t1 = new MyThread();
    MyThread t2 = new MyThread();

    // 4. 调用 start() 方法启动线程
    t1.start();
    t2.start();
  }
}
  • 优点: 编写简单,如果在 run() 方法内部需要获取当前线程,直接使用 this 即可,无须调用 Thread.currentThread()
  • 缺点: 极其不推荐在现代开发中使用。因为 Java 是单继承的,如果你的类已经继承了 Thread,就无法再继承其他业务类,大大限制了代码的扩展性。此外,任务(run 方法)和线程(Thread 对象)强耦合在一起。

练习

  1. 创建一个分线程1,用于遍历 100 以内的偶数

    image-20260401170117850

  2. 创建两个分线程,一个线程用于遍历 100 以内的偶数,另一个线程用于遍历 100 以内的奇数

    方式一:使用标准方法

    image-20260401171741439

    方式二:使用匿名类的方式

    image-20260401172118608

方式2:实现 Runnable 接口

实现 Runnable 接口:

为了解决单继承的限制,Java 提供了 Runnable 接口。你将需要执行的任务写在 Runnable 实现类的 run() 方法中,然后将这个实现类作为一个“目标任务”丢给 Thread 对象去执行

标准写法:

java
// 1. 实现 Runnable 接口
class MyRunnable implements Runnable { 

  // 2. 实现 Runnable 接口的 run() 方法
  @Override
  public void run() {
    // 线程要执行的业务逻辑
    System.out.println(Thread.currentThread().getName() + " 正在执行 Runnable 任务...");
  }
}

public class RunnableDemo {
  public static void main(String[] args) {
    // 3. 创建当前任务对象
    MyRunnable task = new MyRunnable();

    // 4. 将任务对象传入 Thread 构造器,并创建 Thread 类的实例对象
    Thread t1 = new Thread(task, "线程-A");
    Thread t2 = new Thread(task, "线程-B");

    // 5. 调用 start() 方法启动线程
    t1.start();
    t2.start();
  }
}

匿名实现类写法

java
public class RunnableDemo {
  public static void main(String[] args) {象
    Thread t1 = new Thread(new Runnable() {
      @Override
      public void run() {
        // 线程要执行的业务逻辑
        System.out.println(Thread.currentThread().getName() + " 正在执行 Runnable 任务...");
      }
    }, "线程-A").start();
  }
}

现代写法(使用 Lambda 表达式,Java 8+):

java
Thread t3 = new Thread(() -> {
  System.out.println(Thread.currentThread().getName() + " 使用 Lambda 执行任务...");
}, "线程-C");
t3.start();

优缺点

  • 优点:
    1. 打破了单继承的局限性,类还可以继承其他类。
    2. 解耦:将线程和任务彻底分离。
    3. 非常适合多个相同线程去处理同一个资源的情况(例如上述代码中 t1t2 共享同一个 task 对象)。
  • 缺点: run() 方法没有返回值,也无法抛出受检异常(Checked Exception),只能在方法内部 try-catch

注意:Thread类实际上也是实现了Runnable接口的类(代理模式):

java
public class Thread extends Object implements Runnable

练习

  1. 程序阅读

    image-20260401180216584

方式3:实现 Callable 接口与 FutureTask

API:FutureTask

FutureTask API

构造方法

  • FutureTask()(Callable<V> callable)构造方法,将一个带有返回值的 Callable 任务包装成 FutureTask
  • FutureTask()(Runnable runnable, V result)构造方法,包装一个 Runnable 任务,并提前指定好一个返回值 result

实例方法

  • V get()()获取计算结果(死等派)。
    极其重要:如果任务还没执行完,调用这个方法的当前线程会被完全阻塞挂起,直到任务执行完毕返回结果,或者抛出异常。
  • V get()(long timeout, TimeUnit unit)获取计算结果(限时派,极度推荐)。
    如果到了指定时间任务还没出结果,会抛出 TimeoutException。在企业开发中,强烈禁止使用无参的 get() 以防止主线程被永远卡死。

实现 Callable 接口与 FutureTask

如果你希望线程执行完毕后能返回一个结果,或者能抛出异常供外部捕获,那么就需要使用 Callable 接口(Java 5 引入)。

因为 Thread 类的构造器只接受 Runnable,不接受 Callable,所以我们需要一个桥梁——FutureTaskFutureTask 实现了 Runnable 接口,同时它的构造器可以接收 Callable

代码示例:

java
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;

// 1. 实现 Callable 接口,泛型 <Integer> 代表返回值的类型
class MyCallable implements Callable<Integer> {
  @Override
  public Integer call() throws Exception {
    System.out.println(Thread.currentThread().getName() + " 正在计算...");
    Thread.sleep(2000); // 模拟耗时操作
    return 100 + 200;
  }
}

public class CallableDemo {
  public static void main(String[] args) throws Exception {
    // 2. 创建 Callable 任务
    MyCallable task = new MyCallable();

    // 3. 使用 FutureTask 包装 Callable
    FutureTask<Integer> futureTask = new FutureTask<>(task);

    // 4. 将 FutureTask 交给 Thread 执行
    Thread t1 = new Thread(futureTask, "计算线程");
    t1.start();

    System.out.println("主线程可以继续做其他事情...");

    // 5. 获取结果。注意:get() 方法会阻塞当前主线程,直到 call() 执行完毕并返回结果!
    Integer result = futureTask.get();
    System.out.println("计算结果是: " + result);
  }
}
  • 优点: 功能最强大,有返回值,能抛出异常。通过 FutureTask 还可以取消任务、判断任务是否完成。
  • 缺点: 代码相对繁琐。调用 get() 方法时如果没有设置超时时间,可能会导致阻塞。

方式4:使用线程池(推荐)@

使用线程池(Executor 框架):

在实际的企业级项目开发中,我们几乎从来不会手动去 new Thread()。因为频繁创建和销毁线程会消耗极大的系统资源,并且难以统一管理,容易导致内存溢出(OOM)。

业界标准做法是使用线程池。你只需要把 RunnableCallable 任务提交给线程池,线程池会自动分配工作线程来执行它们。

代码示例:

java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class ThreadPoolDemo {
  public static void main(String[] args) throws Exception {
    // 1. 创建一个固定大小为 3 的线程池
    ExecutorService threadPool = Executors.newFixedThreadPool(3); 

    // 2. 提交 Runnable 任务 (使用 execute)
    threadPool.execute(() -> { 
      System.out.println(Thread.currentThread().getName() + " 执行无返回值的任务");
    });

    // 3. 提交 Callable 任务 (使用 submit)
    Future<String> future = threadPool.submit(() -> { 
      System.out.println(Thread.currentThread().getName() + " 执行有返回值的任务");
      return "Task Success"; 
    });
    System.out.println("Callable 返回值: " + future.get()); 

    // 4. 关闭线程池 (不再接受新任务,等待已有任务执行完毕)
    threadPool.shutdown(); 
  }
}

优点:

  1. 资源复用:避免频繁创建销毁线程。
  2. 响应速度快:任务来了直接拿池子里的空闲线程执行。
  3. 便于管理:可以控制最大并发数,提供定时执行、周期执行等功能。

Thread

java.lang.Thread 是 Java 中进行多线程编程的最核心、最基础的类。在 Java 的世界里,任何代码的执行最终都是由 Thread 类的实例来驱动的。

理解 Thread 类的内部结构、构造方式以及它提供的丰富方法,是掌控 Java 并发编程的必经之路。

以下是对 Thread 类的全面解剖:

类的声明与本质

类的声明与本质:

打开 JDK 源码,你可以看到 Thread 类的声明如下:

java
public class Thread implements Runnable {
  // ...
}

这说明了一个非常重要的设计:Thread 类本身也实现了 Runnable 接口

这意味着,Thread 不仅是线程的“驱动器”(负责与底层操作系统交互并分配 CPU 资源),它自己也可以被看作是一个包含了 run() 方法的“任务”。当我们直接继承 Thread 类并重写 run() 方法时,其实就是把“驱动器”和“任务”绑定在了一起。

API:Thread

属性

每个 Thread 对象在底层都有几个极其关键的属性来标识它的状态和特征:

属性:

  • String name线程名称。每个线程都有一个名字。如果没有手动指定,Java 会自动生成类似 Thread-0, Thread-1 的名字。
    在实际开发中,强烈建议给线程起一个有业务意义的名字,这在排查日志和死锁时是救命的。

  • int priority线程优先级。范围从 1 (MIN_PRIORITY) 到 10 (MAX_PRIORITY),默认值是 5 (NORM_PRIORITY)。
    注意:优先级高的线程理论上获取 CPU 时间片的概率更大,但这完全取决于操作系统的具体实现。在 Java 开发中,绝对不能依赖线程优先级来控制业务逻辑的先后顺序。

  • boolean daemon是否为守护线程。分为用户线程(User Thread)守护线程(Daemon Thread)

  • Runnable target目标任务。这就是你在调用 new Thread(Runnable task) 时传进去的那个任务对象。

构造方法

Thread 类提供了多个重载的构造方法,最常用的有以下几个:

构造方法:

  • Thread()(),创建一个新的线程对象,名称自动生成

  • Thread()(String name),创建具有指定名称的线程对象。

  • Thread()(Runnable target),将一个 Runnable 任务对象传递给线程,由线程负责执行该任务。

  • Thread()(Runnable target, String name)最推荐的用法:同时指定要执行的任务和线程名称。

静态方法

为了便于记忆,我们可以将 Thread 类的方法分为三类:静态工具方法线程控制方法属性获取/设置方法

静态方法作用于当前正在执行的线程

  • static Thread
    currentThread()
    ()极度常用。返回对当前正在执行的线程对象的引用。常用于获取当前线程的名称:Thread.currentThread().getName()

  • static void sleep()(long millis),让当前线程休眠(暂停执行)指定的毫秒数。进入 TIMED_WAITING 状态。注意:休眠期间不会释放已经持有的任何对象锁

  • static void yield()()线程让步当前线程主动提示调度器自己愿意让出 CPU 的使用权,状态由运行中变为就绪(Runnable)。但操作系统可以选择忽略这个提示

实例方法:线程控制

实例方法:线程控制

  • void start()启动线程。通知 Java 虚拟机为其分配系统资源,并在就绪后调用该线程的 run() 方法。一个线程的 start() 方法只能被调用一次,否则抛 IllegalThreadStateException

  • void run()线程要执行的具体的业务代码实体。如果直接调用它,它会被当作当前线程下的一个普通方法执行,不会启动新线程

  • void join()等待该线程终止。比如在 主线程 中调用 t1.join(),那么主线程会一直阻塞,直到 t1 线程执行完毕。常用于等待其他线程的计算结果。

  • void interrupt()中断线程。它并不会粗暴地立即停止线程,而是给目标线程打上一个“中断标记”。目标线程需要配合检查这个标记来决定是否安全地退出。

  • boolean isInterrupted()检查中断标记。测试该线程是否已经被中断。

  • boolean isAlive()测试该线程是否处于活动状态(已经 start() 且尚未终止)。

实例方法:属性控制

实例方法:属性控制

  • final String getName()

    final void setName()(String name),获取/设置线程名

  • final int getPriority()

    final void setPriority()(int newPriority),获取/设置优先级

  • final boolean isDaemon()

    final void setDaemon()(boolean on),将该线程标记为守护线程或用户线程必须在 start() 方法之前调用,否则抛出异常。

注意事项

start() vs run()

误区一:start()run() 的天壤之别:

  • 调用 start():真正向操作系统申请创建了一个新的本地线程,新线程启动后会自动去执行 run() 里的代码。这叫多线程
  • 调用 run():仅仅是在当前线程中执行了一个名为 run 的普通对象方法而已,根本没有创建新线程。这叫同步执行

守护线程

误区二:什么是“守护线程 (Daemon)”:

  • 用户线程(默认): 只要还有任何一个非守护线程在运行,JVM 就不会退出。你的 main 主线程就是一个典型的用户线程。
  • 守护线程(后台线程): 为其他线程提供服务的线程(比如 JVM 的垃圾回收线程 GC)。当所有的用户线程都执行完毕退出时,JVM 会毫不犹豫地直接关闭,此时所有的守护线程也会随之被立即强行终止

场景:如果你在后台运行一个定时清理临时文件的线程,应该把它设置为守护线程,这样当你的主程序结束时,它不会阻止程序的退出。

练习:sleep()

题目:如下的代码中 sleep() 执行后,哪个线程进入了阻塞状态?

image-20260506225542974

回答主线程进入了阻塞状态。

线程生命周期

要真正写出健壮的并发程序,或者在生产环境中排查 CPU 飙高、程序卡死(死锁)等问题,深刻理解 Java 线程的生命周期是必不可少的基本功。

API:Thread.State 枚举

在 Java 中,线程的生命周期并不是由操作系统直接决定的,而是由 JVM 明确规定在 java.lang.Thread.State 枚举类中的 6 种状态

java
public enum State {
  NEW,
  RUNNABLE,
  BLOCKED,
  WAITING,
  TIMED_WAITING,
  TERMINATED;
}

线程的生命周期状态

  • NEW新建。线程对象已经被创建出来,但是还没有调用 start() 方法

    • 状态解析: 此时它只是堆内存中的一个普通的 Java 对象,操作系统内核中还没有对应的底层线程。
    • 代码场景Thread t = new Thread();
  • RUNNABLE可运行。线程已经调用了 start() 方法,随时准备好执行,或者正在执行中。

    • 状态解析: 注意,这是一个极易误解的点! 在操作系统的层面,线程分为“就绪(Ready,等待 CPU 分配时间片)”和“运行(Running,正在 CPU 上执行)”。但在 Java 的世界里,JVM 将这两种状态合并统称为 RUNNABLE。因此,处于 RUNNABLE 状态的线程可能正在疯狂运行,也可能在排队等 CPU。
    • 状态流转: NEW -> 调用 t.start() -> RUNNABLE
  • BLOCKED阻塞。线程试图获取一个内部的对象锁(Monitor Lock),但该锁正被其他线程持有,因此当前线程被阻塞

    • 状态解析: 在 Java 中,只有在等待进入 synchronized 代码块或方法时,线程才会进入 BLOCKED 状态。等待 java.util.concurrent.locks.Lock(如 ReentrantLock)时,线程进入的是 WAITING 状态,而不是 BLOCKED
    • 状态流转:
      • RUNNABLE -> 竞争 synchronized 锁失败 -> BLOCKED
      • BLOCKED -> 成功获取到锁 -> RUNNABLE
  • WAITING等待。线程进入一种“无限期等待”的状态。它放弃了 CPU 的使用权,并且不会自动醒来,必须等待另一个线程执行特定的唤醒操作。

    • 状态解析: 处于这种状态的线程通常是在等待某个条件成立。
    • 触发条件(从 RUNNABLE 变为 WAITING):
      • 调用了没有设置超时时间的 Object.wait()
      • 调用了没有设置超时时间的 Thread.join()(本质上也是调用的 wait())。
      • 调用了 LockSupport.park()(JUC 锁的底层基石)。
    • 唤醒条件(从 WAITING 变为 RUNNABLE):
      • 其他线程调用了该对象的 Object.notify()Object.notifyAll()
      • LockSupport.unpark(Thread) 被调用。
  • TIMED_WAITING计时等待。类似于 WAITING,但它是“限时等待”。线程等待一段时间,如果时间到了还没有被唤醒,它会自动醒来并尝试继续执行。

    • 触发条件(从 RUNNABLE 变为 TIMED_WAITING):
      • Thread.sleep(long millis)(最常见,抱着锁睡觉)。
      • 带有超时参数的 Object.wait(long timeout)
      • 带有超时参数的 Thread.join(long millis)
      • LockSupport.parkNanos()LockSupport.parkUntil()
    • 唤醒条件(从 TIMED_WAITING 变为 RUNNABLE):
      • 等待时间到达。
      • 提前被 notify()notifyAll() 唤醒。
  • TERMINATED终止。线程的生命周期走到了尽头,已经停止运行

    • 状态解析: 一旦线程进入 TERMINATED 状态,就绝对不可能再复活。如果你尝试对一个已终止的线程再次调用 start() 方法,会抛出 IllegalThreadStateException 异常。
    • 触发条件:
      • run() 方法中的代码正常执行完毕。
      • 线程在执行过程中抛出了一个未捕获的异常(Exception 或 Error),导致线程意外死亡。

生命周期图解

线程的生命周期

JDK5及之后

image-20260506165726394

JDK5之前

image-20260506165145384

易错点总结

核心面试/实战易错点总结:

为了帮你更好地消化,这里对比几个容易混淆的场景:

场景区别核心说明
BLOCKED vs WAITINGBLOCKED 是因为“别人抢了我的,我只能干等”;WAITING 是因为“我自己主动退居幕后,等待别人给我发信号”。
sleep() vs wait()sleep() 进入 TIMED_WAITING,且不释放锁wait() 进入 WAITING,且必须释放锁,让出临界区资源。
JUC 锁 vs synchronized使用 ReentrantLock.lock() 等不到锁时,线程进入的是 WAITING 状态(底层是 LockSupport.park()),而不是 BLOCKED

理解了线程的生命周期后,我们在实际开发中往往需要对这些状态进行干预,比如在一个线程处于 WAITINGTIMED_WAITING 时安全地打断它。

练习:新年倒计时

题目:模拟新年倒计时,每隔1秒输出一个数字,依次输出:10,9,8...1,最后输出:新年快乐!

代码实现

image-20260506225109056