多线程入门

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

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

特别容易混淆的点:Runnablevs 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();
    }
}

总结这一节的知识点:

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

  2. 怎么解决?

    • 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)

什么是阻塞队列?

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

  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.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 和内存)。线程池的思路是:预先创建好一堆线程放在池子里,用完了不还,而是放回池子,下次再用。

三大好处:

  1. 降低消耗:避免了频繁创建和销毁线程的开销。
  2. 提高响应:任务来了,直接从池子里拿现成的线程用,不用等创建。
  3. 可控管理:可以控制电脑最多跑多少个线程,防止卡死。
标准库中的线程池 (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. "隔":把共享资源隔离开(互斥锁)
  • 核心思想:大家都想改同一个东西(比如同一张银行卡余额),那我就不许你们同时改,必须排队!
  • 武器synchronizedLock
  • 效果:同一时刻,只有一个线程能操作这个资源。
2. "守":守不住的时候就通知(等待-通知机制)
  • 核心思想:如果资源暂时不可用(比如库存为0了),你与其一直问"好了吗?好了吗?"(这就是 CPU 空转,浪费资源),不如你就乖乖睡觉,等资源好了我再叫你(wait/notify)。
  • 武器wait()notifyAll()
  • 效果:避免无效的循环检查,节省 CPU 资源。
3. "查":查出那些看不见的脏读(可见性)
  • 核心思想:有的线程修改变量不告诉别人(因为 CPU 有缓存),导致别的线程还在看旧数据。我们要强制要求:改了就得立刻广播给全世界!
  • 武器volatile
  • 效果:保证大家看到的都是最新、最准的数据。

10. 对比线程和进程

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

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. 底层原理(进程与线程的区别)。
相关推荐
星夜夏空991 小时前
FreeRTOS学习(7)——任务列表
java·前端·学习
Navigator_Z1 小时前
LeetCode //C - 1073. Adding Two Negabinary Numbers
c语言·算法·leetcode
han_hanker1 小时前
BeanUtils.copyProperties 和序列化的问题
java·开发语言·spring boot
醇氧1 小时前
【OpenClaw】更换阿里百炼完整配置指南
算法·ai
野生技术架构师1 小时前
牛客网2026互联网大厂Java面试题汇总,附官方级答案解析
java·开发语言
Tina学编程2 小时前
[HOT100]每日一练------最长连续序列
算法·hot 100
Oo9202 小时前
Prompt工程核心与Python 字典
python·ai编程
csdn_aspnet2 小时前
PHP 算法 LeetCode 编号 70 - 爬楼梯
算法·leetcode·php
feeday2 小时前
gpt4o 图像反推提示词
开发语言·人工智能·python