本文基于《Java 多线程》课程讲义整理,系统梳理 Java 多线程核心知识:进程与线程的概念、创建线程的多种方式、线程状态流转、线程池、Callable/Future、线程同步(synchronized / Lock)、线程通信(wait/notify)、死锁,以及 juc 包下的线程安全集合类。全文附大量代码示例,适合 Java 初学者复习与面试速览。


目录

  1. 进程与线程的基本概念
  2. 创建线程的两种方式
  3. 线程的状态流转
  4. 线程常见方法:sleep 与 join
  5. 线程池:为什么需要 & 怎么用
  6. Callable 与 Future:带返回值的线程任务
  7. 线程安全与线程同步
  8. 线程通信:wait 与 notify
  9. 死锁
  10. 线程安全的集合类(JUC)
  11. 总结

一、进程与线程的基本概念

1.1 什么是进程

进程:操作系统中正在运行的一个应用程序。打开 QQ、迅雷、音乐播放器,每一个都是一个进程。

多进程并发的执行原理可以概括为八个字:宏观并行,微观串行。

  • 并发:一个 CPU 采用时间片轮转的方式"同时"执行多个任务。例如秒杀活动,大量请求在极短时间内被单个或少量 CPU 交替处理。

1.2 什么是线程

线程:在一个进程中可以同时执行的多个任务,每一个任务就是一个线程。线程是进程的执行单元,也称为轻量级进程。

主线程:从 main 方法开始执行,以 main 方法的退出作为结束——即 main 函数就是主线程的执行体。

1.3 线程的组成部分

组成部分说明
CPU操作系统分配时间片
数据堆空间共享,栈空间独立:每个线程都有自己的栈空间,多个线程可以共享同一个堆空间
程序代码线程需要执行的指令逻辑

堆共享、栈独立这一点,是后续理解"线程安全问题"的根本原因:多个线程共享堆中的同一个对象,就可能出现并发修改冲突。


二、创建线程的两种方式

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

定义任务类(目标类),把线程要执行的任务写在 run 方法中,再把任务对象交给线程对象去执行:

// 1. 定义任务类,实现 Runnable 接口
class MyTask implements Runnable {
    @Override
    public void run() {
        // 线程的任务定义在 run 方法中
        for (int i = 0; i < 100; i++) {
            System.out.println("MyTask: " + i);
        }
    }
}

public class TestRunnable {
    public static void main(String[] args) {
        // 2. 创建目标对象(任务对象)
        MyTask task = new MyTask();
        // 3. 创建线程对象,并将目标对象作为参数传递
        Thread t1 = new Thread(task);
        // 4. 开启线程:调用 start 方法,JVM 会自动运行 run 中的任务代码
        t1.start();
    }
}

2.2 方式二:继承 Thread 类

定义子类继承 Thread 并覆盖 run 方法,此时该类本身就是线程类:

// 1. 定义线程类,继承 Thread
class MyThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println("MyThread: " + i);
        }
    }
}

public class TestThread {
    public static void main(String[] args) {
        // 2. 创建线程对象
        MyThread t2 = new MyThread();
        // 3. 开启线程
        t2.start();
    }
}

2.3 两种方式小结与对比

实现 Runnable继承 Thread
类的性质任务类(目标类)线程类
创建对象任务对象 + 线程对象线程对象
开启方式new Thread(task).start()t.start()
扩展性不占用继承名额,推荐占用继承名额

注意:一定要调用 start() 而不是直接调用 run()。直接调用 run() 只是普通的方法调用,并不会开启新线程。


三、线程的状态流转

线程在生命周期中会经历以下状态:

New(初始) --start()--> Ready(就绪) --OS选中--> Running(运行) --run()结束--> Terminated(终止)
                          ↑                     │
                          └────时间片到期────────┘
  • New(初始状态):线程对象被创建。此时只在堆中开辟内存,与常规对象无异。
  • Ready(就绪状态):调用 start() 之后进入,等待 OS 选中并分配时间片。
  • Running(运行状态):获得时间片后进入;如果时间片到期,则回到就绪状态。
  • Terminated(终止状态):主线程 main() 或独立线程 run() 结束,代表线程的任务结束,并释放持有的时间片。

