多线程案例

文章详细介绍了Java中的单例模式,包括饿汉模式和懒汉模式,并讨论了它们的线程安全问题。同时,文章探讨了生产者消费者模型的概念,优势以及如何通过阻塞队列实现,还给出了相关代码示例。此外,文章还简单介绍了Java的定时器使用及其基本实现。
摘要由CSDN通过智能技术生成

 日升时奋斗,日落时自省

目录

1、单例模式

1.1、饿汉模式

1.2、懒汉模式

1.3、饿汉和懒汉的线程安全

2、生产者消费者模型

2.1、理论解释

2.2、优势

2.3、阻塞队列代码解析

2.4、生产者消费者代码解析

2.5、简单实现阻塞队列代码解析

3、定时器

3.1、定时器的使用

3.2、自主实现简单的定时器

1、单例模式

单例模式:是设计模式的一种

模式如何理解,打过小游戏的友友们都知道“攻略”这个词应对不同的BOSS,其实模式也就是一种解决方法,也如下棋一样,会有棋谱如何解决不同的棋局

模式这里挺起来 有点类似于框架,仅仅是类似,因为限制不同

模式与框架的区别

(1)框架是硬性,大佬已经写好的,不按照框架来写,代码跑不起来(不得不遵守)

(2)模式是软性,可以不遵守也能跑起来,但是代码的可可读性,可维护性,可扩展性都比较低(可以不遵守)

单例如何解释:单个 实例对象 在某些情况下只能创建一个实例不应该创建多个实例,单例模式只是在这里限定,并不是说一定要用

创建一个实例,这件事情是程序员自己做的,也可以不用单例模式,自己创建一个,再也不创建了也行(这样的可以称为“君子协定”)

举一个例子解释:

在古代是可以一夫多妻,但是也有只爱一人的,只娶了一个妻子的,这就是君子协定,没有外界因素束缚,他可以娶多个,但是他以君子协定(口头协议)来约束自己(相当于程序员不用模式)

在现代是一夫一妻,只能娶一个人,不只是君子协定,还有法律要求(使用模式)

使用单例模式后,想在创建多个实例都难

单例模式,就是针对上述的需求场景进行了更强制的保证,通过巧用java的现有语法,达成了某个类,只能被创建出一个实例,这样的效果(创建多个实例就报错提醒)

之前的博客中写到JDBC数据库连接,DataSource这个就很适合使用单例模式,为什么呢?

因为只需要创建一个数据源,单例模式不是正好嘛

实现单例模式有很多种方式,这里介绍最常见的两种方式(饿汉模式和懒汉模式)

1.1、饿汉模式

为什么叫做饿汉:这里说的是单例模式,创建一个实例的先后来定义的名字,饿汉在类加载阶段创建实例,以饿汉定名

类加载阶段:运行一个java程序,就需要让java进程能够找到并读取对应的.class文件,就会读取文件内容,并解析,构造类对象...这一系列的过程操作称为 类加载(有点像预处理)

class Singleton{   //本身就是安全的, 因为只读就不会有线程安全问题
    //创建一个实例 ,这就是饿汉模式,为什么有static来修饰,是为了能够与类联系起来
    //static使这里的属性具有唯一性
    private static Singleton instance=new Singleton();
    
    //如果需要使用这个唯一的实例,统一通过Singleton.getInstance() 方式进行来获取
    public static Singleton getInstance(){   //静态方法是为了在main直接能够掉用
        return instance;
    }

    //为了避免Singleton 类不小心被复制出来多份
    //把构造方法设置为private 在类外就不能在通过new的方式创建出来Singleton类来创建
    private Singleton(){
    }
}
public class ThreadDemo_Single {
    public static void main(String[] args) {
//        Singleton s=new Singleton(); //这里就会报错哦
        Singleton s2=Singleton.getInstance();
        Singleton s1=Singleton.getInstance();
        System.out.println(s1==s2);
    }
}

这里我们先附一下代码,解释当前代码

 私有化的构造方法 :如果在外面创建了一个实例化对象(看一下效果)

 这里能看到报错的结果,因为私有化构造方法的限制,保证了只能通过调用方法的形式进行操作实例化对象。

private static Singleton instance=new Singleton();

这个属性和实例无关,而是和类相关

java代码中每个类,都会在编译完成后得到.class文件,JVM运动时就会加载这个.class文件读取其中的二进制指令,并且在内存中构造出对应的类对象(Singleton.class)

