多线程编程设计模式(单例,阻塞队列,定时器,线程池)

发布时间:2023年12月31日

💕"只有首先看到事情的可能性,才会有发生的机会。"💕
作者:Mylvzi
文章主要内容:多线程编程设计模式(单例,阻塞队列,定时器,线程池)
在这里插入图片描述
本文主要讲解多线程编程中常用到的设计模式,包括单例模式,阻塞队列,定时器和线程池,包括所有设计模式在java标准库的使用,源码讲解,模拟实现

一.设计模式的概念

简单来说,设计模式就是程序员的棋谱,在日常的开发中,我们经常会遇到一些经典场景,针对这些经典场景,大佬们就总结出了一套行之有效的代码规范,帮助我们更加合理规范的进行编程(就像象棋开局最经典的当头炮,马来跳一样,这就是一个经典场景)
在这里插入图片描述

同时呢,设计模式也是计算机中非常重要的一门学科,日常最常使用的设计模式有两种

  1. 单例模式
  2. 工厂模式

下面来分别进行讲解

二.单例模式

1.概念

单例模式指的是单个实例,即我们希望有的类只有一个对象,在之前的学习过程其实我们也遇到过,比如Mysql中JDBC的DataSource,它用于描述服务器的位置,他就只能有一个对象.

日常生活中其实也能找到类似的场景,比如你只能有一个对象,你要是有多个对象肯定是要出问题的,还要被骂渣男,为了保证单例,大佬们就创建出了属于单例的设计模式,来帮助我们应对这个场景

可能有人会说,不就是一个实例吗,那我就new一次不就行了么?当然是可以的,但是你怎么就能保证你只new一次呢?人非圣贤孰能无过,是人总会犯错的,但是机器不会啊,我们可以让机器帮助我们检查我们写的代码是否满足单例模式,不满足直接就报错了.不要认为自己一定不会犯错~有机器帮你检查不是更香吗

其实,通过机器校验来避免编程错误也是一种很常见的方式,有很多我们经常犯的错误都可以交给编译器进行检查,比如:

  1. final 我们希望一个变量不被修改,就将其设置为final,一旦我们修改,编译器就报错
  2. interface 当我们规定一个类实现一个接口,就必须要重写接口内部的抽象方法,否则会报错
  3. @override 重写方法 如果重写方法时,方法名称,返回值,参数列表错误,编译器就会报错

但是呢,对于单例模式来说,在语法上并没有类似上述的检查机制,只能通过一些编程技巧来让机器给我们检查

2.单例模式的分类

单例模式主要分为两类:

  1. 饿汉模式
  2. 懒汉模式

下面进行讲解

1.饿汉模式

首先创建出一个类,类名称为SingleTon

class SingleTon {}

单例模式最重要的是实例只有一个,那么我们就将其设置为类成员

class SingleTon {
	// instance 就是唯一的实例
	private static SingleTon instance = new SingleTon();
}

还要保证类外只能获取到instance,而不能对instance进行修改,所以只提供getInstance方法即可,同时将构造方法也设置为私有的,这样在类外就无法构造新的实例

class SingleTon {
	// instance 就是唯一的实例
	private static SingleTon instance = new SingleTon();
	
	//  只提供获取方法  不提供set方法
	public static SingleTon getInstance() {
		return instance;
	}
	
	// 将构造方法设置为私有的
	private SingleTon(){};

	public static void main(String[] args) {
		// 直接通过类来获取唯一的实例
        SingleTon s = SingleTon.getInstance();
        // 如果尝试再次创建一个新的实例  就会报错
//        SingleTon s1 = new SingleTon();
    }
}

这样就完成了饿汉模式的设计,总结来说,需要注意的点有三个

  1. 将唯一的实例设置为static
  2. 只提供获取方法,不提供set设置方法
  3. 将构造方法设置为私有的,保证类外无法创建新的实例

接下来看懒汉模式的设计

2.懒汉模式

这个词在计算机世界中其实是一个褒义词,可以说,正是因为人类的,才促进了计算机的快速发展,当然不仅仅是计算机,还有其他很多物品的出现都是因为,蒸汽机的出现帮助我们少走很多路,我们只需坐在车里休息即可,计算器的出现让我们不用在拿着纸笔算,Excle的出现帮助我们节省了很多重复操作…等等等等所以,其实是一种追求效率的体现(比如笔者最近的博客就是通过MD进行编写的,感觉效率大大提高,还有Vim)

理解了懒的含义,就很容易理解懒汉模式了,懒汉模式就是比饿汉模式效率更高的单例模式,他们最主要的区别在于创建唯一实例的时机不同

我们观察下饿汉模式下,唯一实例的创建时机

可见,饿汉模式下,唯一的实例instance是在类加载的时候就创建了,但是说,我们真的需要一开始就使用么?很多情况下并不是这样的,我们并不需要提前准备,而是应该做到随用随创,我们什么时候想使用,再去创建出来唯一的实例.

就像大学中的很多水课要考试一样,如果一开始就认真听课,努力去背诵,不如等到考试前几天再去背,反正都是背了立马就忘记,帮助我们省去了很多的无意义时间,懒汉模式就是这样,我们并不需要一开始就创建出实例,而是在我们第一次使用的时候再去创建,下面是懒汉模式的代码实现

class SingleTonLazy {
	// instance是唯一的实例  现将其设置为null
	private static SingleTonLazy instance = null;
	
	// 只提供获取方法
	public static SingleTonLazy getInstance() {
		if(instance == null) {
			// 为null 代表是第一次使用 需要创建
			instance = new SingleTonLazy();
		}
		
		return instance;
	}

