1. 文章
  2. Java 进阶线
  3. 文件、配置与并发

多线程、同步与线程池:共享状态如何保持正确

更新于 21 分钟阅读 第 12 / 15 站 #java#java-advanced#day12

从并发存款出发,理解锁、等待条件、任务结果和线程池边界。

我的学习 →

Java 进阶线 · 线路目录
本页导读

day12|java.lang 包下的常用 API:多线程

本日围绕 java.lang.Thread 展开:先掌握线程的三种创建方式与 Thread 的常用 API,再重点理解「多个线程同时修改同一共享资源」为什么会出现业务错误,以及用同步代码块、同步方法、Lock 锁解决它的原理与区别;最后是线程通信(生产者消费者模型)、线程池(ExecutorService/ThreadPoolExecutor/Executors)、并发与并行、线程的生命周期。学完后应能判断一段多线程代码是否存在线程安全问题,并写出正确的加锁版本。

1. 线程与多线程是什么

1.1 线程与单线程程序

线程(Thread)是一个程序内部的一条执行流程。

public static void main(String[] args) {
    // 代码…
    for (int i = 0; i < 10; i++) {
        System.out.println(i);
    }
    // 代码...
}

上面的 main 方法本身由一条「主线程」负责执行。程序中如果只有一条执行流程,那这个程序就是单线程的程序。

(补充说明)main 方法由名为 main 的主线程执行,这也解释了为什么后面可以直接用 Thread.currentThread().getName() 打印出 main。

1.2 多线程是什么,用在哪里

多线程是指从软硬件上实现的多条执行流程的技术(多条线程由 CPU 负责调度执行)。

  • 好处:多条流程可以「同时」推进,充分利用 CPU,提升吞吐与响应速度。
  • 应用场景:下载软件的「同时下载、同时上传」、消息通信、淘宝/京东这类高并发系统,都离不开多线程技术。

下载工具中同时进行下载与上传

12306 这类高并发系统依赖多线程

Java 是通过 java.lang.Thread 类的对象来代表线程的,因此「创建多条线程」在代码里就是「创建多个 Thread 对象并启动」。

2. 多线程的创建方式一:继承 Thread 类

2.1 步骤

  1. 定义一个子类 MyThread 继承线程类 java.lang.Thread,重写 run() 方法;
  2. 创建 MyThread 类的对象;
  3. 调用线程对象的 start() 方法启动线程(启动后还是执行 run() 方法)。

2.2 代码

public class ThreadDemo1 {
    // 注意:main 方法本身就是由一条主线程负责执行的。
    public static void main(String[] args) {
        // 目标:掌握线程的创建方式一:继承 Thread 类。
        // 3、创建线程对象代表具体的线程
        Thread t = new MyThread();
        // 4、启动线程:会自动执行线程的 run 方法
        // 如果直接调用 run,CPU 不会注册新线程执行,此时相当于还是单线程
        t.start();

        for (int i = 0; i < 4; i++) {
            System.out.println("main线程输出:" + i);
        }
    }
}

// 1、定义一个类继承 Thread 类,成为线程类
class MyThread extends Thread {
    // 2、重写 run 方法,声明线程要干的事情
    @Override
    public void run() {
        for (int i = 0; i < 4; i++) {
            System.out.println("子线程输出:" + i);
        }
    }
}

2.3 两个必须注意的点

  1. 启动线程必须是调用 start() 方法,不是调用 run() 方法。直接调用 run() 会当成普通方法执行,此时相当于还是单线程执行;只有调用 start() 才是启动一个新的线程执行。否则主线程一直是先跑完的,相当于是一个单线程的效果了。
  2. 不要把主线程任务放在启动子线程之前。

2.4 方式一的优缺点

维度说明
优点编码简单
缺点线程类已经继承 Thread,无法继承其他类,不利于功能的扩展(单继承的局限性)

3. 多线程的创建方式二:实现 Runnable 接口

3.1 步骤

  1. 定义一个线程任务类 MyRunnable 实现 Runnable 接口,重写 run() 方法;
  2. 创建 MyRunnable 任务对象;
  3. 把 MyRunnable 任务对象交给 Thread 处理;
  4. 调用线程对象的 start() 方法启动线程。

3.2 代码

public class ThreadDemo2 {
    public static void main(String[] args) {
        // 目标:掌握多线程的创建方式二:实现 Runnable 接口的方式。
        // 3、创建任务类的一个对象
        Runnable target = new MyRunnable();
        // 4、把任务对象交给线程对象
        // public Thread(Runnable target)
        Thread t = new Thread(target, "1号");
        // 5、启动线程
        t.start();

        for (int i = 0; i < 4; i++) {
            System.out.println("main线程输出:" + i);
        }
    }
}

