Java EE|多线程代码实例之单例模式与阻塞队列
创始人
2024-05-11 10:20:32

文章目录

    • 前言
      • 设计模式介绍
    • 🔴单例模式
        • 什么是单例模式
        • 单例模式实现方式
          • 饿汉模式
          • 懒汉模式
        • 基于上述单例模式实现线程安全问题讨论
        • 重点回顾
    • 🔴阻塞队列
        • 阻塞队列是什么
        • 标准库中的阻塞队列
        • 典型应用场景:生产者消费者模型
        • 利用系统提供的BlockingQueue实现生产者消费者模型
        • 阻塞队列的实现
        • 自实现阻塞队列下的生产者消费者模型
    • 参考

前言

设计模式介绍

(一)设计模式概念

设计模式类似上古流传的棋谱,是一种大佬总结出来的固定的套路,典型场景下的典型解决方案,相当于解题套路。

【框架与设计模式区别】框架是硬性的、不按照框架写,代码跑不起来,设计模式是软性的,不遵守,代码也能跑起来,但是可能可读性、可维护性、可扩展性都不太行。

(二)常见的设计模式

大佬设计的设计模式有很多,我们目前主要掌握这两个,剩下的以后再掌握完全ok

  • 单例模式
  • 工厂模式

🔴单例模式

什么是单例模式

单例模式能保证某个类中只存在唯一的实例,不能够创建多个实例。实际开发中,这种需求不算少。例如,JDBC中DataSource实例中只需要一个。单例模式从“法律”上硬性规定了类的实例对象个数。类的实例化出来的东西不是叫对象吗,其实也可以叫做实例。这里的单例,就是只实例化出一个对象。

单例模式实现方式

java中,单例模式实现方式其实有很多种,但是我们目前主要掌握饿汉和懒汉模式两种即可。

饿汉模式

饿汉模式中的饿汉是对实例对象创建时机的形象化描述,也就是说创建的比较早。下边我们边写代码边解释。

class Singleton{public static Singleton singleton=new Singleton();private Singleton(){}public static Singleton getInstance(){return singleton;}
}
//for test
public class Code22_SingletonHungry {public static void main(String[] args) {Singleton s1= Singleton.getInstance();Singleton s2= Singleton.getInstance();System.out.println(s1==s2);}
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3IBuLj0v-1673617220932)(F:\typora插图\image-20230113165211098.png)]

可以,看到此时无论去访问几次getInstance,得到的实例都是同一个。

这里,我们在类定义时就直接创建对象。此时对象就会在类加载阶段就直接被创建,相较于一般对象创建时机比较早,所以称为“饿汉”。这里,我们再补充一下,什么是类加载阶段。例如,java代码Hello.java,通过javac变成java.class,这是编译阶段。而类加载过程是运行一个java程序,就让java进程就能找到,并读取对应的.class文件(找文件有一定的规则,在后续学习jvm会有进一步的理解),进行内容解析,根据内容构造类对象,这一系列的过程叫做类加载。

饿汉模式如何确保创建对象是单例的?类定义时创建静态对象+私有构造方法,公开接口get实例,并且设置成静态确保可利用类名直接调用。

懒汉模式

这里的懒汉也是一种形象化的说明,是指实例的常见不需要就不创建,需要时再创建,并且以后再尝试获取的都是第一次创建的。下边我们来看一下代码。

class SingletonL {public static SingletonL singleton=null;private SingletonL(){}public static SingletonL getInstance(){if(singleton==null){singleton=new SingletonL();}return singleton;}
}
//for test
public class Code23_SingletonLazy {public static void main(String[] args) {SingletonL s1= SingletonL.getInstance();SingletonL s2= SingletonL.getInstance();System.out.println(s1==s2);}
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rS2ULZi7-1673617220933)(F:\typora插图\image-20230113165901408.png)]

显然,此时是在第一次尝试获取对象的时候才进行创建的。

