阻塞队列和线程池

目录

1.阻塞队列

1.1生产者消费者模型

1.2生产者消费者的两个优势

1.2.1 解耦合

1.2.2削峰填谷

1.2.3生产者消费者模型付出的代价

2.标准库中的阻塞队列(性质的验证)

3. 线程池

3.1标准库中的线程池

3.1简化线程池的使用


1.阻塞队列

阻塞队列是一种特殊的的队列,也遵守先进先出的原则。阻塞队列是一种线程安全的数据结构,并且具有以下特性:

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

1.1生产者消费者模型

生产者消费者模型就是通过一个容器来解决生产者和消费者的强强耦合问题。生产者和消费者彼此之间不直接通讯,而是通过阻塞队列来进行通讯,所以生产者生产完数据之后等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列中取数据。

这里我们可以打个比方,家里人一起包饺。包饺子可以分为两个步骤一,是擀饺子皮,二是包饺子。如果一个人一边擀饺子,又一边包饺子,另一个人也想擀饺子皮就需要等待第一个人把饺子皮擀完之后才能拿到擀面杖,这时速度就会比较慢。那如果一个人擀饺子皮,一个人包饺子,这样速度就会很快。擀完饺子皮之后我们可以把饺子皮放在盖帘上后面包饺子的人就可以直接从盖帘上取,这里的生产者就是擀饺子皮的人,消费者就是包饺子的人,盖帘就是阻塞队列。如果饺子皮没有了这是消费者只能阻塞等待,等到生产生产出来。这个时候有人想那阻塞了不也会影响效率,效率反而没那么高了。其实这里可以根据双方的效率进行调整,此处谈到的阻塞是在极端情况下,如果生产者效率低可以加个生产者来解决速度不匹配的问题。 

1.2生产者消费者的两个优势

1.2.1 解耦合

耦合指模块之间的关联关系

比如两个服务器之间,A服务器需要把请求发送到B服务器里面B服务器进行执行,同时B服务器需要对A服务器进行响应。如果A直接访问B,此时A和B的耦合度就会更高。编写代码A的时候,多多少少有一些和B相关的逻辑。编写代码B的时候,也会有一些A相关的逻辑。如果通过阻塞队列模型进行交互的话,A和队列交互,B和队列交互,A和B不直接交互了。A的代码和B的代码只能看到队列。A的代码看不见B,B的代码也看不见A。这里面通过降低耦合方便后面的修改。

1.2.2削峰填谷

这里我们可以理解成服务器请求量的曲线图。A服务器这边有大量的一股流量激增,此时每个请求都会转发给 B,B也会承担一样的压力,很容易就把B给搞挂了。为什么是B挂了?

一般说A这种上游的服务器,尤其是入口的服务器,干的活更简单,每个请求消耗的资源数少,像B这种下游服务器,通常承担更重的任务量,复杂的计算/存储工作,单个请求消耗资源数更多。日常生活中,确实会给B这样的角色分配更好的机器,很难保证B承担的访问量能够比A更高。

同时我们需要知道的是服务器处理每个请求时都需要消耗一定的硬件资源的,包括CPU、内存、硬盘、网络宽带。同时有N个请求,消耗量*N,一旦消耗量超出了机器资源的上限,此时,对应的进程就可能崩溃或者操作系统产生卡顿。

接下来我们继续理解这两个服务器面对激增流量的处理方式。A激增流量达到波峰,面对突发的情况时间也会短,趁着峰值过去了,B仍然继续消费数据利用波谷的时间,A赶紧来消费之前积压的数据。而B这边可以不关心队列中的数据量多少,就是按照自己的节奏慢慢处理队列中的请求即可。阻塞队列这边一般都不会出什么事情,阻塞队列服务器针对单个请求,做的事情也少(存贮、转发)队列服务器往往是可以抗很高的请求量。这就是削峰填谷。

1.2.3生产者消费者模型付出的代价

  • 引入队列之后整体的结构会更复杂,此时就需要更多的资源进行部署,生产环境的结构会更复杂,管理起来更麻烦
  • 效率会有影响,本来是直接调用的关系现在通过队列来进行一次交互,生产者和队列进行一次交互,消费者也和队列进行一次交互,并且数据在队列流转中也是要时间,所以效率会受到影响。

2.标准库中的阻塞队列(性质的验证)