	// 将构造方法设置为私有的
	private SingleTonLazy(){ };
}

懒汉模式虽然效率提高了,但是,他也带来了一些线程安全问题,什么安全问题呢 ?我们学习过一个最经典的线程安全问题就是多个线程针对同一个变量进行修改就会引发线程安全问题,在懒汉模式中,创建实例的时机就会引发这样的问题,我们是先进行判断再去创建一个实例,创建这个过程就是一个修改的过程,有可能线程1先判断完之后,并没有创建出实例,而是紧接着线程2去执行判断+创建这样的操作,此时已经通过线程2 创建出了唯一的实例,但是线程1已经判断完了,他只会执行new部分的代码,这样两个线程都创建出了一个实例,这就违背了单例模式
在这里插入图片描述
如何解决呢?使用synchronized进行加锁,保证判断+创建这两步操作是原子的

// 只提供获取方法
	public static SingleTonLazy getInstance() {
		synchronized(SingleTonLazy.class) {
			if(instance == null) {
				// 为null 代表是第一次使用 需要创建
				instance = new SingleTonLazy();
			}
		}
		return instance;
	}

加了锁之后就正确了吗?不是的,此时又产生了一个调度开销问题.观察我们的代码,虽然我们加了锁,保证两个线程不会发生交叉执行的情况,但是我们之后每次获取实例的时候都需要先进行加锁,判断instance是否为null,但实际上我们只需要在第一次创建实例的时候进行判断+创建实例,一旦instance被创建好,以后就不需要进行判断了,直接return instance即可.而且,判断之前还需要进行加锁,也就是以后的每次获取都需要加锁,但加锁其实有一定的开销的,每次都加锁会影响效率的~

解决方法也很简单,我们只需在第一次加锁即可,之后的每次直接return

	// 只提供获取方法
    public static SingleTonLazy getInstance() {
        // 最外面一层的if 加锁的频率太多了  是为了减少加锁的次数  避免不必要的开销  
        // 属于一种优化操作
        // 内层的if 仅仅是懒汉模式的特性  只有在调用的时候采取创建出对象
        if(instance == null) {
            // 加锁 是保证if 和new 操作的原子性
            synchronized (SingleTonLazy.class) {
                if (instance == null) {
                	// 为null 代表是第一次使用 需要创建
                    instance = new SingleTonLazy();
                }
            }
        }
        return instance;
    }

注意理解两层if的实际含义与作用,外层的if是为了减少锁的开销,内层的if是懒汉模式的特征

写到这里,懒汉模式的代码其实还没写完(汗流浃背了吧),还存在一些问题,这就涉及到我们之前学习过的指令重排序问题,指令重排序是编译器的一种优化方式,编译器为了提高效率有可能更改指令的执行顺序,对于懒汉模式来说,其中的new操作底层其实分为三步执行:

  1. 在内存中为对象开辟空间
  2. 创建出具体的对象
  3. 将对象在内存中的地址返还给引用instance

其中1的执行顺序是固定的,只能排在第一位,但是2,3的执行顺序是可以改变的!可以是先传地址,再创建(3,2)也可以先创建,在传地址(2,3),如果是(3,2)就可能出现问题!!!
在这里插入图片描述
如果发生了指令重排序,就有可能引发上述问题.

这个问题其实在现实生活中也能遇到,比如买房子,买的房子分为两类:精装房和毛坯房,无论是精装的还是毛坯的,都需要现有房子对应的空间,但是是先装修还是先给钥匙是不固定的,先装修再给钥匙就是精装房(先创建具体对象,再返还地址)先给钥匙,再装修就是毛坯房(先给地址,再创建具体的对象),但是最后的结果是一样的,你都买到了房子

如何解决呢?使用volatile修饰变量来解决指令重排序!

private static volatile SingleTonLazy instance = null;

懒汉模式的完整代码:

class SingleTonLazy {
    // 初始设置为null  这样就不会在类加载 的时候就创建出实例
    // 添加volatile是为了禁止new操作的 指令重排序
    private static volatile SingleTonLazy instance = null;

	// 只提供获取方法
    public static SingleTonLazy getInstance() {
        // 最外面一层的if 加锁的频率太多了  是为了减少加锁的次数  避免不必要的开销  
        // 属于一种优化操作
        // 内层的if 仅仅是懒汉模式的特性  只有在调用的时候采取创建出对象
        if(instance == null) {
            // 加锁 是保证if 和new 操作的原子性
            synchronized (SingleTonLazy.class) {
                if (instance == null) {
                	// 为null 代表是第一次使用 需要创建
                    instance = new SingleTonLazy();
                }
            }
        }
        return instance;
    }

    // 将构造方法设置为私有
    private SingleTonLazy() { };
}

说明:有的同学可能对于指令重排序哪里有些问题,认为为什么线程1执行完new操作的1,3之后会调度到线程2呢?他不是加锁了吗?要知道只有两个线程执行到加锁代码部分的时候才会发生锁竞争,另一个线程要等待,但实际上,我们的线程2并没有执行到加锁部分,在外层的if中判定instance非空,直接跳到return部分,并没有进入到加锁部分,也就不涉及到两个线程之间的锁竞争.
在这里插入图片描述
总结一下懒汉模式代码的注意事项

  1. 懒汉模式和饿汉模式最大的区别在于instance的创建时机,懒汉模式是随用随创,饿汉模式是在类加载的时候就直接创建
  2. 为了避免两个线程针对同一变量进行修改这样的线程安全问题,我们对判断+创建部分的代码进行synchronized加锁
  3. 为了进一步的减少开销,我们在加锁的外围进一步进行判断,保证只有第一次获取对象的时候加锁,其余时间不加锁
  4. new操作可能涉及到指令重排序问题,还要进一步的对实例instance使用volatile进行修饰,禁止编译器进行指令重排序

