多线程四大经典案例

  时间:2022-05-11 14:14:23  阅读量:12  评论数:0  作者:

本文介绍多线程四大经典案例相关知识,这篇文章的作者不仅说明详细,还时不时举例代码分析,碰到坑的朋友可以阅读理解一下。

本节内容很重要,

油~

目录

1.单线模式

1.1饿汉模式

1.2懒汉模式

2.阻塞式队列

2.1阻塞队列是什么

2.2生产者消费者模型

2.3标准库中的阻塞队列

2.4阻塞队列的实现

3.定时器

3.1定时器是什么

3.2标准库中的定时器

3.3实现定时器

4.线程池

4.1什么是线程池

4.2标准库中的线程池

4.3实现线程池


1.单线模式

①什么是单例模式:

单例模式是校招中最常考的设计模式之一.
②什么是设计模式:
设计模式好比象棋中的 "棋谱". 红方当头炮, 黑方马来跳. 针对红方的一些走法, 黑方应招的时候有一些固定的套路. 按照套路来走局势就不会吃亏。软件开发中也有很多常见的 "问题场景". 针对这些问题场景, 大佬们总结出了一些固定的套路。按照这个套路来实现代码, 也不会吃亏。
③单例模式的特点:
单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例.
④单例模式适用场景以及类型:
这一点在很多场景上都需要. 比如 JDBC 中的 DataSource 实例就只需要一个。单例模式具体的实现方式, 分成 "饿汉" 和 "懒汉" 两种。
这里举一个例子来进一步说明饿汉和懒汉这种模式;
家里面每当吃饭就会面临洗碗这一问题,
a.吃完后马上去洗碗的这种行为我们称为“饿汉模式”
b.吃完后放在那不洗,等下次需要几个的时候去对应洗几个的这种行为,我们称为“懒汉模式”
在日常生活中,饿汉模式更为值得肯定,但是对于计算机而言,懒汉这种模式却能够更大程度地提高效率

1.1饿汉模式

①什么是饿汉模式:

饿汉模式是一种比较着急去创建实例的模式

②在我们写代码之前,我们在这里区分一下实例对象和类对象

static修饰的成员,叫做:"类成员","类属性/类方法"。不加static修饰的成员,叫做:"实例成员","实例属性/实例方法"。在一个java程序中,一个类对象只存在一份(JVM保证的),进一步的也就保证了类的static成员也只有一份

类对象:

类对象就是.class文件,被jvm加载到内存后,表现出的模样。类对象里就有.class文件的一切信息,包括:类名是啥,类里有哪些属性,每个属性的名字,每个属性的类型。

对象:

而对象是基于一个类的模板可以创建出很多的实例(对象)

③饿汉模式的代码:

class Singleton{
    //1.使用static创建一个实例,并且立即进行实例化
    //这个instance对用的实例就是这个类的唯一实例
    private  static  Singleton instance =new Singleton();
    //2.为了防止在其他地方new这个Singleton,就可以把这个Singleton设为私有的
    private  Singleton(){}
    //提供一个方法,让外面能够拿到唯一实例
   public static Singleton getInstance(){
        return instance;
    }
}
public class demo5 {
    public static void main(String[] args) {
        Singleton instance=Singleton.getInstance();
    }
}

1.2懒汉模式

①什么是懒汉模式:

懒汉模式是不那么着急地去创建实例的模式,只是在用的时候,才去创建

②单例模式的懒汉模式代码:

a.你会发现,当多线程时,这个样子由于没有原子性是有很大可能存在线程安全的问题的 

因为我们这里是单例模型,就是这种存在唯一一份实例,而不能存在多份,而在多线程的情况下是会出现多份实例调用的情况的,下面我们举一个双线程的情况进行说明