// 1、定义一个任务类实现 Runnable 接口
class MyRunnable implements Runnable {
    // 2、重写 run 方法
    @Override
    public void run() {
        for (int i = 0; i < 4; i++) {
            System.out.println("子线程输出:" + i);
        }
    }
}

3.3 匿名内部类写法

可以创建 Runnable 的匿名内部类对象,再交给 Thread 线程对象,再调用线程对象的 start() 启动线程。

public class ThreadDemo2_2 {
    public static void main(String[] args) {
        // 1、创建任务类的一个对象
        Runnable target = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 4; i++) {
                    System.out.println("子线程1输出:" + i);
                }
            }
        };
        Thread t = new Thread(target);
        t.start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 4; i++) {
                    System.out.println("子线程2输出:" + i);
                }
            }
        }).start();

        // (补充说明)Runnable 是函数式接口,也可以用 Lambda 简化
        new Thread(() -> {
            for (int i = 0; i < 4; i++) {
                System.out.println("子线程3输出:" + i);
            }
        }).start();
    }
}

3.4 方式二的优缺点

维度说明
优点任务类只是实现接口,可以继续继承其他类、实现其他接口,扩展性强
缺点需要多一个 Runnable 对象;线程有执行结果时不能直接返回

4. 多线程的创建方式三:Callable 接口 + FutureTask 类

4.1 为什么需要方式三

假如线程执行完毕后有一些数据需要返回,方式一、方式二重写的 run() 方法均不能直接返回结果。JDK 5.0 提供了 Callable 接口和 FutureTask 类来实现(多线程的第三种创建方式),这种方式最大的优点:可以返回线程执行完毕后的结果。

4.2 步骤

  1. 定义一个类实现 Callable 接口,重写 call() 方法,封装要做的事情和要返回的数据;
  2. 把 Callable 类型的对象封装成 FutureTask(线程任务对象);
  3. 把线程任务对象交给 Thread 对象;
  4. 调用 Thread 对象的 start() 方法启动线程;
  5. 线程执行完毕后,通过 FutureTask 对象的 get() 方法去获取线程任务执行的结果。

4.3 FutureTask 的 API

成员说明
FutureTask(Callable<V> callable)把 Callable 对象封装成未来任务对象
V get()获取线程执行完毕后的结果;如果线程还没执行完,会在这里等待直到执行完毕再取结果

4.4 代码

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

public class ThreadDemo3 {
    public static void main(String[] args) {
        // 目标:掌握多线程的创建方式三:可以返回线程执行完毕后的结果。
        // 3、创建 Callable 对象
        Callable<String> call = new MyCallable(100);
        // 4、把 Callable 对象,封装成 FutureTask 对象
        //    未来任务对象有两个作用:
        //      它是一个 Runnable 对象
        //      它可以获取线程执行后的结果
        FutureTask<String> task = new FutureTask<>(call);
        // 5、把未来任务对象交给线程对象
        Thread t = new Thread(task);
        // 6、启动线程
        t.start();

        Callable<String> call2 = new MyCallable(200);
        FutureTask<String> task2 = new FutureTask<>(call2);
        Thread t2 = new Thread(task2);
        t2.start();

        try {
            // 如果第一个线程没有执行完毕,会在这里等待第一个线程执行完毕后再取结果
            String rs1 = task.get();
            System.out.println(rs1);
        } catch (Exception e) {
            e.printStackTrace();
        }

        try {
            String rs2 = task2.get();
            System.out.println(rs2);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

// 1、定义一个类实现 Callable 接口
class MyCallable implements Callable<String> {
    private int n;

    public MyCallable(int n) {
        this.n = n;
    }

    // 2、重写 call 方法,声明任务和返回的结果
    @Override
    public String call() throws Exception {
        int sum = 0;
        for (int i = 1; i <= n; i++) {
            sum += i;
        }
        return "子线程求和1-" + n + "的结果是:" + sum;
    }
}

4.5 方式三的优缺点

维度说明
优点线程任务类只是实现接口,可以继续继承类和实现接口,扩展性强;可以在线程执行完毕后去获取线程执行的结果
缺点编码复杂一点

4.6 三种创建方式对比

方式入口方法能否返回结果能否再继承其他类代码复杂度
继承 Thread重写 run()不能不能(已单继承)简单
实现 Runnable重写 run()不能能较简单(多一个任务对象)
实现 Callable + FutureTask重写 call()能,通过 FutureTask.get()能复杂一点

5. Thread 的常用方法

5.1 常用方法一览

方法说明
String getName()获取当前线程的名称,默认线程名称是 Thread-索引
void setName(String name)设置线程名称
public static Thread currentThread()返回对当前正在执行的线程对象的引用
public static void sleep(long time)让当前线程休眠指定的时间,单位为毫秒,时间到了会自动醒来继续执行
public final void join()让调用该方法的线程插队,执行完毕后其他线程再执行

5.2 线程名称与 currentThread

public class ThreadDemo1 {
    public static void main(String[] args) {
        Thread t1 = new MyThread("1号线程");
        t1.start();

        Thread t2 = new MyThread("2号线程");
        t2.start();

        // 这个代码是哪个线程在执行,就会得到哪个线程对象。
        Thread m = Thread.currentThread();
        m.setName("最牛的");
        System.out.println(m.getName()); // main

        for (int i = 0; i < 4; i++) {
            System.out.println(m.getName() + "线程输出:" + i);
        }
    }
}

// 1、定义一个类继承 Thread 类,成为线程类
class MyThread extends Thread {
    public MyThread(String name) {
        super(name);
    }

    // 2、重写 run 方法,声明线程要干的事情
    @Override
    public void run() {
        for (int i = 0; i < 4; i++) {
            // 当前线程对象的名字
            System.out.println(Thread.currentThread().getName() + " => 线程输出:" + i);
        }
    }
}

5.3 线程休眠 sleep

public class ThreadDemo2 {
    public static void main(String[] args) throws Exception {
        // 目标:掌握线程休眠,线程 join。
        for (int i = 1; i <= 5; i++) {
            System.out.println("输出:" + i);
            // 作用:让当前线程执行的慢一点
            Thread.sleep(1000); // 休眠 1s 后再执行
        }
    }
}

5.4 线程插队 join

public class ThreadDemo3 {
    public static void main(String[] args) throws Exception {
        // 目标:join 线程
        Thread t = new MyThread2();
        t.start();

        for (int i = 0; i < 4; i++) {
            System.out.println("main线程输出:" + i);
            if (i == 2) {
                t.join(); // 让 t 插队先执行完毕!
            }
        }
    }
}

class MyThread2 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 4; i++) {
            System.out.println("子线程输出:" + i);
        }
    }
}

