【JavaEE】多线程03—单例模式/阻塞队列
1.单例模式
单例模式,是设计模式中一种非常典型的模式,也是比较简单的模式。
单例模式能保证某个类在程序中只存在唯⼀⼀个实例(对象),而不会创建出多个实例,即 new 多次,这是单例模式的硬性要求,如果在代码中创建了多个实例,会直接编译失败。
就像MySQL中JDBC的 DataSource ,该类是用来描述数据库信息的,它非常适合作为单例,因为类似于数据库信息这样的对象,由于数据库只有一份,即使弄多个这样的对象,也没有意义(new多个对象,里面的信息还是一样的)。还有 DataCenter 这样的类也是一个单例,它是用来组织数据的,一个实例就可能管理 好几百个GB,如果多创建几个实例出来,那么服务器内存可能就爆了。
以上都是单例模式的一些应用场景。
单例模式具体的实现方式有很多,最常见的是 "饿汉" 和 "懒汉" 两种,这两种模式的核心区别在于实例的创建时机。
1.1 以 饿汉模式 构建单例模式
创建时机:类被加载到内存(即类初始化阶段)的同时,就立即创建单例实例/对象,无论后续是否使用。
通常用 static final 字段持有实例,静态成员的初始化是在类加载的阶段触发的,也就是启动程序的时候,而使用final 修饰是保证该成员在声明时直接赋值(在类加载过程中完成一次赋值),且一旦赋值后不可再修改,这里指使用该成员创建完实例后不可再修改,即不可指向其他的对象。
class HungrySingleton {
private static final HungrySingleton INSSTANCE = new HungrySingleton();
private HungrySingleton() {}
public static HungrySingleton getInstance() {
return INSTANCE;
}
}
私有构造方法,是为了阻止类外部通过 new 创建这个类的实例,这是单例模式的核心约束。后续统一通过 getInstance 这个方法来获取这里早已创建好实例,即直接返回 INSTANCE。
如下图,在类外不并不可以去 new 实例:


而是通过公共方法去获取类内部已经创建了一个实例(INSTANCE):

且无论你创建获取多少个实例,都是同一个,保证了饿汉模式唯一实例的特点:


1.2 以 懒汉模式 构建单例模式
懒 和 饿 是相对的,饿 是希望尽早创建实例,而 懒 是希望尽量晚创建实例,即延迟创建,甚至可能不创建了。即懒汉模式的实例是在需要的时候才创建,不像饿汉模式从一开始就创建好。
- 例如:假设有一个很大的文件(千万字的小说),想要在编辑器中打开:
- 如果是把所有的内容都从文件中加载到内存中,再进行显示,由于文件太大了,会有明显的卡顿,而且就算都加载出来了,也看不过来
- 因此,要做的是,只把一部分内容加载并显示出来,后续如果用户翻页,那么随着翻页,随时加载后续的数据
创建时机:首次调用 getInstance() 方法时(即真正需要使用时)才创建单例对象,实现“延迟加载”,而不是在程序启动时就立即创建。
通常会在获取实例的方法中,加入条件判断,判断是否已经创建了实例,如果为null,则创建,如果非null,则直接返回创建好的实例。
在设置成员字段时,在懒汉模式下,不能使用 final 修饰,因为实例不在类加载时创建,而是在首次调用 getInstance() 时按需创建,这意味中:
- 实例的赋值操作发生在某个方法(getInstance)内部,而不是在声明处或静态块中(final 字段必须在这两个时刻之一完成初始化,且一旦赋值后,该引用永远不能改变)
- 这个赋值动作可能发生在程序运行过程中的任意时刻(第一次调用时)
- 如果强行给 instance 加上 final,编译器会报错