b. 而要解决上述问题,我们就需要对该读改操作加锁,使它具有原子性

 上述提到的线程不安全情况确实改变了,变得安全,但是若是当一个线程已经完成了初始化,已经变得安全了,但是仍然存在着大量的锁的竞争,这样运行效率就大大的降低了,因此我们要改变这种无脑的加锁,所以我们对上面的代码进行改进。

而通过了这种改进就实现了,如果为空加锁创建实例,如果不为空直接返回的情况。

c.但是现在又会出现新的情况,当多个线程都去读判断条件if(instance==null)时,这个时候读取到的内容若是一致的,就有很大的可能会使线程去寄存器上进行内容的读取,为了解决这种内存可读性的问题,我们用volatile关键字对instance进行修饰。

所以最终完整的代码如下:

class Singleton2 {
    //1.不是立即进行初始化
    //使用volatile来解决内存可读性的问题
   volatile private static Singleton2 instance = null;
    //2.把构造方法设置为private
    private Singleton2() {
    }
    //3.提供一个方法来获取上述单例的实例;
    public static Singleton2 getInstance() {
        //当实例为空真正需要实例的时候才去创建
        //加锁使得它具有原子性
        if (instance == null) {
            synchronized (Singleton2.class) {
                if (instance == null) {
                    instance = new Singleton2();
                }
            }
        }
        return instance;
    }
}
    public class demo5 {
        public static void main(String[] args) {
            Singleton2 instance=Singleton2.getInstance();
        }
    }

2.阻塞式队列

2.1阻塞队列是什么

阻塞队列及其特点:

阻塞队列是具有队列本身的特点,除此之外,阻塞还具有自身的一些特点。

1.线程安全

2.产生阻塞效果:
a.当队列为空时,这个时候不能够再出队列了,要是尝试出队列,就会发生阻塞,阻塞到队列不为空为止
b.当队列为满时,这个时候不能够再入队列了,要是尝试入队列,就会发生阻塞,阻塞到队列不为满为止

2.2生产者消费者模型

①基于阻塞队列实现的“生产消费者模型”

生产消费者模型,是实际开发中非常有用的一种多线程开发手段,尤其是在服务器开发的场景中(下面通过图例来予以说明)

a.优点1:使用生产消费者模型能够让多个服务器之间更充分地解耦合

(解耦合的含义就是:比如服务器B承接了服务器A传来的信息,要是我们这个时候换服务器C来看它是否可以进行接盘。要是像下图这种非生产消费者模型的话,是不能够进行接盘的)

这个时候比如:在开发A代码的时候我们得到了B提供的一些接口,而在开发B代码的时候也得充分知道A是怎么调用的。而一旦把B换成C,A的改动就太大了,而且要是B服务器挂掉了,那么就可能导致A也直接挂掉的情况。

而使用生产消费者模型是如何降低耦合的呢?

 这个时候我们可以发现,A,B通过阻塞队列连接。换句话说阻塞队列就作为了A,B两者交换的场所,那么A,B之间是没有直接关系的,那么要是这个时候,把B换成C,A也完全感知不到(因为C的相关属性也传到了阻塞队列中,这个时候A并不知道自己所获取的实际上是来自谁的),而若B挂掉,对A也没啥影响(阻塞队列里这个时候已经应有尽有了)。

b.优点2: 能够对于请求进行"削峰填谷"

先来看看不用生产消费者模型会遇到的情况:(就是A把压力给到了B,而B可能无法承受住,而导致的崩盘)

若是A作为入口服务器,B作为应用服务器,当某个时刻A的请求暴涨时,对于A而言,计算量很轻,问题不大。但是对于B来说,计算量很大,需要的系统资源也就越多,当请求进一步增加,申请的资源也会进一步增加,如果主机的硬件不够的话,这个时候程序就会挂掉

 而当我们利用了“生产消费者模型”后(A把压力给了阻塞队列来承担,而B的节奏不会改变)