并且,懒汉模式确保单例是,通过创建静态对象变量+需要时创建对象+提供公开的接口并且设置成静态方法,私有化构造方法。

基于上述单例模式实现线程安全问题讨论

目前,在单线程环境下,饿汉模式、懒汉模式这两种实现单例模式运行结果都是正确的,那么在多线程环境下呢?也就是说这两种实现方式哪一种更可能出现线程不安全问题?应该怎样尽量规避?

经过思考,我们不难发现多线程环境下,懒汉模式更容易出现不安全问题。饿汉模式下,对象在类加载阶段就已经创建好了,在多线程环境运行下,只有读操作,显然没有线程安全问题;而懒汉模式则是getInstance方法中有创建对象的操作,也就是说懒汉模式下即有读操作也有写操作。

在详细讨论之前,我们需要知道实例化一个对象即new操作实际上是有好几步组成的。分别是1.创建内存空间2.调用构造方法,把这个内存空间初始化称为一个合理的对象3.把内存空间的地址赋给实例的引用。

首先,在getInstance方法中,判断部分的逻辑中,有可能中了singleton==null的逻辑,进入之后,new了一个对象,但是还有把这个对象赋值给对象变量,此时cpu被另一个线程调度走了,也就是说当前的singleton变量不是任何对象的引用,此时再次中了if逻辑,又重新new了一次,又这是非原生类,没有常量池概念,所以显然两次new的对象不同,此时我们假设cpu没有被其他线程调度走,那么此时就会返回。之后再回到最开始的线程,将第一次new的对象赋值并返回,显然就破坏了我们想要的单例情况。

其次,上边我们已经提到了new这个动作其实分为好几个操作,所以这里很可能会出现编译器优化的一种情况——指令重排序,原来的不是123吗,这里可能就会变成132,就会出现非法对象,只有实际对象地址,但是却没实质东西。

显然,造成此时线程不安全的第一种情况就是操作不是原子的,对于此我们可以采取加锁的方式解决。至于加锁的对象,虽然这里是在类内,但是由于这里是静态方法,没有隐含的this指针,所以我们这里的加锁对象是类对象SingletonL.class。

造成此线程不安全的第二个原因就是指令重排序,对此我们采取的解决办法就是对singleton变量加volatile关键字,提醒编译器。除此以外,volatile还可以解决内存可见性问题,虽然这里没有涉及到。

那么我们的代码就可以优化成这样

class SingletonL {volatile public static SingletonL singleton=null;private SingletonL(){}public static SingletonL getInstance(){synchronized (SingletonL.class) {if(singleton==null){singleton=new SingletonL();}}return singleton;}
}
//for test
public class Code23_SingletonLazy {public static void main(String[] args) {SingletonL s1= SingletonL.getInstance();SingletonL s2= SingletonL.getInstance();System.out.println(s1==s2);}
}

这里我们尽可能的解决了线程不安全问题。但是我们知道加锁这种解决线程安全的方法是有部分效率消耗换来的,那么有没有一种可能我们既能尽可能的解决线程安全问题,同时又保证代码执行效率尽可能的高?

当然有。我们试着思考,上边我们的加锁主要是为了防止读写这两种操作没有捆绑而导致的线程不安全,而写操作只是在一开始没有new的时候会触发,其他都是读操作。那么我们有没有一种可能判断这是不是需要写操作参与,如果是就加锁,如果不是就不加锁?听起来是可行的,那么我们如何实现呢?经过思考,我们不难发现,写<==>instance是空的,所以我们的代码就可以优化成这样。

class SingletonL {volatile public static SingletonL singleton=null;private SingletonL(){}public static SingletonL getInstance(){if(singleton==null){synchronized (SingletonL.class) {if(singleton==null){singleton=new SingletonL();}}}return singleton;}
}
public class Code23_SingletonLazy {public static void main(String[] args) {SingletonL s1= SingletonL.getInstance();SingletonL s2= SingletonL.getInstance();System.out.println(s1==s2);}
}

重点回顾

