多线程经典案例

多线程案例

文章目录

  • 多线程案例
    • 一、单例模式
      • 1.1 饿汉模式
      • 1.2 懒汉模式
    • 二、阻塞队列
      • 2.1 生产者消费者模型
      • 2.2 阻塞队列带来的作用:
      • 2.3 阻塞队列实现
    • 三、定时器
      • 3.1 标准库定时器
      • 3.2 手动实现定时器
    • 四、线程池
      • 4.1 标准库中线程池
      • 4.2 手动实现线程池

一、单例模式

设计模式有多种,其中校招中涉及到的就是”单例模式“,和”工厂模式“,所谓的设计模式,就是大佬们应对常见的需求场景,制定出的解决方式,这种解决方式就是设计模式。

可以看做成写英文作文的时候,在考场如果你是按照自己写的作文,可能分值并不是很高,很多都是考前背下作文模板(模板都是大佬们写的),然后再从模板里面套,这样你的作文就会更优,分值也就更高了。

设计模式的思想就是“从有到优”的一个过程。

1、单例 ——> 单个实例(类和对象中的对象)

某些类在代码中应该有一个实例,这样可称为“单例”。实现一个代码,保证这个类不会被创建出多个实例,此时这样的代码就叫做“单例模式”。

JDBC,数据源 DataSource 就是单例。

1.1 饿汉模式

单例模式,两种典型的实现:饿汉模式,懒汉模式;


// 某个类在代码中只被创建一个实例
class Sigleton {
    // static 修饰的成员变量是类的属性,一个类对象,在一个程序中,只有唯一一个(JVM机制)
    private static Sigleton instance = new Sigleton();

    // 注意这里的 private 别的地方不能 new Sigleton 了。
    private Sigleton () {

    }

    // 这边是为了,这个 instance 会被其他地方的代码用到(唯一通道)
    public static Sigleton getInstance() {
        return instance;
    }
}
public class Demo16 {
    public static void main(String[] args) {
        // 这里得到的都是同一个实例,无论在代码哪里调用。
        Sigleton sigleton = Sigleton.getInstance();
    }
}

static 成员初始化的时机,是“类加载” 的时候,程序启动之后,用到了这个类,就会立马加载(类加载同时创建了实例),实例创建的时机比较早。在JVM中会详细说明类加载。

这边单例,一边先把程序要用到的实例创建好,一边把外部构造实例的口子给封上。这样就保证了“单例模式”(某个类,只有一个实例)。

1.2 懒汉模式


class SigletonDataSource {
    private static SigletonDataSource instance = null;

    private SigletonDataSource() {

    }

    public static SigletonDataSource getInstance() {
        if (instance == null) {
            instance = new SigletonDataSource();
        }
        return instance;
    }
}

public class Demo17 {
    public static void main(String[] args) {
        SigletonDataSource sigletonDataSource = SigletonDataSource.getInstance();
    }
}

这里的代码是在首次调用到getInstance的时候,才会真正创建实例。

饿汉模式,是立即就会创建;懒汉模式中的“懒”是表示延时的意思,是用到这个方法的时候才调用,然后创建实例。

我们也更倾向于“懒汉模式”,因为我们是用多少就调用多少,而不是一次性调用完。

举个例子,打开一个大文件,有些编辑器就是一次性直接读取整个文件到内存中(饿汉),这样消耗的资源多而且浪费,加载的时间也会慢。但是有些编辑器只把当前屏幕的数据加到内存中,用多少加多少(懒汉)。

❓❓❓现在问题来了,那么这两个模式的线程是否安全尼??

1、饿汉模式,多线程调用到 getInstance()的时候,只是针对同一个变量来“读”。(线程安全)

2、懒汉模式,多线程调用到 getInstance()的时候,大部分情况下也是读,但是也可能会修改。(线程不安全)

多线程经典案例_第1张图片

  • 如何解决??

遇到线程不安全肯定就是”加锁“咯, “加锁”就是把读和修打包成一个原子操作。

public static SigletonDataSource getInstance() {
        synchronized(SigletonDataSource.class) {
            if (instance == null) {
                instance = new SigletonDataSource();
            }
        }
        return instance;
}

多线程经典案例_第2张图片

这里虽然解决了线程安全问题,但是有线程只要每次调用到 getInstance(),程序都会触发锁竞争。

❔❔❔❔如何让加锁操作不再这么频繁;

加锁和解锁操作是开销比较大的事情,如果像上面代码的操作那么执行的效率就低下。

懒汉模式多线程版的改进