如图,通过阻塞队列A请求暴涨会直接导致阻塞队列的请求暴涨,但是因为阻塞队列没有什么计算量,只是单纯地存个数据,就能够抗住压力,从而对B不会造成压力,B仍以原来的速度来消费数据,就不会由于请求的波动而引起崩溃

 

所以,

所谓的"削峰":很多时候是不连续地暴涨请求,一段时间过去后就恢复了原样

所谓的"填谷":是指B按照原来的频率来执行任务 

2.3标准库中的阻塞队列

在 Java 标准库中内置了阻塞队列. 如果我们需要在一些程序中使用阻塞队列, 直接使用标准库中的即可。对其中的某些属性进行一个说明:

a.BlockingQueue 是一个接口,真正实现的类是 LinkedBlockingQueue。
b.put 方法用于阻塞式的入队列, take 用于阻塞式的出队列。
c.BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性。

直接使用标准库中的阻塞队列代码如下:

import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
public class demo4 {
    public static void main(String[] args) throws InterruptedException {
        BlockingDeque<Integer>queue=new LinkedBlockingDeque<>();
        //1.入队列操作
        queue.put(3);
        //2.出队操作
        int b=queue.take();
        System.out.println(b);
    }
}

2.4阻塞队列的实现

这里是指我们自己来实现上述这个操作。

而我们先要实现一个队列,在队列的基础上加上线程安全,再加上阻塞

而队列可以基于链表,也可以基于顺序表,我们这通过基于顺序表来进行简单的说明。我们这通过来实现循环队列的形式来说明

①谈到循环队列我们就要判定:队列的满与空

(这里我们不详细讲,具体可以通过前面讲过的数据结构Java版队列去看看)

我们可以通过size来对队列进行计数,要是size=0,则说明队列为空;size=array.length的话则说明队列已经满了。注意,当队列为空,或者队列为满时,需要将其位置置于head==0,以便再次进行循环。

②我们要支持线程的安全:

入队列调用put,出队列调用take;

我们可以知道,put和take里面的每一行代码都是在操作公共的变量;既然如此,那么直接给整个方法进行加锁即可(加上synchronized)

③实现阻塞效果:
利用wait和notify;
对于put来说,阻塞条件就是队列为满,而take的阻塞条件为队列为空。

我们在对队列的两者情况进行阻塞之后,我们很容易知道,要是对于队列满的进行了阻塞,那么当它队列元素减少了不再为满的时候就阻塞结束了,而对于队列为空进行了阻塞的话,当它队列中放入了新的元素,那么就可以减少,即对应的阻塞就结束了

完整代码:(里面也有详细解释)