5.5 其他控制方法

Thread 类还提供了诸如 yield()、interrupt()、守护线程、线程优先级等线程的控制方法,在开发中很少使用,这些方法会后续需要用到的时候再讲解。

6. 线程安全:问题是什么

6.1 什么是线程安全问题

场景:小明和小红是一对夫妻,他们有一个共同的账户,余额是 10 万元。如果小明和小红同时来取钱,并且 2 人各自都在取钱 10 万元,可能会出现什么问题呢?

多个线程,同时操作同一个共享资源的时候,可能会出现业务安全问题。

6.2 取钱问题的执行过程

两个线程对同一个共享账户(余额 100000)分别执行下面三步:

步骤小明线程小红线程
1判断余额是否足够 → true判断余额是否足够 → true
2吐出 100000 元吐出 100000 元
3更新账户余额更新账户余额

结果:2 人都取钱 10 万,银行亏了 10 万。原因是两个线程都基于「取钱前的余额」做出了判断,CPU 在两条线程之间调度时把复合操作交错了。

6.3 出现线程安全问题的原因

  1. 存在多个线程在同时执行;
  2. 同时访问一个共享资源;
  3. 存在修改该共享资源。

三个条件同时满足时才会出现线程安全问题。

6.4 用程序模拟线程安全问题

需求:小明和小红是一对夫妻,他们有一个共同的账户,余额是 10 万元,模拟 2 人同时去取钱 10 万。

分析:

  1. 需要提供一个账户类,接着创建一个账户对象代表 2 个人的共享账户;
  2. 需要定义一个线程类(用于创建两个线程,分别代表小明和小红);
  3. 创建 2 个线程,传入同一个账户对象给 2 个线程处理;
  4. 启动 2 个线程,同时去同一个账户对象中取钱 10 万。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
    private String cardId;
    private double money; // 余额

    // 小明 小红拿同一个账户对象 acc 取钱
    public void drawMoney(double money) {
        String name = Thread.currentThread().getName();
        if (this.money >= money) {
            System.out.println(name + "取钱成功,吐出:" + money);
            // 更新余额
            this.money -= money;
            System.out.println(name + "取钱成功后,账户余额剩余:" + this.money);
        } else {
            System.out.println(name + "来取钱:余额不足!");
        }
    }
}
// 取钱线程类
public class DrawThread extends Thread {
    private Account acc;

    public DrawThread(String name, Account acc) {
        super(name);
        this.acc = acc;
    }

