8. 多线程案例

8.1 单例模式

单个实例. 在一个 java 进程中, 要求指定的类,只能有唯一一个实例。(尝试 new 多个实例的时候, 就会直接编译报错)

单例模式是校招中最常考的设计模式之⼀.

啥是设计模式?
设计模式好⽐象棋中的 “棋谱”. 红⽅当头炮, ⿊⽅⻢来跳. 针对红⽅的⼀些⾛法, ⿊⽅应招的时候有⼀些固定的套路. 按照套路来⾛局势就不会吃亏.
软件开发中也有很多常⻅的 “问题场景”. 针对这些问题场景, ⼤佬们总结出了⼀些固定的套路. 按照这个套路来实现代码, 也不会吃亏.

单例模式能保证某个类在程序中只存在唯⼀⼀份实例, ⽽不会创建出多个实例.
这⼀点在很多场景上都需要. ⽐如 JDBC 中的 DataSource 实例就只需要⼀个.

什么场景适合使用单例模式?
代码中的有些对象,本身就不应该是有多个实例的.从业务角度就应该是单个实例.
在这里插入图片描述

单例模式具体的实现⽅式有很多. 最常⻅的是 “饿汉” 和 “懒汉” 两种.(掌握这两个, 应付面试 +日常开发)

饿汉模式

类加载的同时, 创建实例.

class Singleton {
 private static Singleton instance = new Singleton();
 private Singleton() {}
 public static Singleton getInstance() {
 	return instance;
 }
}

在这个类被加载的时候,就会初始化这个 静态成员。实例创建的时机非常早,就使用"饿汉!
在这里插入图片描述
万一,其他代码又 new 了这个类的实例咋办呢?需要禁止外部代码来创建该类的实例
在这里插入图片描述
虽然构造方法是 private,但是能否在类外面通过 反射 拿到私有构造方法创建实例??
原则上来说,可以做到。但是, 实际开发中, 反射不敢乱用的!!!反射属于非常规的编程,特殊场景下的特殊解决方案!!!! 使用反射要付出很大的代价(会严重影响代码的可读性和封装性)
类似于,通常情况下,你肯定没法直接闯入别人家里。但是, 你不能进, 不代表jc 蜀黍不能进,如果jc 蜀黍到处乱闯,当然也是不行的
代码中随便滥用反射,是非常糟糕的~~

懒汉模式

懒汉模式-单线程版
类加载的时候不创建实例. 第⼀次使⽤的时候才创建实例.(如果不使用了,就会把创建实例的代价就节省下来了)

class Singleton {
	private static Singleton instance = null;
 	private Singleton() {}
 	public static Singleton getInstance() {
		if (instance == null) {
		instance = new Singleton();
 	}
 	return instance;
 }
}

在计算机中,懒 的思想,就非常有意义
在这里插入图片描述
如果代码中存在多个单例类,使用饿汉模式,就会导致这些实例都是在程序启动的时候扎堆的创建的.可能把程序启动时间拖慢.
如果是懒汉模式,啥时候首次调用, 调用时机是分散的. 化整为零, 用户不太容易感知到"卡顿

如果是首次调用 getlnstance, 那么此时 instance 引用为 null,就会进入 if 条件,从而把实例创建出来,如果是后续再次调用 getlnstance, 由于 instance 已经不再是 null,此时不会进入if, 直接返回之前创建好的引用了。这样设定,仍然可以保证,该类的实例是唯一一个。与此同时,创建实例的时机就不是程序驱动时了,而是第一次调用getlnstance的时候
这个操作的执行时机就看你程序的实际需求。大概率要比饿汉这种方式要晚一些,甚至有可能整个程序压根用不到这个方法,也就把创建的操作给省下了

有的程序, 可能是根据一定的条件,来决定是否要进行某个操作,进一步的来决定创建某个实例
比如,肯德基有个操作“疯狂星期四”,对于 肯德基 点餐系统来说,就可以判定今天星期几。如果是星期四,才加载 疯狂星期四 相关的逻辑和数据,如果不是星期四,就不用加载了(节省了一定的开销)

懒汉模式-多线程版

上述的代码,饿汉模式和懒汉模式,是否是线程安全的?? 如果在多个线程中, 并发的调用 getlnstance, 这两个代码是否是线程安全的呢??
饿汉: getlnstance 直接返回 Instance 实例. 这个操作本质上就是"读操作"。多个线程读取同一个变量,是线程安全的!!
懒汉: 线程不安全,在多线程环境下可能会创建出多个实例!!在懒汉模式中,代码有读也有写,如果 t1 和 t2 按照下列顺序来执行,就会出现问题!!在这里插入图片描述

上⾯的懒汉模式的实现是线程不安全的.
线程安全问题发⽣在⾸次创建实例时. 如果在多个线程中同时调⽤ getInstance ⽅法, 就可能导致创建出多个实例.
⼀旦实例已经创建好了, 后⾯再多线程环境调⽤ getInstance 就不再有线程安全问题了(不再修改instance 了)

加上 synchronized 可以改善这⾥的线程安全问题.

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

懒汉模式-多线程版(改进)
多线程代码, 其实是非常复杂的,代码稍微变换一点,结论就截然不同!!
因此可千万不要以为,代码中写了 synchronized 就一定线程安全,不写 synchronized 就一定线程不安全!!!
一定要具体问题具体分析.要分析这个代码在各种调度执行顺序下可能的情况,确保每个情况都是正确的!!

此处要想让代码执行正确,其实是需要把 if 和 new 两个操作,打包成一个原子的!!
更加合理的做法,应该是把 synchronized 套到if 外头~~
在这里插入图片描述

但上述代码仍然存在问题~~