import java.util.Collection;
import java.util.Iterator;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.TimeUnit;
class MyBlocking {
    //基于数组来实现阻塞队列
    private int[] tmp = new int[1000];
    //队列长度
    private int size = 0;
    //队首元素下标
    private int start = 0;
    //队尾元素下标
    private int end = 0;
    //创建一个对象,以便后续进行锁
    private  Object locker = new Object();
    //实现入队列的操作
    public void put(int val) throws InterruptedException {
        //队列为满的情况进行阻塞
        synchronized (locker) {
            if (size == tmp.length) {
                locker.wait();
            }
            //当队列不为满的时候,每入队一个元素,end向后移一位
            tmp[end] = val;
            end++;
            //当start到达末尾,那么说明该次循环已经结束,就要进入新的循环就需要重新进行指定
            if (end >= tmp.length) {
                end = 0;
            }//每成功入队一个元素,那么,size的个数就要加加
            size++;
            //这里的notify()是用于解锁于队列为空而不能进一步出队列的操作。
            locker.notify();
        }
    }
    //实现出队的操作
    public Integer take() throws InterruptedException {
        synchronized (locker) {
            //遇到空队列进行阻塞
            if (size == 0) {
                locker.wait();
            }
            //当队列不为空时,出元素
            int ret = tmp[start];
            start++;
            //要是start到了队尾说明已经结束了,置为0并开始新的循环
            if (start >= tmp.length) {
                start = 0;
            }
            //每出队一个元素,对应的size--
            size--;
            //当size减了之后说明不再是满队列了,可以向里面放入新的元素了,阻塞结束
            //这里notify解锁用于队列为满而不能进一步入队的操作
            locker.notify();
            //返回出队的元素
            return ret;
        }
    }
}
    public class demo6 {
        public static void main(String[] args) {
            //阻塞队列的创建
            MyBlocking queue = new MyBlocking();
            //实现一个生产者消费者模式
            Thread t = new Thread(() -> {
                int num = 0;
                while (true) {
                    System.out.println("生产了:" + num);
                    try {
                        queue.put(num);
                        //这里保持生产与消费步调的一致
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    num++;
                }
            });
            t.start();
            Thread t2 = new Thread(() -> {
                int num = 0;
                while (true) {
                    System.out.println("消费了:" + num);
                    try {
                        num = queue.take();
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            t2.start();
        }
    }

3.定时器

3.1定时器是什么

定时器也是软件开发中的一个重要组件 . 类似于一个 " 闹钟 ". 达到一个设定的时间之后 , 就执行某个指定好的代码

3.2标准库中的定时器

①标准库提供的加以说明:

标准库中提供了一个 Timer 类. Timer 内部是有专门的线程,来负责执行注册的任务的,Timer 类的核心方法为 schedule .

schedule 包含两个参数:第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后执行 (单位为毫秒)。

②代码演示:

import java.util.Timer;
import java.util.TimerTask;
public class demo7 {
    public static void main(String[] args) {
        Timer timer=new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("执行一下这个定时器");
            }
        },3000);
    }
}

输出结果:

3.3实现定时器

虽然我们在实际中可以直接调用类似上述代码来对定时器来进行实现,但是这里我们来试着自己实现一个定时器

①实现定时器的方法步骤:

a.描述任务

(通过创建一个专门的类来表示一个定时器中的任务)

注意!!!Mytask并没有指定比较规则,所以需要我们手动去指定,所以我们需要实现Comporable接口,并重写它的CompareTo()比较方法

b. 组织任务

(在描述完任务之后,我们还需要通过一些数据结构,把一些任务放到一起)

举个例子:

假设现在有多个任务要去完成,第一件事是20分钟后交作业,第二件事是1个小时后开会,第三件事是30分钟后檫黑板。安排这些任务时是无序的,但是我们却需要有序的进行执行,我们应该把它进行排列,时间最接近的最先执行,这里就引入了数据结构----堆

因为此处的队列要考虑到线程安全的问题,可能在多个线程里进行注册任务,同时还要有一个线程专门来取任务进行执行,所以此处的队列就需要注意线程安全问题

c.执行时间到了的任务

(因为需要先执行最靠前的任务,所以需要有一个线程,不停地去检查优先队列的队首元素,看看当前最靠前的这个任务的时间到了没)

具体代码如下:

import java.util.concurrent.PriorityBlockingQueue;
//创建一个类,表示一个任务
class MyTask implements Comparable<MyTask>{ //实现Comparable接口,设定比较规则
    //任务具体要干什么
    private Runnable runnable;
    //任务具体啥时候干,保存任务要执行的毫秒级时间戳
    private long time;

    //提供一个构造方法
    public MyTask(Runnable runnable, long delay) { //delay是一个时间间隔,不是绝对的时间戳的值
        this.runnable = runnable;
        this.time = System.currentTimeMillis() + delay;
    }

    public void run(){//这里不是Runnable里面的run方法,这里只是自己定义了一个任务类,这个run指的是任务执行的方法
        runnable.run();
    }

    public long getTime() {
        return time;
    }

    @Override
    public int compareTo(MyTask o) {//这个方法的实现是在建堆的时候找最小值的比较过程中,并没有通过此处的代码进行实现
        //让时间小的在前,时间大的在后
        return (int)(this.time - o.time);
    }
}

//定时器
class MyTime{
    private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
    //使用schedule方法来注册任务到队列中
    public void schedule(Runnable runnable,long delay){
        MyTask task = new MyTask(runnable,delay);
        queue.put(task);
        //每次任务插入成功之后,都唤醒一下扫描线程,让线程重新检查一下队首的任务,看是否时间到了要执行
        synchronized (locker){
            locker.notify();
        }
    }
    private Object locker = new Object();
    //创建一个扫描线程
    public MyTime(){
        Thread t = new Thread(()->{
            while (true){
                try {
                    //先取出队首元素
                    MyTask task = queue.take();
                    long curTime =  System.currentTimeMillis();
                    //判断一下时间是否到达
                    if(curTime < task.getTime()){
                        //时间没到,把任务塞回到队列中
                        queue.put(task);
                        //指定一个等待时间
                        synchronized (locker){
                            //当执行任务但没有被notify新插入任务唤醒的时候,阻塞到这里
                            locker.wait(task.getTime() - curTime);
                        }
                    }else {
                        //时间到了,执行任务
                        task.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }
}
public class Test{
    public static void main(String[] args) {
        MyTimer myTimer = new MyTimer();
        myTimer.schedule(new Runnable() {//前面的参数是传入了一个匿名对象
            @Override
            public void run() {
                System.out.println("执行");
            }
        },3000);
        System.out.println("main");
    }
}

部分步骤的解释:

  在这个代码中仍有我们值得注意的点:

(1)为什么要用wait()?

因为如果队列的任务是空着的就还好,这个线程就在这里阻塞了。但是就怕时间还没有到,这个时候就属于“忙等”的情况。忙等这种操作是等了,没有闲着,但是却没有实质性的产出,这个样子的话是非常浪费CPU的。

而当我们把wait()进行了时间限制后,就相当于可控了它的时间,到了指定时间再唤醒,而节省了CPU。

那就会有人问:为什么不使用sleep

这里是因为wait()相比于sleep来说,它是可以被唤醒的,因为每插入新的任务时,我们会进行比较,这个时候需要唤醒,所以这个时候就配合notify。每加入新的任务的时候就notify一下来唤醒阻塞的wait,然后继续进行下面代码的执行。

4.线程池

4.1什么是线程池

①什么是线程池?

类似于之前我们接触过的常量池那些,线程池就是里面可以容纳很多线程,我们需要的时候可以直接从里面去取,而不需要从去申请

②为什么要引进线程池?

前面我们引进了线程,相对于进程来说,线程确实减少了开销,但是我们也知道要是对线程进行频繁地创建销毁,那么产生的开销也是不小的,所以这个时候我们引进了线程池来解决问题,把线程提前创建好,放到池子里,后面要是需要用线程的话,直接从池子里取,就不必从系统中申请了。线程用完了,也不是还给系统,而是放回池子里,以备下次进行使用。

③为什么放在线程池里就比系统申请来得更快呢?

用户态:

因为像我们自己写的代码,就是在最上面这一层来进行运行的,我们把这里的代码都称为"用户态"运行的代码。

内核态:

有些代码,需要调用操作系统的API,进一步的逻辑就会在内核中执行。在内核中运行的代码,称为“内核态”运行的代码。
举几个需要内核态的例子:
(1)调用System.out.printlin本质上要经过write系统调用,进入内核中。
(2)创建线程也需要内核的支持(创建线程的本质是在内核中搞个PCB,然后再加入到链表中)
(3)调用Thread.start其实归根结底,也是进入内核态来运行的

而把创建好的线程放到“池子里”,就是由于池子是用户态来实现的,这个放到池子/从池子取出是不需要涉及到内核态的,就是纯粹的用户态代码就能完成。

一般认为,纯用户态的操作,效率要比经过内核态处理的操作更高

而认为内核态效率低,倒不一定真的就低,而是代码进入了内核态就不可控了,内核啥时候把活干完,啥时候才把结果给你。

举个例子:当你需要把作业交到办公室时,你要是自己直接去办公室提交,这个可以看做是一个用户态,而你让同桌帮你交,可以看做是内核态,因为你不确定同桌啥时候帮你,可能是马上,也可能是她写完才去,或者她上个厕所才去,等等。

4.2标准库中的线程池

①标准库中的线程池:

标准库中的线程池叫做:ThreadPoolExecutor
juc(java.util.concurrent): concurrent并发的意思.Java中很多和多线程相关的组件都在这个concurrent包里.

②重点罗列一下第四个:

以及它的各个参数以及含义:

我们这里把线程池想象成一个公司,公司的员工分为“正式工”和“临时工” 

③面试问题:

有一个程序,这个程序要并发的/多线程的来完成一些任务,如果使用线程池的话,这里的线程数设为多少合适

这要通过性能测试的方式,找到合适的值。理由如下:

根据这里不同的线程池的线程数,来观察程序处理任务的速度,程序持有的CPU的占用率。
当线程数多了,整体的速度是会变快,但是CPU占用率也会高.
当线程数少了,整体的速度是会变慢,但是CPU占用率也会下降.
需要找到一个让程序速度能接受,并且CPU占用也合理这样的平衡点。
不同类型的程序,因为单个任务,里面CPU上计算的时间和阻塞的时间是分布不相同的。

4.3实现线程池

①线程池里有什么?

1.先能够描述任务(直接使用Runnable)

2.需要组织任务(直接使用BlockingQueue)

3.能够描述工作线程.

4.还需要组织这些线程.

5.需要实现,往线程池里添加任务

其实就是两个问题,一是把队列中存在工作线程的话就去获取里面的内容,执行任务,没有的话就阻塞。然后将这些线程任务存放在同一个数据结构中。二是把任务放在线程池中的过程,每放入一个输出具体的语句

②代码如下:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
class MyThreadPool{
    //1.描述一个任务,直接使用Runnable
    //2.使用一个数据结构来组织任务
    private BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();
    //3.描述一个线程,工作线程的功能就是从任务队列中取任务并执行(这里是一个静态内部类)
    static class Worker extends Thread{
        //当前线程池中有若干个Worker线程,这些线程内部都持有上述的任务队列
        private BlockingDeque<Runnable> queue = null;
        public Worker( BlockingDeque<Runnable> queue) {
            this.queue = queue;
        }
        @Override
        public void run() {
            while (true){
                try {
                    //我们没有办法直接使用第9行的queue,因为这是另一个类,所以我们在13行安排了这个变量,
                    // 并且调用worker构造方法把上面第九行的queue给传进来,让worker线程自身持有着这个队列
                    //循环的去获取任务队列的任务,
                    //如果队列为空就直接阻塞,如果队列非空,就获取到里面的内容
                    Runnable runnable = queue.take();
                    //获取到之后,就执行任务
                    runnable.run();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    //4.创建一个数据结构来组织若干个线程
    private List<Thread> workers = new ArrayList<>();
    public MyThreadPool(int n){
        //构造方法中创建出若干个线程,放到上述的数组中
        for (int i = 0; i < n; i++) {
            Worker worker = new Worker(queue);//把上述的线程任务传到这一个结构数组中
            workers.add(worker);
        }
    }
    //5.创建一个方法,允许程序员放任务到线程池当中
    public void submit(Runnable runnable){
        try {//把这个runnale 任务加到queue中
            queue.put(runnable);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
public class demo7 {
    public static void main(String[] args) {
        MyThreadPool myThreadPool = new MyThreadPool(10);
        for (int i = 0; i < 100; i++) {
            myThreadPool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello myThreadPool");
                }
            });
        }
    }
}

关于多线程的4个案例就到这里结束了~

感谢观看~ 

关键词:java,多线程,四大,经典,经典案例,案例