    // 小明 小红
    @Override
    public void run() {
        // 小明 小红拿同一个账户对象 acc 取钱
        acc.drawMoney(100000);
    }
}
public class Test {
    public static void main(String[] args) {
        // 目标:模拟线程安全问题。
        // 1、创建一个账户对象,两个人共享
        Account acc = new Account("ICBC-110", 100000);

        // 2、创建两个线程代表小明和小红,同时去同一个账户取钱。
        new DrawThread("小明", acc).start();
        new DrawThread("小红", acc).start();
    }
}

(补充说明)该演示代码的结果不确定:可能出现「两人都取钱成功、账户余额为 -10 万」的错误数据,也可能其中一个线程先执行完而显示「余额不足」。

7. 线程同步

7.1 认识线程同步

线程同步是解决线程安全问题的方案,其思想是:让多个线程实现先后依次访问共享资源,这样就解决了安全问题。

线程同步的常见方案是「锁对象 + 加锁」:每次只允许一个线程加锁,加锁后才能进入访问,访问完毕后自动解锁,然后其他线程才能再加锁进来。

7.2 方式一:同步代码块

作用:把访问共享资源的核心代码给上锁,以此保证线程安全。

synchronized (同步锁) {
    访问共享资源的核心代码
}

原理:每次只允许一个线程加锁后进入,执行完毕后自动解锁,其他线程才可以进来执行。

同步锁的注意事项:对于当前同时执行的线程来说,同步锁必须是同一把(同一个对象),否则会出 bug。

锁对象的使用规范:

  • 锁对象不要随便选择一个唯一的对象,不好,会影响其他无关线程的执行;
  • 建议使用共享资源作为锁对象,对于实例方法建议使用 this 作为锁对象;
  • 对于静态方法建议使用字节码(类名.class)对象作为锁对象。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
    private String cardId;
    private double money; // 余额

    // 小明 小红拿同一个账户对象 acc 取钱
    public void drawMoney(double money) {
        String name = Thread.currentThread().getName();
        synchronized (this) {
            if (this.money >= money) {
                System.out.println(name + "取钱成功,吐出:" + money);
                // 更新余额
                this.money -= money;
                System.out.println(name + "取钱成功后,账户余额剩余:" + this.money);
            } else {
                System.out.println(name + "来取钱:余额不足!");
            }
        }
    }
}

小结:对出现问题的核心代码使用 synchronized 进行加锁,每次只能一个线程占锁进入访问。

7.3 方式二:同步方法

作用:把访问共享资源的核心方法给上锁,以此保证线程安全。

修饰符 synchronized 返回值类型 方法名称(形参列表) {
    操作共享资源的代码
}

原理:每次只能一个线程进入,执行完毕以后自动解锁,其他线程才可以进来执行。

同步方法的底层原理:同步方法其实底层也是有隐式锁对象的,只是锁的范围是整个方法代码。

  • 如果方法是实例方法:同步方法默认用 this 作为锁对象;
  • 如果方法是静态方法:同步方法默认用 类名.class 作为锁对象。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
    private String cardId;
    private double money; // 余额

    // 小明 小红拿同一个账户对象 acc 取钱
    public synchronized void drawMoney(double money) {
        String name = Thread.currentThread().getName();
        if (this.money >= money) {
            System.out.println(name + "取钱成功,吐出:" + money);
            // 更新余额
            this.money -= money;
            System.out.println(name + "取钱成功后,账户余额剩余:" + this.money);
        } else {
            System.out.println(name + "来取钱:余额不足!");
        }
    }
}

7.4 同步代码块还是同步方法

  • 范围上:同步代码块锁的范围更小,同步方法锁的范围更大;
  • 可读性:同步方法更好。

选择原则是「锁的范围尽量小」,同时保证被锁的范围覆盖了完整的复合操作(判断余额 + 扣款 + 写回余额)。

7.5 方式三:Lock 锁

Lock 锁是 JDK 5 开始提供的一个新的锁定操作,通过它可以创建出锁对象进行加锁和解锁,更灵活、更方便、更强大。Lock 是接口,不能直接实例化,可以采用它的实现类 ReentrantLock 来构建 Lock 锁对象。

Lock 的常用方法:

方法说明
void lock()获得锁
void unlock()释放锁
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@Data
@NoArgsConstructor
public class Account {
    private String cardId;
    private double money; // 余额
    private final Lock lk = new ReentrantLock();

    public Account(String cardId, double money) {
        this.cardId = cardId;
        this.money = money;
    }

    // 小明 小红拿同一个账户对象 acc 取钱
    public void drawMoney(double money) {
        String name = Thread.currentThread().getName();
        try {
            lk.lock(); // 加锁
            if (this.money >= money) {
                System.out.println(name + "取钱成功,吐出:" + money);
                // 更新余额
                this.money -= money;
                System.out.println(name + "取钱成功后,账户余额剩余:" + this.money);
            } else {
                System.out.println(name + "来取钱:余额不足!");
            }
        } finally {
            lk.unlock(); // 解锁
        }
    }
}