效率非常低!!!
如果 Instance 已经创建过了,此时后续再调用 getlnstance 就都是直接返回 Instance 实例了(此处的操作就是纯粹的读操作了,也就不会有线程安全问题了)
此时,针对这个已经没有线程安全问题的代码,仍然是每次调用都先加锁再解锁,此时,效率就非常低了!!!加锁就意味着可能会产生阻塞,一旦线程阻塞,啥时候能解除,就不知道了(你可以认为,只要一个代码里加锁了,基本就注定和“高性能"无缘)
在需要加锁的时候才加锁,不该加锁,不能随便乱加。所以除了 StringBuffer 还提供 StringBuilder, 除了 Vector 还提供 ArrayList
在这里插入图片描述

这个代码仍然有点问题~~

指令重排序,引起的线程安全问题
指令重排序,也是编译器优化的一种方式,调整原有代码的执行顺序,保证逻辑不变的前提下,提高程序的效率
instance = new singletonLazy();
这行代码,其实可以拆成三个大的步骤,(不是三个指令)
1.申请一段内存空间
2.在这个内存上调用构造方法,创建出这个实例
3.把这个内存地址赋值给 |nstance 引用变量
正常情况下,上述代码是按照 123 的顺序来执行的,但是编译器也可能会优化成132的顺序来执行,无论是123 还是132在单线程下都是可以的~~
1 就相当于是你买了个房子,2 就相当于给房子装修,3 就相当于你拿到房子的钥匙。123 拿到钥匙之后,就得到了装修好的房子. 称为"精装房",132你先拿钥匙,然后自己负责装修.称为"毛坏房"。如果你出去买房子,这两种情况都会存在!!!
但是, 如果是在多线程下,指令重排序,就可能引入问题了!!如果你出去买房子,这两种情况都会存在!!!
t1 按照132 的方式来执行这里的 new 操作
在这里插入图片描述
上述代码中,由于 t1 线程执行完13之后,调度走,此时 instance 指向的是一个 非 null 的,但是未初始化的对象。此时 t2 线程判定 instance == null 不成立,就会直接 return.如果 t2 继续使用 instance 里面的属性或者方法,就会出现问题(此时这里的属性都是未初始化的"全 0"值). 就可能会引起代码的逻辑出现问题.
解决上述问题,核心思路, 还是 volatile
volatile 有两个功能
1.保证内存可见性,每次访问变量必须都要重新读取内存,而不会优化到寄存器/缓存中
2.禁止指令重排序.针对这个呗 volatile 修饰的变量的读写操作相关指令,是不能被重排序的!!
在这里插入图片描述

在这里插入图片描述
上述的 123这三个要点,标准的面试回答

以下代码在加锁的基础上, 做出了进⼀步改动:
• 使⽤双重 if 判定, 降低锁竞争的频率.
• 给 instance 加上了 volatile.

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

这个代码是一个经典高频面试题,非常重要,咱们同学们最近这几年秋招也会经常遇到这个问题~~
这个题并不简单。加上这三个点,怎么加,容易答上来,为啥要这么加,每个地方解决的什么问题,要想给面试官解释清楚,没那么容易的!!
(1)写博客,提前梳理好你都要说啥。
(2)给面试官讲的过程中,一定要多画图
线下面试,可以自带纸笔;线上面试,一般面试系统也会支持画图功能,可以共享屏幕。有的面试系统 牛客网面试系统,自身就支持画图,包括 腾讯会议,也支持画图
多去画!!!
目前来看线上面试越来越多,越是好的公司,越是线上面试
面试中考察的方法非常简单:
让你现场写一个单例模式的代码
这个代码咋写?直接就写成现在这个模样嘛??
正确的写法:
1.先写一个不带线程安全的单例模式
2.思索片刻, 线程不安全,把锁加上
3.再次思索片刻,加上 if(双重 if)
4. 再次思考片刻, 加上 volatile
意味着这个题不是你提前准备好,是你现场想出来的,面试官就会觉得,你这边很可能没有准备过/很久之前看的,即使如此,能够通过已经掌握的知识,推理出一些结论
一次写出最终版本,再面试官眼里,他觉得这个问题,你正好准备过,此时说明这个题目就考察不出来啥,这题不算,谈下一话题(面试的时候,大部分面试官,看到你的回答有问题的时候,都会进一步去问的)
人生如戏,全靠演技
把问题引导到你自己擅长的角度,把控整个面试的节奏~~

理解双重 if 判定 / volatile:
加锁 / 解锁是⼀件开销⽐较⾼的事情. ⽽懒汉模式的线程不安全只是发⽣在⾸次创建实例的时候. 因此后续使⽤的时候, 不必再进⾏加锁了.
外层的 if 就是判定下看当前是否已经把 instance 实例创建出来了.
同时为了避免 “内存可⻅性” 导致读取的 instance 出现偏差, 于是补充上 volatile .
当多线程⾸次调⽤ getInstance, ⼤家可能都发现 instance 为 null, 于是⼜继续往下执⾏来竞争锁, 其中竞争成功的线程, 再完成创建实例的操作.
当这个实例创建完了之后, 其他竞争到锁的线程就被⾥层 if 挡住了. 也就不会继续创建其他实例.

  1. 有三个线程, 开始执⾏ getInstance , 通过外层的 if (instance == null) 知道了实例还没有创建的消息. 于是开始竞争同⼀把锁.
    在这里插入图片描述
  2. 其中线程1 率先获取到锁, 此时线程1 通过⾥层的 if (instance == null) 进⼀步确认实例是否已经创建. 如果没创建, 就把这个实例创建出来.
    在这里插入图片描述
  3. 当线程1 释放锁之后, 线程2 和 线程3 也拿到锁, 也通过⾥层的 if (instance == null) 来确认实例是否已经创建, 发现实例已经创建出来了, 就不再创建了
    在这里插入图片描述
  4. 后续的线程, 不必加锁, 直接就通过外层 if (instance == null) 就知道实例已经创建了,从⽽不再尝试获取锁了. 降低了开销.
    在这里插入图片描述

8.2 阻塞队列

之前学的队列,其实是最基础的队列.实际开发中,针对队列还有很多变种
把阻塞队列单独包装成服务器程序,并且使用单独的机器(集群)来部署,这样的队列称为"消息队列"(MQ)
阻塞队列: 数据结构
消息队列: 基于阻塞队列实现服务器程序
在这里插入图片描述
举个例子
在这里插入图片描述
由于消息队列这样的数据结构(本体是数据结构)。太好用了,因此实际开发中,经常会把这样的数据结构封装成单独的服务器程序, 单独部署这样的服务器程序,同样也称为消息队列~~消息队列能够起到的作用,就是实现生产者消费者模型
看是需要在一个进程内(直接使用阻塞队列即可),实现生产者消费者模型
还是需要分布式系统中(需要使用单独部署的消息队列服务器),实现生产者消费者模型(一种解决多线程的问题的典型方案)

阻塞队列是什么

阻塞队列是⼀种特殊的队列. 也遵守 “先进先出” 的原则.
阻塞队列能是⼀种线程安全的数据结构, 并且具有以下特性:
• 当队列满的时候, 继续⼊队列就会阻塞, 直到有其他线程从队列中取⾛元素.
• 当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插⼊元素.
阻塞队列的⼀个典型应⽤场景就是 “⽣产者消费者模型”. 这是⼀种⾮常典型的开发模型.

⽣产者消费者模型

⽣产者消费者模式就是通过⼀个容器来解决⽣产者和消费者的强耦合问题。
⽣产者和消费者彼此之间不直接通讯,⽽通过阻塞队列来进⾏通讯,所以⽣产者⽣产完数据之后不⽤等待消费者处理,直接扔给阻塞队列,消费者不找⽣产者要数据,⽽是直接从阻塞队列⾥取.

⽐如过年⼀家⼈⼀起包饺⼦. ⼀般都是有明确分⼯, ⽐如⼀个⼈负责擀饺⼦⽪, 其他⼈负责包. 擀饺⼦⽪的⼈就是 “⽣产者”, 包饺⼦的⼈就是 “消费者”.
擀饺⼦⽪的⼈不关⼼包饺⼦的⼈是谁(能包就⾏, ⽆论是⼿⼯包, 借助⼯具, 还是机器包), 包饺⼦的⼈也不关⼼擀饺⼦⽪的⼈是谁(有饺⼦⽪就⾏, ⽆论是⽤擀⾯杖擀的, 还是拿罐头瓶擀, 还是直接从超市买的)
包饺子的流程:
1.和面(一般都是一个人负责,没法多线程完成)
(这俩环节就可以多线程完成了)
2.擀饺子皮
3.包饺子
在这里插入图片描述
为了解决这个问题,就可以分工协作。引入生产者消费者模型方案,更好的解决上述问题
在这里插入图片描述

生产者消费者模型,在开发中主要有两方面的意义:

1.阻塞队列就相当于⼀个缓冲区,平衡了⽣产者和消费者的处理能⼒. (削峰填⾕)

⽐如在 “秒杀” 场景下, 服务器同⼀时刻可能会收到⼤量的⽀付请求. 如果直接处理这些⽀付请求, 服务器可能扛不住(每个⽀付请求的处理都需要⽐较复杂的流程). 这个时候就可以把这些请求都放到⼀个阻塞队列中, 然后再由消费者线程慢慢的来处理每个⽀付请求.这样做可以有效进⾏ “削峰”, 防⽌服务器被突然到来的⼀波请求直接冲垮.
在这里插入图片描述
在这里插入图片描述

2.阻塞队列也能使⽣产者和消费者之间 解耦.

实际开发中,经常会涉及到"分布式系统”.服务器整个功能不是由一个服务器全部完成的.而是每个服务器负责一部分功能. 通过服务器之间的网络通信,最终完成整个功能~~
在这里插入图片描述
引入生产者消费者模型,就可以降低上述的耦合~~
在这里插入图片描述

生产者消费者模型付出的代价:
1)引入队列之后,整体的结构会更复杂,
此时, 就需要更多的机器, 进行部署. 生产环境的结构会更复杂,管理起来更麻烦
2)效率会有影响

