多线程(知识点

2. 多线程-初阶

1. 认识线程 (Thread)

1.1 概念

1) 线程是什么?

  • 通俗理解 :如果把CPU 比作一个工厂 ,它有好几个车间。进程 就是这个工厂,线程 就是车间里的工人
    • 一个工厂(进程)里至少有一个工人(线程)。
    • 一个工厂里可以有多个工人(多线程)。
  • 专业理解 :线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。

2) 为啥要有线程?

  • 没有线程的问题(单线程) :以前的程序大多是单线程的,就像只有一个窗口的银行。不管有多少人排队,一次只能服务一个人。如果这个人在办复杂的业务(比如大文件下载),后面的人就只能干等,浪费时间。
  • 有了线程的好处(多线程) :多线程就像开了多个窗口
    • 并发:虽然工厂只有一个(CPU只有一个),但通过快速切换,看起来好像几个工人在同时干活。
    • 效率:比如你在电脑上一边听歌(线程A),一边写代码(线程B),一边下载电影(线程C)。这就是多线程带来的好处,让你的程序"看起来"同时在做多件事。

3) 进程和线程的区别?

  • 根本区别进程是资源分配的最小单位,线程是调度和执行的最小单位。
  • 形象比喻
    • 进程 = 一个独立的APP(比如微信)。它有自己的内存空间、数据、文件。微信崩了,QQ 还能用,因为它们是独立的"进程",互不干扰。
    • 线程 = APP 里的功能模块。比如微信里的"发送消息"和"接收消息"就是两个线程。它们共享微信的内存(比如联系人列表),所以它们之间的通信非常快。
  • 资源开销
    • 进程:像个独立的豪宅,占地大,开门关门(启动关闭)慢。
    • 线程:像个豪宅里的房间,大家共享水电(内存、文件),占地小,切换快。

4) Java的线程和操作系统线程的关系?

  • Java 线程 = 原生线程(1:1 模型)
  • 解释 :你在 Java 代码里 new Thread().start(),Java 虚拟机(JVM)就会去操作系统那里申请创建一个真正的原生线程(由操作系统内核管理)。
  • 结论:Java 线程的底层完全依赖于操作系统的原生线程实现。所以,Java 线程的调度、切换都是由操作系统来控制的。

1.2 第一个多线程程序

目标:写一个程序,让两个任务同时运行。

场景:假设我们有两个任务:

  1. 任务 A:打印 "唱歌..." 50 次。
  2. 任务 B:打印 "跳舞..." 50 次。

代码实现(最基础写法):

复制代码
// 任务 A:唱歌
class SingTask implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 50; i++) {
            System.out.println("唱歌... " + i);
        }
    }
}

// 任务 B:跳舞
class DanceTask implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 50; i++) {
            System.out.println("跳舞... " + i);
        }
    }
}

public class FirstDemo {
    public static void main(String[] args) {
        // 1. 创建任务对象
        Runnable sing = new SingTask();
        Runnable dance = new DanceTask();

        // 2. 创建线程对象,并把任务交给线程
        Thread t1 = new Thread(sing);
        Thread t2 = new Thread(dance);

        // 3. 启动线程!注意:不是调用 run(),而是 start()!
        t1.start();
        t2.start();
    }
}
  • 关键点start()方法才是真正开启一个新线程去执行 run()方法。如果直接调用 t1.run(),那只是在主线程里普通地调用了一个方法,并没有开启新线程。

1.3 创建线程

Java 里创建线程主要有三种方式(前两种最常用):

方法 1:继承 Thread

  • 步骤

    1. 子类继承 Thread
    2. 重写 run()方法(写你要执行的任务)。
    3. 创建子类对象。
    4. 调用 start()启动。
  • 代码示例

    复制代码
    class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("我是继承Thread实现的线程");
        }
    }
    
    public class Test {
        public static void main(String[] args) {
            MyThread t = new MyThread();
            t.start();
        }
    }
  • 缺点 :Java 是单继承,继承了 Thread就不能再继承别的类了,不够灵活。

方法 2:实现 Runnable接口(推荐)

  • 步骤

    1. 类实现 Runnable接口。
    2. 实现 run()方法。
    3. 创建该类对象。
    4. 把这个对象传给 new Thread(对象)
    5. 调用 start()
  • 代码示例

    复制代码
    class MyRunnable implements Runnable {
        @Override
        public void run() {
            System.out.println("我是实现Runnable接口的线程");
        }
    }
    
    public class Test {
        public static void main(String[] args) {
            MyRunnable r = new MyRunnable();
            new Thread(r).start();
        }
    }
  • 优点:解决了单继承问题,更加灵活,符合面向对象的设计(任务与线程分离)。

其他变形(Lambda 表达式,Java 8+)

  • 如果任务逻辑很简单,可以直接用 Lambda 表达式,不需要专门定义一个类。

  • 代码示例

    复制代码
    public class Test {
        public static void main(String[] args) {
            // 直接把 Lambda 表达式传给 Thread
            Thread t = new Thread(() -> {
                System.out.println("我是Lambda创建的线程");
            });
            t.start();
        }
    }

1.4 多线程的优势-增加运行速度

演示代码:模拟任务执行

复制代码
public class SpeedTest {
    public static void main(String[] args) throws InterruptedException {
        // 任务:计算 1~10000000 的总和

        // 1. 单线程执行
        long start = System.currentTimeMillis();
        calc(); // 执行任务
        long end = System.currentTimeMillis();
        System.out.println("单线程耗时: " + (end - start) + "ms");

        // 2. 多线程执行(分成两份,两个线程算)
        long start2 = System.currentTimeMillis();
        Thread t1 = new Thread(SpeedTest::calcHalf); // 前半部分
        Thread t2 = new Thread(SpeedTest::calcHalf); // 后半部分
        t1.start();
        t2.start();
        t1.join(); // 等待 t1 执行完
        t2.join(); // 等待 t2 执行完
        long end2 = System.currentTimeMillis();
        System.out.println("多线程耗时: " + (end2 - start2) + "ms");
    }