补充:如果继续深究懒汉模式的代码,他其实还有两个问题需要考虑:

  1. 使用反射能否打破单例?
  2. 使用序列化/反序列化能否打破单例 ?

这两种情况非常少见,但这里也给出对应的场景,一下内容了解即可

1.反射
反射这种机制能够拿到类的所有方法,包括你的私有的构造方法,在懒汉模式下,我们将构造方法设置为private就是为了保证类外拿不到类的构造方法,但是通过反射这种机制就有可能拿到私有的构造方法,从而违背单例模式的原则

    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {

        // 通过反射获取到私有的构造方法
        Class<?> c1 = SingleTonLazy.class;
        Constructor<SingleTonLazy> con = (Constructor<SingleTonLazy>) c1.getDeclaredConstructor();
        con.setAccessible(true);

        // 创建出了一个新的实例
        SingleTonLazy s1 = con.newInstance();

        SingleTonLazy s2 = SingleTonLazy.getInstance();

        System.out.println(s1 == s2);// 输出false
    }

通过反射创建出的新的实例违背了单例模式的原则,解决方式有多种,可以设置计数器来记录创建instance的次数,也可以直接在构造方法中设置相应的条件,也可以使用枚举类型,因为枚举类型无法通过反射获取他的实例

public enum SingletonEnum {
    INSTANCE; // 单例实例

    // 可以在枚举类型中添加其他方法或属性
}

public class Demo {
    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        // 尝试通过反射创建实例
        Class<?> c1 = SingletonEnum.class;
        Constructor<SingletonEnum> con = (Constructor<SingletonEnum>) c1.getDeclaredConstructor(String.class, int.class);
        con.setAccessible(true);

        SingletonEnum s1 = SingletonEnum.INSTANCE;
        SingletonEnum s2 = SingletonEnum.INSTANCE;

        System.out.println(s1 == s2); // 输出true,说明是同一个实例

        // 尝试通过反射创建实例,这里会抛出异常,因为枚举类型无法通过反射实例化
        try {
            SingletonEnum s3 = con.newInstance("someString", 42);
        } catch (Exception e) {
            System.out.println("Exception caught: " + e);
        }
    }
}

2.序列化

3.总结:饿汉模式和懒汉模式的对比

1.饿汉模式只涉及到变量的读操作,是天然线程安全的;懒汉模式既要读取,又要修改所以会涉及到线程安全问题

三.生产者消费者模型

1.阻塞队列

阻塞队列是一种特殊的队列,它具有以下两种重要的性质

  1. 线程安全
  2. 带有阻塞特性
    1. 当队列为满时,继续往队列中添加数据,阻塞等待,直到队列中有数据被拿出
    2. 当队列为空时,继续从队列中删除数据,阻塞等待,直到队列中有新的数据添加进来

阻塞队列最常见的使用就是用于实现生产者消费者模型

2.基本概念

生产者消费者模型是多线程编程设计模式中常用的一种,通过使用阻塞队列将生产者和消费者分离,从而实现更加健壮,更加高效的代码

举一个日常生活中常见的例子"包饺子",我们知道包饺子需要擀面皮和包饺子,现在假设你家里要包饺子,我们有以下两种分配方式:

  1. 一半人去擀面皮,一半人去包饺子
  2. 只有一个人擀面皮,其余人都去包饺子

对于方案1来说,其实效率并不是很高,因为你家只有一个擀面杖,当一个人擀面皮的时候,另一个也需要擀面皮的人就需要等待,对于方案2来说,是一个效率更高的方案,挑一个擀面皮最熟练的来擀面皮,其余人都去包饺子,假设我擀面皮我就不断地产出饺子皮(生产者),其余人就不断地利用饺子皮包饺子(消费者),这就构成了一个生产者消费者模型

我生产出来的饺子皮需要有地方放啊,一般都是放在盖帘上,包饺子的人就从这个盖帘上拿饺子皮,这个盖帘就相当于阻塞队列

当我擀饺子皮的速度很快时,就会有大量的饺子皮没有被包成饺子,这时候我就可以休息等待(队列为满发生阻塞)

当我擀饺子皮的速度赶不上包饺子的速度时,包饺子的人就可以等待(队列为空发生阻塞)

那为什么要使用生产者消费模型呢?引入阻塞队列的意义是什么?下面讲解生产者消费者模型的意义

3.生产者消费者模型的意义

1.解耦合

耦合度适用于反应代码之间联系性的一种表征,当代码之间的联系性越高,耦合度也就越高;反之,耦合度就越低;对于我们日常的编码来说,我们应该追求低耦合度的代码,因为低耦合度的代码更加健壮,也更加易于修改,尤其是在分布式系统中,更需要低耦合度来保证服务器更加高效的运行,下面是一个简单的分布式系统
在这里插入图片描述
对于这种分布式系统来说,服务器A和服务器B直接交互,此时两个服务器之间的耦合度是很高的,这会带来两个问题:

  1. 当服务器B出现问题时,会直接影响到A,进而影响到整个机房的运行
  2. 如果添加一个服务器C和A进行交互,A中需要修改的代码很多