标准库中的阻塞队列

在 Java 标准库中内置了阻塞队列. 如果我们需要在⼀些程序中使⽤阻塞队列, 直接使⽤标准库中的即可.
• BlockingQueue 是⼀个接⼝. 真正实现的类是 LinkedBlockingQueue.
• put ⽅法⽤于阻塞式的⼊队列, take ⽤于阻塞式的出队列.
• BlockingQueue 也有 offer, poll, peek 等⽅法, 但是这些⽅法不带有阻塞特性.

使用的 put 和 offer 一样都是入队列.但是 put 是带有阻塞功能, offer没带阻塞 (队列满了会返回结果)
take 方法用来出队列,也是带有阻塞功能的.
阻塞队列没有提供带有阻塞功能的获取队首元素的方法

BlockingQueue<String> queue = new LinkedBlockingQueue<>();
// ⼊队列
queue.put("abc");
// 出队列. 如果没有 put 直接 take, 就会阻塞. 
String elem = queue.take();

⽣产者消费者模型

public static void main(String[] args) throws InterruptedException {
 BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<Integer>();
 Thread customer = new Thread(() -> {
	 while (true) {
		 try {
			 int value = blockingQueue.take();
			 System.out.println("消费元素: " + value);
		 } catch (InterruptedException e) {
			 e.printStackTrace();
		 }
	 }
 }, "消费者");
 customer.start();
 Thread producer = new Thread(() -> {
	 Random random = new Random();
	 while (true) {
		 try {
			int num = random.nextInt(1000);
			System.out.println("⽣产元素: " + num);
			blockingQueue.put(num);
			Thread.sleep(1000);
		 } catch (InterruptedException e) {
		 	e.printStackTrace();
		 }
	 }
 }, "⽣产者");
 producer.start();
 customer.join();
 producer.join();
}