    // 模拟一个耗时的计算任务
    public static void calc() {
        long sum = 0;
        for (long i = 0; i < 10000000; i++) {
            sum += i;
        }
    }

    // 计算一半
    public static void calcHalf() {
        long sum = 0;
        // 只算一半的数据,模拟分担任务
        for (long i = 0; i < 10000000 / 2; i++) {
            sum += i;
        }
    }
}
  • 预期结果多线程耗时通常会小于 单线程耗时
  • 原理:原本一个人(CPU核心)算 100 道题要 10 秒。现在两个人(两个线程)各算 50 道,只要 5 秒。
  • 注意 :这仅仅是在多核 CPU 上才有效。如果是单核 CPU,多线程主要是通过"时间片轮转"(快速切换)来实现并发,总时间可能不会减少,甚至因为切换开销而稍微增加。但对于 I/O 密集型任务(如网络请求、读写文件),即使单核,多线程也能大幅提升效率,因为一个线程在等待网络数据时,CPU 可以切去执行另一个线程。

2. Thread类及常见方法

2.1 Thread的常见构造方法

2.2 Thread的几个常见属性

线程也是有"身份证"的,这些属性决定了它是谁、优先级是多少。

核心知识点

  1. id / name:唯一标识和显示名称。
  2. daemon (守护线程) :前面讲过,如果是 true,JVM 退出时它会跟着死;如果是 false(默认),它是主力,不跑完 JVM 不走。
  3. priority (优先级) :1~10。虽然设置了,但操作系统不一定听,只能作为"建议"。

代码演示

复制代码
public class ThreadAttributeDemo {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            System.out.println("线程运行中...");
        });

        // 1. 设置名字
        t.setName("Worker-Thread");

        // 2. 判断是不是守护线程(默认是 false)
        System.out.println("是否是守护线程: " + t.isDaemon()); 

        // 3. 设置优先级(默认是 5)
        t.setPriority(Thread.MAX_PRIORITY); // 设置为最高 10
        System.out.println("线程优先级: " + t.getPriority());

        t.start();
    }
}

2.3 启动一个线程-start()

这是最容易搞混的地方,也是面试常问的坑。

核心知识点

  1. 调用 run()没反应。这只是在当前线程里执行了一个普通方法,没有开启新线程。
  2. 调用 start()才是开启新线程 。底层会调用操作系统的 API 创建一个真实的线程来执行 run()里的代码。
  3. 规则 :同一个线程对象,只能 start()一次 。第二次调用会报错 IllegalThreadStateException

代码演示

复制代码
public class StartMethodDemo {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            System.out.println("线程名: " + Thread.currentThread().getName());
        });

        // 错误示范:这样写不会开启新线程,还是在 main 线程里跑
        // t.run(); 

        // 正确示范:开启新线程
        t.start(); 

        // 错误示范:同一个对象不能 start 两次
        // t.start(); 
    }
}

2.4 中断一个线程

Java 没有 kill命令,不能强制杀死线程(那样会导致数据不一致),只能"商量着来"。

核心知识点

  1. interrupt():主线程调用这个方法,相当于给子线程发了一个"中断信号"。
  2. isInterrupted():子线程内部用来检查自己有没有被标记中断。
  3. Thread.interrupted() :检查并清除中断标志(静态方法,较少用)。

注意 :如果子线程正在 sleepwait,被中断会抛出 InterruptedException,并且标志位会被清除

代码演示

复制代码
public class InterruptDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            // 这是一个死循环,模拟干活
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("我在干活...");
                try {
                    Thread.sleep(1000); // 模拟耗时操作
                } catch (InterruptedException e) {
                    // 如果在 sleep 时被叫停,会进到这里
                    System.out.println("哎呀,被打断了!");
                    // 注意:此时 isInterrupted() 标志已经被清除了,所以循环会继续
                    // 必须在这里 break 才能真的停下来
                    break; 
                }
            }
            System.out.println("线程正常结束。");
        });

        t.start();
        
        // 主线程睡 3 秒,让子线程先跑一会
        Thread.sleep(3000);
        
        // 主线程发出中断信号
        System.out.println("主线程发号施令:停止!");
        t.interrupt();
    }
}

2.5 等待一个线程-join()

想象一下接力赛,你必须等上一棒跑完了,你才能接棒。join就是干这个的。

核心知识点

  1. join():当前线程(比如 main 线程)会阻塞,一直等到目标线程执行完毕。
  2. join(long millis):最多等这么久,超时了就不等了,继续执行。

代码演示

复制代码
public class JoinDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            try {
                System.out.println("子线程开始干活,需要 3 秒");
                Thread.sleep(3000);
                System.out.println("子线程活干完了");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        t.start();
        
        System.out.println("主线程等待子线程完成...");
        // 如果不写下面这行,主线程打印完可能就结束了,导致看不到子线程输出
        t.join(); // 主线程在这里停下来,等 t 跑完
        
        System.out.println("主线程也结束了");
    }
}

2.6 获取当前线程引用

写多线程代码时,你经常需要知道"现在是谁在干活"。

核心知识点

  1. Thread.currentThread():这是一个静态方法,返回当前正在执行的线程对象。

代码演示

复制代码
public class CurrentThreadDemo {
    public static void main(String[] args) {
        // main 方法本身就是一个线程
        Thread mainThread = Thread.currentThread();
        System.out.println("当前线程名字是: " + mainThread.getName());

        // 在子线程里获取自己
        new Thread(() -> {
            Thread current = Thread.currentThread();
            System.out.println("子线程里获取的当前名字: " + current.getName());
        }, "MyThread").start();
    }
}

2.7 休眠当前线程

让当前线程暂停一会儿,把 CPU 让给别人。

核心知识点

  1. Thread.sleep(millis) :静态方法,让当前线程睡眠指定的毫秒数。
  2. 异常 :必须捕获 InterruptedException

代码演示

复制代码
public class SleepDemo {
    public static void main(String[] args) {
        System.out.println("开始执行: " + System.currentTimeMillis());
        
        try {
            // 让当前线程(main)睡 2 秒
            Thread.sleep(2000); 
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        System.out.println("睡醒了: " + System.currentTimeMillis());
    }
}

3. 线程的状态

3.1 观察线程的所有状态

Java 中线程的状态定义在 Thread.State枚举中,一共 6 种:

  1. NEW (新建) :厨师刚招进来,穿好工装,还没进厨房(还没调用 start())。
  2. RUNNABLE (可运行) :厨师在厨房里,正在切菜或者炒菜。注意:在 Java 里,正在运行和排队等待 CPU 时间片统称为 RUNNABLE
  3. BLOCKED (锁阻塞):厨师想要拿盐,发现盐罐子被另一个厨师拿着(有锁),他在等别人用完(等待获取锁)。
  4. WAITING (无限等待) :厨师把刀放下,坐在地上等,谁也不找,一直等到有人喊他(如调用了 wait()join())。
  5. TIMED_WAITING (计时等待) :厨师去抽烟,说"我只抽 5 分钟烟"(调用了 sleep(5)wait(5))。
  6. TERMINATED (终止):厨师下班回家了,活干完了(线程执行完毕)。

3.2 线程状态和状态转移的意义

意义在于:调试 Bug 和生产监控。

如果你发现系统卡死了,你去查日志,看到某个线程的状态一直是 WAITING,你就知道:"哦,它在等人(等锁或者等通知)",这能帮你迅速定位死锁或者线程泄漏的问题。


3.3 观察线程的状态和转移

为了让你看到这些状态,我们需要用到 **jstack**​ 命令或者 IDEA 的线程调试视图。下面的代码展示了状态的流转过程。

复制代码
import java.util.concurrent.TimeUnit;

public class ThreadStateDemo {

    // 定义一个锁对象
    private static final Object LOCK = new Object();

    public static void main(String[] args) throws InterruptedException {
        // 1. NEW 状态
        Thread t1 = new Thread(() -> {
            synchronized (LOCK) {
                try {
                    // 3. 进入 WAITING 状态 (无限期等)
                    System.out.println(Thread.currentThread().getName() + " 拿到锁,准备 wait...");
                    LOCK.wait(); 
                    System.out.println(Thread.currentThread().getName() + " 被唤醒了!");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "Waiter-Thread");

        // 此时 t1 还没 start,状态是 NEW
        System.out.println("1. State after new: " + t1.getState()); // NEW

        t1.start();

        // 休息一下,确保 t1 先跑起来并拿到锁 wait 住
        TimeUnit.MILLISECONDS.sleep(100); 

        // 2. 此时 t1 应该是 WAITING
        System.out.println("2. State after start and wait: " + t1.getState()); // WAITING

        Thread t2 = new Thread(() -> {
            synchronized (LOCK) {
                System.out.println(Thread.currentThread().getName() + " 抢到锁了!准备 sleep...");
                try {
                    // 4. 进入 TIMED_WAITING 状态
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " 睡醒了,准备唤醒别人");
                
                // 唤醒 t1
                LOCK.notify();
            }
        }, "Notifier-Thread");

        t2.start();

        // 休息一下,让 t2 执行
        TimeUnit.SECONDS.sleep(1);
        
        // 5. 此时 t1 还在 WAITING,t2 在 TIMED_WAITING
        System.out.println("3. State of t1 (should be WAITING): " + t1.getState());
        System.out.println("4. State of t2 (should be TIMED_WAITING): " + t2.getState());

        // 等待 t2 彻底结束
        t2.join();
        
        // 6. 线程结束状态
        System.out.println("5. State of t1 after all (should be TERMINATED): " + t1.getState());
    }
}

4. 多线程带来的风险-线程安全(重点)

4.0Runnablevs Running

  • Running (正在运行):这是物理上的概念,指 CPU 正在执行这个线程的指令。
  • Runnable (可运行) :这是 Java 里的概念。它包括 Running 以及 Ready (就绪,排队等 CPU)
    • 就像餐厅里 :正在炒锅里炒菜的是 Running,站在灶台边等火候、手里拿着铲子的也是 Runnable。它们都在"跑道"上,只是有的在跑,有的在排队。
      把"线程不安全"拆解为三个核心问题来解答:
  1. 原子性(Atomicity):操作被打断。
  2. 可见性(Visibility):互相看不见对方的修改。
  3. 指令重排序(Reordering):执行顺序乱了。

4.1 观察线程不安全&4.2 线程安全的概念

通俗理解

你以为你的代码是一行一行按顺序执行的,但在多线程下,这行代码可能还没执行完 ,CPU 就去执行别人的代码了。或者你改了变量,别人永远看不到你改的值。

代码演示:经典的累加错误

我们开 1000 个线程,每个线程给 count加 1,预期结果应该是 1000。

复制代码
public class ThreadUnsafeDemo {
    // 共享数据
    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        int threadCount = 1000;
        Thread[] threads = new Thread[threadCount];

        // 创建 1000 个线程
        for (int i = 0; i < threadCount; i++) {
            threads[i] = new Thread(() -> {
                // 每个线程让 count 加 1
                for (int j = 0; j < 1000; j++) {
                    count++; 
                }
            });
            threads[i].start();
        }

        // 等待所有线程跑完(利用 join,上一节学的知识)
        for (Thread t : threads) {
            t.join();
        }

        // 预期结果:1000 * 1000 = 1_000_000
        System.out.println("最终结果: " + count); 
        // 实际结果:通常是一个小于 100 万的数(比如 998755)
    }
}

4.3 线程不安全的原因

为什么上面的代码算不对?我们来拆解成三个原因:

原因一:原子性被破坏(Atomicity)

什么是原子性? ​ 就像化学里的原子一样,不可分割

count++看起来是一行代码,但在计算机底层其实是三步操作

  1. 读 (Read) :从内存把 count的值拿出来。
  2. 改 (Modify):在 CPU 里加 1。
  3. 写 (Write):把新值放回内存。

惨案现场

当线程 A 读完 count=0,正准备加 1 的时候,CPU 切换到线程 B。

线程 B 读到的也是 count=0,加 1 变成 1 写回去。

然后线程 A 醒过来,它手里拿的还是旧的 0,加 1 变成 1 写回去。

**结果:两次加法,结果只加了 1。**​ 这就是典型的"丢失更新"。

原因二:可见性(Visibility)

什么是可见性? ​ 线程 A 修改了变量,线程 B 能不能立刻看到这个修改?

答案:不能。

现代 CPU 为了性能,每个线程都有自己的一小块高速缓存(Cache),而不是每次都去主内存读写。

  • 线程 A 改了变量,先存在自己的 Cache 里。
  • 线程 B 读变量,读的是自己 Cache 里的旧值。
  • 这就叫:不可见。
原因三:指令重排序(Reordering)

**什么是重排序?**​ 编译器为了优化性能,可能会悄悄改变代码的执行顺序(只要不影响单线程的结果)。

在单线程下没问题,但在多线程下,如果代码的执行顺序变了,可能会导致逻辑错误。

(这是一个比较深的概念,通常在讲 volatile关键字时重点解决,这里先知道有这么个坑即可)

4.4 解决之前的线程不安全问题

既然知道了原因是"原子性"和"可见性"出了问题,Java 提供了关键字来解决:synchronized ​ 和 volatile

方案一:使用 synchronized(万能药)

它有两个作用:

  1. 保证原子性:同一时刻,只有一个人能进这个方法/代码块。
  2. 保证可见性:你改完之后,强制把缓存刷新到主内存,让别人能看到。

修改后的代码:

复制代码
public class ThreadSafeDemo {
    static int count = 0;
    // 创建一个锁对象
    static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        int threadCount = 1000;
        Thread[] threads = new Thread[threadCount];

        for (int i = 0; i < threadCount; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    // 加锁:只有拿到锁的线程才能执行这里的代码
                    synchronized (lock) {
                        count++; 
                    }
                }
            });
            threads[i].start();
        }

