JavaEE & 线程案例 & 单例模式 and 阻塞队列

Posted s:103

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JavaEE & 线程案例 & 单例模式 and 阻塞队列相关的知识,希望对你有一定的参考价值。


你太不小心了,被我抓住了~

你逃不了了~


文章目录

JavaEE & 线程案例 & 单例模式 and 阻塞队列

  • 单例模式是一种很经典的设计模式~

1. 设计模式

  • 下棋有棋谱

  • 弹奏有乐谱

  • 而计算机设计模式,是前人总结下来的一些代码编写套路

    • 按照这些模式,你代码写得也不会太差~
    • 兜底~
  • 主要是因为大佬的代码和其他人的代码能力差距实在是大

    • 大佬们总结了这些模式帮助我们写好代码~

设计模式有很多种(不是23种~)

2. 单例模式

2.1 单例的含义

  • 单例 ==> single instance ==> 单个实例对象

    • 也就是说,单例模式通过一些Java语法,保证某个类,只能有一个实例,即只能new一个对象~
  • 就有一些场合,限制一个类只能有一个对象,而不是多个对象去分担资源。

  • 这些限制是符合“初心写代码”,“针对性写代码”的~

  • 而单例模式有多种写法:

    1. 饿汉模式(急迫)
    2. 懒汉模式(从容)
      • 在计算机,懒是个褒义词
      • 因为可以节省开销
  • 就比如说,你打开一个1000页的pdf

    • 计算机是直接加载1000页
      • 你也没法1000页一起看,也不一定要看1000页
      • 加载1000页需要大量时间和空间
    • 还是每次只加载你看到的1-2页呢~
      • 看似只加载1-2页,你要去看其他页的时候,再给你加载~
      • 读的次数多,但是开销少~
  • 没错

    • 前者就是饿汉模式,很着急的将所有东西加载
    • 后者就是懒汉模式,非必要不加载,你给什么任务我就只做什么任务~
      • 绝对不多干一点活,非必要不做~

2.2 初步代码设计

2.2.1 饿汉模式

class Singleton 
    private static Singleton instance = new Singleton();
    
    public static Singleton getInstance() 
        return instance;
    
    private Singleton() 

    

public class Test 
    public static void main(String[] args) 
        Singleton s1 = Singleton.getInstance();
        //Singleton s2 = new Singleton();报错~
    

  • 在类加载的时候就急切地实例一个对象了
    • 这个很特殊,相当于静态代码块执行实例操作
    • 这个构造方法加载好,是可以在静态代码块内执行的~
    • 而这个单例的建立,必然是线程启动前,所以有绝对的线程安全~
  1. 特殊的语法场景,该属性是类的属性(类对象上),jvm中,类对象只有一份~
  • 那么instance就仅此一份了~

  • 一方面保证单例的特性----“初心” + “针对性”

  1. 用Java语法去禁止外部实例
  • 这个直接编译都通不过
    • 这要比抛异常方便且有效,因为抛异常在这里就有点闷声禁止
  1. 要打破单例,就必须通过“反射”
  • 反射特别不常规!是为了特定的特殊场景,【破例】去访问private
    * 比如说,玩我的世界的时候,常常有玩家说“这是我最后一次开创造”
    • 可以利用枚举类型,枚举的private属性,反射是访问不到的
      • 完美的单例
  1. 通过类名点的方式,访问这个单例
  • get方法~

2.2.2 懒汉模式

class SingletonLazy 
    private static SingletonLazy singletonLazy = null;

    public static SingletonLazy getSingletonLazy() 
        if(singletonLazy == null) 
            singletonLazy = new SingletonLazy();
        
        return singletonLazy;
    
    private SingletonLazy() 

    


public class Test 
    public static void main(String[] args) 
        SingletonLazy s1 = SingletonLazy.getSingletonLazy();
        SingletonLazy s2 = SingletonLazy.getSingletonLazy();
        System.out.println(s1 == s2);
    


  • 与饿汉不同的是
    1. 类加载的时候,并没有实例单例出来,置为初始值null
    2. 在【需要的时候】,即get方法被调用的时候
      • 判断单例是否被实例(是否为null)
      • 未被实例则立马就实例一个

2.3 线程安全角度分析

2.3.1 对于饿汉模式

  • 饿汉模式的单例,绝对是在线程启动之前,所以这一修改操作,不存在线程安全问题
  • 读操作,本身就没有线程安全问题
  • 所以目前我们认为线程是安全的
    • 自己写的时候线程不安全还是会不安全