1.写操作是原子的==>加锁

2.new操作可能出现指令重排序==>加volatile关键字

3.效率提高==>判断是不是需要加锁===>两个if

🔴阻塞队列

阻塞队列是什么

我们已经知道普通队列具有先进先出的特性,事实上还有一些特殊队列,并不一定遵守先进先出的规则,它们往往带有一些特殊功能。

常见的功能队列,除了阻塞队列,还有单调队列/优先级队列、消息队列等。

阻塞队列:具有阻塞/等待功能的队列。如果当前队列已满,禁止入队,尝试入队操作所在的线程进入当前对象的阻塞队列;如果当前队列为空,禁止出队,尝试出队操作所在线程进入当前对象的阻塞队列。

单调队列/优先级队列:具有优先级的队列,优先级的确定规则可以人为规定。

消息队列:是在阻塞队列基础上加了“消息类型 ”,按照“消息类型”进行先进先出。这是一种数据结构。而由于消息队列应用的过于广泛,所以,有业界大佬将其实现成为了一个类似mysql一样的基于客户端-服务器功能工作的程序。此时它的存储能力和转发能力都大大提高,实际开发中很多大型项目都会有它的影子。如果想更好的理解它就必须先理解好阻塞队列。

标准库中的阻塞队列

标准库中提供了实现阻塞队列功能的类和接口。虽然说,阻塞队列本质上还是一个队列,也就是说实现了Queue接口,也有普通队列有的offer、poll等方法,但是我们使用阻塞队列主要使用的不是这些,而是它特有的阻塞功能,此时对应的入队和出队操作的方法分别对应的是put和take方法。我们这里主要演示这两种方法。

另外,实现此接口的类常见的有ArrayBlockingQueue,背后的数据结构是数组;LinkedBlockingQueue,背后的数据结构是链表,还有PriorityBlockingQueue,背后的数据结构是堆。除此以外还有双端队列的阻塞队列,但目前我们主要掌握前三种。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vEuv76JA-1673617220934)(F:\typora插图\image-20230113182243850.png)]

public class Code24_BlockingQueueTest {public static void main(String[] args) throws InterruptedException {BlockingQueue blockingQueue=new LinkedBlockingQueue<>();blockingQueue.put(5);blockingQueue.put(3);blockingQueue.put(4);blockingQueue.put(2);System.out.println(blockingQueue.take());System.out.println(blockingQueue.take());System.out.println(blockingQueue.take());System.out.println(blockingQueue.take());System.out.println(blockingQueue.take());}
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-02XTwHuE-1673617220935)(F:\typora插图\image-20230113182702315.png)]

这里取完了,再取进入阻塞比较好演示;但是放满了,再放不是特别好演示,以及目前个人水平的有限,所以只给出我结合网上资料以及个人对源码的理解给出一个暂时的结论,如若发现,会立即更正。

数组实现的阻塞队列——ArrayBlockingQueue:没有无参构造函数,必须传入初始容量,并且目前没发现同ArrayList等类相似的扩容机制。

链表实现的阻塞队列——LinkedListBlockingQueue:一般来讲,链表没有最大容量的概念,这里提供了一个无参构造函数。基于链表实现的阻塞队列规定了最大容量是整形的最大值。

堆实现的阻塞队列——PriorityQueueBlockingQueue:提供了无参构造函数、有参构造(只容量的、只比较器的、容量加比较器的)。同时无参构造下,阻塞队列有一个默认的容量11,同时,基于堆实现的阻塞队列可以进行扩容操作。可以说,它基本上无界的阻塞队列。

基于上述原因,满了不能放的阻塞情况不是特别好演示,所以请大家自行“脑补”

典型应用场景:生产者消费者模型

正因为阻塞队列重要的阻塞特性与现实很多场景有重合,所以,基于此,大佬开发了“生产者消费者模型”。我们下边来详细了解一下。

对于阻塞队列,生产者是添加元素的一方,消费者是取元素的一方,产品是阻塞队列的元素。生产者和消费者通过阻塞队列相互联系。

