多线程案例应用

一、阻塞队列

什么是阻塞队列

阻塞队列是在多线程代码中常用的一种数据结构,也具有"先进先出"的原则。与普通队列相比,阻塞队列是一种线程安全的队列,并且具有如下特性:

1、当阻塞队列为空时,继续出队,不会抛出异常,而是会阻塞等待,直到其他线程往队列中添加元素为止。

2、当阻塞队列为满时,继续入队,不会抛出异常,而是会阻塞等待,直到其他线程往队列中删除元素为止。

由于阻塞队列是一个数据结构,所以往往用来管理数据,典型的应用场景是"生产者消费者模型"。

生产者消费者模型

生产者消费者模型是一种用来协调多个线程的设计模式。生产者负责生产数据,消费者用来消耗数据。

生产者与消费者之间通过一个阻塞队列来维护数据,即生产者将生成的数据放进阻塞队列中,消费者从阻塞队列中取数据,阻塞队列可以控制数据的流量,防止资源的过度消耗或浪费。

生产者消费者模型的意义

 1、解耦合

耦合指的是两个不同的模块,如果联系紧密(一个模块的修改会影响另一个模块),则称耦合度高。生产者消费者模型通过使用阻塞队列,生产者A不直接与消费者B联系,而是通过阻塞队列,此时耦合就降低了,如果后续增加了一个消费者C,生产者A不需要进行修改,而是只需让消费者C从阻塞队列中取数据即可~

2、削峰填谷

在实际生活中,有一些特定的场景,例如"学校抢课",服务器会在某一时刻收到大量请求,如果直接处理这些请求,服务器可能会扛不住(在处理的某些环节中,可能比较脆弱,如:数据库操作),这时候就可以把这些请求放到阻塞队列中,后续让消费者线程慢慢的处理。


标准库中内置的阻塞队列

1、BlockingQueue是一个接口,实现的类是ArrayBlockingQueue和LinkedBlockingQueue,即底层有链表和数组实现的类。

2、put方法用于阻塞式的入队了,take方法用于阻塞式出队列,offer、poll方法不带有阻塞特性。

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("hello");
        while(!blockingQueue.isEmpty()){
            System.out.println(blockingQueue.take());
        }
        //程序阻塞
        blockingQueue.take();
    }
}

阻塞队列的使用与普通队列类似,注意使用put和take方法即可~

模拟实现阻塞队列

首先实现一个循环队列,然后对put和take方法注入阻塞等待的特性。

class MyBlockingQueue{
    //浪费一个空间,来区分满与空
    private volatile String[] data = new String[2 + 1];
    private volatile int head = 0;
    private volatile int tail = 0;


    public void put(String s) throws InterruptedException {
        synchronized (this){
            while((tail + 1) % data.length == head){
                //如果队列满了,就会阻塞
                this.wait();
            }
            //队列不满,插入元素
            data[tail] = s;
            tail = (tail + 1) % data.length;
            //唤醒take中wait的线程
            this.notify();
        }
    }

    public String take() throws InterruptedException {
        synchronized (this){
            while(head == tail){
                //如果队列为空,阻塞等待
                this.wait();
            }
            String ret = data[head];
            head = (head + 1) % data.length;
            //唤醒put中wait的线程
            this.notify();
            return ret;
        }
    }
}

public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        MyBlockingQueue myBlockingQueue = new MyBlockingQueue();
        Thread t1 = new Thread(() -> {
            try {
                myBlockingQueue.put("1");
                myBlockingQueue.put("1");
                myBlockingQueue.put("1");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        t1.start();
        Thread.sleep(100);

        Thread t2 = new Thread(() -> {
            try {
                System.out.println(myBlockingQueue.take());
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        t2.start();
    }
}

这份代码有一个地方值得注意:put方法中是否需要循环判断队列是否已满?

所以使用wait的时候,需要注意当前wait唤醒的时候,是通过notify唤醒的还是通过interrupt唤醒的,如果是通过notify唤醒的,说明别的线程已经删除了元素,队列不可能为满,如果是interrupt唤醒的,队列可能还满着呢,需要继续判断。因此使用while循环判断的话,就可以保证队列一定是不为满。

总结:使用wait的时候,往往都是使用while作为条件判定的方式,目的是为了让wait唤醒后的线程再确定一次,是否满足条件。上述while循环写法,也是官方文档的建议。

二、单例模式

设计模式———单例模式-CSDN博客

三、定时器

简单使用

定时器是一种非常常用的组件,约定好某一时间,时间到达后,开始执行某些代码(在网络通信中经常出现)。

在java库中内置了一个Timer类,里面有一个核心方法schedule方法。

schedule方法包含了两个参数,第一个是指定要即将执行的任务代码,第二个是指定多久后执行。

import java.util.Timer;
import java.util.TimerTask;

public class Demo1 {
    public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("执行定时器任务");
            }
        }, 1000);
    }
}