java标准库中提供了一些阻塞队列,如果我们需要在程序中使用阻塞队列,直接使用标准库中的即可,接下来我们可以简单的实现阻塞队列。

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class Demo1 {
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> blockingQueue = new LinkedBlockingQueue<>();
        blockingQueue.put("aaa");
        String elem =  blockingQueue.take();
        System.out.println(elem);
    }
}

这里的添加和删除必须使用put和take才有阻塞功能,如果使用offer和poll当然也可以但是不带有阻塞的功能。同时在这里使用put和take会抛出异常,put和take带有阻塞功能抛出的异常InterruptedException具有唤醒线程的作用。

接着我们可以通过代码来验证前面所说的性质,此时如果不进行put队列直接出队列那么会怎么样?此时我们发现什么都没打印,此时我们通过jconsole.exe来观察线程的状态我们可以看到WATING状态说明此时线程产生了阻塞

接下来我们继续判断第二点 ,如果我们此时队列已经满了,再添加元素此时我们可以发现结果只显示队列已满之后开始阻塞了,而且我们可以看到是在第几行开始阻塞。

同时在这里我们需要注意的是capacity,如果我们不设置容量默认是一个非常大的值,实际开发一般建议大家能够设置上你要求的最大值,否则你的队列可能变得非常大,导致把内存消耗,会存在内存超出范围的异常。进入LinkedList源代码我们可以发现他定义的最大是无穷大。

接下来我们写两个线程一个代表生产者一个代表消费者者,如果我们直接运行很难看到两个线程阻塞。但是如果在一个线程中加sleep另一个线程不加sleep我们就会获得不一样的效果。

如果我们在生产者中加入一个sleep,那么生产者的速度就会慢下来,那么对列就会出现为空的现象此时我们会发现速度会比之前慢很多。

 如果在消费线程中加入sleep,此时队列就会出现为满的情况,但是消费线程还是按照自己的速度来消费,如果不加限制那程序会不会一直执行导致程序挂了呢?

我们的队列最多是存21亿元素,每个元素是一个4字节,就算此时没有容量限制也不会挂。21亿元素极端情况下占用的内存也是8GB,不会出现挂的现象。此时我们只需要记住几个数字就可以知道所占用的内存空间。

10亿字节 =>G  

百万 => M

千 => K

接下来我们来模拟实现一下简单的阻塞队列

import java.awt.datatransfer.StringSelection;
import java.util.PriorityQueue;
import java.util.concurrent.BlockingDeque;
class MyBlockingQueue {
    private String [] data = null;
    private int head;//出队列的时候就把元素出掉之后head进行加加
    private  int tail;//入队列时在数组中添加一个元素 tail下标就往后移到最后一个元素时回到0位置
    public MyBlockingQueue(int capacity){
        data = new String [capacity];
    }
    private int size = 0;
    public void put(String elem){
        synchronized (this) {
            if (size >= data.length) {
                return;
            }

            data[tail] = elem;//把元素elem放入tail下标
            tail++;
            if (tail >= data.length) {
                tail = 0;
            }
            size++;
        }
    }
    public String  take(String elem) {
        synchronized (this) {
            if (size == 0) {
                return null;
            }
            String e = data[head];
            head++;
            if (head >= data.length) {
                head = 0;
            }
            size--;
            return e;
        }
    }
        }

这里关于阻塞队列的一段代码我们可以进行思考一下,如下图所示。这两个代码的意思是一样的,但是第一个对于我们来说更好理解,对于第二个的求余操作其实就是当tail到达数组的最后一个下标时进行的计算使tail下标重新变为0。日常开发中我们只需要关注代码是否容易理解是否容易修改就好。

上面的代码知识简单的基本模型但是没有真正的实现带有阻塞功能,此时我们就可以加入wait来进行操作。我们接着可以基于前面的性质在队列中添加wait进行阻塞。

第一个当take执行成功的队列不为满的时候,唤醒

第二个当put执行成功队列不为空的情况下,唤醒

 此时我们加入notify进行唤醒,对应的唤醒如下图所示:

基于这些唤醒我们可以知道当线程进行阻塞的时候要么是在put,要么是在take。比如我们有若干个线程,不可能一些线程在put进行阻塞,一些线程在take进行阻塞。因为队列不可能即为空又为满。如果队列为0我们是没办法进行装东西的。但是此时代码中还存在一些问题,我们打开wait原码就可以发现。

 

 这里建议我们使用while循环来进行操作。如果我们不进行while循环,在take中如果队列出现为空的情况再执行下面的操作时会遇到size--,本来就是0再进行减减此时不就变为-1了吗?正常来说,wait的唤醒是通过另一个线程执行put,另一个线程put成功了,此处的size肯定不是0。那为什么会出现上面我所说的情况呢?wait的唤醒不只是notify还有可能是Interrupt这样的方法中段。如果使用if作为wait的判断条件此时的wait就存在被提前唤醒的风险。但是我们现在写的代码直接把异常往外抛了此时并不会出现往下执行的情况,如果在wait中直接进行try catch操作或者写一个超时时间那么程序就会持续往下执行这样就十分危险,前面的情况就发生了。