由于类对象 在java进程里,只是有唯一一份的,因为类对象内部的 类属性也是唯一一份了

1.2、懒汉模式

为什么叫做懒汉:以为在一开始并没有创建对象,等有人来调用方法的时候,如果没有创建对象,在进行创建;(代码大体上和饿汉模式有几分相似)

 那懒汉模式好嘛,当然嘛,懒汉不是说是贬义,而是褒义,效率更胜,懒在程序和代码里能提高效率,人因为懒才发明了计算机,为了便捷,才发明了更多方便的东西,才有现在的科技

1.3、饿汉和懒汉的线程安全

那友友们觉得谁是安全的呢?

饿汉线程更安全,为什么这么说?

 下面来看一下懒汉模式为啥不安全:

 那我们来加个锁,看一下对不对

 那我们知道要加锁,那如何进行加锁才能解决问题

 那现在线程安全问题也解决了,那是不是就安全,其实还有其他问题,内存可见性问题

假设有很多线程,都去进行getInstance,这个时候,是否就会有编译优化的风险,只有第一次读是真正的读内存,后续都是读寄存器内存可见性问题

也就是说每次在寄存器可能读的都是instance==null,仍然会有问题,相当于加锁没有起效

另外,还会涉及到指令重排序问题

instance=new Singleton();

拆分成三个步骤:

1、申请内存空间

2、调用构造方法,把这个内存空间初始化成一个合理的对象

3、把内存空间的地址赋值给instance引用

正常情况下,123按顺序走,编译器为了提升效率也可能调整代码执行顺序(单线程无所谓)

如果是多线程环境,t1按照132步骤执行的话,13刚刚执行结束,CPU就把t2调度来执行,t2就相当于直接返回instance引用并且可能会尝试使用引用中的属性。

但是t1的2还没有执行完,t2拿到了非法的对象,还没构造完成的不完整对象

volatile解决内存可见性问题

(1)解决内存可见性

(2)禁止指令重排序

下面是附的完整版代码(解决线程问题,解决内存可见性问题,解决指令重排序问题)

class SingletonLazy{   //可以写的就会出现线程安全问题
    //static 使所有的属性具有唯一性  ,懒汉模式就是不会直接创建对象,比较懒,但是懒有懒得好处,它节省资源
    private volatile static SingletonLazy instance=null;
    public static SingletonLazy getInstance(){
        if(instance==null){  //如果满足创建对象条件,就互加锁,
            synchronized (SingletonLazy.class) {
                if(instance==null){
                    instance=new SingletonLazy();
                }
            }
        }
        return instance;
    }
    //单例模式防止 在外面的类中new 实例化
    private SingletonLazy(){
    }
}
public class ThreadDemo_Single2 {
    public static void main(String[] args) {
        SingletonLazy s=SingletonLazy.getInstance();
        SingletonLazy s1=SingletonLazy.getInstance();
        System.out.println(s1==s);
    }
}

2、生产者消费者模型

2.1、理论解释

这个模型一下解释不清楚:在学习这个模型之前先要知道阻塞队列

阻塞队列也是一个队列(数据结构)

先进先出 是队列的一个特点 但是不是所有队列都遵循这个规则

数据结构中的优先级队列(PriorityQueue) 

还有就是这里要提到的阻塞队列,虽然是先进先出,但是同时存在其他的特点(顾名思义)

阻塞(针对多线程,如果是单线程就没有实际意义了):

(1)如果队列为空了,执行出队列的操作,就会阻塞等待,阻塞到另一个线程往队列中添加元素(队列不为空),阻塞结束当前线程继续执行

(2)如果队列满了,执行入队列的操作,就会阻塞等待,阻塞到另一个线程从队列取走元素(队列不满),阻塞结束当前线程继续执行

这里提一下消息队列:也是特殊队列,因为它就是在阻塞队列基础上进行的,加上一个“消息的类型”按照制定类别进行先进先出,此时就构成了一个消息队列,更是一个数据结构,消息队列的应用比较频繁,也被单独实现成了一个程序,该程序可以通过网络的方式和其他程序进行通信,单独部署到一组服务器上,存储能力和转发能力都大大提升了。

以上提及的消息队列也成了一个组件“中间件”

rabbit mq就是一种,还有active mq,rocket mq,kafka都是,因为都是消息队列的应用所以,使用大同小异。

基于阻塞队列的特性,可以实现“生产者和消费者模型”

什么是生产者消费者模型?

简单的举一个例子:

卖货这个流程 简介一下