补充状态:

  • Timed Waiting(计时等待/限期等待):调用 sleep(ms) 进入,到期后回到就绪。
  • Waiting(无限期等待):调用 join() 或等待数据输入等进入,条件满足后回到就绪。
  • Blocked(阻塞状态):线程获取不到锁标记时进入(详见后文 synchronized),获得锁后回到就绪。

注:JDK 5 之后,就绪状态和运行状态统称为 Runnable。


四、线程常见方法:sleep 与 join

4.1 sleep —— 休眠

public static void sleep(long m)

让当前线程休眠 m 毫秒,进入计时等待状态,同时释放 CPU 资源(但不释放锁)。

4.2 join —— 合并线程

public final void join()

允许其他线程加入到当前线程中:调用 join 的线程先执行,等它执行结束后,再执行当前线程自身的任务。

例如在 main 方法中调用 t1.join(),代表主线程让步于 t1 线程:

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5; i++) {
            System.out.println("t1: " + i);
        }
    });
    t1.start();
    t1.join(); // 主线程等待 t1 执行完毕后,才继续执行
    System.out.println("main 结束");
}

五、线程池:为什么需要 & 怎么用

5.1 为什么需要线程池

如果每个任务都 new Thread(),任务结束后就销毁线程,会带来严重问题:

  • 频繁创建及销毁线程,增加资源开销,虚拟机 GC 回收频率也随之提高,造成程序性能下降。

解决思路:线程完成任务后不立即销毁,而是存放在池中;再有新任务时直接复用已有线程,避免频繁创建和销毁——这就是线程的重复利用。

5.2 线程池的概念

线程池:一个线程容器。预先创建线程对象存入池中,将任务提交给线程池后,由线程池分配线程、运行任务,任务结束后线程被复用。

好处:避免频繁创建和销毁线程,提高程序性能。

