深入解析wait与notify及多线程实战案例

一、引言

本期我们将探讨 waitnotify 方法的应用,并结合多线程典型案例进行分析,包括饿汉模式与懒汉模式的实现。此外,还将深入讲解阻塞队列的生产者-消费者模型、线程池的使用以及定时器的实现原理。下面让我们正式开始本期内容。

二、wait AND notify

在多线程开发中,不仅仅只是让线程并发执行,很多场景下我们需要线程之间互相配合、互相通信,一个线程等待、另一个线程唤醒,这就需要用到 Object 类中的 wait、notify、notifyAll 方法。

首先大家一定要记住一个硬性规则:wait、notify、notifyAll 必须在 synchronized 同步代码块中使用,必须持有当前对象的锁,否则会直接抛出异常。

我给大家通俗解释一下三个方法的作用:

wait() :让当前线程进入等待状态,并且主动释放当前持有的锁,让其他线程可以抢到锁执行代码,直到被其他线程唤醒。

notify():随机唤醒一个正在等待该对象锁的线程。

notifyAll():唤醒所有正在等待该对象锁的线程。

这里有个重点区别:sleep 和 wait 完全不一样。sleep 只是让线程休眠,不会释放锁;而 wait 会释放锁,这也是线程之间能够协作的核心原因。

简单总结使用流程:

一个线程拿到锁后,判断条件不满足,调用 wait 进入等待并释放锁;另一个线程抢到锁,修改条件后,调用 notify 唤醒等待线程,两个线程完美配合,这就是最基础的线程通信模型,也是后面阻塞队列、生产者消费者模型的底层原理。

三、单例模式

单例模式是我们开发中最常见的设计模式,核心目的就是:保证整个程序中,一个类只有一个实例对象,全局唯一。主要分为饿汉模式和懒汉模式,两种模式在单线程和多线程下表现完全不同。

3.1 饿汉模式

饿汉模式的特点就是:类加载的时候就直接创建对象,不管你后续用不用,对象提前造好。

这种方式最大的优点就是天然线程安全,因为对象在类加载阶段就已经初始化完成,不存在多线程竞争创建的问题。代码如下

java 复制代码
//饿汉单例,单例是指一个类只能实例化一个对象。
class sing{
    private static sing instance = new sing();
    private sing(){}
    public static sing getInstance(){
        return instance;
    }


}

缺点也很明显:如果这个对象很大、全程不用,也会提前占用内存,造成内存浪费。适合小对象、常驻内存的场景。

3.2 懒汉模式

懒汉模式和饿汉模式刚好相反,核心思想是懒加载:不用不创建,第一次使用的时候才创建实例,节省内存空间。

3.2.1 懒汉单线程

在单线程环境下,懒汉模式完全没问题。每次获取实例先判断对象是否为空,为空就创建,不为空直接返回,保证全局只有一个对象,代码简单、效率高。

3.2.2 懒汉多线程

一旦到了多线程环境,普通懒汉模式就会出现严重的线程安全问题。

当多个线程同时判断对象为空时,所有线程都会进入创建对象的逻辑,最终会创建多个实例,彻底违背单例的设计初衷。

解决办法就是我们经典的 DCL 双重校验锁懒汉单例:配合 synchronized 加锁、双重判断、volatile 禁止指令重排序,既保证了线程安全,又保证了懒加载,同时效率也很高,是工作中最常用的单例写法。下面是代码

java 复制代码
class lucy{
    private static volatile lucy instance = null;
    private lucy(){}
    public  static lucy getInstance(){
        if(instance == null){
            synchronized (sing.class){
                if(instance == null){
                    instance = new lucy();
                }
            }
        }

        return instance;
    }
}

四、阻塞队列(BlockingDeque)

阻塞队列是多线程开发中非常核心的工具,普通队列就是正常存、正常取,而阻塞队列最大的特点就是自带阻塞特性,完美适配多线程并发场景。

核心规则:

队列空了,消费者线程取数据会阻塞等待;队列满了,生产者线程放数据会阻塞等待。

4.1 生产者消费者模型

生产者消费者模型是阻塞队列最经典的应用场景,也是面试高频考点。

简单理解:生产者线程负责往队列里存数据,消费者线程负责从队列里取数据处理。