(补充说明)unlock() 必须放在 finally 中,保证加锁后的代码出现异常时锁依然会被释放;锁对象应作为共享资源(账户对象)的成员,不能让每条线程各自 new 一把。

8. 线程通信【了解】

8.1 什么是线程通信

当多个线程共同操作共享的资源时,线程间通过某种方式互相告知自己的状态,以相互协调,并避免无效的资源争夺。

线程通信的常见模型是「生产者与消费者模型」:生产者线程负责生产数据,消费者线程负责消费生产者生产的数据。

注意:生产者生产完数据应该等待自己,通知消费者消费;消费者消费完数据也应该等待自己,再通知生产者生产!

8.2 案例:包子铺

需求:3 个生产者线程负责生产包子,每个线程每次只能生产 1 个包子放在桌子上;2 个消费者线程负责吃包子,每人每次只能从桌子上拿 1 个包子吃。

@Data
public class Desk {
    private String data; // 放包子数据的地方

    // 消费者:小懒猪 小萝莉
    public synchronized void get() throws Exception {
        String name = Thread.currentThread().getName();
        if (data == null) {
            // 没有包子。暂停自己,唤醒别人
            this.notifyAll();
            this.wait();
        } else {
            // 有包子,开始吃
            System.out.println(name + "吃了:" + data);
            data = null;
            this.notifyAll();
            this.wait();
        }
    }

    // 生产者:大肚婆 小李子 狗不理
    public synchronized void put() throws Exception {
        String name = Thread.currentThread().getName();
        if (data == null) {
            // 没有包子。做包子
            data = name + "做的肉包子";
            System.out.println(name + "生产了一个肉包子!");
            this.notifyAll();
            this.wait();
        } else {
            // 有包子
            this.notifyAll();
            this.wait();
        }
    }
}
// 生产者线程
public class MakeThread extends Thread {
    private Desk desk;

    public MakeThread(Desk desk, String name) {
        super(name);
        this.desk = desk;
    }

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(1000);
                desk.put();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}
// 消费者线程
public class ConsumerThread extends Thread {
    private Desk desk;

    public ConsumerThread(Desk desk, String name) {
        super(name);
        this.desk = desk;
    }

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(1000);
                desk.get();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}
public class Test {
    public static void main(String[] args) {
        // 目标:线程通信。
        // 1、竞争一个桌子
        Desk desk = new Desk();

        // 2、创建 2 个消费者线程
        new ConsumerThread(desk, "小懒猪").start();
        new ConsumerThread(desk, "小萝莉").start();

        // 3、创建 3 个生产者线程
        new MakeThread(desk, "大肚婆").start();
        new MakeThread(desk, "小李子").start();
        new MakeThread(desk, "狗不理").start();
    }
}

8.3 Object 类的等待和唤醒方法

方法说明
void wait()让当前线程等待并释放所占锁,直到另一个线程调用 notify() 或 notifyAll()
void notify()唤醒正在等待的单个线程
void notifyAll()唤醒正在等待的所有线程

注意:上述方法应该使用当前同步锁对象进行调用。

(补充说明)wait()/notify()/notifyAll() 定义在 Object 中,原因是它们必须由「持有该锁的线程」调用,而任意对象都可以用作同步锁;因此上面的代码写作 this.wait()、this.notifyAll()(this 即同步锁对象)。

9. 线程池

9.1 认识线程池

不使用线程池的问题:用户每发起一个请求,后台就需要创建一个新线程来处理,下次新任务来了肯定又要创建新线程处理,而创建新线程的开销是很大的,并且请求过多时,肯定会产生大量的线程出来,这样会严重影响系统的性能。

线程池就是一个可以复用线程的技术。

线程池的工作原理:任务(Runnable/Callable)提交后先进入任务队列(WorkQueue),线程池中的工作线程(WorkThread)从队列中取任务执行,线程执行完任务后不销毁,而是回到池中复用。

9.2 如何得到线程池对象

JDK 5.0 起提供了代表线程池的接口:ExecutorService。

  • 方式一:使用 ExecutorService 的实现类 ThreadPoolExecutor 自创建一个线程池对象;
  • 方式二:使用 Executors(线程池的工具类)调用方法返回不同特点的线程池对象。

9.3 ThreadPoolExecutor 的构造器与七个参数

public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit,
                          BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