综上,这种交互方式的耦合度高,应对风险的能力低,且不便于进行修改,如果引入阻塞队列实现的生产者消费模型就能降低耦合度
在这里插入图片描述
服务器A和服务器B之间是通过阻塞队列进行交互的,当服务器B发生问题时,并不会直接影响到服务器A,降低了服务器之间的耦合性;如果想要添加一个新的服务器C,也不需要对服务器A进行修改,服务器A 可以直接利用阻塞队列和服务器C进行交互

一般将阻塞队列单独封装成一个服务器程序,专门用于其他服务器之间进行交互,也称为消息队列

2.削峰填谷

削峰填谷是地理学中外动力地质作用对于地球表面的影响,他会降低较高的山峰,升高较低的谷地,他是一种平衡的思想,阻塞队列也能起到类似的作用在这里插入图片描述
当短时间内用户发生了大量请求时(比如整点抢票,往往网站会崩溃),这些请求就会传递给服务器A,而因为服务器A和服务器B是直接进行交互的,也就是服务器A处理多少数据,服务器B就处理多少数据.但实际上,两个服务器执行的是不同的业务,不同的逻辑,以及不同的硬件资源,这就导致他们处理数据的能力也是不尽相同的,尤其是当服务器B是分布式系统中较为脆弱的数据库时,就有可能因为短时间内大量数据的涌入导致服务器B崩溃,进而影响服务器A的正常工作

发生崩溃的原因在于服务器B短时间内处理数据超过了其本身能处理的范围,服务器B一次只能处理一个请求,而你直接传入了四个请求,服务器B肯定会发生崩溃,如果引入阻塞队列就能很好的解决上述问题
在这里插入图片描述
服务器A将要处理的请求传入到阻塞队列之中,因为服务器B一次只能处理一个请求,所以他一次就从阻塞队列中拿取一个请求,仍然维持自己的处理速度,虽然对于用户请求的相应变慢了,但总比让整个服务器崩溃好,通过阻塞队列这种类似于缓冲剂的作用,就能很好的应对短时间内大量请求的问题,增加了抗风险能力(而且这种大量请求往往是短时间的,并不会持续)

4.阻塞队列的使用

在java的标准库内部,提供了现成的阻塞队列供我们使用,BlockingQueue

public interface BlockingQueue<E> extends Queue<E>

BlockingQueue实际上是一个接口,需要通过实例化他的对象来使用,实现方式一般有两种:

  1. 基于数组实现
  2. 基于链表实现
		// 都需要指定容量  没有不含参数的构造方法
        BlockingQueue<Integer> queue1 = new ArrayBlockingQueue<>(10);
        BlockingQueue<Integer> queue2 = new LinkedBlockingQueue<>(10);