(1)就是生产 出来  消费者买,生产出来的货正常量,但是恰逢过年大家买年货消费者就多,一购而空,这时候消费者还想要卖货就要等着生产商这边再产货才行,所以需要就阻塞等待.

(2)过年过完了,但是当时生产者囤货又囤的太多了,此时的生产量太大,消费者少了,这下生产者就需要等待了

其实这个例子与前面的阻塞队列在我理解以来是同一个意思。

2.2、优势

1、实现了发送方和接收方之间的“解耦”  

注:解耦就是降低耦合的过程(写代码尽量能够让代码低耦合 、高内聚)

低耦合: 就是将代码模块之间的联系降低 防止一个个方面的代码出现问题导致整个代码都瘫痪

高内聚:就是将代码元素之间的联系程度提高(如 在找衣服的时候,衣服就如元素,衣柜就如代模块,每一个人的衣服都应该在规定的衣柜里就容易找,这就高内聚,不同衣柜就是模块之间低耦合

在实现使用项目中,服务器之间相互调用可以看出(图解)

没有使用“生产者和消费者模型” 时 

使用模型的情况:

 总结:生产者和消费者模型降低了耦合性,同时可以在极端条件下阻塞进程,减少代码带来不必要的崩坏,在A服务器受损时不会影响到服务器B和C,如果B或者C服务器崩坏也不会影响服务器A。

2、生产者消费者模型 ,第二个可以做到“削峰填谷”,保证系统的稳定性

简单表示一下波峰填谷:

 在这里就是阻塞的意思:为了让整个服务器维持平衡,如果充值的数据量一次性暴增,阻塞队列就会在一定数据的时候进行阻塞,阻塞队列中的数据再在通过其他服务器慢慢取出,不会导致服务器直接崩溃,这里的阻塞等待相当于提供了一个缓冲的作用

注:在大型的项目上很实用,因为数据量是不可预知的,服务区要撑得住,所以阻塞队列就是可以满足该情况,就像如果某大型平台突然停止运行,就会导致同类小型的竞争平台数据量暴增导致,如果没有做好措施,服务器,平台,或者网页就可能崩溃,阻塞队列就是解决这种突发数据量增长问题。

2.3、阻塞队列代码解析

阻塞队列在java标准库中使用是BlockingQueue<> 创建对象有数组(ArrayBlockingQueue)、链表(LinkedBlockingQueue)和堆(PriorityBlockingQueue)类型的。

他们是队列自然也包含队列的基本方法offer(),peek(),poll()但是不具有阻塞功能

阻塞功能的主要存在于 put 入队列         take 出队列 这两个方法

以下代码可以尝试如果队列中空时,出队列会进行等待,程序并不会结束,当然这里表现的并不明显,之后,在写一个简单的生产者消费者模型,观察这种情况

public class BlockQueue {
    //阻塞队列
    public static void main(String[] args) throws InterruptedException {
        //阻塞队列  也分为三种不同的队列构成, 数组 链表 和 堆
        BlockingQueue<String> blockingQueue=new LinkedBlockingQueue<>();
        /*put   是入队的
        * take  是出队
        * */
        blockingQueue.put("hello");
        String res=blockingQueue.take();
        System.out.println(res);
        res=blockingQueue.take();
        //产生越界不会进行出问题  会进行等待  观察后可以看出 运行并没有结束
        System.out.println(res);
    }
}

2.4、生产者消费者代码解析

大体思路:由多线程构造出该模型,满足生产元素空时 消费者阻塞等待,消费者满足时 生产者阻塞等待

首先:创建一个阻塞队列 下来才能满足阻塞等待这个条件,这里给阻塞队列设置的是8个空间,便于测试生产速度如果快的话,后来仍然会与消费者的速度同步进行,因为会阻塞队列会进行阻塞

 BlockingQueue<Integer> blockingQueue=new LinkedBlockingQueue<>(8);

接下来创建先生产者或者消费者都行(代码上附加简单注释)

 这里先写生产者 线程 便于大家理解  

//便于理解先创生产者  线程
        Thread producer=new Thread(()->{
            int count=0;   //这里就简单记为  生产者生产的数据
            while(true){
                try {
                    //将生产的数据 放入 阻塞队列中 
                    blockingQueue.put(count);  
                    //这里就做一个标识作为验证
                    System.out.println("生成元素 :"+ count);
                    Thread.sleep(500);
                    count++; //表示新数据
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        producer.start();  //线程执行

 后写消费者的线程

 //创建消费者
        Thread customer =new Thread(()->{
            while(true){
                try {
                    //将对阻塞队列的数据进行提取
                    Integer result=blockingQueue.take();
                    //打印出来检测作为验证
                    System.out.println("消费元素 :" + result);
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        customer.start();

上述代码中生产者线程的代码Thread.sleep(500);没有删除,这里展示一下删除后的运行结果,友友们可以自己试试

 2.5、简单实现阻塞队列代码解析

阻塞队列也需要先写一个普通的队列,可以是链表来写,也可以是数组来写,阻塞队列更像是循环队列,我们这里就以数组的形式进行实现,两个指针,一个头指针,一个尾指针

 循环队列写出来,基础就打好了,但是线程调度是随机的,既然写多线程就会涉及到线程安全问题。阻塞队列的细节:

(1)这里有关++的都会涉及多线程安全问题,所以直接整个循环队列都套上一个锁。

(2)原来判满条件是不能返回的,现在需要的阻塞  所以把return 换成this.wait();

(3)既然有wait就 需要notify唤醒所以在notify唤醒都是在出队列和入队列的最后,出队列时为空就会阻塞,需要入队一次,队列不为空,此时唤醒才行,同理,入队列时为队列线程就会在此阻塞,需要出队一次,队列不为满,此时唤醒

(4)上面三个问题阻塞队列已经基本完成,但是有一个多线程中存在的缺陷,就是多线程调度的时候是可以切换的,所以判满或者判空被唤醒以后可能仍然为空或者为满,但是单单就一个if语句是不能连续判断当前队列是满的还是空的,所以if需要改成while

class  MYBlockingQueue{
    private  int[] items=new int[100];
    private  int head=0;
    private  int tail=0;
    private  int size=0;

    //入队列的 代码   线程安全 离不开锁  所以在以下的操作中需要用到锁的地方很多 ,所以当前位置都需要这些东西,直接给整体都加上一个锁
    public void put(int vaule) throws InterruptedException {
        synchronized (this){
            while (size==items.length){   //防止在过程中  被唤醒后仍然是 满的
                this.wait();// (1)
            }
            items[tail] = vaule;
            tail++;
            //这里为什么不会写成 tail = (tail + 1)%itmes.length
            // 因为当前值已经进行了加加  不在需要进行加1的 操作了
            if(tail>=items.length){
                tail=0;
            }
            size++;
             this.notify();// 唤醒(2)
        }
    }
    //出队列
    public Integer take() throws InterruptedException {
        int result=0;
        synchronized (this){
            while (size==0){   //防止在过程中  被唤醒后仍然是 空的
                this.wait();            //(2)
            }
            result=items[head];
            head++;
            if(head>=items.length){
                head=0;
            }
            size--;
            this.notify(); //唤醒 (1)
        }
        return result;
    }
}

上面附的就是阻塞队列的简单实现了,可以自己用一下main函数进行测试一下生产者消费者模型。

3、定时器

3.1、定时器的使用

一种就是 指定特点时刻 提醒

另一种是 指定特定时间段之后 提醒

这里提及的定时器不是提醒什么,而是执行一个实现准备好的方法或者代码

这个也是咱们开发中一个常用的组件,尤其是网络编程的时候,很容易卡连不上,但是要及时止损,不能一直卡也不说是什么问题,让客户一直等吧。

这里java标准库里也给我们提供了定时器(Timer)

这里粗略使用一下定时器,然后简单实现一个计时器(稍微有一点点繁琐,但是不要有心理负担)

主要使用的是 schedule这个方法其中有两个参数包含了 任务 和 时间

public class TimerTest {
    //简单的了解 定时器的使用凡是
    public static void main(String[] args) {
        System.out.println("启动程序");
        Timer timer=new Timer();    //定时器  java标准库定义
        //使用方法是 schedule 包含 有两个参数 第一个是任务   第二个是 时间 单位是ms 
        //这里每个任务的时间都是不一样的,  因为只有主线程一个线程在跑这些方法,所以会按照时间最短的先跑
        //所以先跑 的是任务3 然后是任务 2  最后是 任务1
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("运行定时器任务 1");
            }
        },3000);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("运行定时任务 2");
            }
        },2000);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("运行定时任务 3");
            }
        },1000);
    }
}