通过阻塞队列解耦了生产和消费,让生产速度和消费速度自动平衡,不会因为生产太快溢出、消费太快空取报错。底层完全依靠我们前面讲的 wait、notify 实现线程等待与唤醒。

下面是代码实现生产者消费者模型

java 复制代码
public class Test9 {
    public static void main(String[] args) throws InterruptedException {
        BlockingDeque<Integer> queue = new LinkedBlockingDeque<>();
        Thread t1=new Thread(()->{
            while(true){

                try {
                    int val=queue.take();
                    System.out.println("消费"+val);
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

            }



        });
        t1.start();
        Thread t2=new Thread(()->{
            while(true){
                int i=new Random().nextInt(1000);
                try {

                    System.out.println("生产"+i);
                    queue.put(i);
                    //Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }




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

4.2 阻塞队列的手动实现

为了吃透原理,我们可以手动实现一个简易阻塞队列。

基于数组结合 wait、notify 机制:队列满时,生产者 wait 阻塞;队列空时,消费者 wait 阻塞。新增数据唤醒消费者,取出数据唤醒生产者。

java 复制代码
//模拟实现阻塞队列
class MyBlockingQueue{
    private int[] index=new int[100];
    private int size;
    private int head;
    private int tail;

    public void put(int n) throws InterruptedException{
        synchronized (this){
            //添加的时候反复判断队列是不是满了
            while (size==index.length){
                wait();
            }
            index[tail]=n;
            tail=(++tail)%index.length;
            size++;
            notifyAll();
        }

    }
    public int take() throws InterruptedException{
        int n;

        synchronized (this){
            while (size==0){
                wait();
            }
             n=index[head];
            head=(head+1)%index.length;
            size--;

            notifyAll();
        }

        return n;

    }
public int getSize(){
    
        return size;
}

五、线程池

在没有线程池之前,我们是用到线程就 new,用完就销毁。但是线程的创建和销毁是非常消耗系统资源的,频繁创建销毁线程会严重降低程序性能。

所以线程池的核心思想就是:提前创建好一批线程,统一管理、重复复用,任务来了直接分配线程执行,任务结束线程不销毁,等待下一个任务,极大减少资源开销,提高并发效率。

5.1 ThreadPoolExecutor

Java里为我们提供了ThreadPoolExecutor的类一共有七个参数,七7 个核心参数,每一个参数都决定了线程池的运行规则、任务处理策略和资源占用情况,下面我用通俗的方式逐一拆解,保证大家一看就懂。在讲解之前先来一段代码演示一下使用

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

public class TestPool {
    public static void main(String[] args) {
        // 手动七大参数创建线程池
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                2,                     // 核心线程
                5,                     // 最大线程
                3L, TimeUnit.SECONDS,  // 非核心空闲时间3秒
                new ArrayBlockingQueue<>(10), // 阻塞队列容量10
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy() // 拒绝策略:抛异常
        );

        // 提交5个任务
        for (int i = 0; i < 5; i++) {
            pool.submit(() -> {
                System.out.println(Thread.currentThread().getName() + "正在执行任务");
            });
        }

        pool.shutdown();
    }
}

5.1.1. corePoolSize(核心线程数)

核心线程是线程池里的常驻线程,不会被轻易回收。线程池初始化时没有线程,任务提交后才会逐步创建线程,当线程数量小于核心线程数时,无论任务多少,都会持续创建核心线程。即使任务执行完毕,核心线程也会保留在线程池中,等待后续任务,保障响应速度。

5.1.2. maximumPoolSize(最大线程数)

代表线程池能容纳的最大线程总数。当核心线程全部忙碌、任务队列也已满,无法承载新任务时,线程池会创建非核心线程处理任务。最终线程总数不会超过 maximumPoolSize,这也是线程池控制最大并发量的关键参数。

5.1.3. keepAliveTime(空闲存活时间)

专门作用于非核心线程。非核心线程处理完任务后,不会立刻销毁,会等待指定的 keepAliveTime 时长。如果这段时间内没有新任务分配,非核心线程就会被自动销毁,释放系统资源;如果有新任务,则继续复用线程。

5.1.4. unit(时间单位)

配合 keepAliveTime 使用,用于指定空闲时间的时间单位,支持毫秒、秒、分钟、小时等,常用的是 TimeUnit.SECONDS(秒)、TimeUnit.MILLISECONDS(毫秒)。

5.1.5. workQueue(任务阻塞队列)

用来存储等待执行的任务,必须是阻塞队列。当所有核心线程都在忙碌时,新提交的任务会进入该队列排队,队列满了之后,才会触发非核心线程的创建。常用队列有 ArrayBlockingQueue、LinkedBlockingQueue 等。

5.1.6. threadFactory(线程工厂)

专门用来创建线程的工厂类,我们可以通过它自定义线程的名称、优先级、是否为守护线程等。规范的线程工厂可以方便后续日志排查、线程问题定位,是实际开发中必备的配置。这里和单例模式一样是另一种模式叫工厂模式工厂模式是 Java 最常用的创建型设计模式 ,核心思想就一句话: **统一用 "工厂类" 造对象,不直接 new 对象。**下面用代码来更直观的来感受一下工厂模式

java 复制代码
// 1.抽象产品:手机父类
abstract class Phone {
    public abstract void showBrand();
}

// 2.具体产品:华为手机
class HuaWei extends Phone{
    @Override
    public void showBrand() {
        System.out.println("生产华为手机");
    }
}

// 具体产品:小米手机
class XiaoMi extends Phone{
    @Override
    public void showBrand() {
        System.out.println("生产小米手机");
    }
}

// 3.工厂类:唯一工厂,负责所有手机实例创建【简单工厂核心】
class PhoneFactory{
    // 根据标识生产对应手机
    public static Phone createPhone(String type){
        if("huawei".equalsIgnoreCase(type)){
            return new HuaWei();
        }else if("xiaomi".equalsIgnoreCase(type)){
            return new XiaoMi();
        }
        return null;
    }
}

// 测试客户端
public class Test {
    public static void main(String[] args) {
        // 客户端只找工厂拿对象,不出现new HuaWei()、new XiaoMi()
        Phone p1 = PhoneFactory.createPhone("huawei");
        Phone p2 = PhoneFactory.createPhone("xiaomi");
        p1.showBrand();
        p2.showBrand();
    }
}

5.1.7. handler(拒绝策略)

这是线程池的兜底机制。当核心线程满、任务队列满、最大线程数也满,线程池已经没有能力处理新任务时,就会触发拒绝策略。JDK 内置了四种拒绝策略,分别是直接抛出异常、丢弃最新任务、丢弃最旧任务、主线程自行执行任务,可根据业务场景灵活选择。以下是四个策略
AbortPolicy(): 超过负荷, 直接抛出异常.
CallerRunsPolicy(): 调⽤者负责处理多出来的任务.
DiscardOldestPolicy(): 丢弃队列中最⽼的任务.
DiscardPolicy(): 丢弃新来的任务

5.2 Executors 快速创建线程池

上面我们学的 ThreadPoolExecutor 是原生底层构造方式,参数多、配置灵活,但写起来比较繁琐。所以 JDK 官方给我们封装了一个工具类 Executors ,它相当于打包好的线程池工具,帮我们提前预设好七大参数,开发者只需一行代码就能快速创建线程池,使用非常简单。

Executors 最常用的一共有 四种线程池,下面我逐个带大家看懂用法、底层原理和适用场景。先用一段代码来给大家直观的演示使用方法。

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

public class ExecutorsTest {
    public static void main(String[] args) {
        //1.固定线程池
        ExecutorService fixed = Executors.newFixedThreadPool(3);
        //2.缓存线程池
        ExecutorService cache = Executors.newCachedThreadPool();
        //3.单一线程池
        ExecutorService single = Executors.newSingleThreadExecutor();
        //4.定时线程池
        ScheduledExecutorService schedule = Executors.newScheduledThreadPool(2);

        //提交任务测试
        fixed.submit(()-> System.out.println("固定池执行任务"));
        cache.submit(()-> System.out.println("缓存池执行任务"));
        single.submit(()-> System.out.println("单线程池执行任务"));
        //延迟1秒执行
        schedule.schedule(()-> System.out.println("定时任务执行"),1,TimeUnit.SECONDS);

        fixed.shutdown();
        cache.shutdown();
        single.shutdown();
        schedule.shutdown();
    }
}
5.2.1. newFixedThreadPool(固定数量线程池)

特点:线程数量固定,不会新增、不会销毁,属于常驻线程池。

底层原理:核心线程数=自定义固定值,最大线程数=核心线程数,无空闲回收时间,队列使用无界阻塞队列。

适用场景:任务量稳定、长期并发执行的业务,保证并发数可控。

java 复制代码
// 创建固定3个线程的线程池
ExecutorService pool = Executors.newFixedThreadPool(3);
5.2.2. newCachedThreadPool(缓存线程池)

特点:线程数量不固定,可无限扩容,线程空闲60秒自动回收。

底层原理:核心线程数=0,最大线程数=Integer.MAX_VALUE,空闲存活60秒,队列为同步队列。

适用场景:大量短时、瞬时突发任务,线程随用随创建、空闲自动回收。

java 复制代码
ExecutorService pool = Executors.newCachedThreadPool();
5.2.3. newSingleThreadExecutor(单线程线程池)

特点:池子里永远只有一个线程,所有任务串行执行,保证任务执行顺序。

底层原理:核心线程数、最大线程数都为1,无空闲回收时间。

适用场景:需要保证任务有序执行、串行执行的业务场景。

java 复制代码
ExecutorService pool = Executors.newSingleThreadExecutor();
5.2.4. newScheduledThreadPool(定时线程池)

特点:支持延时执行、周期性定时执行任务,替代传统Timer定时器。

适用场景:定时任务、延迟任务、循环执行的业务。

java 复制代码
// 创建核心线程数为2的定时线程池
ScheduledExecutorService pool = Executors.newScheduledThreadPool(2);

5.2.5 重点:为什么阿里规范禁止使用 Executors?

虽然 Executors 使用简单,但工作和面试中都不推荐使用 ,核心原因就是隐藏风险太大

  1. newFixedThreadPool / newSingleThreadExecutor:使用无界队列,任务无限堆积,最终会导致 OOM 内存溢出。

  2. newCachedThreadPool:最大线程数无限大,高并发下会创建海量线程,导致 CPU 爆满、程序卡死。

总结一句话:Executors 是简化版线程池,封装了参数、隐藏了风险;ThreadPoolExecutor 是原生完整版,参数可控、安全稳定,生产环境必须手动使用 ThreadPoolExecutor!

5.3 模拟实现线程池

我们可以手动模拟一个简易线程池,核心结构就两个:工作线程 + 任务队列

程序启动预先创建固定数量的工作线程,所有线程循环去任务队列里取任务执行。当没有任务时,线程阻塞等待;当提交新任务,唤醒线程执行任务。

通过手写线程池,我们就能理解线程池的核心原理:线程复用、任务缓冲、统一管控,这也是为什么工作中严禁手动 new Thread 的原因。下面是代码实现

java 复制代码
class MyThreadpool{
    //需要一个阻塞队列来实现线程池,用来放任务接受任务
private BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();
public void submit(Runnable r) throws InterruptedException{
    //添加任务
    queue.put(r);
}
//指定创建线程的数量
public MyThreadpool(int n) throws InterruptedException{
    for(int i=0;i<n;i++){
        Thread t=new Thread((()->{
            //把阻塞队列的任务取出来并执行
            Runnable runnable= null;
            try {
                runnable = queue.take();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            runnable.run();

        }));
        t.start();
    }


}

}

六、定时器

在日常开发中,我们经常需要处理延时执行或定时循环执行的任务需求,这就需要使用定时器来实现。Java 提供了内置的 Timer 定时器类,它是 Java 标准库中用于调度任务执行的一个简单工具。

Timer 的基本使用方式是通过创建一个 Timer 实例,然后调用其 schedule() 或 scheduleAtFixedRate() 方法来安排任务。这些任务被封装在 TimerTask 对象中,需要开发者重写其 run() 方法来实现具体的业务逻辑。例如:

java 复制代码
Timer timer = new Timer();
timer.schedule(new TimerTask() {
    @Override
    public void run() {
        System.out.println("定时任务执行:" + new Date());
    }
}, 1000, 2000); // 延迟1秒后执行,之后每隔2秒执行一次

6.1 模拟实现定时器

我们可以手写一个简易定时器,核心思路:创建一条专属定时线程,维护一个有序的定时任务队列。线程不断轮询判断任务是否到执行时间,到点就自动执行任务,没到时间就阻塞等待。

手写完成后可以清晰理解定时器的底层逻辑:本质就是延时阻塞 + 任务轮询调度。下面是具体的代码实现。

java 复制代码
class MyTask implements Callable<MyTask> {
    public Runnable r;
    public long time;



public MyTask(Runnable r, long time){
        this.r = r;
        this.time=time;
}

    public int compareTo(MyTask o) {
       return (int)(this.time-o.time);
    }

    public long getTime(){
        return time;
    }
    public void run(){
        r.run();
    }

    @Override
    public MyTask call() throws Exception {
        return null;
    }
}
class Mytimer {

    private PriorityQueue<MyTask> queue=new PriorityQueue<>();
    public void schedule (Runnable task,long time){
        synchronized (this){
            MyTask task1=new MyTask(task,System.currentTimeMillis()+time);
            //*添加时间进入队列
            queue.offer(task1);
            this.notify();
        }


    }
    public Mytimer(){
        Thread t1=new Thread(()->{
            try {
                while(true){
                    synchronized (this){
                        while(queue.isEmpty()){
                            this.wait();
                        }
                        MyTask task=queue.peek();
                        if(System.currentTimeMillis()<task.getTime()){
                            this.wait(task.getTime()-System.currentTimeMillis());
                        }else {
                            task.run();
                            queue.poll();
                        }
                    }
                }
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        });
        t1.start();
    }
    
}

七、总结

这期内容我们从头到尾梳理了多线程进阶一系列重点内容,知识点由底层往上层逐步延伸。

最先学习了 wait、notify 线程等待唤醒机制,分清了 wait 和 sleep 的关键差别,wait 会释放锁也是后续阻塞队列能够实现的根基。紧接着聊了单例模式,饿汉加载即创建天然线程安全但容易浪费内存,普通懒汉单线程没问题,多线程环境下会创建多个对象,依靠 DCL 双重校验 + volatile 就能写出安全的懒汉单例。

之后围绕阻塞队列展开,搞懂了空阻塞、满阻塞的特性,借助阻塞队列实现生产者消费者模型,还自己手写简易阻塞队列,落地了 wait 和 notify 的实际用法。

线程池是本篇重中之重,先吃透 ThreadPoolExecutor 七个参数、四种拒绝策略,顺带了解线程工厂也就是简单工厂设计模式;又学习 Executors 提供的四种快捷创建线程池方式,清楚四种池子各自特点,同时记住阿里规范,生产禁用 Executors,优先手动创建 ThreadPoolExecutor,最后手写简易线程池理解线程复用原理。

末尾学习 Timer 定时器用法,手动自研定时器,明白底层靠优先级队列 + 线程延时等待完成任务调度。

整套知识点串联下来,从底层线程通信,到设计模式、并发模型再到开发常用工具,都是面试和日常开发高频考点,吃透之后对多线程的使用会有更清晰的认知。

相关推荐
Flittly3 分钟前
【AgentScope Java新手村系列】(10)实战-多Agent天气助手
java·spring boot·spring
李少兄11 分钟前
从原理到实战:Spring IoC/DI 核心知识体系与高频面试题全解
java·后端·spring
飞天狗11135 分钟前
零基础JavaWeb入门——第五课第二小节:九大内置对象 · 第2个:response(响应对象)
java·开发语言
许彰午42 分钟前
39_Java单元测试JUnit入门
java·junit·单元测试
shushangyun_43 分钟前
2026年快消品B2B系统推荐:支持终端门店订货、促销政策自动化的工具?
java·运维·网络·数据库·人工智能·spring·自动化
JAVA9651 小时前
JAVA面试-JVM篇 03-JVM运行时数据区哪些是线程私有的哪些是共享的
java·jvm·面试
于先生吖1 小时前
教育类Java实战项目:在线错题整理平台分层架构设计与接口源码解析
java·开发语言
慧一居士1 小时前
Feign的GET请求如何传递对象参数?
java·spring cloud
开发小能手-roy2 小时前
Java集合框架选型指南:从ArrayList到ConcurrentSkipListMap
java·开发语言
凡人叶枫2 小时前
Effective C++ 条款41:了解隐式接口和编译期多态
java·开发语言·c++·effective c++