一个简单的使用例子

        // 创建一个阻塞队列
        BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);

        // 生产者线程
        Thread producer = new Thread(() ->{
                try {
                    for (int i = 0; i < 5; i++) {
                    	// 向阻塞队列中添加数据
                        queue.put(i);
                        System.out.println("produce:" + i);
                        Thread.sleep(1000);
                    }
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
        });

        // 消费者模型
        Thread consumer = new Thread(() ->{
            try {
                for (int i = 0; i < 5; i++) {
                	// 从阻塞队列中获取数据并打印
                    Integer ret = queue.take();
                    System.out.println("consume:" + ret);
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        // 开启两个线程
        producer.start();
        consumer.start();
    }

补充:BlockingQueue本质上是一个接口,在Java的标注库内部有四个实现他的类在这里插入图片描述
这四个类分别适用于不同的场景,如果需要任务的执行具有优先级使用PriorityBlockingQueue,如果任务的数量恒定且变化不大,可以使用ArrayBlockingQueue,如果任务的数量不恒定且变化较大,可以使用LinkedBlockingQueue(因为链表方便插入删除数据)

5.阻塞队列的模拟实现

BlockingQueue 的实现方式有两种,基于数组或链表,此处模拟实现数组的BlockingQueue

首先我们对传入的数据的使用有一定的要求先进入的数据要最先被使用,也就是FIFO,所以我们要使用队列,使用普通的队列当然也能实现,但是考虑到实现的简单性,这里我们采用循环队列实现(实际上,java中的源码也是使用循环队列实现的)

1.基本属性

// 这里采用数组实现  数组内部存放String类型的数据
private String[] elem = new String[100];

private volatile int front;// 指向首元素
private volatile int rear;// 指向尾元素
private volatile int cnt = 0;// 用于判满

// 用于加锁的对象
private Object locker = new Object();

说明:

  1. 此处采用计数器的方式来解决循环队列的判满
  2. 使用volatile的原因在于有可能出现多个线程针对同一个变量进行修改的线程安全问题

2.基本操作

1.put
// put 向阻塞队列中存入数据
public void put(String data) throws InterruptedException {
	synchronized(locker) {
		// 如果为满 就要阻塞等待
		// 此处要使用while循环 
		while(cnt == elem.length) {
			// 使用wait进行等待	wait必须搭配synchronized进行使用
			// 直到队列中有元素被take出去 才能进行唤醒
			locker.wait();
			// 被唤醒之后还要再次判断当前队列是否为满
		}

			// 不为空 在队尾存入数据
		this.elem[rear] = data;
		rear = (rear+1) % elem.length;
		cnt++;
		locker.notify();// 用于唤醒take方法中的wait操作
	}
}
2.take
// take 从队列中获取对应的数据
public String take() throws InterruptedException {
	synchronized(locker) {
		// 如果为空就阻塞等待  直到有新的元素进入到队列之中
		while(cnt == 0) {
			// 使用wait进行阻塞等待
			// 当有新的元素被添加进入到队列后 就唤醒
			locker.wait();
			// 被唤醒之后还要再次判断当前队列是否为空
		}
			// 不为空 取出数据
		String ret = this.elem[front];
		front = (front+1) % elem.length;
		cnt--;
		locker.notify();// 用于唤醒put方法内部的wait操作
		return ret;
	}
}

上述代码其实有一个小问题,也是经常被忽视的一个问题.实际上wait被唤醒的方式有两种

  1. 通过wait对象使用notify进行唤醒
  2. 因为InterruptedException异常被唤醒

以put操作为例,如果wait是通过第二种方式进行唤醒,此时队列还是满的,添加新的数据就会覆盖掉之前的数据,发生数据的丢失;同样的,如果take中的wait也是通过异常的方式进行唤醒,就会取出非法数据.所以,wait唤醒之后我们还需要进一步的去判断当前队列的状态,这就构成了一个判断的循环,所以要使用while循环来判断满/空

其实在java的官方文档中也建议wait方法应该总是在循环中使用此方法
在这里插入图片描述
注意,这里官方文档还给出了使用循环的理由"interrupts and spurious wakeups are possible"中断和虚假唤醒都是有可能的.
中断是指在当前线程wait的时候,有可能被其他线程调用interrupt方法导致线程中断,接着抛出异常,让线程继续执行剩余代码,但是这并不是我希望的wait被唤醒的逻辑;

虚假唤醒(suprious wakeups)指被操作系统或JVM引起的不正常的唤醒,这通常是由于线程的调度或是操作系统等引起的,同样这也不是我们期望的唤醒逻辑,要重新进行判断

简单使用

    public static void main(String[] args) {
        // 使用上述阻塞队列实现生产者消费者模型
        MyBlockingQueue queue = new MyBlockingQueue();

        // 生产者模型
        Thread t1 = new Thread(() -> {
            int num = 1;
            while (true) {
                try {
                    queue.put(num+"");
                    System.out.println("生产者生产:" + num);
                    num++;

                    
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        // 消费者模型
        Thread t2 = new Thread(() ->{
            while (true) {
                try {
                    String ret = queue.take();
                    System.out.println("消费者消费:" + ret);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        t1.start();
        t2.start();
    }

说明:在生产者中使用sleep,就是生产的慢,消费的快,每生产出一个就被消费
在这里插入图片描述
如果在消费者中使用sleep,就是生产的快,消费的慢,会有大量的数据存储在阻塞队列之中,当队列为满时,就要阻塞等待,让消费者先消费
在这里插入图片描述
以上就是关于生产者消费者模型的所有内容,接下来介绍另一种设计模式–定时器

四.定时器

1.引言

定时器也是常见的开发组件之一,主要用来定时执行任务.这种操作也是很常见的,比如在进行网络通信的时候,如果客户端向服务器发送了一个请求,但是服务器迟迟没有响应,那客户端需要一直等下去吗?这显然不是一个好的方案,我们应该设置等待期限,到达期限之后再去执行其他任务(重新发送一次请求?直接退出?)

2.定时器的使用

在java的标准库内部也实现了定时器,被封装为一个类Timer

从他的源码部分我们可以了解到关于Timer类的一些知识

  1. 每个Timer类都对应着一个后台线程,用于执行未来的任务或者间隔重复执行默写任务
  2. Timer类通过stop或者cancel方法结束
  3. Timer类内部的任务通过**优先级队列(堆)**进行管理的,调用任务的时间复杂度为O(logN),N是同时调度的任务数
        // 创建出Timer类
        Timer timer = new Timer();

        // 通过schedule方法进行任务的设置
        timer.schedule(new TimerTask() {
            // 任务1将在1s后执行
            @Override
            public void run() {
                System.out.println("这是任务1");
            }
        },1000);

timer类是通过schedule方法进行任务的设置,Timertask是一个匿名内部类,这个类就是定时器要执行的任务,以及任务执行的时间的一个抽象
在这里插入图片描述
所以,schedule方法实际上有两个参数,第一个参数是要执行的任务,第二参数是任务的执行时间的间隔(以当前时间为基准)

    public void schedule(TimerTask task, long delay) {
    	// 如果间隔时间<0  非法  抛异常
        if (delay < 0)
            throw new IllegalArgumentException("Negative delay.");
        // 调用sched方法执行任务  参数1:要执行的任务  参数2:要执行任务的绝对时间(当前时间+间隔时间)
        // 参数3:period 任务重复执行的时间  这里设置为0  代表默认只执行一次
        sched(task, System.currentTimeMillis()+delay, 0);
    }

一个简单的使用

    public static void main(String[] args) {

        // 创建出Timer类
        Timer timer = new Timer();

        // 通过schedule方法进行任务的设置
        timer.schedule(new TimerTask() {
            // 任务1将在1s后执行
            @Override
            public void run() {
                System.out.println("这是任务1");
            }
        },1000);

        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");
                timer.cancel();// 执行完所有的任务后 终止timer内部的线程  否则会一直等待
            }
        },3000);

        System.out.println("定时器的使用");

    }

三个任务依次执行
在这里插入图片描述

3.定时器的模拟实现

由上述源码我们可以总结出要实现定时器的一些关键要点

  1. 要有一个Timer类,表示定时器
  2. Timer类内部有一个方法schedule用于定时执行任务
  3. Timer类内部要有一个线程,专门用于根据执行时间执行任务
  4. 要有一个数据结构根据时间的先后顺序执行任务
  5. 要有一个类似于TimerTask的类用于管理要执行的任务

首先创建出要管理的任务类

// 通过这个类 描述了一个任务
class MyTimerTask implements Comparable<MyTimerTask>{
	// 有两个参数  执行任务  执行时间
	private Runnable runnable;// 要执行的任务
	private long time;// 执行任务的时间  此处的时间是绝对时间
	// 绝对时间易于管理判断  后续判断是否要执行任务 可以直接比较完整的时间戳
	
	// 第二个参数delay是schedule方法传入的  而我们实际要执行任务的时间保存为绝对时间
	public MyTimerTask(Runnable runnable,long delay) {
		this.runnable = runnable;
		this.time = System.currentTimeMillis() + delay;
	}

	// 重写compareTo方法  设置为time小的先执行
	public int compareTo(MyTimerTask o) {
		return (int)(this.time - o.time); 
	}
	
	// 设置获取方法
	public Runnable getRunnable() {
		return runnable;
	}
	
	public long getTime() {
		return time;
	}
}

创建出模拟计时器类MyTimer

class MyTimer {
    // 使用优先级队列管理数据  队列的元素是任务类
    private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();

    // 因为调用schedule的线程和本身的扫描线程都会对queue进行修改
    // 所以存在线程安全问题  要加锁
    // 创建用于加锁的对象
    private Object locker = new Object();

    // 提供schedule方法
    public void schedule(Runnable runnable,long delay) {
        synchronized (locker) {
            // 所以 schedule方法的作用就是将一个任务转化为队列的一个元素
            queue.offer(new MyTimerTask(runnable,delay));
            locker.notify();// 进行唤醒
            // 此处的唤醒两处的wait
            // 一是为空 需要新元素添加进来  此处需要wait
            // 二是距离最快执行任务还有一定的时间  为了减少cpu资源的开销与调度 需要扫描线程进行阻塞等待
        }
    }

    // 扫描线程属于定时器类
    public MyTimer() {
        // MyTimer类的扫描线程  用于管理要执行的任务
        Thread t = new Thread(() -> {
            // 因为要不断的进行扫描 判断是否要执行对应的任务  此处应使用循环
            while(true) {
                try {
                    synchronized(locker) {
                        // 队列为空  没有要执行的任务  阻塞等待  使用wait方法
                        // 等到有新的元素添加进队列之后再唤醒
                        // 所以在schedule方法中进行唤醒
                        // 此处也不能使用sleep方法进行阻塞等待  因为在等待的过程中可能添加新的任务
                        // 新的任务的执行时间有可能比当前队首元素的执行时间更早  要更换执行顺序
                        while(queue.isEmpty()) {
                            locker.wait();
                        }

                        // 不为空  取出队首元素 并判断是否需要执行
                        MyTimerTask task= queue.peek();
                        long curTime = System.currentTimeMillis();
                        if(curTime >= task.getTime()) {
                            // 达到要执行任务的时间  执行任务
                            task.getRunnable().run();
                            // 执行完毕之后需要将此任务从队列中删除
                            queue.poll();
                        }else {
                            // 走到这里代表还未到执行任务的时间
                            // 如果不等待 则会一直进行while循环  会占用cpu资源
                            // 所以这里可以让扫描线程阻塞等待 一直等待到最短的任务执行时间到了
                            locker.wait(task.getTime() - curTime );
                        }
                    }

                }catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        
        // 启动线程
        t.start();
    }
}

简单使用

    public static void main(String[] args) {
        MyTimer timer = new MyTimer();
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("3000");
            }
        }, 3000);
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("2000");
            }
        }, 2000);
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("1000");
            }
        }, 1000);
        System.out.println("程序开始执行");
    }

执行结果
在这里插入图片描述

4 总结:定时器类模拟实现的一些补充说明

  1. 关于线程安全在这里插入图片描述
  2. 关于线程等待
    使用wait的地方有两处,所以schedule方法中的notify操作会唤醒两处的wait
    在这里插入图片描述
  3. 优先级队列中存储的元素必须是能够进行比较的,所以任务类MyTimerTask也要能够进行比较,比较的依据是执行时间的远近,可以让MyTimerTask类实现Comparable接口或者使用Comparator来构造比较器进行比较
    在这里插入图片描述
    定时器的模拟实现虽然代码不多,但是要考虑的地方很多,逻辑性较强,各位读者后续可以勤加练习!!!

五.线程池

1.前言

线程又被称为轻量级进程,原因在于多个线程公用同一个进程的内存资源,省去了内存创建和销毁的开销,但是有对比才有伤害,如果进一步的提高调度的频率,线程的开销也就无法避免了,为了进一步的提高效率,又设计出了两种更加高效的方式

  1. 协程:轻量级线程,他省去了线程通过cpu的调度,而是程序员自己手动去调度,进一步降低了开销,提高了效率;但是这种方式在java的圈子里并不是很流行,原因在于第二种方式线程池更加成熟,使用者更为广泛
  2. 线程池:通过提前创建好线程,在使用的时候直接从线程池里面拿取线程,大大减少了用户态和内核态的交互,进一步提高了效率

2.线程池的基本概念

“xx池"其实在计算机中经常遇到,比如"线程池”“字符串池”“常量池”"数据库连接池"等等,"池"这种思想类似于现实生活中的资源共享,重复利用,通过这种方式能够提高物品的使用效率,降低环境的负载

线程池也是起类似的作用,通过预先创建好一些线程存储到"线程池"内部,在需要调度线程的时候就拿来使用,使用完毕之后不销毁线程,而是重新放到线程池中,这样就省去了线程的开辟和销毁的开销,进一步的提高了效率

"池"这种操作其实还涉及到计算机交互的一个知识,即纯用户态的操作比内核态-用户态交互的方式效率更高!!!直接从线程池中获取线程就属于纯用户态的操作,而通过操作系统创建/销毁线程就属于内核态-用户态的交互,所以线程池的效率更高

为什么说纯用户态的操作效率更高呢?主要有以下三点原因:

  1. 减少上下文的切换:由用户态转换为内核态涉及到上下文的切换,即将处理器的执行状态由用户态转换为内核态或反之,更改处理器的执行状态需要保存当前执行状态,这涉及到寄存器的保存,权限切换操作,开销较大
  2. 减少了系统调用的次数:纯用户态的操作不需要访问系统资源,减少了系统调用的次数,进一步提高了效率
  3. 减少了权限校验和安全检查:当访问内核态的数据时,涉及到频繁地权限检验和安全检查.而纯用户态的操作并不需要进行权限校验和安全检查

内核态的操作就相当于从保险柜里获取数据,要想获取,必须现有钥匙,还要经过一系列的检查,权限认证(不是自己人就不能打开),操作流程繁杂,获取数据的速度慢

3.java标准库内部的线程池

1.基本概念

java标准库内部其实实现了线程池,线程池被封装成了一个类ThreadPoolExecutor

在这里插入图片描述
创建出一个线程池

        // 向上转型
        ExecutorService service = Executors.newCachedThreadPool();

\ \ 此处线程池的创建并没有通过常规的new关键字创建,而是调用了Executors内部的一个方法来创建线程池对象,这种创建对象的方式我们称之为工厂模式,工厂模式也是设计模式的一种,工厂模式的存在主要是为了解决构造方法缺陷,有时候我们对一个类的实现希望其有多种方式,而实现需要通过构造方法来创建,由于构造方法的方法名只能是类名,这就带来了一些使用上的局限性,请看下图

在这里插入图片描述
同理,对于ThreadPoolExecutor的创建来说,他也有很多种实现方式,为了更好的调用,此处就采用工厂模式进行创建(可以将对象的创建方法总结为以下两种)在这里插入图片描述

2.ThreadPoolExecutor的实现方式

ThreadPoolExecutor一般有四种实现方式:
在这里插入图片描述
前两种实现方式比较常用,后两种实现方式不常用,了解即可

3.ThreadPoolExecutor的核心方法

核心方法就两个:

  1. 构造方法
  2. 任务提交方法
1.任务提交

任务提交方法比较简单,创建好ThreadPoolExecutor对象之后使用submit方法进行任务的提交,交给线程池内部线程去执行提交的任务

        service.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("任务提交");
            }
        });