运行结果:

 定时器是前台线程,不会因为主线程结束,而运行结束所以这里的运行结果能明显看出运行的红色方块。

3.2、自主实现简单的定时器

思路:

(1)让被注册的任务,能够在指定事件,被执行

单独在定时器内部写一个线程,让着个线程周期性的扫描,判定任务是否到时间了,如果到时间了就执行,没到时间,就再等等。

(2)一个定时器是可以注册N个任务的,N个任务会被按照最初约定的时间,按顺序执行

但是这个顺序就需要点东西了,这里用的是阻塞队列的一种堆的阻塞队列

(1)有一个扫描线程,负责判定时间/执行任务

(2)还要有一个数据结构,来保存所有被注册的任务

使用一个优先级队列来表示,但是在多线程中就需要注意线程安全,可以加锁来解决线程安全问题,此处还有个选择,标准库提供的PriorityBlockingQueue

 一个类来实现定时器  首先要有一个自定义类来为阻塞队列做一个准备,因为这里需要任务 和 时间。

class MyTask{
    private Runnable runnable;   //定义一个任务
    private  long time;   //定义一个时间

    public MyTask (Runnable runnable, long time){
        this.runnable=runnable;
        this.time=time;
    }

    //获取当前时间
    public  long getTime(){
        return time;
    }
    public  void run(){
        runnable.run();
    }
   /* @Override
    public int compareTo(MyTask o) {
        return (int) (this.time - o.time);
    }*/
}

 第一步完成了,后面就先定义好一个类来写定时器,定时器的需要一个扫描线程,和一个前面提到的阻塞队列(以堆的形式),所以下面定义好了一个空线程,和一个阻塞队列,但是阻塞队列需要时间去比较时间不是吗,所以这里就在里面写了一个比较器,用来比较时间,谁的时间小就放在堆顶,这里就相当于是一个小根堆

