JavaEE初阶——多线程(5)单例模式和阻塞队列

目录

一、单例模式

[1.1 单例模式的概念](#1.1 单例模式的概念)

[1.2 单例模式的实现(饿汉模式)](#1.2 单例模式的实现(饿汉模式))

[1.3 懒汉模式------单线程](#1.3 懒汉模式——单线程)

[1.4 懒汉模式------多线程](#1.4 懒汉模式——多线程)

[1.4.1 给内层上锁](#1.4.1 给内层上锁)

[1.4.2 给外层上锁](#1.4.2 给外层上锁)

[1.5 双重检查锁DCL(Double Check Lock)](#1.5 双重检查锁DCL(Double Check Lock))

二、阻塞队列

[2.1 阻塞队列的概念](#2.1 阻塞队列的概念)

[2.2 生产者消费者模型](#2.2 生产者消费者模型)

[2.3 使用JDK的类创建阻塞队列](#2.3 使用JDK的类创建阻塞队列)

[2.4 阻塞队列的应用场景](#2.4 阻塞队列的应用场景)

[2.4.1 解耦](#2.4.1 解耦)

[2.4.2 削峰填谷](#2.4.2 削峰填谷)

[2.4.3 异步操作](#2.4.3 异步操作)

[2.5 模拟实现](#2.5 模拟实现)

[2.6 生产者消费者代码实现](#2.6 生产者消费者代码实现)


一、单例模式

1.1 单例模式的概念

单例模式是最常考的设计模式之一

设计模式是什么?

设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性。

设计模式就好比下棋时的棋谱,针对对方的走法,我们有一些前人总结的套路,按照特定套路来走就不会吃亏

在软件开发中同样也是,我们会遇到很多常见的"问题场景",针对这些问题场景,大佬们总结了一些固定套路,按照这个套路来实现代码,通常也不会吃亏

我们这里要学习的单例模式,顾名思义就是保证某个类在程序中只存在唯一一份实例对象,而不会创建出多个实例。

那我们该如何保证多个程序员合作写代码的时候不会创建新的实例对象呢?

❌:我们不能靠口头约束,让大家不要new对象,这样是十分不靠谱的。

✅:所以我们选择通过语言自身的语法约束,通过限制一个类只能被实例化一个对象,把限制过程交给程序,按照程序的逻辑执行,只要程序代码可以保证是单例,那么执行后一定是个单例。

1.2 单例模式的实现(饿汉模式)

想要实现单例类,只需要定义一个static修饰的变量 ,就可以保证这个变量全局唯一

java 复制代码
public class SingletonHungry {
    // 定义一个类的成员变量,用static修饰,保证全局唯一
    private static SingletonHungry instance = new SingletonHungry();
}

private:防止外部对这个变量修改

static:修饰过后,保证全局唯一

instance实例对象此时已经全局唯一了

new SingletonHungry()类被加载到JVM中的时候,就会实例化这个变量

java 复制代码
public class Demo_601 {
    public static void main(String[] args) {
        // 获取实例
        SingletonHungry instance1 = new SingletonHungry();
        System.out.println(instance1.getInstance());

        // 获取实例
        SingletonHungry instance2 = new SingletonHungry();
        System.out.println(instance2.getInstance());

        // 获取实例
        SingletonHungry instance3 = new SingletonHungry();
        System.out.println(instance3.getInstance());
}

我们获取三次对象,打印对象显示,三个对象都是相同的,我们确实实现了单例模式

但是我们观察,此时获取对象还是依靠new SingletonHungry() 这样的方法,虽然此时返回的是同一个对象,已经实现了单例,但是代码书写有歧义

所以我们可以创建一个方法来获取该对象

java 复制代码
public class SingletonHungry {
    // 定义一个类的成员变量,用static修饰,保证全局唯一
    private static SingletonHungry instance = new SingletonHungry();

    /**
     * 提供一个公开的方法返回instance对象
      */
    public static SingletonHungry getInstance () {
        // 返回全局唯一的对象
        return instance;
    }
}

代码中我们创建了一个getInstance()方法,返回值为该对象

java 复制代码
public class Demo_601 {
    public static void main(String[] args) {
        // 获取第一个实例
        SingletonHungry instance1 = SingletonHungry.getInstance();
        System.out.println(instance1);
        // 获取第二个实例
        SingletonHungry instance2 = SingletonHungry.getInstance();
        System.out.println(instance2);
        // 获取第三个实例
        SingletonHungry instance3 = SingletonHungry.getInstance();
        System.out.println(instance3);
    }
}

我们通过调用这个方法来获取单例对象

打印对象,仍然是同一个对象,而且我们消除了new的歧义

我们现在希望用户只能通过getInstance方法获得对象,而不能使用new构造方法,我们通过构造方法私有化来实现

java 复制代码
public class SingletonHungry {
    // 定义一个类的成员变量,用static修饰,保证全局唯一
    private static SingletonHungry instance = new SingletonHungry();

    // 构造方法私有化
    private SingletonHungry() {}

    /**
     * 提供一个公开的方法返回instance对象
      */
    public static SingletonHungry getInstance () {
        // 返回全局唯一的对象
        return instance;
    }
}

我们通过private SingletonHungry() {}实现了构造方法私有化

此时再使用new构造方法则报错

java 复制代码
private static SingletonHungry instance = new SingletonHungry();

从代码中我们能看见,这个类在加载的时候就已经完成了对象的初始化,这种创建方式被称为"饿汉模式",就像一个饥饿的人,非常迫切

但是我们在程序启动时候需要加载很多类,有些单例类不需要在启动的时候就进行使用。

那为了节省计算机资源 ,加速程序的启动,我们就可以让单例类在用到的时候再进行初始化 ,而不是在一开始就初始化,也就是new sigletonHungry()操作

1.3 懒汉模式------单线程

java 复制代码
public class SingletonLazy {
    // 定义一个全局的变量
    private static SingletonLazy instance = null;

    // 构造方法私有化
    private SingletonLazy() {
    }

    //对外提供一个获取对象的方法

    public static SingletonLazy getInstance() {
            // 判断一个对象是不是已经创建过
            if (instance == null) {
                // 创建对象
                instance = new SingletonLazy();
            }
        // 返回对象
        return instance;
    }
}

懒汉模式中,我们没有直接初始化单例对象,当用户调用getInstance方法的时候,我们先判断当前对象有没有被初始化,如果没有我们再进行new的初始化操作,如果已经被初始化,则直接返回。

java 复制代码
public class Demo_602 {
    public static void main(String[] args) {
        // 第一次获取
        SingletonLazy instance1 = SingletonLazy.getInstance();
        System.out.println(instance1);
        // 第二次获取
        SingletonLazy instance2 = SingletonLazy.getInstance();
        System.out.println(instance2);
        // 第三次获取
        SingletonLazy instance3 = SingletonLazy.getInstance();
        System.out.println(instance3);

    }
}

我们来用getInstance方法来获取单例对象

结果显示是同一个对象

这是在单线程的情况下的结果,我们现在使用多线程环境,再来测试一下

1.4 懒汉模式------多线程

java 复制代码
public class Demo_603 {
    public static void main(String[] args) {
        // 创建10个线程
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {
                // 获取单例对象
                SingletonLazy instance = SingletonLazy.getInstance();
                // 打印结象结果
                System.out.println(instance);
            });
            // 启动线程
            thread.start();
        }
    }
}

我们创建10个线程,每个线程中都获取单例对象,并且打印,观察结果

结果显示出现问题,10个打印结果出现了不一致的情况,此时单例模式就不满足单例了

我们分析一下问题,很容易就能发现是new单例对象的时候出现了问题,其实就是因为多线程的时候我们没有保证原子性,进行了多次初始化

那我们尝试加synchronized解决问题

1.4.1 给内层上锁

java 复制代码
public static SingletonLazy getInstance() {
    // 第一次判断是否需要加锁
    // 判断一个对象是不是已经创建过
    if (instance == null) {
        // 创建对象
        synchronized (SingletonLazy.class) {
            instance = new SingletonLazy();
        }
    }
    // 返回对象
    return instance;
}

我们在内层new对象操作代码上锁,重新运行

我们观察到还是出现问题,这是因为线程的随机调度,if语句没有上锁,会出现多个线程认为instance==null,此时有多少个线程判断instance==null,就会重复new出来多少个不同的对象

1.4.2 给外层上锁

java 复制代码
public static SingletonLazy getInstance() {
    // 第一次判断是否需要加锁
    synchronized (SingletonLazy.class) {
        // 判断一个对象是不是已经创建过
        if (instance == null) {
            // 创建对象
            instance = new SingletonLazy();
        }
    }
    // 返回对象
    return instance;
}

当我们给外层上锁之后,就不会有多个线程同时Load到instance的值,也就避免了多个线程判断instance==null这样的情况,从而避免了初始化多个对象

重复运行结果显示正确

1.5 双重检查锁DCL(Double Check Lock)

按照我们之前的写法,仍然会出现一个问题

  1. 当第一个线程进行这个方法,如果变量没有初始化,则获取锁进行初始化操作,此时单例对象被第一个线程创建完成
  2. 后面的线程之后永远不会执行new对象这个操作了
  3. 那对于后面的线程,synchronized还有必要存在吗?

此时其实第一个线程把对象创建好后,synchronized就没有必须要存在了,从第二个线程开始,加锁解锁操作都是无效操作了

java 复制代码
public class SingletonDCL {
    // 定义一个全局的变量
    private static volatile SingletonDCL instance = null;

    // 构造方法私有化
    private SingletonDCL() {
    }
    // 对外提供一个获取对象的方法

    public static SingletonDCL getInstance() {
        // 第一次判断是否需要加锁
        if (instance == null) {
            // 获取锁
            synchronized (SingletonDCL.class) {
                // 判断一个对象是不是已经创建过
                if (instance == null) {
                    // 创建对象
                    instance = new SingletonDCL();
                }
            }
        }
        // 返回对象
        return instance;
    }
}

那么我们在加锁之前先判断一下,if(instance==null), 节省资源,如果已经实例化后就直接返回,如果没有实例化就获取锁 ,同时锁内部代码我们也会再次判断if(instance==null),也避免了多次初始化的情况。

同时因为涉及到多个线程修改共享变量,我们也给共享变量加锁volatile关键字,避免指令重排序的情况

指令重排序问题

已知new操作具体步骤为

  1. 在内存中申请一片空间
  2. 初始化对象的属性(赋初值)
  3. 把对象在内存中的首地址赋值给对象的引用

那么13指令是强相关的,2指令并不强相关

正常执行顺序:1 2 3

指令重排序的顺序:1 3 2

如果按照1 3 2这样的顺序,此时instance拿到的就是一个没有初始化的对象,那么在使用对象的时候就可能会出现问题,为了避免这个问题,我们加锁volatile关键字。

二、阻塞队列

2.1 阻塞队列的概念

阻塞队列是一种特殊的队列,也遵守"先进先出"的原则

阻塞队列同时是一种线程安全的数据结构,具有一下特性

当队列满的时候,继续入队列就会阻塞,直到有其他线程从队列中取走元素

当队列空的时候,继续出队列也会阻塞,直到有其他线程往队列中插入元素

简单来说就是有空位才入队,有元素才出队

2.2 生产者消费者模型

这是一个典型的阻塞队列应用场景,一种典型的开发模型。

场景中,生产者负责生产资源,向队列中放入元素;消费者负责消费资源,从队列中取走元素

当队列满后,生产者则阻塞等待,直到有空位后继续生产资源

当队列空时,消费者则阻塞等待,直到有资源后继续消费资源

2.3 使用JDK的类创建阻塞队列

在Java标准库中内置了阻塞队列,如果我们需要在⼀些程序中使⽤阻塞队列,直接使⽤标准库中的即可

  • BlockingQueue是⼀个接⼝.真正实现的类是LinkedBlockingQueue
  • put⽅法⽤于阻塞式的⼊队列,take⽤于阻塞式的出队列
  • BlockingQueue也有offer,poll,peek等⽅法,但是这些⽅法不带有阻塞特性
java 复制代码
public class Demo_701 {
    public static void main(String[] args) throws InterruptedException {
        // 创建一个阻塞队列
        BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(3);
        // 向阻塞队列中添加元素
        queue.put(1);
        queue.put(2);
        queue.put(3);
        System.out.println("已经添加了3个元素");
        queue.put(4); // 开始阻塞
        System.out.println("已经添加了4个元素"); // 这句打印不出来
    }
}

我们的阻塞队列大小为3,观察到添加三个元素之后,不再往下进行执行,这是因为队列已满,执行到queue.put(4); 就阻塞了

java 复制代码
public class Demo_701 {
    public static void main(String[] args) throws InterruptedException {
        // 创建一个阻塞队列
        BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(3);
        // 向阻塞队列中添加元素
        queue.put(1);
        queue.put(2);
        queue.put(3);
        System.out.println("已经添加了3个元素");

        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println("已经取出了3个元素");
        System.out.println(queue.take());
        System.out.println("已经取出了4个元素");
    }
}

我们再演示取出元素,队列中有1,2,3这三个元素,我们依次取出后,再进行第四次取出,此时不再往下执行,因为队列已经为空,System.out.println(queue.take());阻塞等待

2.4 阻塞队列的应用场景

2.4.1 解耦

在这个模型中,服务器A和服务器B要时刻互相感知,调用过程中双方都要直到对方需要的参数和调用凡事,此时如果再加入一个服务器节点C,则要直接对接另外的服务器,ABC调用链路中,如果其中一个出了问题,则影响整个业务的执行

此时我们可以设置一个阻塞队列为消息队列作为中转站

  • 消息队列可以接受服务器A的消息并保存,如果队列有空位则入队,队满则阻塞等待
  • 服务器B可以从消息队列中获取消息并处理,如果队不为空则获取,队空则阻塞等待
  • 如果要添加服务器节点,通过消息队列可以做到解耦,让服务器C和消息队列对接即可

2.4.2 削峰填谷

平时业务程序很难应对流量大幅增长的情况,当流量暴增是,程序会申请很多线程,各种资源。导致最后服务器资源被耗尽崩盘

这样服务器A请求,服务器B处理请求的模型,如果流量暴增,请求暴增,服务器无法承受最终资源被耗尽

如果我们用消息队列进行缓冲,将请求入队,服务器B按照自己的节奏处理请求,就可以保证服务器资源正常分配

2.4.3 异步操作

采用消息队列我们可以实现异步操作,让效率更高

同步操作时,一方发出请求后,会死等,等到回应后再进行下一步操作

异步操作时,一方发出请求后,就去完成其他业务,等另一方响应后再通知

2.5 模拟实现

java 复制代码
public class MyBlockingQueue {
    // 定义一个数组来存放数据,具体的容量由构造方法中的参数决定
    private Integer[] elementData;
    // 定义头尾下标
    private volatile int head = 0;
    private volatile int tail = 0;
    // 定义数组中元素的个数
    private volatile int size = 0;

    // 构造
    public MyBlockingQueue(int capacity) {
        if (capacity <= 0) {
            throw new RuntimeException("队列容量必须大于0.");
        }
        elementData = new Integer[capacity];
    }

    // 插入数据的方法
    public void put(Integer value) throws InterruptedException {
        synchronized (this) {
            // 判断队列是否已满
            while (size >= elementData.length) {
                // 阻塞队列在队列满的时候就应该阻塞等待
                // 等待
                this.wait();
            }
            // 插入数据的过程
            // 在队列尾部插入元素
            elementData[tail] = value;
            // 移动队尾下标
            tail++;
            // 处理队尾下标
            if (tail >= elementData.length) {
                tail = 0; // 回到数据头
            }
            // 修改size的值
            size++;
            // 唤醒阻塞线程
            this.notifyAll();
        }
    }

    // 获取数据的方法
    public synchronized Integer take() throws InterruptedException {
        // 判断队列是否为空
        while (size == 0) {
            this.wait();
        }
        // 出队的过程
        // 获取要出队的元素
        Integer value = elementData[head];
        // 移动队头下标
        head++;
        // 处理队头下标
        if (head >= elementData.length) {
            head = 0; // 回来数据头
        }
        // 处理数组中的元素个数
        size--;
        // 唤醒阻塞等待的线程
        this.notifyAll();
        // 返回元素
        return value;
    }

}

与我们之前实现的队列不同的是,在插入数据和取出数据时,我们需要判断当前队列是否满或空,进行阻塞等待wait(),并且当我们插入数据或取出数据后进行线程唤醒notifyAll(),并且因为方法修改了多个变量,我们要将方法上锁,并且加上volatile关键字。

测试

java 复制代码
public class Demo_702 {
    public static void main(String[] args) throws InterruptedException {
        // 创建阻塞队列
        MyBlockingQueue queue = new MyBlockingQueue(3);
        // 入队元素
        queue.put(1);
        queue.put(2);
        queue.put(3);
        System.out.println("已经入队了3个元素...");
        queue.put(4);
        System.out.println("已经入队了4个元素...");
    }
}
java 复制代码
public class Demo_702 {
    public static void main(String[] args) throws InterruptedException {
        // 创建阻塞队列
        MyBlockingQueue queue = new MyBlockingQueue(3);
        // 入队元素
        queue.put(1);
        queue.put(2);
        queue.put(3);
        System.out.println("已经入队了3个元素...");

        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println("已经取出了3个元素...");
        System.out.println(queue.take());
        System.out.println("已经取出了4个元素...");
    }
}

正常运行,结果正确

2.6 生产者消费者代码实现

java 复制代码
public class Demo_703 {
    public static void main(String[] args) {
        // 定义一个阻塞队列, 交易场所
        MyBlockingQueue queue = new MyBlockingQueue(100);

        // 创建生产者线程
        Thread producer = new Thread(() -> {
            int num = 0;
            // 使用循环不停的向队列中添加元素,直到队列容量占满
            while (true) {
                try {
                    // 添加元素
                    queue.put(num);
                    System.out.println("生产了元素:" + num);
                    num++;
                    // 休眠一会
                    TimeUnit.MILLISECONDS.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        // 启动
        producer.start();

        // 定义一个消费者线程
        Thread consumer = new Thread(() -> {
            // 不断的从队列中取出元素
            while (true) {
                try {
                    // 取元素
                    Integer value = queue.take();
                    System.out.println("消费了元素:" + value);
                    // 休眠一会
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        // 启动消费者线程
        consumer.start();
    }
}

代码中设置阻塞队列的大小为100,设置生产者和消费者两个线程,可以观察到,生产者生成导致队列满后就开始等待消费者消费,消费一个元素后又生成一个元素

相关推荐
m0_372257022 小时前
项目下有多个模块,每个模块有pom文件,是怎么继承的
java·tomcat
oak隔壁找我2 小时前
Spring AI 入门教程,使用Ollama本地模型集成,实现对话记忆功能。
java·人工智能·后端
懒羊羊不懒@2 小时前
JavaSe—Stream流☆
java·开发语言·数据结构
郝开2 小时前
最终 2.x 系列版本)2 - 框架搭建:pom配置;多环境配置文件配置;多环境数据源配置;测试 / 生产多环境数据源配置
java·spring boot·后端
Js_cold2 小时前
(* clock_buffer_type=“NONE“ *)
开发语言·fpga开发·verilog·vivado·buffer·clock
Homeey2 小时前
深入理解ThreadLocal:从原理到架构实践的全面解析
java·后端
ANGLAL2 小时前
27.短链系统
java
周杰伦_Jay2 小时前
【Go微服务框架深度对比】Kratos、Go-Zero、Go-Micro、GoFrame、Sponge五大框架
开发语言·微服务·golang
杰瑞哥哥2 小时前
标准 Python 项目结构
开发语言·python