2.3.2 对于懒汉模式

  • 对于懒汉模式而言,单例第一次实例是在第一次get的时候

    • 这个时候多条线程可能已经启动了
    • 对于单例未被实例的情况下(为null)
    • 就会进行第一次实例~
  • 而这里就会出现一个很重要的问题!

  • 回忆一波,这个场景很熟悉~

  1. 指令重排序

在这里插入图片描述

  • 指令重排序是一方面原因,可能导致,一些线程get到的单例对象,是没有执行构造方法的【毛坯房】
    • 是因为第3执行后,别的线程判定是否有单例的时候,判定为已有,直接return了~
  1. 原子性不受保证~

  • 如图,这个操作可以分为这两步~

  • 那就有以下这种极端情况~

  • 只要该线程过了“if语句这一关”,那么就会导致,多new一个对象
  1. 总结,这两种本质上,就是这一段代码是不保证“原子性”的,所以,我只需要加锁,就可以解决两个问题~

首先,先提一下单例模式的重要性

  • 在一个大工程中,一个核心的类,一个对象包含的内存数据可能是巨大的,比如100G以上~
    • 这个类只需要一个单例就行了~
    • 假设这个单例管理整个项目的加载的所有内存数据
    • 那确实一个就够了
  • 但是,由于线程不安全,即使是低概率事件而引发多new一次(100G -> 200G)
    • 那就是个大事故了~
    • 并且可能再极端一点,new了3个4个的…

2.4 处理懒汉模式线程不安全问题

  1. 法1:

  1. 法2:

  • 这个是错误的!
  • 这只是解决指令重排序,但是并没有完全解决问题~
    • 仍然有可能会出现两个线程同时过了“if大关”~

  • 这个才是正确的~
  1. 法3:
  • 法1法2都有个弊端
    • 就是加锁太频繁了
    • 加锁这个操作本身就开销大,因为其他线程就得阻塞
    • 而实际情况是,没必要多次锁,只需要锁第一次,以后就不会有事~
  • 所以可以这么搞:

  • 两个if是形式一样的,但是目的是不一样的~
    • 内层if是为了防止多次new
    • 外层if是为了尽量减少加锁的次数
      • 当然,可能有极端情况,锁了两次三次的,但是无伤大雅
      • 因为外层if,依旧不保证原子性~
  • 这样设计,在锁过一次之后,基本情况上,就不会再锁了~
  • 但是这种写法,却又有一个缺陷
    • 就是指令重排序的坑,有被挖出来了
    • 法1法2保证了完全的原子性
    • 但是法3没有,因为外层if的存在,是不原子的~
    • 那就会有以下情况:
      • 因为该情况下,进入if语句是不需要争夺锁的~
      • 所以锁在这里并没有解决指令重排序的后果

  • 所以在这里还要对singletonLazy进行禁止指令重排序操作
  • 即使用volatile~

  • 这样,法3就是较优且解决线程安全问题的方式了~

3. 阻塞队列

  • 队列—>先进先出,排好队~
  • 优先级队列—> PriorityQueue —> 堆
  • 阻塞队列—> 带有阻塞特性

3.1 阻塞特性

  1. 如果队列为空
    • 尝试出队列,就要阻塞等待,直到队列不为空
  2. 如果队列为满
    • 尝试入队列,就要阻塞等待,直到队列不为满
  3. 是线程安全的

3.2 Java标准库内自带的阻塞队列BlockingQueue接口

  • BlockingDeque代表的是双端的队列
    • 对应的就是LinkedBlockingDeque和ArrayBlockingDeque

  • 链表实现,默认最大容量是int的极限最大值

  • 顺序表实现,没有给默认容量,必须自己定~
    • 这很合理,因为如果默认为int极限最大值
    • 一下子创建那么大容量的数组,显然是不合理的

3.2.1 方法1 put入队列

  • 多线程编程特别常见且常有的异常

  • 运行结果是这样的:(ctrl + f2终止程序)

3.2.2 方法2 take出队列

  • 现在队列里有五个元素,我take六次
  • 执行结果是这样的:

  • 确实按照先入先出,但是程序好像并没有结束,这是因为第六次take的时候,发现是空队列,阻塞等待了~
    • 五次则刚好可以结束~
    • 可按ctrl + f2结束程序~


这两个方法是阻塞队列的核心方法,其他方法跟普通队列别无二致~

peek的时候队列空,阻塞等待,但是几乎不用这个方法和其他方法~

3.3 阻塞队列的好处

  • 写多线程代码时,多线程之间若进行数据交互,可以用阻塞队列简化代码编写~

  • 在go语言中,支持多并发编程,并且引入了一个“轻量级线程”的“协程”,协程与协程之间进行交互数据的时候,会通过一个阻塞队列:channel