//定时器  需要 一个 执行方法 和 一个扫描线程
    private Thread t=null;

    //有一个阻塞优先级队列 ,来保存任务

    private PriorityBlockingQueue<MyTask> queue=new PriorityBlockingQueue<>(8,new Comparator<MyTask>(){
        @Override
        public int compare(MyTask o1, MyTask o2) {
            return (int) (o1.getTime()- o2.getTime());
        }
    });
    

 那接下来写什么呢,那就是构造方法了,指定两个参数,一个是任务 第二个是 时间,但是步骤稍微多了那么一点点(这里有一个阻塞等待,现在不说为什么,先写剩余的代码,后面在解释wait的作用)

public MyTimer(){
        t=new Thread(()->{
            while(true){
                try {
                    //取出首元素 检查队列首元素是否满足时间条件
                    //如果时间没到 就把任务塞回去
                    //另外时间到了就正常执行
                    synchronized (this){
                        MyTask myTask=queue.take();
                        Long curTime=System.currentTimeMillis();
                        //如果时间大于了我们当前的时间 就再把当前时间装进队列里
                        if(curTime<myTask.getTime()){
                            queue.put(myTask);
                            this.wait(myTask.getTime()-curTime);
                        }else{
                            //如果时间小于或者等于当前时间就把该时间的任务执行了
                            myTask.run();
                        }
                    }
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t.start();
    }

扫描线程已经写完了,思路的第一个步骤也算是走完了,但是第二步骤就是用一个方法将任务装进队列中,

//指定两个参数
    //第一就参数是  任务 内容
    //第二参数 是 任务执行多 少秒
    public void schedule(Runnable runnable,long after){
        MyTask task=new MyTask(runnable,System.currentTimeMillis() + after);
        queue.put(task);
        synchronized (this){
            this.notify();
        }
    }

这里遗留了一个问题,其实是两个问题,为什么要在线程上加锁,为什么要用wait等待。

(1)先解决wait等待问题,前面的博客中提到wait是可以有自己的时间限制的,因为如果时间不到,不能一直执行if语句从队列中弹出,再塞回去也是要有消耗的呀,所以并不能单单的用if语句再这里解决问题,所以这里使用wait确定一个等待时间自动开始,或者有新任务入队列,就会进行一次notify唤醒,从新计算等待时间

(2)然后就是加锁问题,因为线程是会进行随机调度的,所以可能线程在计算wait等待时间前就已经调度走了,在这样的情况下入队列是一个最小时间,那再回来的时候wait会进行更新吗,答案是当然不会,因为我们之前已经take出队列了,所以wait等待的时间是上一次的时间,所以加锁的目的就是为了能够保证wait能够执行完,notify唤醒通知不会被放鸽子,同时也不会导致新任务添加入队列时wait时间计算错误

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值