        for (Thread t : threads) {
            t.join();
        }

        // 结果一定是 1000000
        System.out.println("最终结果: " + count); 
    }
}

原理synchronized就像一个单人厕所。如果一个人进去了(锁住了),外面的人(其他线程)必须在门口排队。等他出来(解锁),下一个人才能进去。这样里面的操作就不会被打扰了。


方案二:使用 volatile(专治可见性)

如果你的问题仅仅是"我改了变量,别人看不见",用 volatile就够了。

它只保证可见性禁止重排序不保证原子性

适用场景:通常是用来修饰一个标志位。

复制代码
public class VolatileDemo {
    // 加上 volatile,t1 修改后,t2 能立刻看见
    static volatile boolean flag = true;

    public static void main(String[] args) {
        // 线程 1:负责干活,直到 flag 变 false
        Thread t1 = new Thread(() -> {
            while (flag) {
                // 空转
            }
            System.out.println("线程1 停止了");
        });

        // 线程 2:负责喊停
        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(1000); // 睡一会
            } catch (InterruptedException e) {}
            
            System.out.println("线程2 把 flag 改成 false");
            flag = false; // 修改 flag
        });

        t1.start();
        t2.start();
    }
}
  • synchronized :最强王者,既管原子性又管可见性(通过互斥锁)。
    • volatile:轻量级,只管可见性和有序性(通过内存屏障),不管原子性。

5. synchronized 关键字-监视器锁monitor lock

synchronized就像是给代码加了一把排他性的锁。它的核心思想是:一次只能有一个人进屋办事,办完出来换下一个人。

5.1 synchronized 的特性

1) 互斥性 (Mutual Exclusion)

  • 含义:同一时刻,只有一个线程能持有这把锁。
  • 效果 :当一个线程进入 synchronized代码块时,其他线程必须在外面等待,直到锁被释放。

2) 可重入性 (Reentrancy)

  • 含义:同一个线程,可以多次获取自己已经持有的锁。
  • 生活类比:就像你进了家门(获取锁),进卧室不需要再掏钥匙开门,因为你已经在屋里了。JVM 会记录持有锁的线程和重入次数,防止自己把自己锁死(死锁)。

5.2 synchronized 使用示例

写法一:同步实例方法(锁的是 this对象)

复制代码
public class Counter {
    private int count = 0;

    // 锁的是当前的 Counter 对象实例
    public synchronized void increment() {
        count++;
    }
}

写法二:同步静态方法(锁的是 Class对象)

复制代码
public class Counter {
    private static int count = 0;

    // 锁的是 Counter.class 对象(全局唯一)
    public static synchronized void increment() {
        count++;
    }
}

写法三:同步代码块(锁的是指定对象,推荐)

复制代码
public class Counter {
    private int count = 0;
    // 专门定义一个锁对象
    private final Object lock = new Object(); 

    public void increment() {
        // 只有拿到 lock 对象锁的线程才能执行这里的代码
        synchronized (lock) {
            count++;
        }
    }
}

注:写法三粒度最细,性能最好,因为只锁定必要的代码段。

5.3 Java 标准库中的线程安全类

Java 提供了一些内部已经加锁的类,你可以直接使用:

  • Vector, Hashtable(老类,现在通常用 Collections.synchronizedList(new ArrayList<>())代替)
  • StringBuffer(对比 StringBuilder,前者是线程安全的)

6. volatile 关键字

如果说 synchronized是"大门",那 volatile就是"大喇叭"。

volatile 能保证内存可见性

  • 痛点:为了提高效率,每个线程可能会把自己的变量拷贝一份放在"工作内存"里。如果一个线程修改了变量,另一个线程可能还在看旧值。
  • 解决方案volatile告诉 JVM:"每次读这个变量,都要去主内存读;每次写这个变量,都要立刻刷回主内存。"

volatile 不保证原子性

  • 陷阱volatile只管"看见",不管"连贯"。
  • 例子i++。虽然线程 A 看到了最新的 i,但在它执行 +1的这一瞬间,线程 B 可能已经把 i改了。volatile救不了这种"读-改-写"的复合操作。

7. wait 和 notify

这两个方法是 Object类的,必须配合 synchronized使用。它们是线程间通信的工具(比如:生产者-消费者模型)。