3.3.1 生产者消费者模型

  • 这是很关键的,服务器开发中一种很常见的代码写法~
  • 我们更希望代码执行起来更像“流水线”一样

举个栗子:

  • 这个包饺子工程,显然方案二的效率会更高
    • 两个线程同时进行,进行数据交互
  • 这个模型就是“生产者消费者模型”

3.3.2 生产者消费者模型的优点

  1. 解耦合
  • 我们常听到一个词语“高内聚低耦合”
    • 这是代码风格的良好习惯~
    • 耦合代表,两个模块关联度越高,耦合性越高,关联度越低,耦合性越低
    • 内聚代表,关联度高的模块应该聚集在一起,则为高内聚,反之,低内聚~

  • 在这里,以阻塞队列为中介,即作为两个线程进行数据交互的桥梁
  • 这样就可以减少生产者与消费者之间的关联度,即解耦合
    • 这样子做有利于防止一个线程bug严重影响另一个线程
  • 生产者只认识队列不知道消费者存在
  • 消费者只认识队列不知道生产者存在
    • 一方挂了对另一方影响较小
    • 并且,引入一个新生产者,和新消费者,都很好办
      • 只需要新人与阻塞队列联系就好了

      • 新人的到来也对其他人影响最小化了

由于阻塞队列非常好使,大佬们将阻塞队列功能单独拎出来做成一个单独的服务器~

  • 消息队列服务器~
  • 这个服务器我们以后可能会用到,核心数据结构就是阻塞队列~
  • 这个服务器会挂吗
    • 会,但是其概率比你写的代码挂的概率低得低得低~
    • 人家可是固定下来的,大佬写的~
  1. 削峰填谷

  • 如图,该曲线可以代表,擀面皮产生的面皮量与时间的关系
  • 而图中出现的波峰与波谷就会产生一些问题
    • 如果没有阻塞队列
      • 波峰的产生会导致消费者一下子接受大量的数据,而常常消费者的“条件”是低于生产者的,所以消费者很可能会遇到麻烦~
      • 波谷的产生会导致消费者很快的消耗完饺子皮,那么就会处于无饺子皮的情景~
    • 而削峰填谷的含义就是 “中和”,让线程之间的交互更加稳定

  1. 生产者生产太多,导致队列满了,则进入阻塞,直到队列不满
  2. 消费者消耗太多,导致队列空了,则进入阻塞,直到队列不空
  • 有点像三峡大坝,上流水太多关闸门防洪,下流水太少开闸门防旱

3.4 代码实现生产者消费者模型

  • 下面将介绍一些情景~

    • 大部分是线程不安全的~

    • 当这只是打印顺序问题~

    • 但是阻塞队列绝对是安全的

    • 还有“死锁”情况

3.4.1 生产者 < 消费者

public class Test1 

    public static void main(String[] args) 
        BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>(5);

        Thread t1 = new Thread(() -> 
            for (int i = 0; i < 25; i++) 
                try 
                    Thread.sleep(100);
                    blockingQueue.put(1);
                    System.out.println("生产1个");
                 catch (InterruptedException e) 
                    e.printStackTrace();
                
            
        );
        t1.start();
        Thread t2 = new Thread(() -> 
            for (int i = 0; i < 25; i++) 
                try 
                    Thread.sleep(10);
                    blockingQueue.take();
                    System.out.println("消耗1个");
                 catch (InterruptedException e) 
                    e.printStackTrace();
                

            
        );
        t2.start();
    
    

  • 我们理论上想要的结果是,生产一个消费一个,因为是消费者在阻塞(速度快)
  • 而结果是:
    • 这里是因为,消费者阻塞被唤醒时,生产者线程还来不及打印那句话~

    • 只需要加一把锁就行了~

      • 至于锁在哪个线程加,无所谓~

  1. 由于线程2比较快,所以它的take基本上是在等put的
    • 由于take和println非原子,所以有以上这种情况
  2. 即使线程2,但是还是有可能put被调度在take前
    • 只是缺少了个线程阻塞的过程罢了,本质上就是线程有元素了,不需要阻塞
    • 皆大欢喜~
    • 但是也因为非原子性,会有以上这种情况~
  • 修改线程2:(快捷键,鼠标选中需包围的语句 + ctrl + alt + t + synchronized)
Thread t2 = new Thread(() -> 
    for (int i = 0; i < 25; i++) 
        try 
            Thread.sleep(10);

            synchronized (blockingQueue) 
                blockingQueue.take();
                System.out.println("消耗1个");
            
         catch (InterruptedException e) 
            e.printStackTrace();
        

    
);
t2.start();

  • 结果:

  • 不要加两把,会死锁!
    • 因为加两把
    • 生产者要填入元素,也要进行阻塞等待(等待锁)
    • 那么就无法唤醒消费者,导致两人都进入阻塞态~