直接运行,生产者和消费者两个线程的速度,旗鼓相当,所以很难见到阻塞效果。
在生产者中加 sleep,看到的是队列满 还是空?? 空!!

阻塞队列实现

学习编程,大概分成这么几个层次: 1.掌握基本使用方法 2.理解背后的原理 3.能够自己实现出来类似的.

自己实现阻塞队列:

  1. 先实现普通队列
    基于数组来实现(环形队列)
    泛型,平时开发中很少用到. 实现库/框架的人,可能会用到泛型。面试的时候,如果人家让你现场写代码,最好不要写泛型的.
    在这里插入图片描述
    在这里插入图片描述
  2. 再加上线程安全
    在这里插入图片描述
    如何加锁,锁放到哪里合适?加了锁之后是否还有问题?
    都需要我们仔细考虑!!(多线程的难点),确保所有执行顺序下程序的结果都是对的!!
    比如:这个 put 正好是添加最后一个元素,如果代码是这种顺序执行,这个代码就会多加一个元素
    在这里插入图片描述
  3. 再加上阻塞功能
    在这里插入图片描述
    在这里插入图片描述
    还需要有其他线程唤醒!!
    队列不满,就可以唤醒了!!!翻译翻译,什么叫做"队列不满",出队列成功,就是队列不满!!!
    对于满了的情况的阻塞,是在出队列成功后唤醒。
    队列空了,再出队列,同样也需要阻塞, 同样是在另一个入队列成功后的线程中唤醒
    薛定谔的队列
    比如有若干线程使用这个队列。要么所有的线程阻塞在 put, 要么所有的线程阻塞在 take,不可能有一些线程阻塞在 put, 一些阻塞在 take
    咱们的队列,一定是"要么空,要么满"不能既是空,又是满(薛定谔的队列)
    在这里插入图片描述
    上述代码还有一个关键环节~~
    在这里插入图片描述

在这里插入图片描述
比如, 我每天早上闹钟 定 7:30(上学),我有可能, 6:30 就醒了 要做的第一件事,拿出手机, 看看几点了每次被唤醒, 都应该确认一下,看看当前是否就应该要继续执行,还是再等待一会!!
在这里插入图片描述
而且 java 标准库推荐咱们, 使用 wait 要搭配 while.多一次确认操作!!(N次)
在这里插入图片描述

• 通过 “循环队列” 的⽅式来实现.
• 使⽤ synchronized 进⾏加锁控制.
• put 插⼊元素的时候, 判定如果队列满了, 就进⾏ wait. (注意, 要在循环中进⾏ wait. 被唤醒时不⼀定队列就不满了, 因为同时可能是唤醒了多个线程).
• take 取出元素的时候, 判定如果队列为空, 就进⾏ wait. (也是循环 wait)

public class BlockingQueue {
 private int[] items = new int[1000];
 private volatile int size = 0;
 private volatile int head = 0;
 private volatile int tail = 0;
 public void put(int value) throws InterruptedException {
	 synchronized (this) {
		 // 此处最好使⽤ while.
		 // 否则 notifyAll 的时候, 该线程从 wait 中被唤醒,
		 // 但是紧接着并未抢占到锁. 当锁被抢占的时候, 可能⼜已经队列满了
		 // 就只能继续等待
		 while (size == items.length) {
		 	wait();
		 }
		 items[tail] = value;
		 tail = (tail + 1) % items.length;
		 size++;
		 notifyAll();
	 }
 }
 public int take() throws InterruptedException {
	 int ret = 0;
	 synchronized (this) {
		 while (size == 0) {
		 	wait();
		 }
		 ret = items[head];
		 head = (head + 1) % items.length;
		 size--;
		 notifyAll();
	 }
	 return ret;
 }
 public synchronized int size() {
 	return size;
 }
 // 测试代码
 public static void main(String[] args) throws InterruptedException {
	 BlockingQueue blockingQueue = new BlockingQueue();
	 Thread customer = new Thread(() -> {
		 while (true) {
			 try {
			 	int value = blockingQueue.take();
			 	System.out.println(value);
			 } catch (InterruptedException e) {
			 	e.printStackTrace();
			 }
		 }
	 }, "消费者");
	 customer.start();
	 Thread producer = new Thread(() -> {
		 Random random = new Random();
		 while (true) {
			 try {
			 	blockingQueue.put(random.nextInt(10000));
			 } catch (InterruptedException e) {
			 	e.printStackTrace();
			 }
		 }
	 }, "⽣产者");
	 producer.start();
	 customer.join();
	 producer.join();
 }
}

在这里插入图片描述
在这里插入图片描述

8.3 定时器

定时器是什么

定时器也是软件开发中的⼀个重要组件. 类似于⼀个 “闹钟”. 达到⼀个设定的时间之后, 就执⾏某个指定好的代码.
定时器是⼀种实际开发中⾮常常⽤的组件.

比如 写博客, 定时发布. 比如每天早上 9:00 发布, 可能有更高的访问量。 就可以使用定时功能~~
(这个时间点,很多程序员在上班的路上或者刚到公司,要刷一会手机啥的,摸摸鱼,再开始工作)如果你是头一天晚上发布,到了第二天早上,你的博客就已经被其他博客给踩到下面了
⽐如 ⽹络通信中, 如果对⽅ 500ms 内没有返回数据, 则断开连接尝试重连.
⽐如 ⼀个 Map, 希望⾥⾯的某个 key 在 3s 之后过期(⾃动删除).
类似于这样的场景就需要⽤到定时器.

标准库中的定时器