参数含义课件的形象比喻
corePoolSize指定线程池的核心线程的数量正式工:3
maximumPoolSize指定线程池的最大线程数量最大员工数:5(正式工 3 + 临时工 2)
keepAliveTime指定临时线程的存活时间临时工空闲多久被开除
unit指定临时线程存活的时间单位(秒、分、时、天)—
workQueue指定线程池的任务队列客人排队的地方
threadFactory指定线程池的线程工厂负责招聘员工的(HR)
handler指定线程池的任务拒绝策略(线程都在忙,任务队列也满了的时候,新任务来了该怎么处理)忙不过来咋办?

9.4 线程池的注意事项

  1. 临时线程什么时候创建?新任务提交时发现核心线程都在忙,任务队列也满了,并且还可以创建临时线程,此时才会创建临时线程。
  2. 什么时候会开始拒绝新任务?核心线程和临时线程都在忙,任务队列也满了,新的任务过来的时候才会开始拒绝任务。

9.5 线程池处理 Runnable 任务

使用 ExecutorService 的方法:void execute(Runnable target)。

public class ThreadPoolExecutorDemo1 {
    public static void main(String[] args) {
        // 目标:创建线程池对象,处理 Runnable 任务
        // 1、创建线程池
        ExecutorService pool = new ThreadPoolExecutor(3, 5, 1, TimeUnit.MINUTES,
                new ArrayBlockingQueue<>(3), Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.CallerRunsPolicy());

        // 2、处理任务
        Runnable target = new MyRunnable();
        pool.execute(target); // 自动创建线程,并处理此任务
        pool.execute(target); // 自动创建线程,并处理此任务
        pool.execute(target); // 自动创建线程,并处理此任务
        pool.execute(target); // 核心线程已满,进入任务队列
        pool.execute(target); // 核心线程已满,进入任务队列
        pool.execute(target); // 核心线程已满,进入任务队列
        pool.execute(target); // 到了临时线程的时机了
        pool.execute(target); // 到了临时线程的时机了
        pool.execute(target); // 到了任务拒绝策略了!

        // 3、线程池没有死亡。
        // pool.shutdownNow(); // 立即关闭,不管任务是否完成!!返回没有执行完的任务
        // pool.shutdown();    // 等待全部任务执行完毕后,再关闭
    }
}
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 1; i <= 3; i++) {
            System.out.println(Thread.currentThread().getName() + "输出:" + i);
            System.out.println(Thread.currentThread().getName() + "线程进入休眠!~");
            try {
                Thread.sleep(Integer.MAX_VALUE);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

9.6 线程池处理 Callable 任务

使用 ExecutorService 的方法:Future<T> submit(Callable<T> command),返回的未来任务对象用于获取任务执行完后的结果。

public class ThreadPoolExecutorDemo2 {
    public static void main(String[] args) {
        // 1、创建线程池
        ExecutorService pool = new ThreadPoolExecutor(3, 5, 1, TimeUnit.MINUTES,
                new ArrayBlockingQueue<>(3), Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.CallerRunsPolicy());

        // 2、处理任务
        Future<String> f1 = pool.submit(new MyCallable(100));
        Future<String> f2 = pool.submit(new MyCallable(200));
        Future<String> f3 = pool.submit(new MyCallable(300));

        try {
            String s = f1.get();
            System.out.println(s);
        } catch (Exception e) {
            e.printStackTrace();
        }
        try {
            String s = f2.get();
            System.out.println(s);
        } catch (Exception e) {
            e.printStackTrace();
        }
        try {
            String s = f3.get();
            System.out.println(s);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
// 1、定义一个类实现 Callable 接口
public class MyCallable implements Callable<String> {
    private int n;

    public MyCallable(int n) {
        this.n = n;
    }

    // 2、重写 call 方法,声明任务和返回的结果
    @Override
    public String call() throws Exception {
        int sum = 0;
        for (int i = 1; i <= n; i++) {
            sum += i;
        }
        return Thread.currentThread().getName() + "求和1-" + n + "的结果是:" + sum;
    }
}

9.7 ExecutorService 的常用方法

方法说明
void execute(Runnable command)执行 Runnable 任务,没有返回值
Future<T> submit(Callable<T> task)执行 Callable 任务,返回未来任务对象用于获取线程执行结果
void shutdown()等任务执行完毕后关闭线程池

9.8 新任务拒绝策略

策略行为
ThreadPoolExecutor.AbortPolicy丢弃任务并抛出 RejectedExecutionException 异常(默认策略)
ThreadPoolExecutor.DiscardPolicy丢弃任务,但是不抛出异常(不推荐)
ThreadPoolExecutor.DiscardOldestPolicy抛弃队列中等待最久的任务,然后把当前任务加入队列
ThreadPoolExecutor.CallerRunsPolicy由主线程负责调用任务的 run() 方法,从而绕过线程池直接执行

9.9 Executors 工具类实现线程池

Executors 是一个线程池的工具类,提供了很多静态方法用于返回不同特点的线程池对象。

方法特点
public static ExecutorService newFixedThreadPool(int nThreads)创建固定线程数量的线程池,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程替代它
public static ExecutorService newSingleThreadExecutor()创建只有一个线程的线程池对象,如果该线程出现异常而结束,那么线程池会补充一个新线程
public static ExecutorService newCachedThreadPool()线程数量随着任务增加而增加,如果线程任务执行完毕且空闲了 60s 则会被回收掉
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize)创建一个线程池,可以实现在给定的延迟后运行任务,或者定期执行任务

注意:这些方法的底层,都是通过线程池的实现类 ThreadPoolExecutor 创建的线程池对象。

public class ExecutorsDemo3 {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(3);

        // 2、处理任务
        Future<String> f1 = pool.submit(new MyCallable(100));
        Future<String> f2 = pool.submit(new MyCallable(200));
        Future<String> f3 = pool.submit(new MyCallable(300));

        try {
            System.out.println(f1.get());
            System.out.println(f2.get());
            System.out.println(f3.get());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

9.10 Executors 使用可能存在的陷阱

大型并发系统环境中使用 Executors 如果不注意可能会出现系统风险。对此,阿里巴巴 Java 开发手册中有强制要求:

阿里巴巴 Java 开发手册对 Executors 创建线程池的限制

  • 【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
  • FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
  • CachedThreadPool 和 ScheduledThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

结论:Executors 不适合做大型互联网场景的线程池方案,建议使用 ThreadPoolExecutor 来指定线程池参数,这样可以明确线程池的运行规则,规避资源耗尽的风险。

10. 其它细节:并发与并行

10.1 进程与线程

正在运行的程序(软件)就是一个独立的进程。线程是属于进程的,一个进程中可以同时运行很多个线程。进程中的多个线程其实是并发和并行执行的。

任务管理器中一个进程下挂着多条线程

10.2 并发的含义

进程中的线程是由 CPU 负责调度执行的,但 CPU 能同时处理线程的数量有限,为了保证全部线程都能往前执行,CPU 会轮询为系统的每个线程服务,由于 CPU 切换的速度很快,给我们的感觉这些线程在同时执行,这就是并发。并发对应的是单核 CPU 的场景。

10.3 并行的理解

并行指的是在同一个时刻上,同时有多个线程在被 CPU 调度执行(例如 4 核 CPU 上同时调度 4 条线程)。并行需要多核 CPU 的支持。

10.4 小结

  • 并发:CPU 分时轮询的执行线程;
  • 并行:同一个时刻同时在执行。

11. 线程的生命周期

11.1 Java 线程的状态

线程的生命周期,也就是线程从生到死的过程中,经历的各种状态及状态转换。理解线程这些状态有利于提升并发编程的理解能力。Java 总共定义了 6 种状态,6 种状态都定义在 Thread 类的内部枚举类中。

public class Thread {
    // ...
    public enum State {
        NEW,
        RUNNABLE,
        BLOCKED,
        WAITING,
        TIMED_WAITING,
        TERMINATED;
    }
    // ...
}

11.2 6 种状态的互相转换

起点触发条件到达状态
New 新建start()Runnable 可运行
Runnable 可运行执行完毕 / 出现异常Terminated 被终止
Runnable 可运行未获得锁对象Blocked 锁阻塞
Blocked 锁阻塞获得锁对象Runnable 可运行
Runnable 可运行获得锁对象后调用了 wait()Waiting 无限等待
Waiting 无限等待被其他线程 notify 并获得锁对象Runnable 可运行
Waiting 无限等待被其他线程 notify,但未获得锁对象Blocked 锁阻塞
Runnable 可运行1、sleep(毫秒) 2、wait(毫秒)Timed Waiting 计时等待
Timed Waiting 计时等待1、sleep 时间到 2、wait 时间到,并得到锁对象 3、wait 时间没到,被其他线程 notify,并得到锁对象Runnable 可运行
Timed Waiting 计时等待1、wait 时间到,未获得锁对象 2、wait 时间没到,被其他线程 notify,未获得锁对象Blocked 锁阻塞

11.3 6 种状态总结

状态名称说明
NEW新建线程刚被创建,还没有调用 start()
RUNNABLE可运行线程已经调用了 start(),正在等待 CPU 调度或正在执行
BLOCKED锁阻塞线程等待获取锁对象(想进入同步代码块/同步方法但锁被别的线程持有)
WAITING无限等待线程调用了 wait(),需要其他线程 notify 唤醒并抢到锁才能继续
TIMED_WAITING计时等待线程调用了 sleep(毫秒) 或 wait(毫秒),时间到了会自动转入下一状态
TERMINATED被终止线程执行完毕或出现异常

(补充说明)课件把 RUNNABLE 归为「可运行」,它涵盖了操作系统层面的就绪与运行两种情形;Java 线程状态不区分这两者。

12. 作业与练习

作业文档:day12-多线程/作业/03_题目/day09每日练习.md(文件名沿用 day09,内容是多线程的 5 道加强训练/综合扩展练习,均要求使用同步方法或同步代码块保证数据安全)。

题目要求训练点
题目 1(加强训练)模拟简单银行系统:账户初始余额 1000 元,两个线程各存 10 次、每次存 100 元,不允许出现错误数据账户类 Account、任务类 Transfer implements Runnable、synchronized 同步方法/同步代码块
题目 2(加强训练)小明和小王一起抄写 100 遍 HelloWorld,每人的惩罚次数与内容可变Runnable 方式创建线程 + 共享资源计数 + 同步
题目 3(综合扩展)抽奖箱中有 10 个奖品,「第一组」「第二组」两条线程随机抽取List 共享集合 + 死循环内嵌套 synchronized + 随机下标移除
题目 4(综合扩展)年会两个入口模拟 100 名员工入场并各发一张双色球彩票,统计每个入口人数工具类生成双色球、任务类计数、同步保护共享集合
题目 5(综合扩展)4 条线程:2 条打印 1-52,2 条打印 A-Z,各自单独看为升序一个任务类交给多条线程、同步代码块的范围设计

题目 1 参考要点:

public class Transfer implements Runnable {
    private Account account;

    public Transfer(Account account) {
        this.account = account;
    }

    public void run() {
        for (int i = 0; i < 10; i++) { // 向账户中存入 10 次钱
            transfer();                // 调用同步方法
        }
    }

    // 同步方法
    public synchronized void transfer() {
        account.deposit(100); // 向账户中存入 100 块钱
        System.out.println(Thread.currentThread().getName()
                + " 存入100元后,账户的余额是: " + account.getLeftMoney());
    }
}
public class Test01 {
    public static void main(String[] args) {
        Account account = new Account(1000);
        Transfer transfer = new Transfer(account);
        Thread t1 = new Thread(transfer, "小王");
        Thread t2 = new Thread(transfer, "小李");
        t1.start();
        t2.start();
    }
}

(片段)题目 1 也可改写为同步代码块,把 account.deposit(100) 与打印语句一起放进 synchronized (this) { ... } 中。

13. 复习要点

  1. 线程是程序内部的一条执行流程,Thread 对象代表线程,多条线程由 CPU 负责调度;main 方法由主线程执行。
  2. 创建线程三种方式:继承 Thread(重写 run())、实现 Runnable(任务对象交给 Thread)、实现 Callable + FutureTask(用 get() 取结果)。启动线程必须调用 start(),直接调 run() 等于单线程。
  3. 三种方式取舍:Thread 编码简单但有单继承局限;Runnable 扩展性强但不能返回结果;Callable 能返回结果但编码复杂。
  4. Thread 常用方法:getName()/setName()、currentThread()、sleep(long)、join();yield、interrupt、守护线程、线程优先级在开发中很少使用。
  5. 线程安全问题出现的三个条件:多条线程同时执行 + 访问同一共享资源 + 存在修改该资源;「取钱」案例说明复合操作被交错会写坏数据。
  6. 同步代码块 synchronized (锁对象) { ... }:锁对象对同时执行的线程必须是同一把;实例方法建议用 this,静态方法建议用 类名.class。
  7. 同步方法:在修饰符上加 synchronized,隐式锁对象同样是 this 或 类名.class;锁的范围比同步代码块大,但可读性更好。
  8. Lock 锁(JDK 5 起,实现类 ReentrantLock):lock()/unlock() 手动加解锁,unlock() 要放在 finally 中,更灵活但容易忘记释放。
  9. 线程通信(生产者消费者模型):wait()/notify()/notifyAll() 来自 Object,必须用当前同步锁对象调用。
  10. 线程池:ExecutorService 代表线程池,可用 ThreadPoolExecutor 自建(7 个参数:核心线程数、最大线程数、临时线程存活时间与单位、任务队列、线程工厂、拒绝策略),也可用 Executors 返回常用线程池;execute() 处理 Runnable,submit() 处理 Callable。
  11. 拒绝策略有 AbortPolicy(默认)、DiscardPolicy、DiscardOldestPolicy、CallerRunsPolicy;Executors 创建的线程池队列或线程数可能无界,大型互联网场景应改用 ThreadPoolExecutor 明确规则。
  12. 并发是 CPU 分时轮询执行线程(单核场景),并行是同一时刻多条线程同时被执行(多核);线程有 6 种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。