5.3 获取线程池(java.util.concurrent 包)

  • Executor:线程池的顶级接口。
  • ExecutorService:Executor 的子接口,通过 submit(Runnable task) 提交任务代码。
  • Executors 工厂类:提供大量静态方法获取线程池对象:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TestThreadPool {
    public static void main(String[] args) {
        // 固定数量的线程池:参数指定池中线程数量
        ExecutorService pool = Executors.newFixedThreadPool(3);

        // 提交任务
        pool.submit(() -> System.out.println("任务1"));
        pool.submit(() -> System.out.println("任务2"));
        pool.submit(() -> System.out.println("任务3"));

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

常用工厂方法:

方法说明
newFixedThreadPool(int nThreads)固定数量的线程池
newCachedThreadPool()动态数量的线程池,不够则创建新线程,没有上限

六、Callable 与 Future:带返回值的线程任务

6.1 Runnable 的缺点

思考:run 方法有哪些缺点?

  1. 不能抛出异常(run 方法签名中不含 checked 异常);
  2. 没有返回值。

6.2 Callable 接口

Callable 位于 java.util.concurrent 包中,JDK 5.0 加入,与 Runnable 类似,实现它即代表一个线程任务。核心方法:

V call() throws Exception  // 有泛型返回值、可以抛异常

示例:两个线程分别计算 150 与 51100 的和,最后累加输出:

import java.util.concurrent.*;

public class TestCallable {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(2);

        Callable<Integer> task1 = () -> {
            int sum = 0;
            for (int i = 1; i <= 50; i++) sum += i;
            return sum;
        };
        Callable<Integer> task2 = () -> {
            int sum = 0;
            for (int i = 51; i <= 100; i++) sum += i;
            return sum;
        };

        Future<Integer> f1 = pool.submit(task1);
        Future<Integer> f2 = pool.submit(task2);

        System.out.println("总和 = " + (f1.get() + f2.get())); // 5050
        pool.shutdown();
    }
}

6.3 Future 接口

Future 位于 java.util.concurrent 包中,通过 ExecutorService.submit(Callable c) 的返回值可以存储 call 方法的返回结果:

  • V get():获取 call 方法的结果,泛型取决于 Callable 接口的具体数据类型。

七、线程安全与线程同步

7.1 一个经典的并发 Bug

设想一个封装了 String 数组的 MyList 类(长度 5,用 index 记录元素个数),两个线程分别往 list 添加 "C" 和 "D"。多次运行,结果可能出错。

原因分析——对 list 添加元素包括两个过程:

① as[index] = s;   // 将元素添加到目标位置
② index++;         // 将元素的个数加 1

这两个过程应该作为一个整体一起执行(要执行都执行,不执行都不执行)。但在多线程并发下,两个步骤可能被分开交错执行,导致结果出错。

7.2 三个核心概念

  • 临界资源:多线程并发时,多个线程共享的同一个对象。
  • 原子操作:不可分割的多步操作,被视作一个整体,其顺序和步骤不可被打乱或缺省。
  • 线程同步:多线程并发时,为了保证临界资源的正确性,不能破坏程序中的原子操作。

7.3 同步代码块

对临界资源加锁:

synchronized (临界资源对象) {
    // 代码(原子操作)
}

执行原理:线程只有获取到临界资源对象的锁标记,才能执行同步代码块中的内容,且必须将 {} 中所有代码执行完成才释放锁标记;如果没有获取到锁标记(被其他线程占用),该线程进入阻塞状态,直到拿到锁标记,同时获取 CPU 时间片后才执行 {} 中的内容。

7.4 同步方法

public synchronized void add(String s) {
    // 代码(原子操作)
}

注意:同步方法等同于同步代码块,即等价于:

synchronized (this) {
    // 代码(原子操作)
}

7.5 同步注意事项

  • 任意对象都拥有单一的锁标记(监视器);
  • 线程同步时,必须保证多个线程共用同一个对象的锁,才能保证共享资源的正确性。

回顾经典面试题——ArrayList 和 Vector 的区别:

ArrayListVector
线程安全不安全安全
运行效率快慢
内部方法非同步方法同步方法

7.6 Lock 接口

从 JDK 5.0 开始,Java 提供了更强大的线程同步机制:通过定义锁对象,灵活控制锁的开启和释放。Lock 位于 java.util.concurrent.locks 包中:

  • void lock():获取锁,如锁被占用则等待;
  • void unlock():释放锁;
  • 常用实现类:ReentrantLock。
import java.util.concurrent.locks.ReentrantLock;

class Counter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
        lock.lock();      // 获取锁
        try {
            count++;      // 原子操作
        } finally {
            lock.unlock(); // 释放锁(务必放在 finally 中)
        }
    }
}

八、线程通信:wait 与 notify

当多个线程需要协作交替执行时,需要使用线程通信方法(均属于 Object 类):

  • wait():等待。让当前线程进入等待状态,释放 CPU 同时释放锁标记,让其他线程可以执行。等待结束后,需要再次获取临界资源对象的锁标记和 CPU 才能继续执行。
  • notify():唤醒处于等待状态的一个线程。
  • notifyAll():唤醒处于等待状态的所有线程。

重要约束:wait() / notify() / notifyAll() 必须使用在调用它的对象的同步代码块(或同步方法)中,否则会抛出 IllegalMonitorStateException。

经典练习:两个线程交替打印 A~Z(t1 打印 A,t2 打印 B,t1 打印 C……依次交替),思路就是用 wait/notify 控制执行权的转移。

注意区分:sleep 释放 CPU 但不释放锁;wait 释放 CPU 且释放锁。


九、死锁