TimeTask是一个抽象类,实现了Runnable接口,所以需要进行重写run方法,通过run方法描述任务的详细情况。

主线程在执行schedule方法时,会把这个任务放到timer对象,而在timer对象内部包含了一个"扫描线程",一旦时间到了,扫描线程就会执行刚才安排的任务。(主线程的结束,并不会影响扫描线程~)

模拟实现

分析:

1、首先需要一个类来描述任务以及什么时候执行任务。

2、然后在Timer中需要一个线程,循环判断是否有任务已经到达时间了。

3、最后选择某个数据结构用来管理多个任务,由于一定是时间小的先执行,那么可以使用一个优先队列。


如何描述每个任务?通过创建一个类,这个类中包含一个Runnable属性(描述任务),和一个执行任务时间的属性time(在这使用绝对时间)。

class MyTimerTask{
    //通过重写run,描述执行的任务
    private Runnable runnable;
    //执行任务的时间,这里使用绝对时间
    private long time;

    public MyTimerTask(Runnable runnable, long time) {
        this.runnable = runnable;
        //传入的是相对时间,这里计算绝对时间
        this.time = System.currentTimeMillis() + time;
    }
}

接下来创建Timer类,在Timer类中需要加入一个schedule方法,来添加任务。

class MyTimer{
    private PriorityQueue<MyTimerTask> priorityQueue = new PriorityQueue<>();
    
    public void schedule(Runnable runnable, long time){
        priorityQueue.offer(new MyTimerTask(runnable, time));
    }

    public MyTimer() {
        //描述线程
        Thread thread = new Thread(() -> {
            
        });
        thread.start();
    }
}

由于优先队列会进行比较的操作,所以我们需要让MyTimerTask类实现一下Comparable接口。

class MyTimerTask implements Comparable<MyTimerTask>{
    //通过重写run,描述执行的任务
    private Runnable runnable;
    //执行任务的时间,这里使用绝对时间
    private long time;

    public MyTimerTask(Runnable runnable, long time) {
        this.runnable = runnable;
        //传入的是相对时间,这里计算绝对时间
        this.time = System.currentTimeMillis() + time;
    }

    @Override
    public int compareTo(MyTimerTask o) {
        return (int) (this.time - o.time);
    }
}

通过前面分析,Timer中应该有一个while循环,一直扫描是否有任务到时间该执行了,不过我们还要对还未添加任务的情况处理,跟之前的阻塞队列类似,我们可以让程序先阻塞着,直到添加了任务在进行执行,此时就需要用到wait方法,再进一步发现,在多线程的应用场景下,schedule方法和构造方法可能会同时对队列进行修改操作,因此我们还需要加锁。

完整代码

import java.util.PriorityQueue;

class MyTimerTask implements Comparable<MyTimerTask>{
    //通过重写run,描述执行的任务
    private Runnable runnable;
    //执行任务的时间,这里使用绝对时间
    private long time;

    public MyTimerTask(Runnable runnable, long time) {
        this.runnable = runnable;
        //传入的是相对时间,这里计算绝对时间
        this.time = System.currentTimeMillis() + time;
    }

    public long getTime() {
        return time;
    }

    public Runnable getRunnable() {
        return runnable;
    }

    @Override
    public int compareTo(MyTimerTask o) {
        return (int) (this.time - o.time);
    }
}

class MyTimer{
    private PriorityQueue<MyTimerTask> priorityQueue = new PriorityQueue<>();
    private Object locker = new Object();

    public void schedule(Runnable runnable, long time){
        synchronized (locker){
            priorityQueue.offer(new MyTimerTask(runnable, time));
            locker.notify();
        }
    }