改动两个线程:

public static void main(String[] args) 
    BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>(5);
    Thread t1 = new Thread(() -> 
        for (int i = 0; i < 25; i++) 
            try 
                Thread.sleep(100);
                synchronized (blockingQueue) 
                    blockingQueue.put(1);
                    System.out.println("生产1个");
                
             catch (InterruptedException e) 
                e.printStackTrace();
            
        
    );
    t1.start();
    Thread t2 = new Thread(() -> 
        for (int i = 0; i < 25; i++) 
            try 
                Thread.sleep(10);
                synchronized (blockingQueue) 
                    blockingQueue.take();
                    System.out.println("消耗1个");
                
             catch (InterruptedException e) 
                e.printStackTrace();
            

        
    );
    t2.start();

  • 死锁了:

3.4.2 生产者 > 消费者

public static void main(String[] args) 
    BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>(5);
    Thread t1 = new Thread(() -> 
        for (int i = 0; i < 25; i++) 
            try 
                Thread.sleep(10);
                blockingQueue.put(1);
                System.out.println("生产1个"); 
            catch (InterruptedException e) 
                e.printStackTrace();
            
        
    );
    t1.start();
    Thread t2 = new Thread(() -> 
        for (int i = 0; i < 25; i++) 
            try 
                Thread.sleep(100);
                blockingQueue.take();
                System.out.println("消耗1个");
             catch (InterruptedException e) 
                e.printStackTrace();
            

        
    );
    t2.start();

  • 同样的,一个锁都不加的情况下,也会出现一些差错~

  • 加一把锁后,结果正常~
    • 双锁会死锁~
public static void main(String[] args) 
    BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>(5);
    Thread t1 = new Thread(() -> 
        for (int i = 0; i < 25; i++) 
            try 
                Thread.sleep(10);
                blockingQueue.put(1);
                System.out.println("生产1个"); 
            catch (InterruptedException e) 
                e.printStackTrace();
            
        
    );
    t1.start();
    Thread t2 = new Thread(() -> 
        for (int i = 0; i < 25; i++) 
            try 
                Thread.sleep(100);

                synchronized (blockingQueue) 
                    blockingQueue.take();
                    System.out.println("消耗1个");
                

             catch (InterruptedException e) 
                e.printStackTrace();
            

        
    );
    t2.start();

3.4.3 正常写法

  • 一般不会让两个线程都不sleep

    • 这样,“打印”这个操作就很大概率会结果出错
  • 让一方留足够的时间等对方~


    public static void main(String[] args) 
        BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>(5);
        Object o = new Object();
        Thread t1 = new Thread(() -> 
            for (int i = 0; i < 25; i++) 
                try 
                    System.out.println通过应用flatMap,您实际上确实得到了至少与初始Flux 一样多的发射。我假设你真正想要使用的是collectList(),不是吗?
                
              
                
            
        
    

            

 
             
            【参考方案1】: 
                

    
    

让我们假设这个任务是这样的:

从数据库中获取一些值 当所有值都到达时,将它们包装在请求中并发送出去 带有响应的压缩结果

然后这导致我们是这样的:

Flux<Foo> foos = dao.getAll();
Mono<List<Foo>> everything = foos.collectList();

Mono<MyRequest> request = everything
    // collect the data into another Mono, then into request
    .map(list -> list.stream().map(Foo::getData).collect(toList()))
    .map(data -> new MyRequest(data));

return request.zipWhen(request -> api.send(request));

或者,如果您映射初始 foos,您可以更轻松地收集构建请求:

Flux<Data> data = dao.getAll().map(Foo::getData);
Mono<MyRequest> request = data.collectList().map(MyRequest::new); 

【讨论】:

但我需要从 MyResponse 中返回 Flux 的更改 @MaksymShamanovskyi 为什么?如果你真的需要发射几个脉冲,那么这就是原始代码所做的,所以没有问题。如果您确实只需要发射一次,那么这就是Mono&lt;T&gt; 的用例。如果你想隐藏你做了多少脉冲,为什么不返回一个Publisher?无论如何,还有一个Mono::flux

以上是关于JavaEE & 线程案例 & 单例模式 and 阻塞队列的主要内容,如果未能解决你的问题,请参考以下文章

JavaEE基础

多线程技术&KLT与ULT&多线程实现混合策略

单线程 Flux 中的 Mono

创建线程的7种方法&单线程 VS 多线程

Java Review - 线程池资源一直不被释放案例&源码分析

单例模式——使用GCD实现单例模式 & 非ARC单例模式 &使用GCD和线程锁实现单例模式-b