这样说可能会比较抽象,所以下边我们来举个例子

例如:服务器之间的相互调用。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TYW9ob7Q-1673617220935)(F:\typora插图\image-20230113203917457.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TbQaRXwC-1673617220936)(F:\typora插图\image-20230113203937981.png)]

生产者消费者模型是一种非常典型的开发模型。它有什么作用呢?第一,它实现了发送方和接受方的解耦(白话:关联程度)。

第二,它可以削峰填谷,保证系统稳定性。这句话可能比较抽象,我们试着想象,假定你在维护一个服务器,突然很多用户同一时刻发送请求,那么如果服务的负载能力不强,承受不了大规模的冲击,很可能会崩掉。但是如果我们使用一个负载能力非常强(即队列容量非常大)的阻塞队列作为缓冲,就可以很大程度的缓解这个问题,即使某一时刻用户请求量非常大,负责具体业务的服务器也能正常工作,或者某一时刻用户请求量非常少,负责具体业务的服务器也不会过于悠闲。

利用系统提供的BlockingQueue实现生产者消费者模型

public class Code27_BQCP {public static void main(String[] args) {BlockingQueue blockingQueue=new LinkedBlockingQueue<>();Thread customer=new Thread(()->{while(true){try {Thread.sleep(500);int thing= (blockingQueue.take());System.out.println("消费元素:"+thing);} catch (InterruptedException e) {throw new RuntimeException(e);}}});Thread producer=new Thread(()->{int count=0;while(true){try {blockingQueue.put(count);count++;System.out.println("生产元素:"+count);Thread.sleep(500);} catch (InterruptedException e) {throw new RuntimeException(e);}}});//这里顺序不用手动控制,因为是在阻塞队列中,随便的顺序都可以customer.start();producer.start();}
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UprIZtz5-1673617220937)(F:\typora插图\image-20230113205315449.png)]

观察运行结果,很容易看出,生产元素和消费元素基本上成对出现的,同时,不会出现要消费了,元素还没有生产出来的情况。

阻塞队列的实现

(一)思路分析

这里我们使用数组实现阻塞队列,因为阻塞队列只是在原来的基础上加了个特殊功能,类比数组实现普通队列,很容知道,此处我们需要使用逻辑上的“循环/环形数组”

我们要想实现阻塞队列,那么首先实现一个普通队列,只不过不需要普通的队列那么多的成员方法,只需要一个put和一个take,实现逻辑类比offer和poll。

首先,我们决定使用数组实现,先定义一个数组。大小自己决定。类型的话,我们这里只是为了感受阻塞队列的实现逻辑,并不需要跟源码一样,非要泛型,这里为了方便编写,我们采用int类型。

又因为是循环数组,所以需要begin和end两个指针,同时为了方便,我们采用增加一个size变量而非浪费一个空间实现唤醒数组。

其次,put方法中,我们需要首先判断队列有没有满,其次不满的话将end位置设置成指定值,end++,同时将size++,注意,这里需要对end位置合法性进行判断,如果超过长度,直接置为0.需要特别说明的是,这里的判断和重新设置end的方法,虽然取余的方法经常用,但是不是特别清晰明了,所以我们这里直接采用end>=array.length,一旦中这个逻辑直接end=0;同理take方法中,begin也是这样,只不过begin是++,size–。

然后,现在我们要考虑阻塞的功能了。对于阻塞无非就是如果满/空需要当前线程需要阻塞等待,由于调度的随机性,这个时间我们无法准确估计,那么相较于sleep,我们使用wait和notify可能更为恰当一些。