那加while循环的目的是干什么呢?这里循环的目的是为了“二次验证”判断当前这里的条件是否成立,wait之前先判断一次,wait唤醒也判断一次(再一次确认一下,队列是否不空),如果是if的话那就不具有再次确认的功能。在多线程的情况下如果put(123)都因为队列满阻塞了,第四个线程take了一下执行notify唤醒了前面阻塞的线程,此时即使被唤醒了wait还是会再次判定条件,再次进行阻塞。

这个时候我们自己实现的阻塞队列也就差不多完成了。代码如下:

class MyBlockingQueue {
    private String [] data = null;
    private int head;//出队列的时候就把元素出掉之后head进行加加
    private  int tail;//入队列时在数组中添加一个元素 tail下标就往后移到最后一个元素时回到0位置
    public MyBlockingQueue(int capacity){
        data = new String [capacity];
    }
    private int size = 0;
    public void put(String elem) throws InterruptedException {
        synchronized (this) {
            while (size >= data.length) {
                //如果队列满了我们要进行阻塞
                this.wait();
            }

            data[tail] = elem;//把元素elem放入tail下标
            tail++;
          if (tail >= data.length) {
             tail = 0;
         }

            size++;
          this.notify();
        }
    }
    public String  take() throws InterruptedException {
        synchronized (this) {
            while (size == 0) {
                //当队列为空的时候也进行一次阻塞
                this.wait();
            }
            String e = data[head];
            head++;
            if (head >= data.length) {
                head = 0;
            }
            size--;
            this.notify();//唤醒put里面的wait
            return e;
        }
    }
        }