• 标准库中提供了⼀个 Timer 类. Timer 类的核⼼⽅法为 schedule .
• schedule 包含两个参数. 第⼀个参数指定即将要执⾏的任务代码, 第⼆个参数指定多⻓时间之后执⾏(单位为毫秒).

Timer timer = new Timer();
timer.schedule(new TimerTask() {
	 @Override
	 public void run() {
	 System.out.println("hello");
	 }
}, 3000);

在这里插入图片描述

定义一个 timer 添加多个任务,每个任务同时会带有一个时间!
什么样的情况能够使用 lambda?得是函数式接口才行~~ interface 里头只能有这一个方法

import java.util.TimerTask;

public class Demo {
   public static void main(String[] args) throws InterruptedException {
       Timer timer = new Timer();
       timer.schedule(new TimerTask() {
           @Override
           public void run() {
               // 时间到了之后, 要执行的代码
               System.out.println("hello timer 3000");
           }
       }, 3000);
               
       timer.schedule(new TimerTask() {
           @Override
           public void run() {
               System.out.println("hello timer 2000");
           }
       }, 2000);

       timer.schedule(new TimerTask() {
           @Override
           public void run() {
               System.out.println("hello timer 1000");
           }
       }, 1000);

       System.out.println("hello main");
       Thread.sleep(5000);

       timer.cancel();
   }
}

timer里面执行完了也不结束?
timer 不知道你的代码是否还会添加新的任务进来,处在"严阵以待"的状态
需要使用 cancel 主动结束.否则 Timer 不知道是否其他地方还要继续添加任务的
在这里插入图片描述

实现定时器

虽然面试不会让你写定时器,理解定时器背后做的工作,也是很重要的事情

思考一下, Timer 里面要包含哪些内容~~
需要有一个 线程,负责帮咱们掐时间. 等任务到达合适的时间,这个线程就负责执行!!
还需要有一个队列/数组,能够保存所有 schedule 进来的任务!!

直观想,这个线程,就可以不停的去扫描上述队列中的每个元素,看每个任务是否到时间了.到时间就执行呗!
但是!如果队列很长,这个遍历的过程开销就很大了 O(N)
map set 虽然有序, 但是获取到最小值,有代价:O(logN)
优先级队列!!!yes !!优先级队列是 O(1)
每个任务都是带有 delay 时间的.肯定是先执行时间小的,后执行时间大的呀!!扫描线程就不必遍历了,只需要关注队首元素是否到时间.如果队首没到时间,后续其他元素,也一定没到时间!!
就可以使用标准库提供的 PriorityQueue(线程不安全),手动加锁控制~~
(标准库也提供了 PriorityBlockingQueue(线程安全),在咱们此处的场景中,不太好控制,容易出问题)

这个代码,有两个核心问题,是需要解决的!!!

  1. 线程安全问题
    在这里插入图片描述
    如果把锁加在while循环外面
    在这里插入图片描述
    要放到while里面
    在这里插入图片描述
  2. 不要“忙等”,应该用wait把 cpu 资源让出来, 让给其他有需要的线程!!
    在这里插入图片描述
    这个代码使用sleep不太合适
    在这里插入图片描述
    在这里插入图片描述

定时器的构成
• ⼀个带优先级队列(不要使⽤ PriorityBlockingQueue, 容易死锁!)
• 队列中的每个元素是⼀个 Task 对象.
• Task 中带有⼀个时间属性, 队⾸元素就是即将要执⾏的任务
• 同时有⼀个 worker 线程⼀直扫描队⾸元素, 看队⾸元素是否需要执⾏

1.Timer 类提供的核⼼接⼝为 schedule, ⽤于注册⼀个任务, 并指定这个任务多⻓时间后执⾏.

public class MyTimer {
	 public void schedule(Runnable command, long after) {
	 	// TODO
	 }
}

2.Task 类⽤于描述⼀个任务(作为 Timer 的内部类). ⾥⾯包含⼀个 Runnable 对象和⼀个 time(毫秒时
间戳)
这个对象需要放到 优先队列 中. 因此需要实现 Comparable 接⼝.

class MyTask implements Comparable<MyTask> {
	 public Runnable runnable;
	 // 为了⽅便后续判定, 使⽤绝对的时间戳
	 public long time;
	 public MyTask(Runnable runnable, long delay) {
		 this.runnable = runnable;
		 // 取当前时刻的时间戳 + delay, 作为该任务实际执⾏的时间戳
		 this.time = System.currentTimeMillis() + delay;
	 }
	 @Override
	 public int compareTo(MyTask o) {
		 // 这样的写法意味着每次取出的是时间最⼩的元素
		 // 到底是谁减谁?? 俺也记不住!!! 随便写⼀个, 执⾏下, 看看效果~~
		 return (int)(this.time - o.time);
	 }
}

3.Timer 实例中, 通过 PriorityQueue 来组织若⼲个 Task 对象.
通过 schedule 来往队列中插⼊⼀个个 Task 对象.

class MyTimer {
	 // 核⼼结构
	 private PriorityQueue<MyTask> queue = new PriorityQueue<>();
	 // 创建⼀个锁对象
	 private Object locker = new Object();
	 public void schedule(Runnable command, long after) {
		 // 根据参数, 构造 MyTask, 插⼊队列即可
		 synchronized (locker) {
			 MyTask myTask = new MyTask(runnable, delay);
			 queue.offer(myTask);
			 locker.notify();
		 }
	 } 
}

4.Timer 类中存在⼀个 worker 线程, ⼀直不停的扫描队⾸元素, 看看是否能执⾏这个任务.
所谓 “能执⾏” 指的是该任务设定的时间已经到达了.

