欢迎光临
我们一直在努力

2,多线程,并发编程

这本笔记非常详尽地记录了多线程的核心概念,特别是从进程与线程的区别深入到线程安全问题和异常机制。我看你笔记里重点标注了“进程是资源分配的基本单位,线程是调度执行的基本单位”,这是理解多线程的基石。

结合你的笔记内容,我帮你把核心知识点梳理并补充完整,形成一个更系统的体系,方便你复习:

一、 为什么需要多线程?(进程 vs 线程)

笔记中提到“多进程是并发编程解决方案”,但“开销大”,所以引入了线程。

  • 进程:系统进行资源分配和调度的基本单位。创建/销毁进程开销大(需要切换内存空间)。
  • 线程:进程内部的执行单元,是CPU调度和执行的基本单位。
  • 关系:一个进程包含一个或多个线程。线程共享进程的资源(如内存、文件描述符),但有自己独立的栈空间和寄存器状态。
  • 优势:相比于多进程,线程的创建/销毁和切换开销小(轻量级),且通信更方便(直接读写共享内存)。

二、 多线程编程的核心难点(笔记重点)

你笔记右页的第一点提到了最核心的痛点,这里展开一下:

  • 线程安全问题

    • 原因:多个线程同时操作同一个数据(共享变量),导致冲突。
    • 后果:出现数据不一致(Bug)。
    • 解决思路:笔记中提到的 synchronized(加锁)和 volatile(保证可见性)。
      • synchronized:保证同一时刻只有一个线程能执行某段代码,解决原子性问题。
      • volatile:强制线程每次读取变量都从主内存读,解决可见性问题。
  • 异常处理

    • 机制:单个线程如果发生了未捕获的异常,默认会导致整个进程终止。
    • 解决:在 run()方法里使用 try-catch捕获异常。Java 后来也引入了 UncaughtExceptionHandler接口来处理这种情况。
  • 三、 笔记中隐含的一个小问题

    你在左页中间画了一个层级图,写到了“线程 -> 操作系统”。

    • 补充:在 Java 中,我们写的 Thread类是 Java 层面的线程对象。当它调用 start()方法时,底层会通过 JNI 调用操作系统的 API(如 Linux 的 pthread_create)去创建一个真正的内核级线程。所以 Java 的线程本质上是对操作系统线程的一层封装。

    四、 进程调度与线程状态

    笔记右页提到“进程调度:只有一个线程的进程”。

    • 补充:实际上,单核 CPU 下,即使是多进程,也是通过时间片轮转来调度的。而线程是 CPU 调度的最小单位。
    • 线程状态(Java 中):新建 (New) -> 就绪 (Runnable) -> 运行 (Running) -> 阻塞 (Blocked/Waiting) -> 死亡 (Terminated)。

    📘 Java 多线程终极深度解析:全对话历史完整版

    适用读者:已经有一定 Java 基础,但对多线程底层机制、对象与线程关系、Lambda 本质感到困惑的学习者。

    本文目标:完整复现我们数十轮对话中的所有疑问、解答、纠错与升华,形成一套从代码表象到JVM 内存再到操作系统内核的闭环知识体系。


    第一部分:基础概念大扫除(你最初的疑惑)

    1.1 线程到底是什么?

    你的原话:“线程是什么?执行流?还是对象?”

    终极答案:

    • 在 Java 代码层面:Thread是一个类,你 new出来的是对象。
    • 在 JVM/OS 层面:线程是操作系统(OS)​ 中的一个执行流(Execution Flow),是 CPU 调度的最小单位。

    核心比喻:

    Thread对象 = 遥控飞机

    真实线程 = 飞机本身

    你操作遥控器(t.start()),飞机(OS 线程)才飞起来。

    1.2 进程 vs 线程(银行例子)

    你的原话:“张三、李四、王五办理业务。”

    详细解释:

    • 进程:一家独立的银行(有自己的内存空间、票据库)。进程之间不能直接访问对方的内存。
    • 线程:银行里的柜员(张三、李四)。他们共享银行的资源(内存空间),但同时干活。
    • 区别:
      • 进程切换:搬家(开销大)。
      • 线程切换:换个工作岗位(开销小)。

    1.3 为什么要有线程?

    你的原话:“为啥要有线程?”

    详细解答:

  • 硬件限制:CPU 单核性能遇到瓶颈,必须多核并行。
  • 效率提升:IO 等待(如网络、磁盘)时,CPU 闲置。多线程可以让 CPU 在等待时去处理别的任务。
  • 成本考量:创建/销毁线程比创建/销毁进程快得多。

  • 第二部分:创建线程的四种写法(你的代码实践)

    2.1 继承 Thread(不推荐)

    class MyThread extends Thread {
    @Override
    public void run() {
    System.out.println("干活");
    }
    }
    new MyThread().start();

    缺点:Java 单继承,限制了类的扩展性。

    2.2 实现 Runnable(推荐 ✅)

    class MyRunnable implements Runnable {
    @Override
    public void run() {
    System.out.println("干活");
    }
    }
    new Thread(new MyRunnable()).start();

    优点:任务(Runnable)与执行者(Thread)解耦。

    2.3 匿名内部类

    new Thread(new Runnable() {
    @Override
    public void run() {
    System.out.println("干活");
    }
    }).start();

    2.4 Lambda 表达式(现代写法 ✅✅✅)

    new Thread(() -> System.out.println("干活")).start();


    第三部分:Lambda 的终极揭秘(你最困惑的地方)

    3.1 Lambda 是什么?

    你的原话:“我只知道接口,Lambda 是函数接口。”

    深度解析:

    • Lambda 本质:是一个匿名函数,但它必须依附于函数式接口。
    • 函数式接口:有且仅有一个抽象方法的接口(如 Runnable)。
    • 为什么:因为 Lambda 太懒了,它不知道你要实现哪个方法,除非接口里只有一个方法。

    3.2 Lambda 在 new Thread()里是传参吗?

    你的原话:“这个 lambda 是在 new 对象里面是传参吗,是构造方法吗?”

    绝对肯定的回答:是的,它是传参,传给 Thread 的构造方法。

    执行顺序详解(针对你的截图):

    sequenceDiagram
    participant Code as 你的代码
    participant JVM as Java虚拟机

    Code->>JVM: 执行 new Thread( () -> {…} )
    Note right of JVM: 1. 先计算括号里的参数<br>(Lambda 表达式)
    JVM->>JVM: 2. 在堆内存中创建一个 Runnable 对象<br>(编译器自动生成的匿名类实例)
    JVM->>JVM: 3. 调用 Thread 构造方法<br>把刚创建的 Runnable 对象地址传进去
    JVM->>JVM: 4. 构造方法把地址存到 Thread 内部变量(target)
    JVM–>>Code: 5. 返回 Thread 对象引用
    Code->>JVM: 6. 调用 t.start()
    JVM->>JVM: 7. 启动 OS 线程,执行 target.run()

    3.3 返回值传参?

    你的原话:“是它的返回值传参给 Thread 对象吗?”

    纠正:没有返回值。

    • Thread的构造方法是 void的。
    • 你传进去的是一个对象实例(Runnable 实例)。
    • 这个对象被存起来了,等着以后 start()的时候用。

    第四部分:Thread 对象 vs 真实线程(内存与成本)

    4.1 引用关系(你的核心疑惑)

    你的原话:“cur 的引用和 t 的引用怎么可以是同一个?”

    深度解析:

    • 引用变量(t, cur):是遥控器,存储在栈(Stack)​ 里。
    • 对象(new Thread()):是电视机,存储在堆(Heap)​ 里。
    • 赋值操作:Thread cur = t;只是把遥控器复制了一份,它们指向同一台电视机。

     

    graph LR
    A[栈内存] –>|引用 t| B[堆内存: Thread 对象]
    A –>|引用 cur| B
    B –>|持有| C[堆内存: Runnable 任务]

    4.2 内存开销(你敏锐的发现)

    你的原话:“创建对象不消耗内存启动线程就要消耗内存是吗?”

    详细对比表:

    操作消耗内存位置说明
    new Thread() 极小 (KB级)​ JVM 堆 只是创建一个 Java 对象,存几个属性。
    t.start() 极大 (MB级)​ OS 内核 操作系统必须分配线程栈(默认 1MB)、PCB 等。

    结论:这就是为什么需要线程池。线程池复用已经创建好的“员工”,避免每次都花大价钱“招新员工”。


    第五部分:线程的生命周期与控制(中断与守护)

    5.1 主线程与子线程的关系

    你的原话:“是不是只依赖主线程结束,如果主线程不结束,其他线程将一直执行?”

    精确答案:

    • 不依赖:主线程结束,子线程(非守护)可以继续跑。
    • JVM 退出条件:所有非守护线程都结束,JVM 才退出。

    5.2 中断机制(优雅停机)

    你的原话:“终端线程,是不是只依赖主线程结束?”

    深度解析:

    Java 没有强制杀线程的 API(stop()已废弃)。使用的是协作式中断。

    // 子线程代码
    while (!Thread.currentThread().isInterrupted()) {
    // 干活
    }
    // 主线程代码
    t.interrupt(); // 仅仅是打个招呼,设置中断标志位

    5.3 守护线程(Daemon)

    你的原话:“后台线程有守门员机制。”

    详细解释:

    • 前台线程:公司的正式员工。只要有一个在,公司(JVM)不能关门。
    • 后台线程(守护):公司的保洁。正式员工下班了,保洁也被赶走。
    • 代码:t.setDaemon(true);(必须在 start()前调用)。

    第六部分:异常处理与 Finally(你没想到的坑)

    6.1 Finally 的特点

    你的原话:“finally 有什么特点吗?”

    核心特点:

  • 必执行:只要进入 try,finally必执行(除非 JVM 强退)。
  • 改变返回值:finally里的 return会覆盖 try里的 return(严禁这样写)。
  • 吞异常:finally抛异常会覆盖 try的异常。
  • 6.2 守护线程中的 Finally(致命陷阱)

    你的原话:“这个第三点关于 finally 的知识点我还不知道,为什么会这样子呀?”

    原因:

    守护线程是被 JVM 强制终止的,不是自然死亡的。

    // 守护线程
    Thread t = new Thread(() -> {
    try {
    while (true) System.out.println("干活");
    } finally {
    System.out.println("我不一定执行!"); // JVM 退出时直接拔电源
    }
    });
    t.setDaemon(true);
    t.start();

    结论:守护线程不能做资源清理工作。


    第七部分:PCB 与操作系统(内核视角)

    7.1 PCB 是什么?

    你的原话:“pcb 是结构体我知道,是对线程的描述。”

    详细解释:

    • PCB (Process Control Block):操作系统内核中的结构体。
    • 作用:描述线程的一切(ID、状态、寄存器、栈指针)。
    • Java 的 Thread 对象:是 JVM 层面的一个“壳”,里面封装了对 OS 的 PCB 的操作。

    7.2 TGID 与主线程

    你的原话:“tgid 既是进程号,又是主线程的线程号。”

    深度解析(Linux 设计):

    • Linux 没有“进程”和“线程”的严格区分,只有“任务”。
    • 主线程:第一个任务,它的 PID 被当作 TGID(线程组 ID)。
    • 子线程:后续的任务,有自己的 PID,但 TGID 等于主线程的 PID。
    • 链表:操作系统用链表把所有 TGID 相同的线程串起来,这就是一个“进程”。

     

    graph TD
    A[TGID=1001<br>主线程] –>|链表| B[PID=1002<br>子线程1]
    A –>|链表| C[PID=1003<br>子线程2]


    第八部分:终极全景图(所有知识串联)

    这是对你所有问题的终极总结。请保存此图。

    graph TD
    subgraph "1. Java 代码层"
    A1[main 方法] –> A2[new Thread(Runnable)]
    A2 –> A3[t.start()]
    end

    subgraph "2. JVM 内存层"
    B1[栈内存: t 引用]
    B2[堆内存: Thread 对象]
    B3[堆内存: Runnable 对象]
    B1 –> B2
    B2 –>|持有| B3
    end

    subgraph "3. JVM 调用 OS"
    C1[native start0()]
    end

    subgraph "4. 操作系统内核层"
    D1[PCB 结构体]
    D2[线程栈 1MB]
    D3[CPU 调度器]
    end

    subgraph "5. 生命周期控制"
    E1[中断标志位]
    E2{isInterrupted?}
    E3[守护线程?]
    end

    %% 连接逻辑
    A3 –> C1
    C1 –> D1
    D1 –> D2
    D1 –> D3
    D3 –>|执行| B3

    B3 –>|轮询| E2
    E2 — 是 –> F[跳出循环]
    E2 — 否 –> D3

    E3 — 是 –> G[JVM 退出时强制销毁]
    E3 — 否 –> H[阻止 JVM 退出]


    第九部分:面试级总结(你可以直接背诵)

  • Thread 是对象,线程是 OS 实体。​ new是创建对象,start是创建线程。
  • Runnable 是任务,Lambda 是其语法糖。​ 编译后会生成一个实现了接口的匿名内部类对象。
  • 引用赋值不创建新对象。​ cur = t只是两个遥控器指向同一台电视。
  • 中断是协作。​ interrupt()只是设置标志位,线程需自行检查并退出。
  • 守护线程是炮灰。​ JVM 退出时强制杀死,其 finally块不一定执行。
  • 成本差异巨大。​ 创建 Thread 对象消耗 KB 级堆内存;启动线程消耗 MB 级系统栈内存。因此必须使用线程池。
  • 2.1 Thread 的常见构造方法(怎么造出一个线程?)

    就像你造一辆车需要不同的配置(两厢、三厢、敞篷),创建线程也有几种不同的方式。

    核心知识点:

  • 给线程起名字:方便调试和日志排查。
  • 传入任务:可以是 Runnable,也可以是 lambda表达式。
  • 设置线程组:把线程归类(一般默认即可)。
  • 代码演示:

    public class ThreadConstructorDemo {
    public static void main(String[] args) {
    // 方式1:最标准的写法,传入 Runnable
    Thread t1 = new Thread(new MyRunnable(), "我的线程-01");

    // 方式2:匿名内部类
    Thread t2 = new Thread(new Runnable() {
    @Override
    public void run() {
    System.out.println("我是 t2");
    }
    }, "我的线程-02");

    // 方式3:Lambda 表达式(最常用,因为简洁)
    Thread t3 = new Thread(() -> System.out.println("我是 t3"), "我的线程-03");

    t1.start();
    t2.start();
    t3.start();
    }

    static class MyRunnable implements Runnable {
    @Override
    public void run() {
    System.out.println("我是 t1");
    }
    }
    }


    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 第一个多线程程序

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

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

  • 任务 A:打印 "唱歌…" 50 次。
  • 任务 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类

    • 步骤:
    • 子类继承 Thread。
    • 重写 run()方法(写你要执行的任务)。
    • 创建子类对象。
    • 调用 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接口(推荐)

    • 步骤:
    • 类实现 Runnable接口。
    • 实现 run()方法。
    • 创建该类对象。
    • 把这个对象传给 new Thread(对象)。
    • 调用 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.2 Thread 的几个常见属性(线程的身份信息)

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

    核心知识点:

  • id / name:唯一标识和显示名称。
  • daemon (守护线程):前面讲过,如果是 true,JVM 退出时它会跟着死;如果是 false(默认),它是主力,不跑完 JVM 不走。
  • 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() (最重要的方法)

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

    核心知识点:

  • 调用 run():没反应。这只是在当前线程里执行了一个普通方法,没有开启新线程。
  • 调用 start():才是开启新线程。底层会调用操作系统的 API 创建一个真实的线程来执行 run()里的代码。
  • 规则:同一个线程对象,只能 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命令,不能强制杀死线程(那样会导致数据不一致),只能“商量着来”。

    核心知识点:

  • interrupt():主线程调用这个方法,相当于给子线程发了一个“中断信号”。
  • isInterrupted():子线程内部用来检查自己有没有被标记中断。
  • Thread.interrupted():检查并清除中断标志(静态方法,较少用)。
  • 注意:如果子线程正在 sleep或 wait,被中断会抛出 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就是干这个的。

    核心知识点:

  • join():当前线程(比如 main 线程)会阻塞,一直等到目标线程执行完毕。
  • 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 获取当前线程引用

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

    核心知识点:

  • 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 让给别人。

    核心知识点:

  • Thread.sleep(millis):静态方法,让当前线程睡眠指定的毫秒数。
  • 异常:必须捕获 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.1 观察线程的所有状态

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

  • NEW (新建):厨师刚招进来,穿好工装,还没进厨房(还没调用 start())。
  • RUNNABLE (可运行):厨师在厨房里,正在切菜或者炒菜。注意:在 Java 里,正在运行和排队等待 CPU 时间片统称为 RUNNABLE。
  • BLOCKED (锁阻塞):厨师想要拿盐,发现盐罐子被另一个厨师拿着(有锁),他在等别人用完(等待获取锁)。
  • WAITING (无限等待):厨师把刀放下,坐在地上等,谁也不找,一直等到有人喊他(如调用了 wait()或 join())。
  • TIMED_WAITING (计时等待):厨师去抽烟,说“我只抽 5 分钟烟”(调用了 sleep(5)或 wait(5))。
  • 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());
    }
    }

    核心状态转移图(记忆版)

     

    特别容易混淆的点:Runnablevs Running

    • Running (正在运行):这是物理上的概念,指 CPU 正在执行这个线程的指令。
    • Runnable (可运行):这是 Java 里的概念。它包括 Running​ 以及 Ready (就绪,排队等 CPU)。
      • 就像餐厅里:正在炒锅里炒菜的是 Running,站在灶台边等火候、手里拿着铲子的也是 Runnable。它们都在“跑道”上,只是有的在跑,有的在排队。

    把“线程不安全”拆解为三个核心问题来解答:

  • 原子性(Atomicity):操作被打断。
  • 可见性(Visibility):互相看不见对方的修改。
  • 指令重排序(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++看起来是一行代码,但在计算机底层其实是三步操作:

  • 读 (Read):从内存把 count的值拿出来。
  • 改 (Modify):在 CPU 里加 1。
  • 写 (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(万能药)

    它有两个作用:

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

    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();
    }
    }

    总结这一节的知识点:

  • 为什么会不安全?​ 因为 count++不是原子操作(读-改-写被打断),且线程间有缓存(看不见彼此的修改)。

  • 怎么解决?

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

  • 5. synchronized 关键字(监视器锁)

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

    5.1 核心特性

    1) 互斥性 (Mutual Exclusion)

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

    2) 可重入性 (Reentrancy)

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

    5.2 使用示例(三种写法)

    写法一:同步实例方法(锁的是 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告诉 JVM:“每次读这个变量,都要去主内存读;每次写这个变量,都要立刻刷回主内存。”

    致命局限:不保证原子性

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

    7. wait 和 notify 方法

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

    • 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.1 单例模式 (Singleton Pattern)

    什么是单例模式?

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

    为什么要用?

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

    1. 饿汉模式 (Eager Initialization)

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

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

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

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

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

    2. 懒汉模式 (Lazy Initialization) – 面试重点

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

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

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

    // 多线程下可能创建两个对象,因为 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;
    }
    }


    8.2 阻塞队列 (Blocking Queue)

    什么是阻塞队列?

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

  • 队列空了,取数据的线程会自动挂起(Wait)等着。
  • 队列满了,放数据的线程会自动挂起(Wait)等着。
  • 一旦条件满足(有数据了或腾出空间了),线程会自动唤醒。
  • 这就完美替代了我们在上一节讲的 wait和 notify!

    生产者-消费者模型示例 (使用 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.3 定时器 (Timer)

    什么是定时器?

    就是设定一个时间点,让任务在那个时间执行。比如:每天早上 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(线程池版的定时器)。


    8.4 线程池 (ThreadPool)

    什么是线程池?

    线程是昂贵的资源(创建和销毁要消耗 CPU 和内存)。线程池的思路是:预先创建好一堆线程放在池子里,用完了不还,而是放回池子,下次再用。

    三大好处:

  • 降低消耗:避免了频繁创建和销毁线程的开销。
  • 提高响应:任务来了,直接从池子里拿现成的线程用,不用等创建。
  • 可控管理:可以控制电脑最多跑多少个线程,防止卡死。
  • 标准库中的线程池 (Executors)

    虽然 Executors工厂类很好用,但在大厂面试中,通常要求直接使用 ThreadPoolExecutor构造器来创建,因为这样可以更精细地控制队列大小和拒绝策略。

    手写简易版线程池核心逻辑(理解原理):

    import java.util.concurrent.BlockingQueue;
    import java.util.concurrent.LinkedBlockingQueue;

    public class SimpleThreadPool {
    // 任务队列
    private final BlockingQueue<Runnable> taskQueue;
    // 线程数组
    private final WorkerThread[] workers;

    public SimpleThreadPool(int poolSize) {
    this.taskQueue = new LinkedBlockingQueue<>();
    this.workers = new WorkerThread[poolSize];

    // 初始化线程池里的线程
    for (int i = 0; i < poolSize; i++) {
    workers[i] = new WorkerThread("Worker-" + i);
    workers[i].start();
    }
    }

    // 提交任务
    public void execute(Runnable task) {
    try {
    taskQueue.put(task); // 把任务扔进队列,如果满了就等待
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }

    // 工作线程类
    private class WorkerThread extends Thread {
    public WorkerThread(String name) {
    super(name);
    }

    @Override
    public void run() {
    while (true) {
    try {
    // 从队列里取任务,如果没任务就阻塞等待 (take方法)
    Runnable task = taskQueue.take();
    System.out.println(getName() + " 拿到任务并执行…");
    task.run(); // 执行任务逻辑
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    }
    }

    // 测试
    public static void main(String[] args) {
    SimpleThreadPool pool = new SimpleThreadPool(3); // 3个工作线程

    for (int i = 0; i < 10; i++) {
    int taskId = i;
    pool.execute(() -> {
    System.out.println("任务 " + taskId + " 正在运行");
    try { Thread.sleep(1000); } catch (Exception e) {}
    });
    }
    }
    }

    这是咱们多线程课程的最后一块拼图。这部分内容不考你具体的 API,而是考你的底层逻辑思维。理解了这两部分,你在面试中遇到“多线程问题”就能一眼看穿本质了。

    我们还是用最通俗的大白话来讲。


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

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

    1. “隔”:把共享资源隔离开(互斥锁)

    • 核心思想:大家都想改同一个东西(比如同一张银行卡余额),那我就不许你们同时改,必须排队!
    • 武器:synchronized、Lock。
    • 效果:同一时刻,只有一个线程能操作这个资源。

    2. “守”:守不住的时候就通知(等待-通知机制)

    • 核心思想:如果资源暂时不可用(比如库存为0了),你与其一直问“好了吗?好了吗?”(这就是 CPU 空转,浪费资源),不如你就乖乖睡觉,等资源好了我再叫你(wait/notify)。
    • 武器:wait()、notifyAll()。
    • 效果:避免无效的循环检查,节省 CPU 资源。

    3. “查”:查出那些看不见的脏读(可见性)

    • 核心思想:有的线程修改变量不告诉别人(因为 CPU 有缓存),导致别的线程还在看旧数据。我们要强制要求:改了就得立刻广播给全世界!
    • 武器:volatile。
    • 效果:保证大家看到的都是最新、最准的数据。

    10. 对比线程和进程

    这部分是面试必问的。你可以把CPU想象成一个大工厂,里面有不同的车间和工人。

    10.1 线程的优点

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

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

  • 省钱(省内存):

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

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

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

    我们可以用“开公司”来打个比方:

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

    面试

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

    这句话怎么理解?

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

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

     

    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 第一个多线程程序

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

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

  • 任务 A:打印 "唱歌…" 50 次。
  • 任务 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类

    • 步骤:
    • 子类继承 Thread。
    • 重写 run()方法(写你要执行的任务)。
    • 创建子类对象。
    • 调用 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接口(推荐)

    • 步骤:
    • 类实现 Runnable接口。
    • 实现 run()方法。
    • 创建该类对象。
    • 把这个对象传给 new Thread(对象)。
    • 调用 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的几个常见属性

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

    核心知识点:

  • id / name:唯一标识和显示名称。
  • daemon (守护线程):前面讲过,如果是 true,JVM 退出时它会跟着死;如果是 false(默认),它是主力,不跑完 JVM 不走。
  • 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()

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

    核心知识点:

  • 调用 run():没反应。这只是在当前线程里执行了一个普通方法,没有开启新线程。
  • 调用 start():才是开启新线程。底层会调用操作系统的 API 创建一个真实的线程来执行 run()里的代码。
  • 规则:同一个线程对象,只能 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命令,不能强制杀死线程(那样会导致数据不一致),只能“商量着来”。

    核心知识点:

  • interrupt():主线程调用这个方法,相当于给子线程发了一个“中断信号”。
  • isInterrupted():子线程内部用来检查自己有没有被标记中断。
  • Thread.interrupted():检查并清除中断标志(静态方法,较少用)。
  • 注意:如果子线程正在 sleep或 wait,被中断会抛出 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就是干这个的。

    核心知识点:

  • join():当前线程(比如 main 线程)会阻塞,一直等到目标线程执行完毕。
  • 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 获取当前线程引用

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

    核心知识点:

  • 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 让给别人。

    核心知识点:

  • Thread.sleep(millis):静态方法,让当前线程睡眠指定的毫秒数。
  • 异常:必须捕获 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 种:

  • NEW (新建):厨师刚招进来,穿好工装,还没进厨房(还没调用 start())。
  • RUNNABLE (可运行):厨师在厨房里,正在切菜或者炒菜。注意:在 Java 里,正在运行和排队等待 CPU 时间片统称为 RUNNABLE。
  • BLOCKED (锁阻塞):厨师想要拿盐,发现盐罐子被另一个厨师拿着(有锁),他在等别人用完(等待获取锁)。
  • WAITING (无限等待):厨师把刀放下,坐在地上等,谁也不找,一直等到有人喊他(如调用了 wait()或 join())。
  • TIMED_WAITING (计时等待):厨师去抽烟,说“我只抽 5 分钟烟”(调用了 sleep(5)或 wait(5))。
  • 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。它们都在“跑道”上,只是有的在跑,有的在排队。
        把“线程不安全”拆解为三个核心问题来解答:
  • 原子性(Atomicity):操作被打断。
  • 可见性(Visibility):互相看不见对方的修改。
  • 指令重排序(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++看起来是一行代码,但在计算机底层其实是三步操作:

  • 读 (Read):从内存把 count的值拿出来。
  • 改 (Modify):在 CPU 里加 1。
  • 写 (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(万能药)

    它有两个作用:

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

    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 阻塞队列

    什么是阻塞队列?

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

  • 队列空了,取数据的线程会自动挂起(Wait)等着。
  • 队列满了,放数据的线程会自动挂起(Wait)等着。
  • 一旦条件满足(有数据了或腾出空间了),线程会自动唤醒。
  • 这就完美替代了我们在上一节讲的 wait和 notify!

    生产者-消费者模型示例 (使用 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,为什么要用池的方式解决,因为我们操作系统,也就是内核(这里的代码执行在内核态),上接应用程序(这里的代码执行在用户态),下接硬件

    平时使用 的操作系统分为

  • 内核(核心功能所在,比如进程管理操作)
  • 配套的应用程序
  • 当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​ 创建线程池。
    • 原因:默认的 FixedThreadPool和 SingleThreadExecutor使用无界队列,可能导致任务堆积过多引发 OOM(内存溢出);CachedThreadPool最大线程数为 Integer.MAX_VALUE,可能创建过多线程导致 OOM。

    四、 线程池的工作流程

  • 提交任务后,首先判断核心线程是否有空闲,有则直接执行。
  • 若核心线程都在忙,将任务加入阻塞队列等待。
  • 若队列已满,判断当前线程数是否小于最大线程数,是则创建非核心线程执行。
  • 若已达最大线程数且队列已满,触发拒绝策略。
  • 五、 线程池的关闭与监控

    1. 如何优雅关闭?

    • shutdown():不再接受新任务,等待已提交任务执行完毕。
    • shutdownNow():尝试中断正在执行的任务,并返回未执行的任务列表。

    2. 监控指标

    • 当前线程数、活跃线程数。
    • 已完成任务数、总任务数。
    • 队列中积压的任务数。

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

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

    1. “隔”:把共享资源隔离开(互斥锁)

    • 核心思想:大家都想改同一个东西(比如同一张银行卡余额),那我就不许你们同时改,必须排队!
    • 武器:synchronized、Lock。
    • 效果:同一时刻,只有一个线程能操作这个资源。

    2. “守”:守不住的时候就通知(等待-通知机制)

    • 核心思想:如果资源暂时不可用(比如库存为0了),你与其一直问“好了吗?好了吗?”(这就是 CPU 空转,浪费资源),不如你就乖乖睡觉,等资源好了我再叫你(wait/notify)。
    • 武器:wait()、notifyAll()。
    • 效果:避免无效的循环检查,节省 CPU 资源。

    3. “查”:查出那些看不见的脏读(可见性)

    • 核心思想:有的线程修改变量不告诉别人(因为 CPU 有缓存),导致别的线程还在看旧数据。我们要强制要求:改了就得立刻广播给全世界!
    • 武器:volatile。
    • 效果:保证大家看到的都是最新、最准的数据。

    10. 对比线程和进程

    10.1 线程的优点

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

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

  • 省钱(省内存):

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

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

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

    我们可以用“开公司”来打个比方:

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

    面试

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

    这句话怎么理解?

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

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

     

    3. 多线程-进阶

    1. 常见的锁策略

    1.1 乐观锁 vs 悲观锁(心态划分)

    乐观锁

    假设数据⼀般情况下不会产⽣并发冲突,所以在数据进⾏提交更新的时候,才会正式对数据是否产⽣ 并发冲突进⾏检测,如果发现并发冲突了,则让返回⽤⼾错误的信息,让⽤⼾决定如何去做
    即假设不会冲突的乐观 心态

    悲观锁

    总是假设最坏的情况,每次去拿数据的时候都认为别⼈会修改,所以每次在拿数据的时候都会上锁, 这样别⼈想拿这个数据就会阻塞直到它拿到锁。
    即假设下一秒就会冲突的悲观心态

    乐观锁与悲观锁是用来预测加锁之后的遇到锁竞争的概率

    维度
     

    悲观锁
     

    乐观锁
     

    开销​ 大(涉及上下文切换) 小(无阻塞)
    适用场景​ 写多读少 读多写少
    安全性​ 需要处理好重试逻辑

    1.2 重量级锁 vs 轻量级锁(加锁开销划分)

    轻量级锁

    完全在用户态通过 CAS 操作实现的锁,不依赖操作系统。
    缺点:如果锁被长时间占着,线程会一直循环(自旋),就会浪费CPU

    重量级锁

    依赖操作系统的 Mutex(互斥量​ 来实现的锁。
    缺点:好处虽然是当线程拿不到锁的时候,会被挂起(Blocked)放入等待队列,但是这需要从用户态到内核态的切换,开销成本大(大概需要几千个cpu周期

    1.3 自旋锁 (Spin Lock)VS挂起等待锁(等待策略划分)

    自旋锁

    线程获取锁失败后,不放弃 CPU,而是执行一个空循环(while循环),不断尝试获取锁。也就是忙等,
    优点: 不涉及线程上下文切换,响应快,适合临界区极短的场景
    缺点:浪费CPU资源

    挂起等待锁

    线程获取锁失败时,主动放弃 CPU,由操作系统将其挂起(阻塞),放入等待队列;当锁释放时,再由调度器唤醒。
    优点: 不浪费CPU,适合长时间持锁
    缺点:涉及上下文切换,开销大,相应会延迟
    结合等待队列和唤醒机制

    对比维度
     

    自旋锁
     

    挂起等待锁
     

    等待方式 忙等待(while) 阻塞等待(sleep)
    是否占用 CPU ✅ 是 ❌ 否
    是否上下文切换 ❌ 无 ✅ 有
    响应速度 较慢
    实现复杂度 简单 较复杂
    适用锁持有时间 极短 较长
    是否可用于中断上下文 ✅ 可 ❌ 不可
    典型实现 spinlock_t pthread_mutex_t

    举个例子
    自旋类似原地等待,等到到他了
    而挂起等待类似还没到他,就先走了过段时间再看有没有到他

    1.4 公平锁 vs 非公平锁(排队规则划分)

    公平锁(先来后到)

    • 规则:先来后到。线程在队列里排队,只有队首的线程能拿到锁。
    • 优点:不会“饥饿”,每个线程迟早能执行。
    • 缺点:性能低。因为要维护队列,且唤醒线程需要时间。
    • Java:ReentrantLock(true)。

    非公平锁(概率公平)

    • 规则:允许插队。线程来了不管队列里有没有人,先尝试抢锁,抢不到再进队列。
    • 优点:性能高。如果刚释放锁,刚好一个新线程来了,直接抢到,就不用唤醒队列里的老线程了。
    • 缺点:可能导致队列里的线程一直抢不到(饥饿)。
    • Java:synchronized和 ReentrantLock(false)默认都是非公平锁

    1.5 可重入锁 vs 不可重入锁(死锁防护角度)

    不可重入锁

    lock.lock();
    // … 业务逻辑
    lock.lock(); // 第二次加锁,死锁!

    可重入锁

    • 原理:锁内部记录持有者线程 ID和计数器。
      • 第一次加锁:计数器=1。
      • 第二次加锁(同一线程):计数器=2。
      • 解锁:计数器-1。计数器归0,锁才真正释放。
    • Java:synchronized和 ReentrantLock都是可重入的。

    1.6 读写锁VS普通锁(多读的角度)

    读写锁(可以多读,但写要排队

    • 核心规则:读读不互斥,读写互斥,写写互斥。
    • 原理:将读操作和写操作区分对待。多个线程同时读数据时不需要加锁互斥,只有当有线程要写数据时,才会与其他读写线程产生互斥。
    • 适用场景:非常适合“频繁读,不频繁写”的业务场景(例如:电商商品详情页、教务系统查成绩等)。
    • 注意点:Java标准库中的 synchronized不是读写锁,它无法区分读写操作进行优化。
    • 在synchronized中,不管读还是写都是互斥的,都需要排队

    场景
     

    能不能同时进行
     

    原因
     

    读 + 读 ✅ 可以 数据不会被改
    读 + 写 ❌ 不行 防止读到半成品
    写 + 写 ❌ 不行 防止数据覆盖

    1.7 synchronized原理

    特性

  • 开始时是乐观锁,当发生锁冲突频繁时,转为悲观锁
  • 开始是轻量级锁,当锁被持有的时间长了就转换成重量级锁
  • 实现轻量级锁的时候是用到自旋锁的策略,重量级锁的时候大概率是挂起等待锁
  • 不是公平的锁,线程不排队,是随机的
  • 是可重入锁,不会死锁
  • 不是读写锁,不能同时读
  • 加锁的过程(一步一步加深,自适应的过程)

    当锁升级后不会降级

  • 无锁(没有进入代码块)
  • Object obj = new Object();
    // 还没进入 synchronized

  • 偏向锁(进入synchronized代码块)
    此时没有真正加锁只是标记(开销很小),
    • 当没有线程竞争这把锁,就没有关系

    • 当有线程竞争这把锁,就会升级为自旋锁,就不会竞争而是等待(忙等)

    • 如果后续更多的锁等待,就升级为重量级锁,就不是等待而是挂起

    • (一个阈值,竞争线程的数量决定是升级为自旋锁还是重量级锁)

  • 自旋锁(轻量级锁)
    当另一个线程 B​ 尝试进入 synchronized:
    撤销偏向锁
    使用 CAS + 自旋​ 抢锁
    在栈帧中创建 Lock Record
    这就是自旋锁阶段
    • 线程 不挂起
    • 在用户态忙等
    • 适合锁持有时间极短
  • 重量级锁
    • 自旋失败
    • 竞争线程太多
    • 自旋时间过长
      升级为 重量级锁(Monitor)
    • 依赖 OS 的 互斥量(mutex)
    • 线程进入 阻塞 / 挂起状态
    • 涉及 上下文切换
      稳定、可靠,但是性能开销大

    无锁

    偏向锁(只有一个线程)
    ↓(发生竞争)
    轻量级锁(自旋 CAS)
    ↓(竞争激烈 / 自旋失败)
    重量级锁(OS 阻塞)

    1.8锁的粗化

    锁粗化就是 JVM 帮我们把细碎的锁合并成一把大的锁,目的是减少加锁/解锁的开销。

    // 没优化前:锁在循环里,每次循环都加锁解锁,非常耗时
    for (int i = 0; i < 1000; i++) {
    synchronized(this) {
    list.add(i);
    }
    }

    // 锁粗化后:JVM 会自动把锁移到循环外面,只加锁一次
    synchronized(this) {
    for (int i = 0; i < 1000; i++) {
    list.add(i);
    }
    }

    2. CAS

    2.1 什么是 CAS(循环等待精髓)

    CAS的全称Compare and swap,即比较并交换

    boolean CAS(address, expectValue, swapValue) {
    if (&address == expectedValue) {
    &address = swapValue;
    return true;
    }
    return false;
    }

    但这不是函数,这是一条指令,原子的,是最小单位,发生在内存和两个寄存器身上
    CPU提供了CAS指令
    由操作系统对CAS指令进行封装
    操作系统提供了API通过API使用CAS机制
    JVM就可以封装调用操作系统的API
    IDEA的代码就可以使用jvm的API

    通过CAS不是在禁止穿插,而是识别穿插,如果被穿插了就会进入循环,也就是自旋
    所以自旋锁(轻量级锁)内部靠的就是CAS,这样子就不必加重量级锁

    因为是原子性的,大不了循环几次

    2.2 CAS 是怎么实现的

    针对不同的操作系统,JVM⽤到了不同的CAS实现原理,简单来讲: • java的CAS利⽤的的是unsafe这个类提供的CAS操作;
    • unsafe的CAS依赖了的是jvm针对不同的操作系统实现的Atomic::cmpxchg;
    • Atomic::cmpxchg的实现使⽤了汇编的CAS操作,并使⽤cpu硬件提供的lock机制保证其原⼦ 性

    2.3 CAS 有哪些应用

  • 实现原⼦类
  • package thread;

    import java.util.concurrent.atomic.AtomicInteger;

    public class Demo36 {
    //不使用int使用原子类
    //private static int count=0;
    private static AtomicInteger count = new AtomicInteger(0);

    static void main(String[] args)throws InterruptedException {
    Thread t1=new Thread(()->{
    for(int i=0;i<100;i++){
    //count++;
    count.getAndIncrement();
    //++count;
    // count.incrementAndGet(); //count–; // count.getAndDecrement(); //–count; //count.decrementAndGet(); //count+=2; //count.addAndGet(2); }
    });
    Thread t2=new Thread(()->{
    for(int i=0;i<100;i++){
    //count++;
    count.getAndIncrement();
    }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println("count=" +count.get());
    }
    }

  • 实现⾃旋锁
  • 2.4 CAS 的 ABA 问题

    CAS不算特备的严禁,因为只是检查值首尾没有改变,不考虑中途的改变,即A->B->A

    问题的核心

    通过A的值相同判定是否插队
    于是约定了版本号的概念

    2.5 相关面试题

    CAS是什么

    定义:
    线程 A 读取到一个值 A,此时线程 B 把值从 A​ 改成了 B,随后又改回了 A。当线程 A 进行 CAS 检查时,发现值还是 A,误以为“期间没人动过”,于是 CAS 操作成功。
    关键点:

    CAS 只关心值变没变,不关心值在中间过程经历了什么。

    有什么应用场景(CAS如何实现的)

    CAS的ABA问题是什么样的

    提出版本号的概念

    3. synchronized 原理

    3.1 基本特点

    3.2 加锁工作过程

    3.3 其他的优化操作

    3.4 相关面试题

    4. JUC(java.util.concurrent) 的常见类

    4.1 Callable 接口

    类似Runnable,void run表示任务没有返回值

    package thread;

    public class Demo37 {
    private static int result = 0;

    static void main(String[] args)throws InterruptedException {
    //创建线程,让这个线程计算1+到1000
    //主线程在这个计算线程执行完毕后,输出线程计算的结果
    Thread t1=new Thread(new Runnable(){

    @Override
    public void run() {
    int sum=0;
    for(int i=0;i<1000;i++){
    sum+=i;
    }
    result = sum;
    }
    });
    t1.start();
    t1.join();
    System.out.println(result);

    }
    }

    Callable接口,是call方法,有一个返回值
    callable避免出现成员变量,尽可能不蒙圈,这是和Runnable的区别
    callable搭配FutureTask使用

    package thread;

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

    public class Demo38 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
    Callable<Integer> callable = new Callable<Integer>() {
    @Override
    public Integer call() throws Exception {
    int sum = 0;
    for(int i=0;i<1000;i++){
    sum+=i;
    }
    return sum;
    }
    };
    //对于Thread来说,无法接受Callable对象作为参数
    //此处搭配FutureTask使用
    //可以理解成,通过这个对象获取到未来的结果,call方法可以认为是加工麻辣烫的过程,煮之前会发号码牌
    //其实这是Thread的run方法就会调用FutureTask的run方法
    FutureTask<Integer> futureTask = new FutureTask<>(callable);
    Thread thread = new Thread(futureTask);
    thread.start();
    //get方法拿到call的返回值
    //如果call没有执行完,get会阻塞等待!!
    System.out.println(futureTask.get());

    }
    }

    4.2 ReentrantLock

    是可重入锁,可以说是sychronized的老版本,上古时期用的,现如今synchronized代替ReentrantLock
    可以说是锁的经典用法了
    lock和unlock之间可能有returne,throws

    此外,

  • 还支持trylock
    lock行为加锁不成就会阻塞的等待
    trylock行为加锁不成们可以直接放回(放弃)也支持指定超时时间
  • 支持公平锁
    按照先来后到方式,锁内部记录各个线程的加锁顺序,按照顺序从队列中取确保拿到的锁的线程是最早申请的线程
  • 等待通知机制
    synchronized是搭配wait notify 使用 notify唤醒的是随机一个
    ReetrantLock搭配Condition类使用,可以指定唤醒
  • package thread;

    import java.util.concurrent.ExecutionException;
    import java.util.concurrent.locks.ReentrantLock;

    public class Demo39 {
    private static int count=0;
    public static void main(String[] args) throws InterruptedException, ExecutionException {
    ReentrantLock lock = new ReentrantLock();
    Thread t1=new Thread(()->{
    for(int i=0;i<1000;i++){
    try{
    if( lock.tryLock()){
    count++;
    }else{
    //放弃
    }
    }finally{
    lock.unlock();}
    }
    });
    Thread t2=new Thread(()->{
    for(int i=0;i<1000;i++){
    lock.lock();
    count++;
    lock.unlock();
    }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(count);
    }
    }

    //解决的是同一个问题(count++),但“拿不到资源时的态度”完全不同。
    // 方式1:CAS(自旋,不睡觉)
    while (!atomicCount.compareAndSet(old, old + 1));

    // 方式2:lock()(睡觉排队)
    lock.lock(); count++; lock.unlock();

    // 方式3:tryLock()(不排队,直接走)
    if (lock.tryLock()) {
    try { count++; } finally { lock.unlock(); }
    }

    技术
     

    拿不到资源怎么办?
     

    形象比喻
     

    CAS / AtomicInteger​ 原地转圈圈重试(自旋) 你站在奶茶店门口一直问:“好了没?好了没?”
    lock()​ 去旁边睡觉排队(阻塞) 你拿个号,去座位上玩手机,叫号再回来
    tryLock()​ 直接走人​ 看一眼人多,扭头去喝咖啡了
         

    4.3信号量Semaphore

    在操作系统会提到,这是Java把操作系统提供的机制进行进一步的封装
    信号量就是技术器,描述可用资源,类似商场停车位的显示信号
    P操作是申请资源
    V操作是释放资源
    当计数器为零,还申请资源P操作,就会触发阻塞
    本质上操作计数器的值是原子的过程
    信号量和锁
    P操作1->0,加锁
    V操作,0->,解锁

    package thread;

    import java.util.concurrent.ExecutionException;
    import java.util.concurrent.Semaphore;

    public class Demo40 {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
    Semaphore semaphore = new Semaphore(4);
    semaphore.acquire();
    System.out.println("进行P操作用的aquire方法");
    semaphore.acquire();
    System.out.println("进行P操作用的aquire方法");
    semaphore.acquire();
    System.out.println("进行P操作用的aquire方法");
    semaphore.acquire();
    System.out.println("进行P操作用的aquire方法");
    semaphore.release();
    System.out.println("V操作用的release");
    semaphore.release();
    System.out.println("V操作");
    }
    }

    //这样子也可以解决线程安全问题
    package thread;

    import java.util.concurrent.Semaphore;

    public class Demo41 {
    private static int count=0;
    public static void main(String[] args) throws InterruptedException {
    Semaphore semaphore = new Semaphore(1);
    Thread t1 = new Thread(()->{
    for(int i=0;i<10;i++){
    try {
    semaphore.acquire();
    count++;
    semaphore.release();
    } catch (InterruptedException e) {
    throw new RuntimeException(e);
    }

    }
    });
    Thread t2 = new Thread(()->{
    for(int i=0;i<10;i++){
    try {
    semaphore.acquire();
    count++;
    semaphore.release();
    } catch (InterruptedException e) {
    throw new RuntimeException(e);
    }
    }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(count);
    }
    }

    解决线程安全问题的方式多种

  • 调整代码结构,避免多个线程修改同一个变量
  • synchronized
  • ReentrantLock
  • Semaphore
  • CAS/原子类
  • 4.4countdownlatch

    闭锁,原子变量,怎么翻译都不对
    大概就是大的任务=》拆成多个小任务=》所有小的任务执行完,整个任务才算完
    用百米跑步的例子

    package thread;

    import java.util.concurrent.CountDownLatch;

    public class Demo42 {
    public static void main(String[] args) throws InterruptedException {
    CountDownLatch countDownLatch = new CountDownLatch(8);
    //这个跑道八个人跑
    for (int i = 0; i < 8; i++) {
    int id = i;
    Thread thread = new Thread(() -> {
    System.out.println("运动员"+id+"开始");
    try {
    Thread.sleep(2000);
    } catch (InterruptedException e) {
    throw new RuntimeException(e);
    }
    System.out.println("运动员"+id+"到达终点");
    //“撞线操作”latch中保存的计数值-1
    countDownLatch.countDown();
    });
    thread.start();
    }
    //主线程中,如何判定所有的运动员到终点
    //通过await进行等待,等待过程中,当前线程阻塞
    //await==all wait
    countDownLatch.await();
    System.out.println("比赛结束");

    }
    }

    5. 线程安全的集合类

    Vector,Hashtable ,Stack,StringBuffer,核心方法都有synchronized关键字,即线程安全

    5.1 多线程环境使用 ArrayList

    线程不安全

    5.2 多线程环境使用队列

    线程安全

    使用时拷贝

    这是一种解决线程安全的方法,不推荐
    因为只适合小数据,在修改数据的时候是先复制一份,然后再副本修改,副本盖住原来的数据

    5.3 多线程环境使用哈希表

    1.ConcurrentHashMap

    将每个链表的头节点作为锁对象,hash是挂着多个链表
    多个线程竞争同意把锁才会冲突,就是在同一个链表才会冲突

    2.ConcurrentHashMap使用CAS的方式对size进行修改(避免加锁)

    3.ConcurrentHashMap扩容场景

    hash表扩容是创建更大的数组,原hash表的每个元素都查到新的hash表数组中,这会花大量时间
    所以是分成多次完成,所以ConcurrentHashMap会同时维护两个数组

    5.4 相关面试题

    6. 死锁

    6.1 死锁是什么

    6.2 如何避免死锁

    6.3 相关面试题

    7. 其他常见问题

     

    赞(0)
    未经允许不得转载:171主机测评 » 2,多线程,并发编程
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址