public class Demo2 {
    public static void main(String[] args) {
        MyBlockingQueue queue = new MyBlockingQueue(1000);
        Thread producer = new Thread(()->{
            int n = 0;
            while(true){
                try {
                    queue.put(n+" ");
                    System.out.println("生产元素"+n);
                    n++;
                 //   Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

            }
        });
        Thread customer = new Thread(()->{
            while(true) {
                String n = null;
                try {
                    n = queue.take();
                    System.out.println("消费元素" + n);
                    //Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        producer.start();
        customer.start();

    }
}

如果在生产者中加入sleep也会出现阻塞现象,消费者加入此等sleep也是一样会出现前面相同的情况。

3. 线程池

线程池就是把线程提前创建好,放到一个地方(类似于数组),需要用的时候随时去取,用完了还回池子中。

相比于线程池我们更熟悉的可能是常量池。常量池是字符串在java程序最初构建的时候就一定准备好,等程序运行的时候,这样的常量就加载到内存中。使用的时候直接从内存构造好的线程里面拿出来,省下了构造开销的时间。计算机中池都是一个意思,不仅仅是线程池还有进程池、内存池、数据库连接池,都是备用可以随时拿出来使用。

最初引入线程的原因是频繁的创建销毁进程太慢了,随着互联网的发展,我们对于线程性能的要求更进一步,频繁的创建销毁线程咋们有点不能接受了。因此发明了线程池和协程(协程目前在圈子里还没有普遍使用),线程池就是为了让我们高效的创建销毁线程,减少每次启动、销毁线程的损耗 。 

那为什么直接创建线程开销比从池子里取线程更大?

那要理解这样的逻辑我们需要了解更多关于操作系统的用户态和内核态。

一个操作系统 = 内核 + 配套的应用程序。其中内核包含各种核心功能,它的功能一是管理硬件设备(如鼠标),二是给软件提供稳定的运行环境(关于进程管理进程调度)。一个操作系统,内核就是一份,一份内核要给所有的应用程序提供服务支持。如果一段代码是应用程序自行完成的,整个执行过程是可控的,如果一段代码需要进入到内核中,由内核完成一系列工作,整个过程是不可控的,咋们程序员写的代码干预不了。从线程池取现成的线程纯应用程序代码可以完成(可控的),从操作系统创建新线程就需要操作系统内核配合完成(不可控),使用线程池可以省下应用程序切换到内核中运行这样的开销。因此通常认为可控的过程比不可控的过程更高效。

3.1标准库中的线程池

Java标准库中提供了可以直接使用的线程池:ThreadPoolExecutor。标准库中的线程池就是准备好了一些线程,让这些线程执行一些任务。其中ThreadPoolExecutor中的核心方法就是submit(Runnable)通过Runnable描述一些要执行的任务,通过submit任务放到线程池中,此时线程池里的线程会执行这样的任务。构造这个类的时候构造方法比较麻烦(参数有点多)。

要学会使用那就必须了解这些参数的含义

1.int corePoolSize:核心线程数。至少有多少个线程,线程池一创建这些线程也要随之创建,直到整个线程池销毁这些线程才会销毁。

2.int maximumPoolsize:最大线程数,核心线程数+非核心线程(自适应 不繁忙就销毁,繁忙就再创建)。java线程池里面包含几个线程,是可以动态调整的,任务多的时候,自动扩容成更多的线程;任务少的时候,把额外的线程干掉,节省资源。

3.long KeepAliveTime 非核心线程允许空闲的最大时间

4.TimeUnit unit (枚举类型表示时间)时间单位,类似下面的样子

5.BlockingQueue<Runnable>workQueue:工作队列

线程池本质上也是生产者消费者模型,调用submit就是在生产任务,线程池里的线程就是在消费任务。生产者消费者之间就需要这样的阻塞队列来传递数据,此时这个workQueue就是起到传递任务的作用。线程池自己指定任务队列,可以灵活的选择使用数组还是链接,指定capacity,指定是否要带有优先级、比较规则。

6.ThreadFactory threadFactory:线程工厂。是工厂模式的体现,是标准库中给线程类提供的工厂类。此处引入工厂模式目的不是提供多种构造方式,此处是想通过类检验初始化的操作。线程中有一些属性是可以设置的比如前台后台这些,线程池是一组线程在设置这些模式时肯定是要统一设置,通过 threadFactory统一的构造初始化线程。

工厂模式也是一种设计模式(编程固定的套路)和单例模式是并列的关系,用来弥补构造方法的缺陷。构造方法的缺陷有哪些地方呢?我们来举个例子,比如我们设置一个点坐标。首先我们我们想到的是x轴y轴,我们构造一个方法来表示x、y。但是其实我们还有一种方法来表示坐标,如 x = r * cos(a),y = r * sin(a)。但是此时再构造一个构造方法时我们无法做到参数类型相同也就是无法重载,导致出现错误。在这里我们同时要注意的是,构造方法的名字是固定的要提供不同的版本,就需要通过重载有时候不一定能构成重载。

那如何解决这个问题呢?工厂模式就是用来解决上述问题的。工厂方法的核心就是通过静态方法,把构造对象new的过程(各种属性初始化的过程),封装起来。提供多组静态方法,实现不同情况的构造。

import java.awt.*;
import java.util.concurrent.TimeUnit;
 
  class PointFactory{
      public static  Point makePointByXY(double x, double y){
          Point p = new Point();
          //通过 x 和 y 给P进行属性设置
          return p;
      }
      public static Point makePointByRA(double r,double a){
          Point p = new Point();
          //通过 r 和 a 给P进行属性设置
          return p;
      }
  }
public class Demo1 {
    public static void main(String[] args) {
       Point p = PointFactory.makePointByRA(10,20);


    }
}

 7.RejectedExecutionHandler handler:拒绝策略,最重要最复杂的。

那什么叫拒绝策略呢?我们知道在sumbit把任务添加到阻塞队列中,如果队列满了再添加,任务队列就是阻塞队列。但是在线程池里面不希望程序阻塞太多,对于线程池来说,发现入队列操作时如果队列满了,不会真的触发“入队列操作”,不会真阻塞。而是执行拒绝策略相关代码,舍弃一些任务或者做一些处理。接下来我们可以看看java官方文档对此的描述,四个类代表了不同的拒绝策略。

1AbortPolicy线程池直接抛出异常,线程池就可能无法继续工作

2.CallerRunsPolicy让调用submit的线程自行执行任务。submit方法暗藏玄机,一是判断队列是否满,二是判断当前策略是否CallerRunsPolicy,紧接着submit内部就会直接调用Runnable.run()

该段程序在线程池中的执行策略如下图所示。如果一个进程里面有好几个线程正常情况下是直接执行put在线程池里添加一组线程来执行任务,假设有四个线程abcd又有一个新的线程x调用submit方法此时如果符合if条件就直接由submit线程自己执行Runnable.run()方法

3DiscardOldestPolicy丢弃队列中最老的任务

4DiscardPolicy丢弃最新的任务,当前submit的这个任务

这里我们可以举个例子,老板让我立刻做一件事情但是手头上还有旧的任务也要立刻执行此时我们对应采取下面的措施

1.

我直接哭了起来,心态崩了

2.

老板让我进行执行 ,我直接说老板我实在没空老板你自己解决吧

3.

老板说你手上的旧任务不要执行了,先执行新的任务

4.

老板说这个新的任务先不执行了,有空再说

3.1简化线程池的使用

针对上面的使用的复杂情况,java标准库也提供了一组类针对ThreadPoolExecutor进行了进一步封装,简化线程池的使用也是基于工厂设计模式,工厂设计模式不仅可以解决版本问题还可以使构造对象更简单。使用代码如下所示:

import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Demo1 {
    public static void main(String[] args) {
       ExecutorService threadPool = Executors.newFixedThreadPool(4);
        for (int i = 0; i < 1000; i++) {
            int id = i;
            threadPool .submit(()->{
                System.out.println("hello " + id + "," +Thread.currentThread().getState());
            });
        }

    }
}

同时我们也可以使用,该方法没有指定线程个数一般开销会比较大可以无限增加。但是有些公司会明确规定使用ThreadPoolExecutor这个版本因为Executor线程数目、拒绝策略等信息都是隐士的不好控制。

3.2.实现一个固定数量的线程

代码如下:

import java.util.Collection;
import java.util.Iterator;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.TimeUnit;

//实现一个固定线程数量的线程池
class MyThreadPool{
    //初始化线程池,创建固定线程数量的线程
    //这里使用ArrayBlockingQueue作为任务队列,容量为1000
    public BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>(1000);
    public MyThreadPool(int n){

        for(int i = 0; i < n ; i++){
            Thread t = new Thread(()->{
                try {
                    while (true) {
                        Runnable task = queue.take();
                        task.run();
                    }
                }catch (InterruptedException e){
                    throw new RuntimeException(e);
                }
            });
            t.start();
        }
    }
    //创建n个线程
    public void submit(Runnable task) throws InterruptedException {
     //将任务放入队列中
        queue.put(task);
    }
}
public class Demo5 {
    public static void main(String[] args) throws InterruptedException {
    MyThreadPool pool = new MyThreadPool(10);
        for (int i = 0; i < 100; i++) {
            int id = i;
            pool.submit(() ->{
                System.out.println(Thread.currentThread().getName()+" id "+id);
            });
        }
    }
}

该段代码首先初始化线程池,创建固定线程数量的线程,这里使用ArrayBlockingQueue作为任务队列,容量为1000。接着我们创建submit方法往线程池中添加任务(任务就是Runnable)。最后我们在构造方法中把指定的线程个数创建出来同时持续不断尝试读取任务,取到了就执行没取到就阻塞等待。

写完上述代码运行结果发现进程并没有结束,线程池里的这些线程还在take阻塞。这是因为线程池中的线程是前台线程阻止进程结束。在start加上 t.setDaemon(true) 就变成了后台线程,但是其实不变成后台线程也是一样的,在java标准库中的线程池里面也是设置成前台线程。但是java这里也提供了一个方法可以使进程结束的方法,但是不能保证任务全部执行完毕,如下所示:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Dem2 {
    public static void main(String[] args) {
       // ExecutorService threadPool = Executors.newCachedThreadPool();
        ExecutorService threadPool = Executors.newFixedThreadPool(4);
        for (int i = 0; i < 1000; i++) {
            int id = i;
            threadPool .submit(()->{
                System.out.println("hello " + id + "," +Thread.currentThread().getState());
            });
        }
        //把线程池里面的线程全部关闭,但是不能保证线程池里的任务全部执行完毕
            threadPool.shutdown();
    }
}

如果想要全部执行完毕的话就需要 awaitTermination 方法。

在运行完成看代码结果的时候按下旁边的暂停按钮会发现结束时有如下标识:

和线程自动结束后的标识符不一样

这是什么意思呢?上面的数字标识符是退出码的意思,和C语言的return 0 是一样。main函数的返回值就是进程腿粗码,操作系统中约定了退出码为0表示正常结束,非0表示异常结束(不同的数字表示不同的原因),java中System.exit也是类似的效果。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值