// 在这⾥构造线程, 负责执⾏具体任务了
public MyTimer() {
	 Thread t = new Thread(() -> {
		 while (true) {
			 try {
				synchronized (locker) {
					 // 阻塞队列, 只有阻塞的⼊队列和阻塞的出队列, 没有阻塞的查看队⾸元素
					 while (queue.isEmpty()) {
					 	locker.wait();
					 }
					 MyTask myTask = queue.peek();
					 long curTime = System.currentTimeMillis();
					 if (curTime >= myTask.time) {
						 // 时间到了, 可以执⾏任务了
						 queue.poll();
						 myTask.runnable.run();
					 } else {
						 // 时间还没到
						 locker.wait(myTask.time - curTime);
					 }
				 }
			 } catch (InterruptedException e) {
			 	e.printStackTrace();
			 }
		 }
	 });
	 t.start();
}

在这里插入图片描述
完整代码

import java.util.PriorityQueue;

// 通过这个类, 来描述一个任务
class MyTimerTask implements Comparable<MyTimerTask> {
    // 在什么时间点来执行这个任务.
    // 此处约定这个 time 是一个 ms 级别的时间戳.
    private long time;
    // 实际任务要执行的代码.
    private Runnable runnable;

    public long getTime() {
        return time;
    }

    // delay 期望是一个 "相对时间"
    public MyTimerTask(Runnable runnable, long delay) {
        this.runnable = runnable;
        // 计算一下真正要执行任务的绝对时间. (使用绝对时间, 方便判定任务是否到达时间的)
        this.time = System.currentTimeMillis() + delay;
    }

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

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

// 通过这个类, 来表示一个定时器
class MyTimer {
    // 负责扫描任务队列, 执行任务的线程.
    private Thread t = null;
    // 任务队列
    private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
    // 搞个锁对象, 此处使用 this 也可以.
    private Object locker = new Object();

    public void schedule(Runnable runnable, long delay) {
        synchronized (locker) {
            MyTimerTask task = new MyTimerTask(runnable, delay);
            queue.offer(task);
            // 添加新的元素之后, 就可以唤醒扫描线程的 wait 了.
            locker.notify();
        }
    }

    public void cancel() {
        // 结束 t 线程即可
        // interrupt
    }