死锁:两个或多个线程,相互占用对方需要的资源而都不释放,导致彼此相互等待对方释放资源,产生无限制等待的现象。

结果:出现死锁后不会抛出异常,也没有任何提示,只是所有线程都处于阻塞状态。如果没有外力介入,程序将无法继续。

典型场景:线程 A 持有锁 1 等待锁 2,线程 B 持有锁 2 等待锁 1。规避方法包括:保证加锁顺序一致、缩小锁范围、尝试使用 tryLock 设置超时等。


十、线程安全的集合类(JUC)

10.1 CopyOnWriteArrayList

位于 java.util.concurrent 包中,是 List 的实现类:

  • 读操作没有加锁,写操作进行加锁,读与写之间不阻塞;
  • 写操作时,先拷贝一个副本,在副本基础上进行写操作,最后用新文本替换旧文本(写时复制);
  • 牺牲写操作的效率,提升读操作的效率;
  • 使用场景:读操作次数远远大于写操作时效率较高;
  • 使用方式与 ArrayList 无异。

10.2 ConcurrentHashMap

位于 java.util.concurrent 包中,是 Map 的实现类:

  • 采用分段锁,缩小锁定对象的范围,从而减少锁冲突的可能性,提高系统并发能力;
  • 使用场景:只有类似 size() 这种获取全局信息的方法不频繁时,减小锁粒度的方式才有真正的意义;
  • 注意:JDK 8.0 及以上版本采用 CAS 算法和 synchronized 保证线程安全。

10.3 Queue 接口(队列)

Queue 是 Collection 的子接口,用于模拟队列数据存储结构:

  • FIFO:先进先出(first in first out);
  • 常用方法:
    • boolean add(E e):顺序添加一个元素(到达上限后再添加会抛出异常);
    • E remove():获得第一个元素并移除(队列没有元素时抛异常)。

10.4 BlockingQueue 接口(阻塞队列)

BlockingQueue 是 Queue 的子接口,在队列基础上增加了两个"无限期等待"的方法:

  • void put(E e):插入元素,如果没有可用空间则等待;
  • E take():获取并移除队头元素,如果没有可用元素则等待。

实现类:

实现类结构特点
ArrayBlockingQueue数组有界队列(手工固定上限)
LinkedBlockingQueue链表无界队列

10.5 ConcurrentLinkedQueue

Queue 的常用实现类,基于链表实现:

  • 无锁并且线程安全,采用比较交换算法,简称 CAS 无锁算法。

十一、总结

知识点关键结论
进程 vs 线程进程是运行中的应用,线程是进程的执行单元;堆共享、栈独立
创建线程实现 Runnable(推荐)/ 继承 Thread;必须调用 start()
线程状态New → Ready → Running → Terminated,补充 Timed Waiting / Waiting / Blocked
线程池复用线程避免频繁创建销毁;Executors 工厂 + ExecutorService.submit
Callable/Future解决 run 不能抛异常、无返回值的缺点
线程同步synchronized 同步块/方法、Lock/ReentrantLock;必须共用同一把锁
线程通信wait/notify 必须在同步代码块中;wait 释放锁,sleep 不释放
死锁互占资源不释放,无异常无提示,程序永久阻塞
JUC 集合CopyOnWriteArrayList(读多写少)、ConcurrentHashMap(JDK8 CAS+synchronized)、BlockingQueue(put/take 阻塞)、ConcurrentLinkedQueue(CAS 无锁)

多线程是 Java 面试和实际开发中的绝对高频考点。建议把文中代码亲手跑一遍,尤其是"两个线程同时添加元素"的并发 Bug 和 wait/notify 交替打印 A~Z 这两个练习,理解会深刻很多。

原创整理不易,觉得有帮助请点个赞、收藏一下再走吧~ 欢迎评论区交流讨论!


本文基于 Java Platform SE 课程讲义《多线程》整理,示例代码均可在 JDK 8+ 环境直接运行。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