因此,不可以使用 final 修饰。
class LazySingleton {
private static LazySingleton instance = null;
private LazySingleton() {}
public static LazySingleton getInstance() {
if(instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
依然不可以通过 new 对象来创建该类的实例:

而是应该使用getInstance 方法来获取已经创建好的实例:

————————
其实上述的懒汉/饿汉模式,是存在缺陷的,比如可以通过反射的方式来创建该类的实例,但是反射本身就是非常规的手段,在日常开发中,不推荐使用反射。
————————
1.3 是否涉及线程安全
那么,我们上述谈的单例模式,与线程有什么关系呢?
饿汉模式,懒汉模式,是否是线程安全的,也就是说,这两个版本的 getInstance 在多线程环境下调用,是否会出现 bug?
在饿汉模式下,getInstance 只涉及到了return,也就是读操作,和 String 一样只有读操作而没有修改的操作,不涉及线程安全,因此,饿汉模式天然是线程安全的。

而在懒汉模式下,getInsatnce 就同时涉及到了读和修改的操作,在前面我们说过 = 赋值操作符是原子的操作,这是不涉及线程安全的,但是我们应该整体来看,即 if + = ,这一整个操作涉及到了多线程的修改,因此,上述我们写的懒汉模式版本它是线程不安全的。

而且,在单线程模式下,懒汉模式是符合单例模式的要求的,但是在多线程模式下,懒汉模式不符合单例模式只能有一个实例的要求。
1.4 懒汉模式 - 多线程
观看以下懒汉模式在多线程下的一个执行过程:

该过程中,线程1 执行了判断 instance 的操作后,调度到了线程2 这里,也执行了这个判断条件,之后又调度回来继续执行 线程1 的接下来的创建实例操作,最后又调度到了 线程2 的创建实例操作,这时候随着 线程2 这个操作,会覆盖掉 线程1 创建出来的对象,
这样覆盖掉了好像也没有什么问题,但是不要忘记,new 的这个对象,在 new 的过程中,可能要把 100G 的数据从硬盘中加载到内存上,本来程序启动时间是10分钟,由于上述的 bug 加载了两份,导致最终的时间远远超过 10分钟,而且这破坏了单例模式只能创建一个实例的要求。
这充分说明了,该版本的懒汉模式是线程不安全的
- 线程安全问题发生在首次创建实例时,如果在多个线程中同时调用 getInstance 方法,就可能导致创建 出多个实例 —— 多个线程同时进入 if (instance == null) 判断,可能都发现 instance 为 null,于是各自创建实例,导致单例被破坏。
- 但是,⼀旦实例已经创建好了, 后⾯再多线程环境调用 getInstance 就不再有线程安全问题了(不再修改 instance 了) —— 一旦实例创建完成(instance 已经指向一个对象),后续所有线程再调用 getInstance(),都只是读取 instance 的值并直接返回,不再进行修改操作。
那么如何解决线程不安全的问题呢?—— 就是加锁,把不是原子的操作,打包成原子的操作。

引入加锁之后,后执行的线程就会在加锁的位置阻塞等待,阻塞到前一个线程解锁,当后一个线程进入条件的时候,前一个线程已经修改完毕,instance 不再为 null,就不会进行后续的 new 条件,而是直接执行返回操作,即后续在调用 getInstance ,此时都是直接执行 return,如果只是进行 if 判定 + return,是存粹的读操作,不涉及线程安全。

但是,每次调用上述的方法,都会触发一次加锁操作,虽然不涉及线程安全问题了,多线程情况下,这里的加锁,就会相互阻塞,影响程序执行的效率。
如果解决这个问题呢? —— 就是 按需加锁 ,真正涉及到线程安全的时候再加锁,不涉及就不加。
那么按照上述的代码,如果实例已经创建过了,就不涉及线程安全问题,不加锁;如果还没有创建,就涉及线程安全,加锁。
1.5 懒汉模式 - 多线程优化
使用双重 if 判定,降低锁竞争的频率,提升程序执行效率:
- 外层的 if ,判定是否需要加锁
- 内层的 if ,判定是否需要 new 对象
【在单线程中,连续两个相同的 if 是无意义的,因为单线程中的执行流只有一个,上一个 if 的判定结果和下一个 if 是一样的。但是在多线程中,两次判定之间,可能存在其他线程,把 if 中的 instance 变量修改了,也就会导致这里两次 if 的结果可能不同。这里两个相同的 if ,其实是一种巧合】

上述的代码,确实提高了执行效率,但是,仍然存在问题 —— 可能存在 内存可见性 问题。
可能出现 线程1 在读取 instance 的时候,线程2 正在修改 instance ,也就是可能存在编译器优化这个事情,为了从根本上杜绝内存可见性问题,直接给 instance 变量加上 volatile 关键字。

还有,这里更关键的问题,是 指令重排序,它也是导致线程完全的原因之一。
1.5.1 指令重排序
指令重排序也是编译器优化的一种体现形式,编译器会在代码逻辑不变的前提下,调整代码执行的顺序,以达到提升性能的效果。
例如,去超市买菜,当然是选择图中的第二种方式,这样才能提高效率,指令重排序说的就是这个意思:

指令重排序,只要代码逻辑不变,就可以调整顺序,但是,在多线程环境下,这里的判定可能出现错误:
以下 instance = new LazySingleton2() ,需要三个步骤:
- 申请内存空间 —— 相当于 买房
- 在空间上构造对象(初始化) —— 相当于 装修
- 内存空间的首地址,赋值给引用变量 instance —— 相当于 拿到钥匙

正常来说,这三个步骤是按照 1 2 3 这样的顺序来执行的,但是在指令重排序下,可能成为 1 3 2 这样的顺序( 1 的顺序是不会变的,第一步就是需要申请空间),在单线程中,哪一种顺序无所谓,但是在多线程下,1 3 2 这样的顺序,可能会出现 bug :

不过这样的问题不只是指令重排序引起的,也和双重 if 有关。
如何解决指令重排序的问题呢?
—— 之前我们在 instance 变量上加的 volatile ,有两方面的功能:
- 确保每次读取操作,都是读内存 - 解决内存可见性问题
- 关于该变量的读取和修改操作,不会触发重排序 - 解决指令重排序问题
1.5.2 总结
多线程下,保证 懒汉模式 线程安全,需要的步骤:
- 使用双重 if 判定, 降低锁竞争的频率
- 给 instance 加上了 volatile,解决内存可见性和指令重排序问题
class LazySingleton {
private static volatile LazySingleton instance = null;
private static Object locker = new Object();
private LazySingleton() {};
public static LazySingleton getInstance() {
if(instance == null) {
snychronized(locker) {
if(instance == null) {
instance = new LazySingleton();
}
}
}
return instance;
}
}
2.阻塞队列
阻塞队列是⼀种特殊的队列. 也遵守 "先进先出" 的原则。
阻塞队列是一种线程安全的数据结构,并具有以下的特征:
- 当队列满的时候,继续入队列就会阻塞,直到有其他线程从队列中取走元素
- 当队列空的时候,继续出队列也会阻塞,直到有其他线程往队列中插入元素
阻塞队列的⼀个典型应用场景就是 "生产者消费者模型",这是⼀种非常典型的开发模型。
2.1 生产者消费者模型
生产者和消费者彼此之间不直接通讯,而是通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,而是直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取。
现实比喻:面包店
- 生产者:面包师,不停地烤面包。
- 消费者:顾客,不停地买面包。
- 缓冲区:一个玻璃柜台(容量有限,比如只能放10个面包)。
规则:
- 如果柜台放满了(10个),面包师就得等(阻塞),直到顾客买走一个空出位置。
- 如果柜台空了,顾客就得等(阻塞),直到面包师烤好一个新面包。
这个玻璃柜台就是阻塞队列。它自动处理了“满了等待”和“空了等待”的逻辑,生产者和消费者只需要专心做自己的事。
2.2.1 优势
- 1. 解耦合
以上的生产者消费者模型的概念就体现了一个优势:阻塞队列使生产者和消费者之间解耦合 —— 生产者和消费者不直接交互,只通过阻塞队列通信,一方修改不会影响另一方。

- 2. 削峰填谷
将短时间内产生的大量请求/数据先暂存起来,再按照下游能够承受的速度逐步处理,从而避免下游系统被瞬间流量冲垮。
- 削峰:削去流量高峰的“尖峰部分”,不让它直接冲击下游。
- 填谷:用队列里积压的数据,在流量低谷期继续“填满”下游的处理时间,使负载更平滑。
以下的波形图,可以理解成服务器收到的请求量曲线图。

为什么需要削峰填谷?
- 上游(生产者)的速率往往不稳定,可能出现突发流量:
- 秒杀活动:1秒内10万请求
- 日志采集:某时刻突然大量日志
- 传感器数据:异常时瞬间上报
- 下游(消费者)的处理能力通常是固定且有限的(如数据库连接数、CPU、IO)。如果直接让上游流量打到下游,会导致:
- 响应超时、请求失败
- 下游系统过载崩溃(雪崩效应)

削峰填谷 = 用缓冲区吸收瞬时流量高峰,再以稳定的低速处理,保护下游系统。
2.2.2 缺点
生产者消费者模型也是有代价的,
- 引入队列之后,整体的结构会更复杂,此时就需要更多的机器进行部署,那么生产环境的结构也会更复杂,管理起来更麻烦
- 效率也会有影响
2.2 Java标准库中的阻塞队列
在 Java 标准库中内置了阻塞队列 BlockingQueue . 如果我们需要在⼀些程序中使用阻塞队列, 直接使用标准库中的即可。
BlockingQueue 是⼀个接⼝,它同样继承于 Queue 接口,它的实现类有 LinkedBiockingQueue 和 ArrayBlockingQueue,其中 LinkedBlockingQueue 比较常用:

BlockingQueue 接口中入队列和出队列的方法有多个,其中,put() 方法用于阻塞式的入队列,take() 方法用于阻塞式的出队列,而offer, poll, peek 等⽅法不带有阻塞特性,该接口中并没有提供带有阻塞的获取对首元素的方法。
如下,put 和 take 方法会抛出 InterruptedException 异常,说明它们是带有阻塞的方法:

2.2.1模拟阻塞队列满时,入队列阻塞等待


此时队列确实陷入了阻塞状态,put 确实是阻塞式的方法

2.2.2模拟阻塞队列空时,出队列阻塞等待



注意:我们在使用BlockingQueue的时候,最好使用它的带参数的构造方法,即指定容量的构造方法。如果是不指定容量的构造,默认容量无限大。
2.3 实现生产者消费者模型
生产者和消费者可以有多个,但是都至少有一个。

以上的代码直接运行,生产者和消费者两个线程的速度旗鼓相当,很难见到阻塞的效果,因此我们可以在适当的位置加 sleep,让其到达想要的效果。

在生产者中加 sleep,实现当队列为空时,take 出队列阻塞等待的效果:

看运行结果,消费者想要消费元素时,需要等待生产者去生产元素,生产者生产一个,消费者就立即消费一个:

在消费者中加 sleep,实现当队列满时,put 入队列阻塞等待的效果:

看运行结果,生产者生产的元素达到上限1000时,无法再入队列,需要阻塞等待消费者去消费元素,消费者消费完元素后,队列中又有了空间,生产者继续入队列:

2.4 模拟实现阻塞队列
模拟实现一个简单的阻塞队列,并基于这个阻塞队列实现生产者消费者模型。
这里不使用泛型,直接用String,通过"循环队列" 的方式来实现,即基于数组实现阻塞队列。
【关于循环队列,不了解的请看:Java数据结构:栈和队列】
首先需要做的是创建一个String数组,变量head表示队首元素,tail表示队尾元素,size变量记录元素个数;构造一个指定容量的构造方法。
public MyBlockingQueue {
private String[] data = null;
//队首
private int head = 0;
//队尾
private int tail = 0;
//记录元素个数
private int size = 0;
public MyBlockingQueue(int capacity) {
data = new String[capacity];
}
}
put() 入队列 和 take() 出队列
入/出 队列的逻辑就是循环队列的思路,最主要要注意的是,加锁和阻塞:
- 阻塞:
- 在 put 入队列时,如果队列满,当前线程会阻塞等待 wait(),直到其他线程执行成功take后,此时的队列不再为满,在其他线程中执行take后,使用 notify() 唤醒当前线程,让该线程继续执行 put 入队列操作。
- 在 take 出队列时,如果队列空,当前线程会阻塞等待 wait(),直到其他线程执行成功put后,此时的队列不再为空,在其他线程中执行put后,使用 notify() 唤醒当前线程,让该线程继续执行 take 出队列操作。
- 加锁:
- put() 和 take() 方法是线程安全的方法,我们在模拟实现的时候,也需要加锁synchronized 来保证它的原子性,确保线程安全。
- put “检查是否满 → 等待 → 成功插入”的整个流程使用 synchronized 关键字加锁,整个流程是原子的,不会出现多个线程同时插入导致数据损坏或超出容量。
- 同样,take “检查是否空 → 等待 → 成功取出”是原子的
- 即 要么所有线程都在阻塞 put,要么所有线程都在阻塞 take。不可能存在一些线程在阻塞 put,一些线程在阻塞 take,队列不可能即是空,又有满。
且该过程是内存可见的,因为一个线程 put 的元素,在另一个线程 take 时能立刻看到。
public MyBlockingQueue {
private String[] data = null;
//队首
private int head = 0;
//队尾
private int tail = 0;
//记录元素个数
private int size = 0;
public MyBlockingQueue(int capacity) {
data = new String[capacity];
}
//入队列
public void put(String elem) {
synchronized(this) {
if(size == date.length) {
//队列满了,阻塞等待
this.wait();//当队列不为不满时,才需要唤醒,即其他线程执行成功take后,使用notify唤醒这个阻塞
}
date[tail] = elem;//入队列
tail++;
if(tail >= data.length) {
tail = 0;
}
size++;
this.notify();//这里唤醒其他线程中正在阻塞的wait,表示的是其他的线程想要take,但是队列为空,在该线程中成功put之后就不为空,可唤醒
}
}
//出队列
public String take() {
synchronized(this) {
if(size == 0) {
//队列为空,阻塞等待
this.wait();//当队列已经不为空时,才需要唤醒,即其他线程执行成功put后,使用notify唤醒这个阻塞
}
String ret = date[head];
head++;//出队列
if(head >= data.length) {
head = 0;
}
size--;
this.notify();//这里唤醒其他线程中正在阻塞的wait,表示的是其他线程想要put,但是队列为满,在该线程中成功take之后就不为满,可唤醒
return ret;
}
}
}
到这里还没有完全结束,上述的代码中,还有一个关键的环节:我们查看一下 wait 的源码的说明:

在进行是否需要阻塞等待的时候,使用的是 while 而不是 if,我们使用 wait 是用来确保接下来的操作是有意义的,就像在 take 方法中,使用 wait 等待是为了确保之后执行到以下图中的逻辑时,size 不为0,否则后续再 -- 时,size就变成了负数:

正常来说,wait 的唤醒是通过另一个线程执行 put ,这个线程执行 put 成功了,那么此时的 size 肯定不为0,但是 wait 不一定只是被 notify 唤醒,还可能被 interrupt 这样的方法给中断,由于我们在模拟实现的时候,采用的是 if 进行判断,那么只会判断一次,此时如果 wait 被 interrupt 方法中断后,也就相当于 wait 在被提前唤醒了,take 方法就会继续执行剩下的逻辑,但是此时的 size 还是为0,那么执行这些逻辑的结果就是错误的结果;
但是如果是使用 while 循环判断,就可以避免这种情况,当 wait 被 interrupt 中断后,由于是while 循环,需要再次判断一下 size 的值,此时就发现 size 还是0,也就是队列还是为空,就会继续 wait 阻塞等待,直到 size 不为0,才会再次被唤醒(二次验证)。
wait 方法源代码中的说明,翻译过来就是这个意思:
因此,将 put 和 take 方法中,让 wait() 方法搭配 while 循环使用:
public MyBlockingQueue {
private String[] data = null;
private int head = 0;
private int tail = 0;
private int size = 0;
public MyBlockingQueue(int capacity) {
data = new String[capacity];
}
public void put(String elem) {
synchronized(this) {
while(size >= data.length) { //循环判断
this.wait();
}
data[tail] = elem;
tail++;
if(tail >= data.length) {
tail = 0;
}
size++;
this.notify();
}
}
public String take() {
synchronized(this) {
while(size == 0) {
this.wait();
}
String ret = data[head];
head++;
if(head >= data.length) {
head = 0;
}
size--;
this.notify();
return ret;
}
}
}
使用模拟实现的阻塞队列实现生产者消费者模型

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)