✨个人主页:bit me
✨当前专栏:Java EE初阶
✨每日一语:we can not judge the value of a moment until it becomes a memory.
单例模式是校招中最常考的设计模式之一.
啥是设计模式?
设计模式好比象棋中的 “棋谱”. 红方当头炮, 黑方马来跳. 针对红方的一些走法, 黑方应招的时候有一些固定的套路. 按照套路来走局势就不会吃亏.
软件开发中也有很多常见的 “问题场景”. 针对这些问题场景, 大佬们总结出了一些固定的套路. 按照这个套路来实现代码, 也不会吃亏.
单例模式目的:有些对象,在一个程序中应该只有唯一一个实例,就可以使用单例模式。单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例。
一个程序中应该只有唯一一个实例是程序猿来保证的,不一定靠谱,于是在单例模式下借助语法,强行限制咱们不能创建多个实例。
Java 里的单例模式,有很多种实现方式,主要介绍两个大类:饿汉模式
和 懒汉模式
饿汉模式:程序启动,则立即创建实例
懒汉模式:程序启动,先不着急创建实例,等到真正用的时候,再创建
单例模式具体实现方式:
class Singleton{
private static Singleton instance = new Singleton();
public static Singleton getInstance(){
return instance;
}
//构造方法设为私有!其他的类想来 new 就不行了
private Singleton(){ }
}
public class Demo19 {
public static void main(String[] args) {
Singleton instance = Singleton.getInstance();
Singleton isstance2 = Singleton.getInstance();
System.out.println(instance == isstance2);
}
}
按照现在的代码,当 Singleton 类被加载的时候,就会执行到此处的实例化操作!!实例化时机非常早!(非常迫切的感觉)
class Singletonlazy{
private static Singletonlazy instance = null;
public static Singletonlazy getInstance(){
if(instance == null){
instance = new Singletonlazy();
}
return instance;
}
private Singletonlazy(){ }
}
上面俩种模式还涉及到线程安全。
饿汉模式是线程安全的,多线程涉及 getInstance ,只是多线程读,没事儿
懒汉模式是线程不安全的,有的地方在读,有的地方在写,一旦实例创建好了之后,后续 if 条件就进不去了,此时也就全是读操作了,也就线程安全了。
如何解决懒汉模式线程不安全?
方法就是加锁:
synchronized (Singletonlazy.class) {
if (instance == null) {
instance = new Singletonlazy();
}
}
把读和写两个步骤打包在一起,保证读 判定 修改 这组操作是原子的!
懒汉模式,只是初始情况下,才会有线程不安全问题,一旦实例创建好了之后,此时就安全了!既然如此,后续在调用 getlnstance 的时候就不应该再尝试加锁了!当线程安全之后,再尝试加锁,就非常影响效率了。
如上代码我们只需要再嵌套一个 if 判定即可
public static Singletonlazy getInstance(){
if (instance == null) {
synchronized (Singletonlazy.class) {
if (instance == null) {
instance = new Singletonlazy();
}
}
}
return instance;
}
注意!不要用单线程的理解方式来看待多线程代码!如果是单线程,连续两个一样的 if 判定,毫无意义!但是多线程就不是了,尤其是中间隔了个加锁操作!
- 加锁操作可能就涉及到阻塞,前面的 if 和后面的 if 中间可能就隔了个 “沧海桑田”。
- 外层 if 判定当前是否已经初始化好,如果未初始化好,就尝试加锁,如果是已初始化好,那么就直接往下走。
- 里层的 if 是在多个线程尝试初始化,产生了锁竞争,这些参与锁竞争的线程,拿到锁之后,再进一步确认,是否真的要初始化。
理解双重 if 判定:最核心的目标,就是降低锁竞争的概率
当多线程首次调用 getInstance,发现 instance 为 null, 于是又继续往下执行来竞争锁,其中竞争成功的线程, 再完成创建实例的操作。当这个实例创建完了之后, 其他竞争到锁的线程就被里层 if 挡住了. 也就不会继续创建其他实例
- 有三个线程, 开始执行 getInstance , 通过外层的 if (instance == null) 知道了实例还没有创建的消息. 于是开始竞争同一把锁.
- 其中线程1 率先获取到锁, 此时线程1 通过里层的 if (instance == null) 进一步确认实例是否已经创建. 如果没创建, 就把这个实例创建出来.
- 当线程1 释放锁之后, 线程2 和 线程3 也拿到锁, 也通过里层的 if (instance == null) 来确认实例是否已经创建, 发现实例已经创建出来了, 就不再创建了.
- 后续的线程, 不必加锁, 直接就通过外层 if (instance == null) 就知道实例已经创建了, 从而不再尝试获取锁了. 降低了开销.
很多线程尝试读,这样的读,是否会被优化成读寄存器呢?
第一个线程读,把内存的数据读到寄存器了,第二个线程也去读,会不会就直接重复利用上述寄存器的结果呢?由于每个线程有自己的上下文,每个线程有自己的寄存器内容,因此按理来说是不会出现优化的,但是实际上不一定,
因此在这个场景下,给 instance 加上 volatile 是最稳健的做法!
volatile private static Singletonlazy instance = null;
对懒汉模式的总结:
加锁
双重 if 判定(外层 if 为了降低加锁的频率,降低锁冲突的概率,里层 if 才是真正判定是否要实例化)
volatile
阻塞队列是什么?
阻塞队列是一种特殊的队列. 也遵守 “先进先出” 的原则.
阻塞队列能是一种线程安全的数据结构, 并且具有以下特性:
- 阻塞队列:能够保证 “线程安全”
- 无锁队列:也是一种线程安全的队列,实现内部没有使用锁,更高效,消耗更多的 CPU 资源。
- 消息队列:在队列中涵盖多种不同 “类型” 元素,取元素的时候可以按照某个类型来取,做到针对该类型的 “先进先出” (甚至说会把消息队列作为服务器,单独部署)
阻塞队列的一个典型应用场景就是 “生产者消费者模型”. 这是一种非常典型的开发模型
生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取。
优点:
A 直接给 B 发送数据,就是耦合性比较强,开发 A 的时候就得考虑 B 是如何接收的,开发 B 的时候就得考虑 A 是如何发送的。极端情况下 A 出现问题挂了 可以能也造成 B 出现问题导致 B 也挂了,反之 B 出现了问题,也会牵连 A 导致 A 挂了。
于是在阻塞队列的影响下,A 和 B 不再直接交互
开发阶段:A 只用考虑自己和队列如何交互,B 也只用考虑自己和队列如何交互,A 和 B 之间都不需要知道对方的存在。
部署阶段:A 如果挂了,对 B 没有任何影响;B 如果挂了,对 A 没有任何影响。
程序猿无法控制外网有多少个用户在访问 A,当出现极端情况,外网访问请求大量涌入的时候,A 把所有请求的数据一并转让给 B 的时候,B 就容易扛不住而挂掉。
在阻塞队列的影响下
多出来的压力队列承担了,队列里多存一会儿数据就行了,即使 A 的压力比较大,B 仍按照固定的频率来取数据。
标准库中的阻塞队列
在 Java 标准库中内置了阻塞队列. 如果我们需要在一些程序中使用阻塞队列, 直接使用标准库中的即可
生产者消费者模型:
public class Demo20 {
public static void main(String[] args) {
BlockingDeque<Integer> queue = new LinkedBlockingDeque<>();
Thread customer = new Thread(()->{
while (true){
try {
int value = queue.take();
System.out.println("消费元素:" + value);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
customer.start();
Thread producer = new Thread(()->{
int n = 0;
while (true){
try {
System.out.println("生产元素:" + n);
queue.put(n);
n++;
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
producer.start();
}
}
阻塞队列实现:
class MyBlockingQueue {
// 假定最大是 1000 个元素,当然也可以设定成可配置的
private int[] items = new int[1000];
//队首的位置
private int head = 0;
//队尾的位置
private int tail = 0;
//队列的元素个数
private int size = 0;
//入队列
public void put (int value) throws InterruptedException {
synchronized (this) {
while (size == items.length) {
//队列已满,继续等待
this.wait();
}
items[tail] = value;
tail++;
if (tail == items.length) {
//注意 如果 tail 到达数组末尾,就需要从头开始
tail = 0;
}
size++;
//即使没人在等待,多调用几次 notify 也没事,没负面影响
this.notify();
}
}
//出队列
public Integer take() throws InterruptedException {
int ret = 0;
synchronized (this) {
while (size == 0) {
//队列为空,就等待
this.wait();
}
ret = items[head];
head++;
if (head == items.length) {
head = 0;
}
size--;
this.notify();
}
return ret;
}
}
public class Demo21 {
public static void main(String[] args) throws InterruptedException {
MyBlockingQueue queue = new MyBlockingQueue();
queue.put(100);
queue.take();
}
}
- 入队列中的 wait 和出队列中的 notify 对应,满了之后,入队列就要阻塞等待,此时在取走元素之后,就可以尝试唤醒了。
- 入队列中的 notify 和出队列中的 wait 对应,队列为空,也要阻塞,此时在插入成功之后,队列就不为空了,就能够把 take 的等待唤醒。
- 一个线程中无法做到又等待又唤醒
- 阻塞之后,就要唤醒,阻塞和唤醒之间是沧海桑田,虽然按照当下代码是有元素插入成功了,条件不成立,等待结束。但是更稳妥的做法是把 if 换成 while ,在唤醒之后,再判断一次条件!万一条件又成立了呢?万一接下来要继续阻塞等待呢?
测试代码:
public class Demo21 {
public static void main(String[] args) {
MyBlockingQueue queue = new MyBlockingQueue();
Thread customer = new Thread(()->{
while (true){
int value = 0;
try {
value = queue.take();
System.out.println("消费:" + value);
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
customer.start();
Thread producer = new Thread(()->{
int value = 0;
while (true){
try {
queue.put(value);
System.out.println("生产:" + value);
value++;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
producer.start();
}
}
延缓了消费代码,也可以把生产代码延缓,调用 sleep 即可