更重要的是ThreadPoolExecutor的构造方法,这也是面试常考的!!!

2.构造方法(重点)

进入到javase的标准文档,查看ThreadPoolExecutor的构造方法
在这里插入图片描述
一共有四个构造方法,但实际上前三种都是简化版本,省去了一些参数,第四种是最全的构造方法,这里重点掌握第四种方法

依次来看第四种构造方法的参数

**int corePoolSize, int maximumPoolSize**

corePoolSize:核心线程的数目
maximumPoolSize:线程池内部最多持有的线程数目

什么是核心线程呢?对于一个线程池来说,其内部存储的线程分为两类:

  1. 核心线程
  2. 非核心线程

核心线程是一个线程池内部始终持有的线程,无论任务有多少,核心线程的数目始终固定不变;非核心线程不是线程池始终持有的,可以根据要执行任务的多少添加或删除,当任务多时,就新建几个非核心线程去应对高任务量,任务少时就删除几个非核心线程.

可以把核心线程想象为一个公司的正式员工,而非核心线程就是实习生,对于正式员工来说,是不能随便删除的(因为劳动法~),而实习生是可以随便开除的,当任务多时,就多招几个实习生来帮我干活,任务少了,就开除这些实习生(老铁扎心了吧)

核心线程保证了低负载情况下任务的正常运行,非核心线程可以有效应对高负载的情况