public static SigletonDataSource getInstance() {
        if (instance == null) {
            synchronized(SigletonDataSource.class) {
                if (instance == null) {
                    instance = new SigletonDataSource();
                }
            }
        }
        return instance;
    }

这里有两个 if 判断语句,第一层if是竞争锁,谁先竞争到锁就谁先创建实例;第二层if是判断是否还需要创建实例。

多线程经典案例_第3张图片

到了这一步看似没问题,其实还有一个小问题,多线程操作,如果说有多个线程同时去调用 getInstace() 的时候,就会设计到很多线程都去读 instance 的内存值,这里就相当于内存被 CPU 读取了多次,就可能会触发编译器优化,出现“内存可见性”问题,后续的内存读取就会被取消,直接读 CPU 的寄存器。

一旦有线程读了寄存器里面的值,那么之前修改了 instance 的值,此时后续的新线程可能就感知不到,就会白白多加一次锁,减少效率。

最好的解决方式就是不要让编译器优化,在变量前加上 volatile 就行。

private static volatile SigletonDataSource instance = null;

写一个线程安全的单例模式完整代码关键点:

1、合适的位置加锁;

2、双重 if 判定 (搞清楚每一层 if 是什么含义)

3、防止“内存可见性” ,加上 volatile

加锁的副作用大,影响效率,优先使用StringBuilder,如果是多线程 就得用 StringBuffer,单线程用StringBuffer,程序不会出错,但是执行速度会更慢。

完整代码:

class SigletonDataSource {
    private static volatile SigletonDataSource instance = null;
    private SigletonDataSource() {}
    public static SigletonDataSource getInstance() {
        if (instance == null) {
            synchronized(SigletonDataSource.class) {
                if (instance == null) {
                    instance = new SigletonDataSource();
                }
            }
        }
        return instance;
    }
}
public class Demo {
    public static void main(String[] args) {
        SigletonDataSource sigletonDataSource = SigletonDataSource.getInstance();
    }
}

二、阻塞队列

阻塞队列是一种特殊的队列. 也遵守 “先进先出” 的原则

阻塞队列也是一个线程安全的队列。(安全的数据结构)

功能:

1、当队列为空的时候,尝试进行出队列,会进行阻塞等待,一直等到队列不空为止;

2、当队列满的时候,尝试进行入队列,会进行阻塞等待,一直等到队列不为满为止;

java 的标准库里面,提供了现成的阻塞队列。

2.1 生产者消费者模型

阻塞队列的典型场景就是 “生产者消费者模型”。这是一种非常典型的开发模型 。

实际开发中,也是非常常用的一种代码写法。

生产者消费者模型,一种更好的让多线程搭配工作的实现方式。

而生产者和消费者就是通过一个容器来解决生产者和消费者的 强耦合问题

多线程经典案例_第4张图片

这里面的工厂相当于多线程来完成产品,使产品高效,然后商店就充当“交易场所”,而我们的交易场所一般都是使用 阻塞队列来实现。

生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取

2.2 阻塞队列带来的作用:

1、阻塞队列也能使生产者和消费者之间 解耦

就像上面图为例子,生产者和消费者之间不用直接的联系,生产者不关心是谁要来买自己的产品,只要能给钱就行,而消费者也不关心这个产品是那个工厂生产的,只用实用就行。所以他俩的联系并不紧密,甚至可以达到解耦的效果。

2、阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力 。

在请求突然暴增的峰值中,起到“削峰填谷“的效果;

多线程经典案例_第5张图片

为了一端挂了,不牵连到另一端,就可以在中间加一个阻塞队列起到平衡的作用。

多线程经典案例_第6张图片

这里的阻塞队列,在实际开发中是一个或者一组的服务器,这个服务器的功能,肯定是用阻塞队列的数据结构实现的(核心功能),这些服务器程序单独的会部署在特定的主机上。

这样的服务器不只是阻塞队列的功能,往往功能及其的丰富,会有很多的辅助功能(数据持久化存储,多通道存储,管理页面,进行配置和统计监督…),

还有一个叫做“消息队列“,本质上比阻塞队列功能更丰富 (message queue =》 mq)

多线程经典案例_第7张图片

上面提及的“削峰填谷”,就是一方的请求数量暴增,就会影响到另一端服务器收到的请求也会暴增。经过暴增,可能B就会挂掉(每个请求处理的时候都需要分配硬件资源)。

解决的办法也是,把这些请求都放在阻塞队列里面,由阻塞队列来承担这样的请求压力,B仍然按照原有的状态来处理请求。

2.3 阻塞队列实现

阻塞队列代码:


/**
 * 阻塞队列:
 * 1、要满足线程安全
 * 2、如何实现阻塞,关键在于什么时候触发阻塞;
 */

class MyBlocking1 {
    // 设置队列初始长度1000;
    private int[] items = new int[1000];
    // 队列中有效元素的个数
    private int size = 0;
    // 记录对首位置
    private int head = 0;

    private int tail =0;

    // 需要一个锁对象
    private Object locker = new Object();

    public void put(int value) throws InterruptedException {
        synchronized (locker) {
            if (size == items.length){
                // 队列满了
                //return;

                /**
                 * 当队列满的时候,需要阻塞等待,等到,这个队列里面的元素被取走
                 */
                locker.wait();
            }
            items[tail] = value;
            tail++;
            // tail++如果 >= 这个队列的长度 就循环返回 0 小标
            if (tail >= items.length){
                tail =  0;
            }
            size++;
            // 元素进来只有,就可以唤醒锁了
            locker.notify();
        }

    }

    public Integer take() throws InterruptedException {
        synchronized (locker) {
            if (size == 0){
                // 队列为空
                //return null;

                /**
                 * 当这个队列null 的时候,需要等待队列有元素进来。
                 */
                locker.wait();
            }
            int ret = items[head];
            head++;
            if (head >= items.length){
                head = 0;
            }
            size--;
            // 这里队列里面被取走了,就可以唤醒队列了。
            locker.notify();
            return ret;
        }

    }
}
public class Demo19 {
    public static void main(String[] args) {
    }
}

这里面的实现逻辑主要是阻塞对列什么时候被触发;那就是队列满的时候等待,当队列不满就唤醒,队列空的时候等待,队列不空的时候唤醒。

实现步骤:

1、完成一个简单队列,就是放队列,和弹出队列

2、在简单队列的基础上,添加触发阻塞队列的时机

最后有主方法实现消费者生产者模型:

public class Demo19 {
    // 使用队列作为交易场所
    public static MyBlocking1 queue = new MyBlocking1();
    public static void main(String[] args) {
     Thread producer = new Thread(()->{
         int num = 0;
         while(true){
             try {
                 System.out.println("生产者消费量:"+ num);
                 queue.put(num);
                 num++;
                 // 这里给生产者加个时间,说明生产者生产满,消费者消费快,中间消费者在阻塞等待
                 //Thread.sleep(1000);
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }
     });
     producer.start();

     Thread customer = new Thread(()->{
         while(true){
             try {
                 int num = queue.take();
                 System.out.println("消费者消费了:"+num);
				 //当消费者设置时间等待的时候,生产者生产快,消费者消费慢了
                 //Thread.sleep(100);
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }
     });
     customer.start();
    }
}

当生产者消费满的时候,消费者就会阻塞等待,等待生产者把东西放在一个容器里面,一旦产品放到了容器里面消费者就可以立马取出产品。

多线程经典案例_第8张图片

当给消费者加上时间的时候,生产者就会生产快,消费者就消费慢了,相当于生产者先把消费好的东西放入一个容器中,消费者等到时间就会去取容器里面的产品。

多线程经典案例_第9张图片

三、定时器

是软件开发中非常常用的组件,类似于“闹钟”,达到一个设定的时间过后,就执行某个指定好的代码。

当A服务器发送请求给B服务器的时候,B的响应会多久过来,A服务器并不会知道;如果A超出了等待时间,那么A则会断开连接尝试重新连接。那么在这给场景下就会用到定时器。

3.1 标准库定时器

java给我们提供了标准库的定时器,主要就是用到Timer类Timer类中的核心方法就是schedule


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

public class Demo20 {
    public static void main(String[] args) {
        Timer timer = new Timer();

        // schedule --》有安排的意思,第一个参数表示的就是任务,也就是TimerTask类,类似于Runnable接口,重写了run方法
        // 第二个参数就是定时器设置的时间,需要过了多少时间程序才会响应第一个参数任务里面的代码。
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("过了5秒后,我就会出现");
            }
        },5000);
        System.out.println("main");
    }
}

3.2 手动实现定时器

在定时器中包含了多个任务,就像设置闹钟一样,你的手机里可能会设置不同时间段的闹钟,每个时间段的闹钟要干的事情都是不一样的,所以说定时器中会包含对个任务;on the other hand 每个任务是在多长时间后执行,都是不确定的,所以我们要先理清楚逻辑。

1、描述;

​ a、先描述清楚每个任务都具体做什么工作 (写一段代码)

​ b、再描述清楚任务啥时候被执行(记录任务的执行时间)