7.1 wait()方法&7.2 notify()方法& 7.3 notifyAll()方法

  • wait() : 线程说"我现在没货/条件不满足,我要等着"。注意:调用 wait 会释放锁!
  • notifyAll(): 线程说"货来了/条件满足了,大家起来干活了"。

标准写法模板:

复制代码
synchronized (lock) {
    while (条件不满足) { // 必须用 while,防止虚假唤醒
        lock.wait();    // 释放锁,进入等待
    }
    // 执行任务...
    lock.notifyAll();   // 唤醒其他等待的线程
}

7.4 wait 和 sleep 的对比 (面试题)

比较项 Object.wait() Thread.sleep()
归属 java.lang.Object的方法 java.lang.Thread的静态方法
锁的行为 会释放锁。释放当前持有的 monitor 锁。 不会释放锁。睡着的时候还占着锁不放。
使用前提 必须在 synchronized 代码块或方法中。 任何地方都可以用。
用途 线程间协作(等待/通知)。 单纯让线程暂停一段时间。
异常 抛出 InterruptedException 抛出 InterruptedException

一句话总结

  • wait 是"我去厕所了,把门开着等我,你们谁着急谁先用"(释放锁,等待唤醒)。
  • sleep 是"我趴在桌上睡五分钟,谁也别叫我,醒了我还得接着干"(不释放锁,时间到了自动醒)。

8. 多线程案例

8.1 单例模式

什么是单例模式?

确保一个类在整个程序运行期间 ,永远只有一个对象实例

为什么要用?

通常用于管理共享资源。比如:网站的计数器(所有人访问同一个数)、日志系统的文件写入(不能同时写同一个文件)、数据库连接池(连接数是有限的,统一管理)。

8.1.1饿汉模式

特点:类一加载,实例就创建好了。

评价:简单、绝对线程安全(ClassLoader 保证),但如果这个对象很大且一直没用,就浪费内存了。

复制代码
class HungrySingleton {
    // 1. 私有化构造方法,别人不能 new
    private HungrySingleton() {}

    // 2. 类加载时就直接创建实例
    private static final HungrySingleton INSTANCE = new HungrySingleton();

    // 3. 提供一个全局访问点
    public static HungrySingleton getInstance() {
        return INSTANCE;
    }
}
8.1.2懒汉模式

特点:什么时候用,什么时候才创建(延迟加载)。

痛点:在多线程下,如果不加控制,可能会创建多个对象。

❌ 错误写法(非线程安全):

复制代码
// 多线程下可能创建两个对象,因为 if(instance == null) 这一步可能被同时执行
public class WrongLazy {
    private static WrongLazy instance;
    public static WrongLazy getInstance() {
        if (instance == null) { // 线程A执行到这里,还没赋值,线程B也进来了
            instance = new WrongLazy();
        }
        return instance;
    }
}

✅ 正确写法(双重检查锁定 Double-Checked Locking):

这是最经典的写法,既保证了线程安全,又保证了性能(只加一次锁)。

复制代码
class CorrectLazy {
    // volatile 关键字非常重要!
    // 它能防止指令重排序。new LazySingleton() 分为三步:申请内存->赋值->指向引用。
    // 如果没有 volatile,可能对象还没初始化完,就被别的线程拿去用了(拿到半吊子对象)。
    private static volatile CorrectLazy instance;

    public static CorrectLazy getInstance() {
        // 第一次检查:如果已经创建了,直接返回,不需要加锁,提升性能
        if (instance == null) {
            synchronized (CorrectLazy.class) {
                // 第二次检查:防止多个线程都通过了第一次检查,排队进来后重复创建
                if (instance == null) {
                    instance = new CorrectLazy();
                }
            }
        }
        return instance;
    }
}

jdbc中datasource变成单例模式

8.2 阻塞队列

什么是阻塞队列?

它是一个特殊的队列,不仅是用来装数据的,它还带自动控制线程的功能:

  1. 队列空了,取数据的线程会自动挂起(Wait)等着。
  2. 队列满了,放数据的线程会自动挂起(Wait)等着。
  3. 一旦条件满足(有数据了或腾出空间了),线程会自动唤醒。

这就完美替代了我们在上一节讲的 waitnotify

生产者-消费者模型示例 (使用 Java 标准库)

Java 的 ArrayBlockingQueue内部已经帮我们实现了锁和等待逻辑。

复制代码
import java.util.concurrent.ArrayBlockingQueue;