**long keepAliveTime, TimeUnit unit**

keepAliveTime:非核心线程在空闲状态下的存活时间
unit:时间单位

对于非核心线程来说,如果在一定时间内处于空闲状态,没有执行任务,系统就会讲这些空闲的非核心线程销毁,节省系统资源,keepAliveTime就是规定最多空闲时间是多少,TimeUnit unit是空闲时间的单位,TimeUnit 是一个枚举类型,里面存放时间的的单位(秒/分/时)

比如:keepAliveTime是5,unit为TimeUnit.SECONDS,这意味着非核心线程在空闲5s之后就会被销毁

**BlockingQueue<Runnable> workQueue**

workQueue:用于存放要执行任务的阻塞队列,等待线程池中的线程从阻塞队列中获取相应的任务并执行,此时用户端就是生产者,执行任务的线程池就是消费者.队列中的元素就是要执行的任务Runnable

不同的阻塞队列的使用场景也不同,主要考虑容量限制和阻塞策略,可以根据不同队列的性质进行选择

**ThreadFactory threadFactory**

threadFactory:通过工厂模式创建出来的定制化的线程
ThreadFactory 是一个接口,只有一个方法,用于创建自定义的线程

public interface ThreadFactory {
    Thread newThread(Runnable r);
}

也就是ThreadFactory threadFactory这个参数就是让我们为线程池提供自己创建的自定义的线程,以下是一个简单的使用例子