以上两个步骤,就可以封装一个类,通过这个类来表述当前这个任务的基本情况,这个类的名字叫做Task。

2、组织;

​ a、需要把新的任务加进定时器里;

​ b、从多个任务中找出时间快要到的任务出来;

相当于把任务放进数据结构里,是有排序找出时间小的任务,这里的排序使用“堆”最适合。

3、定时器需要有一个单独的线程来扫描;

这个线程需要不断的扫描堆中最小的元素,检出当前的任务时间是否已经到了,如果到了则执行代码。

  • 完整代码
import java.util.concurrent.PriorityBlockingQueue;

/**
 * 步骤:
 * 1、描述  1)、执行的任务   2)、什么时候被执行
 * 2、组织  1)、线程安全的优先级队列 PriorityBlockingQueue  ---》put 插入元素;take 取元素
 * 3、单独线程来扫描定时器
 */

class MyTimer{
    // 这个内部类是用来描述的。
    static class Task implements Comparable<Task>{

        //执行一个什么样的任务
        private Runnable runnable;
        // 啥时候执行任务
        private long time;

        public Task(Runnable runnable, long after){
            //after 是在多久之后执行
            this.runnable = runnable;
            // 当前时间+需要多久才能执行任务时间
            this.time = System.currentTimeMillis()+after;
        }

        public void run(){
            runnable.run();
        }

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


    // 2、描述好之后就要进行组织了,把task里面的任务放入堆中,进行堆排序,找出时间差小的那个任务。
    // 注意:这里需要一个线程来排序,后面也需要一个线程进行扫描堆顶元素时间是否已到,就会产生多线程
    // 多线程就会引发线程不安全,这个时候可以用到java标准库里面的 PriorityBlockingQueue(带有优先级阻塞队列)。
    private PriorityBlockingQueue<Task> tasks = new PriorityBlockingQueue<>();


    // 这个方法是在定时器中注册一个任务
    // 在 after ms 之后,执行 runnable 里面的 run()方法。
    public void  schedule (Runnable runnable ,long after){
        Task task = new Task(runnable,after);
        tasks.put(task);
    }


    private Object locker = new Object();
    /**
     * 3、创建扫描线程,死循环检查堆顶元素时间是否已到,判断是否该执行任务。
     * 需要在MyTimer实例化时,创建线程
     */
    public MyTimer() {
        Thread t = new Thread(()->{
            while(true) {
                try {
                    Task task = tasks.take();
                    long curTime = System.currentTimeMillis();
                    //取出队尾元素,判读时间是否超了
                    if (curTime < task.time) {
                        tasks.put(task);
                        synchronized (locker) {
                            locker.wait(task.time-curTime);
                        }
                    } else {
                        task.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }
}


public class Demo21 {
    public static void main(String[] args){
        MyTimer myTimer = new MyTimer();
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("5秒到了");
            }
        },5000);
        System.out.println("main");
    }
}

多线程经典案例_第10张图片

最后的结果由图可示,5秒到了过后程序并未结束,因为定时器会有多个任务,这里只是其中的一个线程完成了任务,还有其他的线程还在工作,所以这里的程序就没有结束。

这段代码有两处值得注意的点:

1、多线程下的synchronized;(让扫描别“忙等”,通过合适的 wait 完成放弃 CPU 的操作)

2、优先级阻塞队列排序的指标是什么;(让Task是可比较的,才能放入队列汇总)

多线程经典案例_第11张图片

编写多线程程序的时候,主要任务就是管理时间,调配资源,如果资源一直花在判断定时器上就非常的不值,这里加一个synchronized减少资源开销。

多线程经典案例_第12张图片

四、线程池

​ 我们是使用多线程来实现并发操作,但是如果线程太频繁的创建销毁,线程也会应付不过来,并且开销还是会大。

类似于餐馆的老板需要有人送外卖,这是boss雇佣了一个骑手,等骑手送完外卖就解雇,但是下一次boss需要有人送外卖的时候又得雇佣一个骑手,这样反复雇佣解雇就会显得麻烦,而且开销大。

如果老板先雇佣5个骑手,只要一有订单,就叫一个骑手去送,送完骑手就让骑手等待;如果比较忙的时候,5个骑手都去送外卖了,那么就先等5个骑手送完外卖回来再让他们送其他外卖。

​ 这上面的栗子就是用到了线程池,可以把每个骑手都看成一个线程,每个线程都在一个池子里面,需要工作的时候线程就开始活动,休息的时候线程就待在池子里,这就是所谓的线程池模式。


再次这里也涉及到了操作系统进行创建销毁,是一个成本高的事情,但是用户态来管理线程成本就比较低。

用户态 VS 内核态:

去银行取钱,取钱需要复印资料,但是柜台里面和柜台的外面都有一个复印机,这时你打算自己打印还是托给柜台人员给你打印?

1、让柜台人员帮忙打印,工作人员可能先去处理其他事务在来给你打印,这样你就会等的时间慢;

2、你自己去打印,可以只需要1-2分钟搞定;

通过上面的例子可以证明,系统他是很忙的,复印机相当于创建一个线程,你让系统帮你创建线程,你无法判定系统啥时候给你去创建;但是你自己去创建,可以1-2分钟的事情。

4.1 标准库中线程池

标准库的线程池类 ThreadPoolExecutor 这是一个复杂的类,里面有很多的参数,用起来麻烦。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Nf4uY7Ue-1656148222348)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220625150702333.png)]