class MyBQ{private int[] items=new int[500];private int begin=0;private int end=0;private int size=0;public void put(int a) throws InterruptedException {if(size==items.length){this.wait();//采用声明中断异常处理这个异常}items[end++]=a;if(end>= items.length){end=0;}size++;this.notify();}public Integer take() throws InterruptedException {if(size==0){this.wait();}int ret=items[begin++];if(begin>=items.length){begin=0;}size--;this.notify();return ret;}
}

当然这里可能有人会疑问这两对wait/notify不会混吗?答案显而易见不会,因为一旦线程进入阻塞等待状态,当前的的队列要么空要么满,只可能中一个逻辑,没有其他可能。

最后,单线程下这个代码没什么问题了,那么多线程下呢??显然是有问题的。下边我们来具体分析问题在哪,并且需要怎样的方法去解决。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ArNedaEt-1673617220937)(F:\typora插图\image-20230113213302409.png)]

除此以外,多线程环境下很可能会出现wait完当前队列并不是空的或者并不是满的情况,这个时候需要加上再进行多次判断,直至符合要求,所以这里我们需要加上一个while循环进行判断。

(二)代码实现

class MyBQ{volatile private int[] items=new int[500];volatile private int begin=0;volatile private int end=0;volatile private int size=0;synchronized public void put(int a) throws InterruptedException {while(size==items.length){this.wait();}items[end++]=a;if(end>= items.length){end=0;}size++;this.notify();}synchronized public Integer take() throws InterruptedException {while(size==0){this.wait();}int ret=items[begin++];if(begin>=items.length){begin=0;}size--;this.notify();return ret;}
}
public class Code25_MyBlockingQueue {public static void main(String[] args) throws InterruptedException {MyBQ2 myBQ=new MyBQ2();myBQ.put(5);myBQ.put(6);System.out.println(myBQ.take());System.out.println(myBQ.take());Sstem.out.println(myBQ.take());}
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aBAa9WNz-1673617220938)(F:\typora插图\image-20230113213534623.png)]

显然,当没有元素的时候,当前线程进入阻塞等待。

自实现阻塞队列下的生产者消费者模型

class MyBQ2 {volatile private int[] items=new int[500];volatile private int begin=0;volatile private int end=0;volatile private int size=0;synchronized public void put(int a) throws InterruptedException {while(size==items.length){this.wait();}items[end++]=a;if(end>= items.length){end=0;}size++;this.notify();}synchronized public Integer take() throws InterruptedException {while(size==0){this.wait();}int ret=items[begin++];if(begin>=items.length){begin=0;}size--;this.notify();return ret;}
}
public class Code26_MyBQCP {public static void main(String[] args) throws InterruptedException {MyBQ2 myBQ=new MyBQ2();Thread customer=new Thread(()->{while(true){try {Thread.sleep(500);int thing= (myBQ.take());System.out.println("消费元素:"+thing);} catch (InterruptedException e) {throw new RuntimeException(e);}}});Thread producer=new Thread(()->{int count=0;while(true){try {myBQ.put(count);count++;System.out.println("生产元素:"+count);Thread.sleep(500);} catch (InterruptedException e) {throw new RuntimeException(e);}}});//这里顺序不用手动控制,因为是在阻塞队列中,随便的顺序都可以customer.start();producer.start();}
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ndW09Eju-1673617220939)(F:\typora插图\image-20230113213752518.png)]

参考

ArrayBlockingQueue理解参考

相关内容

热门资讯

阿西吧是什么意思 阿西吧相当于... 即使你没有受到过任何外语培训,你也懂四国语言。汉语:你好英语:Shit韩语:阿西吧(아,씨발! )日...
猫咪吃了塑料袋怎么办 猫咪误食... 你知道吗?塑料袋放久了会长猫哦!要说猫咪对塑料袋的喜爱程度完完全全可以媲美纸箱家里只要一有塑料袋的响...
demo什么意思 demo版本... 618快到了,各位的小金库大概也在准备开闸放水了吧。没有小金库的,也该向老婆撒娇卖萌服个软了,一切只...
苗族的传统节日 贵州苗族节日有... 【岜沙苗族芦笙节】岜沙,苗语叫“分送”,距从江县城7.5公里,是世界上最崇拜树木并以树为神的枪手部落...