public class BlockingQueueDemo {
    public static void main(String[] args) {
        // 创建一个容量为10的阻塞队列
        ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(10);

        // 生产者线程
        Thread producer = new Thread(() -> {
            try {
                for (int i = 0; i < 100; i++) {
                    // put 方法:如果队列满了,就会自动阻塞等待
                    queue.put("产品-" + i);
                    System.out.println("生产了: 产品-" + i);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        // 消费者线程
        Thread consumer = new Thread(() -> {
            try {
                for (int i = 0; i < 100; i++) {
                    // take 方法:如果队列空了,就会自动阻塞等待
                    String product = queue.take();
                    System.out.println("消费了: " + product);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        producer.start();
        consumer.start();
    }
}

8.2.1 阻塞队列的实现

复制代码
//阻塞队列的实现  
//不考虑泛型  
class MyBlockingQueue{  
    //手动定义锁对象,使用this也可以  
    private Object locker=new Object();  
    //用数组表示当前阻塞队列  
    private String[]data;  
    private int head;  
    private int tail;  
    private int size=0;  
    //构造方法初始化  
    public MyBlockingQueue(int capacity){  
        if(capacity<0){  
            throw new IllegalArgumentException("capacity must be positive");  
        }  
        data=new String[capacity];  
    }  
    public void put(String elem)throws InterruptedException{  
        synchronized (locker){  
            while(size==data.length){  
                locker.wait();  
            }  
            data[tail++]=elem;  
            if(tail>=data.length){  
                tail=0;  
            }  
            size++;  
            locker.notify();  
        }  
    }  
    public String take()throws InterruptedException{  
        synchronized (locker){  
            while(size==0){  
                locker.wait();  
  
            }  
            String ret=data[head++];  
            if(head>=data.length){  
                head=0;  
            }  
            size--;  
            locker.notify();  
            return ret;  
        }  
    }  
}  
  
public class Demo19 {  
    static void main(String[] args) {  
        MyBlockingQueue queue=new MyBlockingQueue(1000);  
        Thread t1=new Thread(()->{  
            long n=0;  
            while(true){  
                try{  
                    queue.put(n+"");  
                    System.out.println("生产:"+n);  
                n++;  
                } catch (InterruptedException e) {  
                    throw new RuntimeException(e);  
                }  
            }  
        });  
        Thread t2=new Thread(()->{  
            while(true){  
                try{  
                    System.out.println(queue.take());  
                    Thread.sleep(500);  
                } catch (InterruptedException e) {  
                    throw new RuntimeException(e);  
                }  
            }  
        });  
        t1.start();  
        t2.start();  
    };  
  
}

8.3 定时器

什么是定时器?

就是设定一个时间点,让任务在那个时间执行。比如:每天早上 8 点发送报表,或者 3 秒后关闭弹窗。

使用标准库 Timer

Timer内部其实就是一个单线程在执行任务。

复制代码
import java.util.Timer;
import java.util.TimerTask;

public class TimerDemo {
    public static void main(String[] args) {
        Timer timer = new Timer();

        // 安排任务:任务,延迟多久执行(ms),每隔多久执行一次(ms)
        // 这里表示:立即执行,然后每隔1秒执行一次
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("定时任务执行了: " + System.currentTimeMillis());
            }
        }, 0, 1000);
    }
}

⚠️ 注意Timer有一个缺点,如果某个任务执行时间太长(超过了间隔时间),会导致后续任务积压或执行不准。实际开发中,更多使用 ScheduledExecutorService(线程池版的定时器)。

只看骨架,可以理解,Timer是闹钟,TimerTask是到点后要做的任务,任务是一次性的
,schedule是安排,设置多长时间后做什么事情,run就是任务里面的动作,sleep就是继续睡一会,cancel就是闹钟取消了,但是特别注意,取消后就是停止Timer,释放后台线程,程序准备结束

task是schedule的参数,schedule是Timer的方法,Timer还有cancel方法

注意Timer是单线程的,所有任务共用一个线程,如果一个任务执行太久会阻塞后面的任务

复制代码
public class Demo33 {
    public static void main(String[] args) throws InterruptedException {
        Timer timer = new Timer();

        // 定义任务
        TimerTask task = new TimerTask() {
            public void run() {
                System.out.println("do something");
            }
        };

        timer.schedule(task, 1000);
        Thread.sleep(4000);
        timer.cancel();
    }
}
定时器的实现
复制代码
package thread;  
  
import java.util.PriorityQueue;  
  
//创建任务类,表示定时器中要执行的任务  
class MyTimerTask implements Comparable<MyTimerTask>{  
    private  long time;  
    private Runnable runnable;  
    public MyTimerTask(Runnable runnable,long delay){  
        this.runnable=runnable;  
        this.time = System.currentTimeMillis()+delay;  
    }  
    //因为runnable和time是私有的  
    //获取得到任务在什么时候被执行的方法  
    public long getTime(){  
        return time;  
    }  
    //执行任务的方法  
    public void run(){  
        runnable.run();  
    }  
    @Override  
    public int compareTo(MyTimerTask o) {  
        //目的是让time最小的在第一个  
        //如果this<other,返回负数  
        return (int) (this.time - o.time);  
    }  
}  
//自定义的定时器类  
class Mytimer{  
//使用集合类,把多个安排的任务给保存起来  
    //不用阻塞(优先级)队列,因为有锁,  
    private PriorityQueue<MyTimerTask> queue=new PriorityQueue<>();  
    private Object lock=new Object();  
    public Mytimer(){  
        //创建新线程,线程负责执行这里的逻辑  
        Thread t=new Thread(()->{  
            try{  
            while(true){  
                synchronized (lock){  
                    MyTimerTask task=queue.peek();  
                    while(task==null){  
                        //用wait不忙等  
                        lock.wait();  
                        task=queue.peek();  
                    }  
                    //判断时间是否到达  
                    long curTime=System.currentTimeMillis();  
                    if(curTime>=task.getTime()){  
                        //时间到了  
                        task.run();  
                        //执行后就从队列移除任务  
                        queue.poll();  
                    }else{  
                        //时间没到,等 到时间到的时间  
                        lock.wait(task.getTime()-curTime);  
                    }  
                }  
            }  
            }catch (InterruptedException e){  
                throw new RuntimeException("wait interrupted");  
            }  
        });  
        t.start();  
    }  
    public void schedule(Runnable runnable,long delay){  
        synchronized (lock){  
            MyTimerTask task=new MyTimerTask(runnable,delay);  
            queue.add(task);  
            lock.notify();  
        }  
    }  
}  
public class Demo34 {  
    static void main(String[] args) {  
        Mytimer timer=new Mytimer();  
        timer.schedule(()->{  
            System.out.println("hello 3000");  
        },3000);  
        timer.schedule(()->{  
            System.out.println("hello 2000");  
        },2000);  
        timer.schedule(()->{  
            System.out.println("hello 1000");  
        },1000);  
    }  
  
}

用后台线程+优先级队列+锁+wait+notify实现定时器

整体结构,三个类,MyTimerTask表示一个"定时任务",Mytimer定时器本身(核心),Demo34测试类

MyTimerTask 任务包含成员变量,构造方法,其中time=delay+当前时间,compareTo,用来比较时间

Mytimer,定时器的核心大脑包含成员变量构造方法,在里面进行启动一个后台线程,不断检查任务,一旦new Mytimer,定时器就开始活着

线程里的核心逻辑线取最早的任务,队列空就等待,通过判断时间有没有到而确定是进行任务还是精确等待

schedule,往定时器里塞任务先创建任务,然后加入队列,再唤醒工作线程


8.4 线程池

关于线程池故名思意,就是放线程的池,比如之前学的常量池,就是放string,int-128-127范围的对象,这种用完就回收而不是用完就销毁的手段,可以提高效率

并发编程又叫多核cpu,为什么要用池的方式解决,因为我们操作系统,也就是内核(这里的代码执行在内核态),上接应用程序(这里的代码执行在用户态),下接硬件

平时使用 的操作系统分为

  1. 内核(核心功能所在,比如进程管理操作)
  2. 配套的应用程序

当t.start(),创建线程,就会现在用户态执行一系列逻辑,然后再到内核态调用操作系统的api,最后又回到用户态执行,

用户态=>内核态=>用户态,这是很大的开销

就因此引出线程池的方案,提前把线程通过系统api创建好,把这些线程放到Thread集合类中,后续要用就直接到Thread类中取,就变成纯用户态代码了

后续还有协程...

概念
1. 什么是线程池?
  • 定义:一种线程使用模式,预先创建一定数量的线程,放入池中管理。
  • 核心优势
    • 降低开销:减少线程频繁创建、销毁带来的系统开销。
    • 提高响应:任务到达时可直接使用已有线程,无需等待创建。
    • 资源管控:有效控制并发线程数,防止过多线程耗尽系统资源。
2. 为什么要用线程池?(解决的问题)
  • 解决 new Thread()创建线程的弊端(资源浪费、不可控)。
  • 提供统一的任务调度和监控机制。

当t.start(),创建线程,就会现在用户态执行一系列逻辑,然后再到内核态调用操作系统的api,最后又回到用户态执行,

用户态=>内核态=>用户态,这是很大的开销

就因此引出线程池的方案,提前把线程通过系统api创建好,把这些线程放到Thread集合类中,后续要用就直接到Thread类中取,就变成纯用户态代码了

后续还有协程...

概念
线程池七大核心参数详解

(注:这是 ThreadPoolExecutor 的构造核心)

1. 核心线程数 (corePoolSize)
  • 含义:线程池中长期存活的线程数量(常驻线程)。
  • 特性 :即使线程空闲,也不会被回收(除非设置 allowCoreThreadTimeOut)。
2. 最大线程数 (maximumPoolSize)
  • 含义:线程池中允许存在的最大线程数量。
  • 场景:当核心线程都在忙,且阻塞队列已满时,线程池会扩容至该数量。

线程数量分为核心线程和最大线程,可以理解,核心线程数量是公司里面的og员工数量,不会裁的,最大线程数量是公司的最大招进的员工数量,包含og员工也包含随时被裁的实习生,

3. 存活时间 (keepAliveTime)
  • 含义:非核心线程(即超出核心数的线程)在空闲状态下的最大存活时间。
  • 作用:当任务高峰期过后,释放多余线程,节约资源。
4. 时间单位 (unit)
  • 含义keepAliveTime的时间单位(如 TimeUnit.SECONDS, TimeUnit.MILLISECONDS)。
5. 工作队列 (workQueue)
  • 含义:用于存放待执行任务的阻塞队列。
  • 常见类型
    • LinkedBlockingQueue:无界/有界队列(默认无界,需注意 OOM)。
    • ArrayBlockingQueue:有界数组队列(性能较好)。
    • SynchronousQueue:同步移交队列(不存储任务,直接交给线程,适合任务量波动大且需快速响应的场景)。

任务队列用的就是阻塞队列,主要是当队列里面为空的时候可以线程等待,当队列满的时候,有拒绝策略

6. 线程工厂 (threadFactory)
  • 含义:用于创建新线程的对象。
  • 作用:统一设置线程的名称、优先级、守护状态等(便于排查问题)。

线程工厂,我们设计模式有单例模式,也有工厂模式,工厂模式位线程池的线程同意初始化提供了方案

ThreadFactory threadFactory就是thread类的工厂类,这个类给我们提供多个工厂方法,创建出对象

线程工厂形成的动机就是一个类里面只能有一个构造方法,为了解决构造函数重载冲突

复制代码
// 问题:两个构造函数参数类型相同,Java无法区分
class Point {
    // 笛卡尔坐标构造函数 ❌
    Point(double a, double b) { ... }
    
    // 极坐标构造函数 ❌
    Point(double a, double b) { ... }  // 编译错误!
}

// 解决方案:静态工厂方法 ✅
class Point {
    private Point(double x, double y) { ... }
    
    // 用有意义的名称区分
    public static Point fromCartesian(double x, double y) { ... }
    public static Point fromPolar(double radius, double angle) { ... }
}

线程工厂解决的问题是,线程的创建和配置统一化

复制代码
// 普通创建线程
Thread thread1 = new Thread(() -> { ... });
thread1.setName("my-thread");  // 需要额外设置属性
thread1.setDaemon(true);
thread1.setPriority(5);
// 每创建一个线程都要重复这些设置

// 使用线程工厂
ThreadFactory factory = r -> {
    Thread t = new Thread(r);
    t.setName("custom-thread-" + counter.incrementAndGet());
    t.setDaemon(true);
    t.setPriority(Thread.MAX_PRIORITY);
    return t;
};

// 线程池用这个工厂创建线程
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    5, 10, 60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(),
    factory  // 这里传入线程工厂
);
// 线程池创建的每个线程都会有自定义属性

线程工厂是标准工厂模式 的一种实现,但它不是解决构造函数重载,而是解决对象创建的标准化和定制化

复制代码
坐标工厂 → 解决"如何用不同方式创建同一个类"
线程工厂 → 解决"如何用统一的方式创建不同的线程"

package thread;  
//工厂模式解决不能有两个构造方法的问题  
//如果不用工厂模式  
//表达平面上的一个点,我们可以用xy坐标也可以用极坐标  
//class Point {  
//    //构造方法,用xy坐标  
//    public  Point(int x,int y){  
//        //    }  
//    //构造方法,用极坐标ra  
//    public     Point(int r,int a){  
//        //  
//    }  
//    //当两个构造方法参数数量类型方法名返回类型都一样的时候无法重载  
//}  
  
//解决上述问题引用工厂模式  
class Point{  
    public Point(){  
          
    }  
    //提供一系列Set方法,赖针对类进行设置  
    void setxxx(){  
          
    }  
}  
//这个类就是Point 的工厂类  
class PointFactory{  
    public static Point buildPointByXY(int x,int y){  
        Point p=new Point();  
        //把xy通过set方法设置进去  
        p.setxxx();  
        return  p;  
    }  
    public static Point buildPointByRA(int r,int a){  
        Point p=new Point();  
            p.setxxx();  
            return  p;  
    }  
}  
public class Demo30 {  
}
7. 拒绝策略 (handler)
  • 含义:当线程池和队列都已达到饱和状态时,对新提交任务的处理策略。
  • JDK 内置四种策略
    • AbortPolicy(默认):直接抛出 RejectedExecutionException异常。
    • CallerRunsPolicy:由提交任务的线程(调用者)自己执行该任务。
    • DiscardPolicy:默默丢弃新任务,不抛异常。
    • DiscardOldestPolicy:丢弃队列中最老的一个任务,然后尝试重新提交新任务。
标准库中的线程池
1. Executors 工厂类
  • Java 提供的快速创建线程池的工具类。
2. 常见的四种快捷线程池
  • FixedThreadPool:固定大小线程池(核心=最大,无界队列)。
  • CachedThreadPool:缓存线程池(核心=0,最大=MAX,同步队列,适合短任务)。
  • ScheduledThreadPool:定时任务线程池。
  • SingleThreadExecutor:单线程池(保证任务按顺序执行)。
3. 避坑指南(重要)
  • 不推荐直接使用 Executors 创建线程池。
  • 原因 :默认的 FixedThreadPoolSingleThreadExecutor使用无界队列,可能导致任务堆积过多引发 OOM(内存溢出);CachedThreadPool最大线程数为 Integer.MAX_VALUE,可能创建过多线程导致 OOM。
四、 线程池的工作流程
  1. 提交任务 后,首先判断核心线程是否有空闲,有则直接执行。
  2. 若核心线程都在忙,将任务加入阻塞队列等待。
  3. 若队列已满,判断当前线程数是否小于最大线程数 ,是则创建非核心线程执行。
  4. 若已达最大线程数且队列已满,触发拒绝策略
五、 线程池的关闭与监控
1. 如何优雅关闭?
  • shutdown():不再接受新任务,等待已提交任务执行完毕。
  • shutdownNow():尝试中断正在执行的任务,并返回未执行的任务列表。
2. 监控指标
  • 当前线程数、活跃线程数。
  • 已完成任务数、总任务数。
  • 队列中积压的任务数。

9. 总结-保证线程安全的思路

其实,让线程变安全,就像治理交通拥堵或者保护公共财产一样,无非就是那么几个套路。总结起来就三个字:"隔"、"守"、"查"

1. "隔":把共享资源隔离开(互斥锁)
  • 核心思想:大家都想改同一个东西(比如同一张银行卡余额),那我就不许你们同时改,必须排队!
  • 武器synchronizedLock
  • 效果:同一时刻,只有一个线程能操作这个资源。
2. "守":守不住的时候就通知(等待-通知机制)
  • 核心思想:如果资源暂时不可用(比如库存为0了),你与其一直问"好了吗?好了吗?"(这就是 CPU 空转,浪费资源),不如你就乖乖睡觉,等资源好了我再叫你(wait/notify)。
  • 武器wait()notifyAll()
  • 效果:避免无效的循环检查,节省 CPU 资源。
3. "查":查出那些看不见的脏读(可见性)
  • 核心思想:有的线程修改变量不告诉别人(因为 CPU 有缓存),导致别的线程还在看旧数据。我们要强制要求:改了就得立刻广播给全世界!
  • 武器volatile
  • 效果:保证大家看到的都是最新、最准的数据。

10. 对比线程和进程

10.1 线程的优点

为什么要多搞出个"线程"的概念?直接用"进程"不行吗?

答案:为了省钱、省时、灵活。

  1. 省钱(省内存)

    • 进程像是一个独立的豪宅,启动一个进程要申请很多独立资源,成本高。
    • 线程就像是豪宅里的一个个房间,大家共享客厅(内存)、水电(文件句柄),只需要一点点额外空间就能造出一个新线程。
  2. 省时(切换快)

    • 从 A 进程切换到 B 进程,就像从北京飞到上海,成本高、时间长。
    • 从 A 线程切换到 B 线程,就像在同一个大楼里换办公室,一秒钟搞定。
  3. 通信方便

    • 进程之间说话很难(比如微信和网易云音乐不能直接互相读写对方的内存),需要特殊的管道。
    • 线程之间说话很容易,因为它们住在一起,直接喊一声就行(共享堆内存)。

10.2 进程与线程的区别

我们可以用**"开公司"**来打个比方:

维度 进程 (Process) 线程 (Thread)
资源拥有 大老板 。拥有独立的内存空间、数据库链接、文件权限。资源是隔离的 员工 。原则上不拥有系统资源,必须生活在进程(公司)里,共享公司的资源。资源是共享的
地位 独立单位。一个进程崩溃了,通常不会影响隔壁的其他进程(比如 QQ 崩了,微信还在)。 调度单位。线程是 CPU 调度和执行的基本单位。一个线程崩溃(比如数组越界),会导致整个进程一起陪葬。
开销 。创建、销毁、切换都需要操作系统介入,消耗大量时间和内存。 。创建、销毁、切换极快,开销远小于进程。
通信机制 。必须通过内核(操作系统)中转(如 Socket、管道)。 。直接读写同一块内存区域即可。
包含关系 容器。一个进程可以包含多个线程(公司里有多个员工)。 子集。线程不能脱离进程单独存在。
面试

"进程是资源分配的最小单位,线程是 CPU 调度和执行的最小单位。"

这句话怎么理解?

  • 你想买地盖楼(申请内存、文件),你得找政府批文(操作系统),批下来的是一块地(进程)。
  • 楼盖好了,里面的住户是谁、谁去上班干活,这是由物业安排的(CPU 调度),干活的人就是(线程)。

  1. 线程的状态流转。
  2. 为什么会出现线程不安全(可见性、原子性)。
  3. 如何解决不安全(synchronized、volatile、wait/notify)。
  4. 经典并发模型(单例、阻塞队列、线程池)。
  5. 底层原理(进程与线程的区别)。