除此之外,标准库中也提供了封装的版本(针对 ThreadPoolExecutor 进一步的封装),提供了更简便的接口,直接使用。

Executors 这个类进行了封装,也给 ThreadPoolExecutor 准备了默认的参数,并提供了一组静态方法,通过这些静态方法,就能够创建出一些具体策略的线程池出来。

1、newFixedThreadPool,创建一个固定线程数的线程池(线程手动指定);

2、newCachedThreadPool ,线程池里面的线程数会动态发生改变;

3、newSingleThreadExecutor ,创建一个包含单个线程的线程池;

4、newScheduledThreadPool ,创建一个类似于定时器的线程池,也是延迟执行一个任务 。

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

public class Demo22 {
    public static void main(String[] args) {
        // 使用 Executors 标准库封装版本,固定创建了5个线程
        ExecutorService pool = Executors.newFixedThreadPool(5);
        // 通过 submit 注册一个任务到线程池中
        for (int i =0 ;i<100 ;i++){
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello");
                }
            });
        }
    }
}

4.2 手动实现线程池

线程池实现步骤:
1、描述一个任务,Runnable即可;
2、如何去组织多个任务,用到的数据结构是一个普通的阻塞队列;
3、有一组线程来执行这里的任务,这样的线程称之为 工作线程,不能只有一个;
4、使用一定数据结构,把若干个线程组织起来;
5、创建构造方法,启动线程,指定有多少个线程添加到线程池中
6、实现submit,来安排任务到线程池中。
  • 完整代码

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

class MyThreadPool {
    // 描述了任务
    private Runnable runnable;
    // 把任务组织在阻塞队列里面  --- 任务队列
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();

    // 3、啥样的工作线程
    static class Worker extends Thread {
        private BlockingQueue<Runnable> queue = null;

        // worker线程有多个,他们要共享同一个任务队列
        // 这里的构造方法 -- purpose 让任务队列传到线程里面,好让线程去取任务。
        public Worker(BlockingQueue<Runnable> queue) {
            this.queue = queue;
        }

        // 这里是一个线程一个线程的执行任务
        @Override
        public void run() {
            // 需要反复从队列中读取线程,然后执行任务
            while(true) {
                try {
                    Runnable task = queue.take();
                    task.run();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

        }
    }

    // 4、使用一定数据结构,把若干个 工作线程 组织起来;
    private List<Worker> workerList = new ArrayList<>();

    // 5、创建构造方法,启动线程,指定有多少个线程添加到线程池中
    public MyThreadPool(int t) {
        for (int i = 0; i < t; i++) {
            Worker worker = new Worker(queue);
            worker.start(); // 记得要启动线程。
            workerList.add(worker);
        }
    }

    // submit ,注册任务到线程池中
    public void submit(Runnable runnable) {
        try {
            queue.put(runnable);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class Demo23 {
    public static void main(String[] args) {
        MyThreadPool pool = new MyThreadPool(5);
        for (int i = 0; i < 100; i++) {
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello");
                }
            });
        }
    }
}

这里实现的是 newFixedThreadPool 固定线程数的线程池。


这篇帖子写的真滴是挺久的,已经时隔一个月,主要是中途有很多的事情都被打断了,不过还好现在都完成了。这里还是主要讲的是多线程,这篇帖子主要都是经典的案例,没事可以像JDBC一样多敲敲,并且以后遇到了写多线程定时器和线程池的时候要想到这里的背后原理是怎样的。

铁汁们,觉得笔者写的不错的可以点个赞哟❤,收藏关注呗,你们支持就是我写博客最大的动力!!!!

你可能感兴趣的:(单例模式,java)