    // 构造方法. 创建扫描线程, 让扫描线程来完成判定和执行.
    public MyTimer() {
        t = new Thread(() -> {
            // 扫描线程就需要循环的反复的扫描队首元素, 然后判定队首元素是不是时间到了.
            // 如果时间没到, 啥都不干
            // 如果时间到了, 就执行这个任务并且把这个任务从队列中删除掉.
            while (true) {
                try {
                    synchronized (locker) {
                        while (queue.isEmpty()) {
                            // 暂时先不处理
                            locker.wait();
                        }
                        MyTimerTask task = queue.peek();
                        // 获取到当前时间
                        long curTime = System.currentTimeMillis();
                        if (curTime >= task.getTime()) {
                            // 当前时间已经达到了任务时间, 就可以执行任务了.
                            queue.poll();
                            task.run();
                        } else {
                            // 当前时间还没到, 暂时先不执行
                            // 不能使用 sleep. 会错过新的任务, 也无法释放锁.
                            // Thread.sleep(task.getTime() - curTime);
                            locker.wait(task.getTime() - curTime);
                        }
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        // 要记得 start !!!!
        t.start();
    }
}

public class ThreadDemo31 {
    public static void main(String[] args) {
        MyTimer timer = new MyTimer();
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello 3000");
            }
        }, 3000);
    }
}

注意

有些集合类,是对于元素有特定要求的
在这里插入图片描述
此处是期望根据时间,时间小的作为优先级更高~~这里千万不要背!!!写代码试一试 就行了
在这里插入图片描述
在这里插入图片描述

真正站在 多线程 的思维来考虑这个程序的执行!!!
大家还没有建立起"多线程"思考代码的能力(当前觉得这个代码有困难,非常正常的)有一个先模仿的过程
这个代码最大的难点,在于,执行过程不是“顺序执行”,这个代码大家下来之后自己尝试写个 2,3 遍,都是不过分的!!

在这里插入图片描述
漫长的 wait 3s 的过程:
在这里插入图片描述
task.run()
在这里插入图片描述

拓展:(了解即可)
定时器,除了基于 堆(优先级队列) 方式来实现的定时器之外,还有一种方案,基于"时间轮
在这里插入图片描述

8.4 线程池

池 非常重要的概念
在这里插入图片描述

线程池是什么

最开始, 使用多进程确实能够解决 并发编程 问题。但是频繁创建销毁进程,成本比较高,引入了线程(轻量级进程).复用资源的方式,来提高了创建销毁效率
随着创建销毁线程的频率进一步提升,线程的创建销毁开销仍然无法忽略不计了!!(抛开 剂量谈毒性 都是耍流氓)
就需要想办法优化此处的线程的创建销毁效率

解决方案, 有两种:

  1. 引入 轻量级 线程 =>也称为 纤程/协程
    Java21 里引入的"虚拟线程"就是这个东西
    Go 是比较早支持协程的(这个概念很多年前就有,但是真正集成到语言中,Go 是比较早)Go 也是凭借语法简单,协程,就火了
    协程本质,是程序猿在用户态代码中进行调度,不是靠内核的调度器调度的. 节省了很多的调度上的开销
    用户代码中,基于线程封装出来的.
    协程底层是怎么封装,有不同的实现.可能是 N 个协程对应 1个线程,也可能是 N个协程对应M个线程,
    使一个代码中,可以创建出很多的协程.(一个进程创建上千个线程,基本上程序就卡死了,跑不起来的) 创建上千个协程,没啥事 都是小意思
  2. 线程池
    把要使用的线程提前创建好.用完了也不要直接释放而是以备下次使用. 就节省了创建/销毁线程的开销
    在这个使用的过程中, 并没有真的 频繁创建销毁,而只是从线程池里,取线程使用,用完了还给线程池

为啥 从线程池 里取线程,就比从系统申请更高效呢??
最关键的要点:
直接创建/销毁线程,是需要用户态+内核态配合完成的工作
线程池/协程,创建销毁,只通过用户态即可,不需要内核态的配合
在这里插入图片描述
基本的结论:

  • 如果一个工作,自己就能完成, 就更可控,更高效.
    如果使用线程池,提前把线程都创建好,放到用户态代码中写的数据结构里面(提前把要使用的线程,在线程池中准备好)。后面用的时候,随时从池子里取,用完了放回池子里去。这个过程,完全是用户态代码,不需要和内核进行交互
    从线程池里取线程,纯用户态代码,就比从内核操作更快(可控的)
  • 如果一个工作,要拜托银行的柜员来完成,就不可控,更低效!!
    直接调用 api, 通过系统申请创建线程, 销毁线程,这个过程需要内核完成,内核完成的工作很多时候是不太可控的.(不太可控)

线程池最⼤的好处就是减少每次启动、销毁线程的损耗

标准库中的线程池

  1. ThreadPoolExecutor 提供了更多的可选参数,可以进⼀步细化线程池⾏为的设定.
  2. 标准库还提供了另一个版本(因为ThreadPoolExecutor 本身用起来比较复杂),把 ThreadPoolExecutor 给封装了一下,简化线程池的使用。
ThreadPoolExecutor 类

标准库, ThreadPoolExecutor 类表示线程池(java.util.concurrent 并发(很多多线程相关的内容就在这个包里))
ThreadPoolExecutor 提供了更多的可选参数, 可以进⼀步细化线程池⾏为的设定.

动态扩展
标准库提供的线程池, 持有的线程个数,并非是一成不变的,会根据当前任务量,自适应线程个数(任务非常多,就多搞几个线程; 任务比较少,就少搞几个线程)
在这里插入图片描述

ThreadPoolExecutor 这个类,构造方法, 有很多个参数~~ 需要咱们了解一下.(也是经典面试题).通常情况下,面试不会考察 apì 的细节。但是线程池这里是例外,构造方法的参数,侧面映射出线程池的设计思路了
参数都是啥意思??(经典面试题)

  • corePoolSize: 正式员⼯的数量. (正式员⼯, ⼀旦录⽤, 永不辞退)

核心线程数,一个线程池里,最少得有多少个任务

  • maximumPoolSize: 正式员⼯ + 临时⼯的数⽬. (临时⼯: ⼀段时间不⼲活, 就被辞退).

最大线程数,一个线程池里,最多最多能有多少个线程

  • keepAliveTime: 保持存活时间(临时⼯允许的空闲时间).
  • unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值(s, min, ms, hour…).

实习生线程,允许最大的空闲摸鱼时间
如果发现某个实习生正在摸鱼 (这个线程空闲),此时要立即马上把这个实习生开除掉嘛? 不应该的!!!担心出现,这边开除了,结果下一时刻,任务突然多了~~ 此处 keepAliveTime,意思就是实习生线程,空闲时间超过了这个时间阈值,就会被销毁掉
实习生线程, 被销毁了,就没了。未来某一天,线程池还会重新招聘实习生,但是不是之前的那个了,不存在"再次放回来"概念

  • workQueue: 传递任务的阻塞队列

BlockingQueue < Runnable > workQueue,使用 Runnable 来作为描述任务的主体。和定时器类似,线程池中也可以持有很多个任务~~
也可以设置 PriorityBlockingQueue,带有优先级~

  • threadFactory: 创建线程的⼯⼚, 参与具体的创建线程⼯作. 通过不同线程⼯⼚创建出的线程相当于对⼀些属性进⾏了不同的初始化设置.

线程工厂~~ 通过这个工厂类,来创建线程对象(Thread 对象)
在这个类里面提供了方法(也不一定非得是静态的),让方法封装 new Thread 的操作,并且同时给 Thread 设置一些属性,构成了 ThreadFactory 线程工厂
工厂模式,也是一种常见的设计模式,通过专门的"工厂类" / "工厂对象"来创建指定的对象

工厂模式本质上是给 java 的语法填坑的(如果,语法层面上,不强制要求,构造方法名字必须和类名一致,就没有上述模式的必要了)
举个栗子
在这里插入图片描述
为了解决上述问题,就引入了"工厂模式“
使用普通的方法来创建对象,就是把构造方法封装了一层~~
在这里插入图片描述

  • RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
    – ◦ AbortPolicy(): 超过负荷, 直接抛出异常.
    – ◦ CallerRunsPolicy(): 调⽤者负责处理多出来的任务.
    – ◦ DiscardOldestPolicy(): 丢弃队列中最⽼的任务.
    – ◦ DiscardPolicy(): 丢弃新来的任务.

面试官考察 线程池的参数含义,最想听的就是你对于第七个参数的理解。是整个线程池上述七个参数中,最重要, 最复杂的
面试官考察线程池的参数,就是在考这个~~
面试官问你: 线程池的参数都是啥意思 ??其实考的就是你对于这个参数的理解.前面 6个都是添头
拒绝策略
线程池中,有一个阻塞队列. 能够容纳的元素有上限的,当任务队列已经满了,如果继续往队列中添加任务,那么线程池会咋办??(你秋招拿 3 个 offer, 但是实际上只能去1个.就需要把另外两个给拒绝掉,具体怎么拒绝,拒绝哪两个??)
正常来说,除非特殊说明,我们写的代码是不希望有这种突发性的阻塞的(可能会对程序造成不可预估的影响),因此直接让添加任务的线程阻塞,其实是不太好的,不太好就意味着应该要有别的办法
标准库的线程池就引入了“拒绝策略”,不同的策略会有不同的效果:
这四个策略,都要记住 (背下来)
在这里插入图片描述
这里每个策略,具体的英文名字,单词,不必刻意去背,(稍微翻一下文档就行了)

Executors类

ThreadPoolExecutor 本身用起来比较复杂,因此标准库还提供了另一个版本,把 ThreadPoolExecutor 给封装了一下,简化线程池的使用,也是基于 工厂设计模式。
Executors :工厂类,通过这个类来创建出不同的线程池对象(在内部把ThreadPoolExecutor 创建好了并且设置了不同的参数)

• 使⽤ Executors.newFixedThreadPool(10) 能创建出固定包含 10 个线程的线程池.
• 返回值类型为 ExecutorService
• 通过 ExecutorService.submit 可以注册⼀个任务到线程池中.

ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
 @Override
 public void run() {
 	System.out.println("hello");
 }
});

Executors 创建线程池的⼏种⽅式
• newFixedThreadPool: 创建固定线程数的线程池
• newCachedThreadPool: 创建线程数⽬动态增⻓的线程池.
• newSingleThreadExecutor: 创建只包含单个线程的线程池.
• newScheduledThreadPool: 设定 延迟时间后执⾏命令,或者定期执⾏命令. 是进阶版的 Timer.
在这里插入图片描述

Executors 本质上是 ThreadPoolExecutor 类的封装.

啥时候使用 Executors 啥时候使用 ThreadPoolExecutor ??
Executors: 只是简单用一下
ThreadPoolExecutor:希望高度定制化

业界/网络上 流传了一份"武林秘籍”:阿里巴巴 Java 编程规范手册
这份规范中,明确说: 使用线程池,要用 ThreadPoolExecutor 这个版本,而不应该使用 Executors
理由是,使用 Executors 线程数目/拒绝策略 等信息都是隐式的,可能不好控制(用 ThreadPoolExecutor 意味着一切都在掌控之中,避免出现一些不可控的因素)
可以参考,也不必奉为金科玉律,也不是说 Executors 完全就不能用.(简单当然也是优点)。大家要以以后实际入职的公司的编程规范为准

(实际开发)创建线程池的时候,很多时候需要设定线程池的线程数量,这个数量应该怎么设置比较合适??
只要你说出具体的数字,就都是错误的!!

网上很多关于这个问题的资料,都是错误的!!!
假设 cpu 的逻辑核心数是 N,网上的资料就有这些说法:线程数量 N,N+1,1.5N,2N…
不同的程序,能够设定的线程的数量是不同的
必须要具体问题具体分析
在这里插入图片描述
综上,由于程序的复杂性,很难直接对线程池的线程数量进行估算。
更合适的做法,通过实验/测试的方式找到合适的线程数目!!!
尝试给线程池,设定不同的线程数目,分别进行性能测试,衡量每种线程数目下,总的时间开销,和系统资源占用的开销,找到这两者之间的合适的值。

实现线程池

自己写代码实现一个简单的线程池~ 直接写一个固定线程数目的线程池(暂时不考虑线程的增加和减少)

面试倒不会让我们写. 但是至少大家要能够理解这里面干了啥

(1)提供构造方法,指定创建多少个线程,在构造方法中,把这些线程都创建好
(2)有一个阻塞队列,能够持有要执行的任务
(3)提供 submit 方法, 可以添加新的任务.

• 核⼼操作为 submit, 将任务加⼊线程池中
• 使⽤ Worker 类描述⼀个⼯作线程. 使⽤ Runnable 描述⼀个任务.
• 使⽤⼀个 BlockingQueue 组织所有的任务
• 每个 worker 线程要做的事情: 不停的从 BlockingQueue 中取任务并执⾏.
• 指定⼀下线程池中的最⼤线程数 maxWorkerCount; 当当前线程数超过这个最⼤值时, 就不再新增线程了.

class MyThreadPool {