    public MyTimer() {
        Thread thread = new Thread(() -> {
            //扫描线程,需要不停的扫描队首元素,看是否到达时间
            while(true){
                synchronized (locker){
                    //如果当前没有任务,阻塞等待
                    while(priorityQueue.isEmpty()){
                        try {
                            locker.wait();
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    //判断是否到执行时间了
                    long curTime = System.currentTimeMillis();
                    MyTimerTask task = priorityQueue.peek();
                    if(curTime >= task.getTime()){
                        //执行完任务,并删除队首元素
                        task.getRunnable().run();
                        priorityQueue.poll();
                    }else{
                        //如果还没有到时间,怎让线程阻塞等待
                        try {
                            //可以不做等待处理,但是会让线程一直去查看当前是否到达执行时间
                            //会浪费cpu资源,可以让其阻塞至指定时间
                            locker.wait(task.getTime() - curTime);
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                }
            }
        });
        thread.start();
    }
}

四、线程池

为什么要有线程池?

首先我们需要知道为什么要使用多线程而不使用多进程呢?这是因为进程创建太过重量,当需要频繁销毁/创建的时候, 就不能忽视这些资源开销了。而线程依赖与进程,多个线程共享同个进程的资源,不过线程的创建/销毁也会消耗一定的资源(相比进程少),因此到达一定程度,这些开销也不能忽视了!!!

两种解决方案:

1、使用协程

协程相比于线程来说,更加轻量级,因为协程把系统调度的过程给省略了,程序猿可以手动调度。不过在Java中,标准库没有协程,只有一些第三方库中有,但第三方库靠不靠谱?使用协程更多的是Go和Python。

2、使用线程池

在计算中"池"是一个重要的思想方法,例如:线程池、进程池、内存池.......

大致思想就是,一次多创建几个线程,后续要用到的话,直接从池子里取出来,可为什么从池子里取出就比重新创建效率高呢?

原因

在计算机工作中,操作系统会在"内核态"和"用户态"两种状态来回切换,创建一个新的线程,就需要调用系统API,让操作系统的内核去完成,而操作系统内核是需要给所有的进程提供服务的,可能并不会马上回应你,这是不可控的,可能操作系统花费了很多时间才来理睬捏。但如果是从线程池中取出一个线程,这个操作是只需要"用户态"来完成,程序立马就能去执行,这是可控的。

标准库中的线程池

简单使用

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

public class Demo1 {
    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(10);
        
        service.submit(() -> {
            System.out.println("111");
        });
        
    }
}

在这里线程池对象不是直接new出来的,而是通过一个专门的方法,返回了一个线程池对象。(在这使用到了一个设计模式——工厂模式)。

在这使用了Executors.newFixedThreadPool方法,可以创建一个固定包含10个线程的线程池,其返回值类型是ExecutorService,通过调用里面的submit方法,可以将一个任务放到线程池中。


Executors创建线程池有这几种方式

  • newFixedThreadPool: 创建固定线程数的线程池.

  • newCachedThreadPool: 创建线程数目动态增长的线程池.

  • newSingleThreadPool: 创建单个线程的线程池.

  • newScheduleThreadPool: 设置延迟时间后执行命令,或者定期执行命令.

上述的几个工厂方法生成的线程池,本质上是Executors类对ThreadPoolExecutor类的封装。


ThreadPoolExecutor中的重要的方法就两个

  1. 构造方法

  2. submit方法(注册任务)

参数含义

  • int corePoolSize:表示核心线程数(线程池中可以摸鱼的线程数目)。

  • int maxiumPoolSize:表示线程池中最大线程的个数。

  • long keepAliveTime:表示非核心线程可以摸鱼的时间,一到时间就会销毁。

  • TimeUnit unit:表示keepAliveTime的时间单位。

  • BlockingQueue<Runnable> workQueue:表示用来维护任务的容器,但必须为阻塞队列或其子类(优先阻塞队列)。

  • ThreadFactory threadFactory:使用某个工厂对象,线程由这个对象创建。使用工厂类是为了在创建过程中对线程属性做一些修改。

  • RejectedExecutionHandler handler:线程池的拒绝策略,一个线程池中线程数达到了最大容量,当继续往线程池中添加任务,的话就需要采用某种策略来处理。


线程池拒绝策略:

  • AbortPolicy:直接抛出异常。(摆烂~老子不干了!!)

  • CallerRunsPolicy:新添加的任务,由添加任务的那个线程执行。(假装没听到~~依旧是自己干)

  • DiscardOldestPolicy:丢弃任务队列中最老未被执行的任务。(放弃很久之前没做的事,将任务添加进来)

  • DiscardPolicy:丢弃当前新加的任务。(任务都别干了)


如果使用newFixedThreadPool方法来构建线程池的话,初始构建多少个合适?根据cpu核心数设置?

应当根据实际项目来设置。在一个线程中,执行的代码主要有两类:第一类为cpu密集型,在代码中主要进行算术运算/逻辑判断,另一类为IO密集型,在代码中主要进行IO操作。

假设一个线程的代码都是cpu密集型,这个时候线程池中的个数不应该超过cpu的核心数,此时如果超过了,也无法进一步提高效率了,反而回应为太多线程影响调度开销。

假设一个线程的代码都是IO密集型,这个时候不吃cpu,设置的线程数就以超过N,一个核心可以通过调度来执行并发。

所以需要根据实际需求,进行多轮测试的方式,来找到最佳的线程池的线程数目。

模拟实现

在这就实现一个最简单的线程池(固定个数,不使用工厂模式)

要点

  • 线程池中核心的操作时submit方法,将线程添加到线程池中。

  • 由于有多个线程任务,考虑使用阻塞队列来管理这些任务。

实现代码

class MyThreadPool{
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();

    public void submit(Runnable runnable) throws InterruptedException {
        queue.put(runnable);
    }
    
    MyThreadPool(int n){
        //创建n个线程,如果没有任务,则阻塞等待
        for(int i = 0; i < n; i++){
            Thread t = new Thread(() -> {
                try {
                    //如果没有任务,让线程阻塞等待
                    Runnable runnable = queue.take();
                    runnable.run();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            });
            t.start();
        }
    }
}
  • 25
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值