// 创建自定义的线程  先让其实现ThreadFactory接口
class MyCustomThread implements ThreadFactory {
    // 设置属性  自定义线程的前缀
    private final String threadNamePrefix = "MyCustomThread - ";

    // 自定义线程的编号
    private int threadCnt = 0;

    @Override
    public Thread newThread(Runnable r) {
        // 规定线程要执行的任务
        Thread t = new Thread(r);
        t.setName(threadNamePrefix + ++threadCnt);
        return t;
    }
}
public class Demo2 {
    public static void main(String[] args) {
        MyCustomThread myCustomThread = new MyCustomThread();

        // 利用自定义线程创建出线程池
        ExecutorService executorService = Executors.newFixedThreadPool(5,myCustomThread);

        // 提交任务
        for (int i = 0; i < 5; i++) {
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    // 打印当前正在执行任务的线程名称
                    System.out.println(Thread.currentThread().getName());
                }
            });
        }
    }
}

打印结果:
在这里插入图片描述
注意:因为在多线程编程中,线程的调度是随机的,所以每次打印的结果也是不同的

**RejectedExecutionHandler handler**

handler:线程池的拒绝策略 对于一个线程池来说,其能容纳的线程数量是有限的,当超过最大的容量时,线程池会有一定的拒绝策略来阻止容量超过最大的限制,不同的拒绝策略有不同的效果,具体来说有以下四种拒绝策略:

  1. 直接抛出异常(我就是不让你超过限制,一超过限制就报错)这种策略是默认策略
  2. 丢弃当前新加的任务(添加进来新的任务就抛弃)
  3. 丢弃任务队列中最老的任务(老弱病残终究会被淘汰的)
  4. 添加的任务由添加任务的线程负责执行(这样做是为了尽量不丢失任务,添加任务的线程不是线程池中的线程,哪个线程往线程池中提交的任务就交给谁执行)
    在这里插入图片描述
    以上就是ThreadPoolExecutor类构造方法所有参数的讲解,其中corePoolSizeRejectedExecutionHandler是面试中最常考的!!!

4.线程池的模拟实现

如何去模拟实现一个线程池呢?需要先清楚了解线程池的基本组成,线程池由以下三部分组成

  1. BlockingQueue:任务队列 用于存放线程池中线程要执行的任务
  2. 线程池:线程池的核心主体还是多个用于执行任务的线程
  3. submit方法:用于提交任务,用于连接任务队列和线程池

代码实现:

class MyThreadPool {
    // 创建一个任务队列  用于存放线程池要执行的任务  10代表次任务队列最多存放的任务数量是10  超过10就要阻塞等待
    private BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(10);

    // 创建提交方法  将任务提交到任务队列之中
    public void submit(Runnable runnable) throws InterruptedException {
        // 此处采用的拒绝策略就是使用阻塞队列  队列满 阻塞等待
        queue.put(runnable);
    }

    // 创建构造方法
    public MyThreadPool(int n) {
        // 创建n个线程  就相当于newFixedPool的效果 创建出制定容量的线程池
        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();
        }

    }
}
public class Demo3 {
    public static void main(String[] args) throws InterruptedException {
        MyThreadPool myThreadPool = new MyThreadPool(10);
        for (int i = 0; i < 10; i++) {
            final int id = i;
            myThreadPool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("线程执行id" + id);
                }
            });
        }
    }
}

运行结果:
在这里插入图片描述

5.线程数量如何决定?

在使用线程池的时候,如何确定线程池内部的线程数量呢?在网上其实有很多种说法,假设cpu的逻辑核心数是N,线程的数目可以设置为N,N-1,N-2,2N,1.5N等等,其实这些说法都不准确,再你没有接触到实际的项目之前,线程的数目是不可能确定的

我们要执行的代码可以分为以下两类:

  1. cpu密集型:代码中大量存在需要进行算术运算和逻辑判断
  2. I/O密集型:代码涉及到I/O操作

假设一个代码中都是cpu密集型的代码(很吃cpu资源),cpu的逻辑核心数是N,那你设置的线程数目最多只能是N,一个线程的执行对应着一个cpu逻辑核心,如果创建的线程数目比逻辑核心N还多,就没有cpu来执行多出的线程了,反而造成了资源的浪费

假设一个代码中全是I/O密集型的代码,此时线程池中的线程数目是可以大于N的,因为一个cpu上可以调度执行这些操作~I/O操作不吃cpu

对于我们所写的代码来说,我们不知道有多少cpu密集型的,有多少I/O密集型的,也就无法直接确定设置的线程数目,正确方法应该是通过性能测试来确定线程的数量,即不断地更换线程数,看什么情况下能达到性能的最优化,通过测试找出应设置的线程数

补充:I/O操作是指计算机系统与外部链接设备之间的数据传输,常见的I/O操作包括文件读取,数据库连接,网络通信等等

6.线程池构造方法的进一步认识

1.newFixedThreadPool

源码:

    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

讲解
在这里插入图片描述

2.newCachedThreadPool

源码:

    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

讲解:
在这里插入图片描述

3.newSingleThreadExecutor

源码:

    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

讲解:
在这里插入图片描述

4.ScheduledThreadPoolExecutor

源码:

    public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    }

讲解:
在这里插入图片描述

最后附上多线程设计模式的思维导图
在这里插入图片描述
创作不易!!!欢迎大家多多转发支持!

文章来源:https://blog.csdn.net/Mylvzi/article/details/135218801
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。