 // 就是一个用来保存任务的队列.
 private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
 
 // 通过这个⽅法, 来把任务添加到线程池中.
 public void submit(Runnable runnable) throws InterruptedException {
 	queue.put(runnable);
 }
 // n 表⽰线程池⾥有⼏个线程.
 // 创建了⼀个固定数量的线程池.
 public MyThreadPool(int n) {
	 for (int i = 0; i < n; i++) {
		 Thread t = new Thread(() -> {
		 	 // 线程要做的事情就是把任务队列中的任务不停的取出来, 并且进行执行
			 while (true) {
				 try {
					 // 此处的 take 带有阻塞功能的.
             // 如果队列为 空, 此处的 take 就会阻塞.
					 Runnable runnable = queue.take();
					 // 取出一个任务就执行一个任务即可
					 runnable.run();
				 } catch (InterruptedException e) {
				 	e.printStackTrace();
				 }
			 }
		 });
		 t.start();
	 }
 }
}
// 线程池
public class Demo {
 public static void main(String[] args) throws InterruptedException {
	 MyThreadPool pool = new MyThreadPool(4);
	 for (int i = 0; i < 1000; i++) {
		 pool.submit(new Runnable() {
			 @Override
			 public void run() {
				 // 要执⾏的⼯作
				 System.out.println("执行任务" + n + " , 当前线程为: " + Thread.currentThread().getName());
				 //System.out.println(Thread.currentThread().getName() + "hello");
			 }
		 });
	 }
 }
}

注意:

  1. 如果写成这种代码会因为变量捕获而编译会出错!!!

在这里插入图片描述
咋改呢?(最开始lambda 的时候讲到的,后来线程创建,也又讲了一遍)
在这里插入图片描述

  1. 一定要注意,多个线程之间的执行顺序是不确定的

在这里插入图片描述

小结-多线程案例

在这里插入图片描述
在这里插入图片描述
多线程初阶,就完了~~线程的基础知识,面试要考 +工作要用的
接下来的
多线程进阶,主要讲的是面试要考的(工作中不太用到)

更多推荐