目录
单例模式
什么是单例模式?
—— “饿汉模式”
—— “懒汉模式”
——懒汉模式-多线程版
——懒汉模式-多线程版(改进版)
总结“懒汉模式”—— 多线程(线程安全版) 的要点
阻塞队列
什么是阻塞队列?
什么是“生产者消费者模型”?
“生产者消费者模型” 的优势 和 意义
1.解耦合
2.削峰填谷
—— 削峰
—— 填谷
标准库中阻塞队列的使用
⁜⁜自己实现阻塞队列
讲解思路:
小问题 (主要关于wait 的条件判断方式)
完整代码:
借助 阻塞队列 实现 “生产者消费者模型”
完整代码
定时器
什么是定时器?
标准库中定时器的使用
实现一个简单的定时器
完整代码:
单例模式
非常经典的设计模式,校招中常见的设计模式
什么是设计模式呢?
程序员必备技能
设计模式就 相当于 程序员的 “棋谱”,在实际的开发过程中,我们会遇到 很多 “经典场景”
针对这些经典的场景,大佬们就提出了一些解决方案,我们按照这些解决方案 来进行编码,那这个代码就不会写的很差。
什么是单例模式?
单例 ———— 单个实例(对象)
有一些场景中,我们希望有的类,只能有一个对象,不能有多个,在这样的场景下,就可以使用单例模式。(代码中,很多用于管理数据的对象,就应该是 单例 的)
我们在写代码的时候,确实可以在一个类里 只 new 一次,但是人不靠谱,这时候就需要让编译器来帮我们做监督,确保这个对象不会出现多个,当出现多个的时候就报错。—— 强制要求,
所以这个 单例模式 很有必要。
语法里没有支持的,那接下来我们就来介绍 如何 通过 编程技巧,来实现 单例模式:
—— “饿汉模式”
instance 是 static 成员,当 Singleton 被加载的时候,就会执行到这里 创建实例的操作
因为上面的实例 是 private 修饰的,所以我们还需要一个方法来获取这个实例 ,后续我们想使用这个类的实例,都通过 getInstance这个方法来获取
如果我们在这里 又去创建一个实例,那这就不是单例模式了,那我们该如何解决这个问题呢,接着往下看
我们把构造方法设置成 私有的,此时类外面的其他代码,就无法 new 出这个类的对象了
此时你在看 下面这个实例的创建就报错了 :
这些就是单例模式的核心代码了:
我们再来捋一遍逻辑 :
1.我们在类的内部 提供一个线程的实例。
2.把构造方法设为 私有的(private), 避免其他代码能够创建出实例。
(一方面,我自己提供了实例,另一方面,我不让别人创建)
通过上述方式,我们就强制了其他程序员在使用这个类的时候,不能创建出多个实例了 。保证这个类就是 单例情况了。
那如何使用呢?
用 getInstance 调用就可以 (getInstance 是静态方法,可以直接通过 类名的方式进行调用)
另一个方面,你调用多次,会发现调用的是同一个 对象
但是这种写法有个注意点,这个实例的创建时机,是在类加载的时候,比较早 ,太急切了,所以叫 “饿汉模式”
那我们能不能找个比较晚的,更合适的时机 去创建实例呢,当然有 ———— “懒汉模式”
—— “懒汉模式”
“懒汉模式” 不像 "饿汉模式" 那么急切,它比较从容,在第一次使用的时候,才去创建实例。
和 "饿汉模式" 有差别,"饿汉模式" 直接就 创建了,“懒汉” 这里先赋值成 null
“懒汉模式” 也可以使 SingletonLazy 这个类 只有唯一一个实例,与 “饿汉模式” 的直接创建 不同,
“懒汉模式” 是在首次调用 getInstance 的时候,才会真的创建出实例。(如果不调用,就不创建)
懒汉模式 比 饿汉模式 更好,效率更高(例如,我们平时看的电子书,都是先加载出你当前正在看的内容,然后随着 用户的 翻页,动态的加载出之后的内容,这就是 懒汉 的思维)
以上都是 铺垫,接下来,才是重点,我们要把 “单例模式” 和 “多线程” 联系起来 。
——懒汉模式-多线程版
思考一下,我们上面写的两种写法,是线程安全的吗?(如果多个线程调用getInstance,是否会出现问题。)
(之前讲过线程安全)
上面写的这两种方式,其中 “饿汉模式” 是线程安全的 ,而“懒汉模式” 是不安全的。
这是因为,多个线程,同时修改同一个变量,此时就可能会出现线程安全问题,但是,多个线程,同时读取同一个变量,就没事儿,不会出现线程安全问题。
所以,你看,“饿汉模式” 的 getInstance 只是 return 一个值,就只是读取
而, “懒汉模式”,就不一样了,它既涉及到了 读取,又涉及到了 修改,这就可能出现问题了。
我们画图来看一下,出现问题的时候 的情况
那可能就会出现这样的情况
t1先执行到 判断 是不是空那里,发现为空,那就准备进入条件,创建实例(new),
但是在进行 new 操作之前,t2线程被调度到cpu上了,t2开始执行,因为此时 t1 线程还没来得及修改,t2条件判断那里,还是成立的,所以紧接着,t2就会进行 new 操作,修改了instance,
t2修改完,回到了 t1 这里 ,由于此时已经进入到了条件里面,那就直接执行到了 new 操作了,这就导致 实例多了一个,违背了单例的要求。
这就出bug了,有 线程安全 问题了
那如何保证 懒汉模式 是线程安全的呢?———— 加锁
那这个锁 该加在那里 呢?
我们加在这里,可以吗? 显然是不行的。
我们观察 上面出现的线程安全问题的原因 是因为 if 条件和之后的语句 分开了,所以我们加锁的关键操作是要保证这两部分是一个 整体。(原子)
所以正确的 锁 是这样加的:
但是解决了一个问题,有出现了新的问题,
我们发现,一旦这样写代码,后续每次调用 getInstance 的时候, 都要先加锁了,
但有必要,每次都加锁吗,(把这俩放到一块儿),实际上,懒汉模式 的 线程安全问题只出现在最开始的时候(对象还没 new 时),一旦 new 完了,后续再调用 getInstance,就只有 读取 操作了,就不会不安全了。
那上面的代码就有些 多余了,加锁是一个开销很大的操作,加锁就可能会涉及到锁冲突,一冲突就会引起阻塞等待,(这样就与"高性能"无缘了)
这一版的代码
那是否 有办法,既可以让代码线程安全,又不会对执行效率产生太大影响呢?
——懒汉模式-多线程版(改进版)
对上述问题进行改进
我们只需要在 加锁语句的外层,再引入一个 if 条件,判定一下,当前的锁是否要加上
但这个判定条件是什么呢?
如果对象已经有了,线程就安全了,此时就不用加锁了,如果对象还没有,就不安全,就得加锁
代码怎么写是不是清晰可见了 。
这时我们就发现,一样的条件写了两遍 ,但意义不同
第一个 if 用来判定是否需要加锁;
第二个 if 用来判定是否需要 new。
但是我们再分析一下,就又会发现点问题,上述代码可能会出现 指令重排序的问题 :
这是因为,new操作可以大致 拆分成三步
1.申请一个内存空间
2.在空间内存中构造对象(执行构造方法)
3.把内存的地址,赋值给 instance 使用
而这三个步骤是可以进行指令重排序的,
可以按照 1,2,3 来执行,也可以 按照 1,3,2 来执行 (1肯定是先执行的)
2,3谁先执行,谁后执行,在 单线程 中无所谓,反正都能得到能正确使用的 instance
但是在 多线程中, 就可能出问题了😵😵,
假设是按照 1,3,2来执行。当t1 执行完 1 和 3 的时候,此时 instance 就已经非空了(赋值完了),但是此时 instance 指向的是一个还没初始化的 非法对象(还没构造呢),假设,现在还没执行到 2 的时候,t2 开始执行了,t2先判定 instance 是不是空,发现 instance 不为空,就直接走到 返回 instance 那里了,
进一步t2 线程里的代码就可能 会访问 instance 里面的属性和方法了,但是此时的 instance 还是一个还没初始化好的非法对象呢,那你去访问 instance 可能就会出 bug!!
这时候 就又需要用到 volatile 关键字了 (多线程基础那里有介绍)
让 volatile 修饰 instance ,此时就可以保证 instance 在修改的过程中就不会出现指令重排序了
看代码:
总结“懒汉模式”—— 多线程(线程安全版) 的要点
1.我们要正确的进行 加锁
2.我们要进行两重 if 判断
3.要加上 volatile ,避免 指令重排序
(缺一不可)
阻塞队列
什么是阻塞队列?
多线程代码中比较常用的一种数据结构
阻塞队列是一种 特殊的队列。
阻塞队列相对于 普通队列来说,主要特殊在两方面:
1.线程安全
2.带有阻塞特性
阻塞队列的阻塞特性
1.如果在队列为空时,继续出队列,就会发生阻塞。 阻塞到其他线程 往队列里添加元素为止
2.如果在队列为满时,继续入队列,也会发生阻塞,阻塞到其他线程从队列中取走元素为止
阻塞队列,最大的意义就是 可以用来实现 “生产者消费者模型” (一种常见的 多线程代码编写方式)。
什么是“生产者消费者模型”?
举一个生活中的例子,假如 过年那天,你们一家三口包饺子,妈妈负责擀饺子皮(妈妈不停的生产出饺子皮),你和爸爸 负责包饺子(你们两个不停的消耗 饺子皮)
这种方式,就是 ”生产者消费者模型“。
这里有一个重要的问题,就是 妈妈生产的饺子皮,得有地方放 —— 盘子,这个盘子就相当于一个阻塞队列。
生产者 把生产出来的内容,放到阻塞队列中,消费者,就会从这个阻塞队列中获取内容。
进一步来讲,如果生产者 生产的 慢,那消费者 就得等一等了(从空的队列里获取元素就会阻塞)
如果生产者,生产的 块,那生产者就等一等 (队列被放满了,发生阻塞,等消费者获取元素)
“生产者消费者模型” 的优势 和 意义
从上面例子的介绍,我们得知 “生产者消费者模型” 的优势之一: 可以进一步的降低一些资源的竞争,从而提高程序的执行效率 。
除此之外, “生产者消费者模型” 还有一些其他的优势 和 意义,接下来我们来介绍一下:
1.解耦合
两个模块,联系越紧密,耦合就越高
对于一个分布式系统来说,这样的解耦合是更加有意义的
比如,我们现在考虑一个简单的分布式系统
如果 A 和 B 直接交互,(A直接把请求发给B,B把响应返回给A) ,那他们彼此之间的 耦合 就是比较高的。
高耦合就意味着,一方面,如果B 出了bug,很有可能就把A 也影响到了,另一方面,如果未来在添加一个C,就需要对 A 这边的代码,做出一定的改动。
相比之下,使用 “生产者消费者模型” 就可以有效的解决刚才的耦合问题。
在两个 服务器之间 引入一个 阻塞队列(这个阻塞队列也可能是个 单独的服务器 (把阻塞队列封装成单独的服务器程序 部署到特定的机器上这时候就把这个队列 称为“消息队列”))
此时耦合就会降低,达到 解耦合的目的,如果B 出现问题,就不会直接影响到 A (A只和 队列交互,根本不知道 B 的存在)
如果后续,我们再进一步增加一个 服务器C ,也只需要让 C 从队列中获取数据 就行了,A 不必进行任何修改。
2.削峰填谷
削峰:短时间内,请求量比较多
填谷:短时间内,请求量比较少
—— 削峰
我们还用上面服务器的例子来解释:
在上图的结构下,一旦客户端这边发起的请求非常多了,那此时 A 收到的请求,都会立即发给 B
也就是 A 扛下来 多少,那B 也就扛下来多少,这时候可能就会出现问题了,
不同的服务器,上面跑的业务不同,虽然访问量是一样的,但是单个访问 所消耗的硬件资源不一样,可能A承担这些并发量没什么事,但是B 承担这些 并发量就出事了(挂了)
那引入“生产者消费者模型”,上述问题也会得到很大的改善
按照上图模式来看,就是A 这边收到了较大的请求量,A会把对应的请求写入到队列中,B仍然可以按照之前的节奏 来处理请求 。(通过这个队列把这个赋值抗住)
—— 填谷
像上述的峰值情况,一般情况下不会持续存在,只会短时间出现 ,过了峰值之后,A的请求量就恢复正常了,B就可以逐渐的把积压的数据给处理了。
有了削峰填谷这样的机制之后,就可以 保证在突发情况来临的时候,整个服务器系统仍然可以正确执行
标准库中阻塞队列的使用
在 java 标准库中,就已经提供了现成的 阻塞队列,供我们使用
我们演示一下标准库中的阻塞队列如何使用:
因为 BlockingQueue 是一个接口,所以我们不能直接 new 一个 BlockingQueue,我们需要 new 一个他的实现,在标准库里,针对 BlockingQueue 提供了两种实现方式:
1.基于数组
2.基于链表
基于数组(记得给容量)
基于链表
看 BlockingQueue 的源码,我们发现,BlockingQueue接口 继承 Queue接口,
这意味着,Queue这里提供的各种方法,BlockingQueue也可以使用,但是一般不建议使用这些方法,因为这些方法都不具备 “阻塞特性”
所以我们就要用, BlockingQueue专门提供的具有“阻塞特性”的方法
1.put —— 阻塞式的入队列
2.take —— 阻塞式的出队列
代码演示:
运行效果
我们发现 第五次这里什么都没有, 在队列为空时,继续出队列,就会发生阻塞。
⁜⁜自己实现阻塞队列
讲解思路:
基于一个普通的队列,加上线程安全,加上阻塞,就可以了。
代码实现: (不写成泛型了,队列存储就字符串形式)
我们基于数组实现(数组实现队列,在数据结构栈和队列里的循环队列里 讲过,在数据结构专栏里可以找到)
先创建一个数组,(这里的长度,你也可以在构造方法里搞)
在初始化一下变量(就是循环队列的写法)
开始写核心方法了,入队列和出队列
然后我们来实现里面的具体方法:
入队列
我们先来 写一个基础的(栈和队列里的循环队列里详细的讲了)
接下来就要涉及到阻塞,加锁,和线程安全等问题了
看我们的代码,对一些关键的变量几乎都存在修改的操作 ,这就很容易涉及的线程安全问题,所以我们在这整个上面都加上锁(这里我们简单的使用 this 作为对象即可,你也可以创建一个对象,多线程基础2,加锁那里都有详细的讲)
这就保证在执行 put 的时候,不会穿插其他线程的put (保证了原子性)
接下来我们实现阻塞
使用 wait 和 notify 机制(多线程基础2里也详细讲了)
当队列满了的时候,继续添加元素的时候,就会发生阻塞
这里的阻塞,要阻塞到,有另一个线程调用 take 方法时,此时就要唤醒 wait 。
出队列
我们也先来写个基础的
跟上面入队列 一样加锁 ,这就保证在执行 take 的时候,不会穿插其他线程的take
和入队列同理,这里的出队列时,队列为空时 也要发生阻塞 ,
同理,要阻塞到 有其他线程往队列理添加元素(put)的时候 ,所以
小问题 (主要关于wait 的条件判断方式)
然后我们现在来想一个问题:当 put方法,因为队列满了,进入 wait 之后,此时,wait 被唤醒,那此刻的队列一定就是不满的吗?
wait 除了 notify 唤醒之外,是否有别的方式唤醒呢?
当然有,interrupt 方法是有可能中断 wait 的状态的(interrupt 在终止一个线程的时候,也是可能把 wait 给唤醒的(多线程基础1中讲过中断线程))
使用interrupt 唤醒的时候,就会出现 interruptedException 这样的异常,
在当前的代码中,如果 interrupt 唤醒了 wait,那整个方法就结束了,因为我们是使用 throws 抛出的异常,这个时候代码是没事的(整个都终止了),但是如果这么写:
就会出事了,如果出现异常,方法不会结束,会继续往下执行,执行到下面的逻辑:
此时就会把 tail 指向的元素覆盖掉 。(那tail原来指向的元素就丢了),就不合理呀!!
所以我们在使用 wait 的时候,一定要注意,要考虑到此时的 wait 唤醒,到底是通过 notify 唤醒的,还是通过 interrupt 唤醒的。
notify唤醒 ,说明其他线程调用了 take 此时队列不满,可以继续加元素。
interrupt唤醒,那此时队列其实还是满着的,继续添加元素,肯定会出问题
怎么解决呢?
关键要点:当 wait 返回的时候,需要进一步的确认一下,当前的队列是不是满的,(因为队列满,进入阻塞,解除阻塞后,再确认一次,队列满不满,如果队列还是满着的,就继续 wait)
那如果向上边这样写代码,那我们第二个if 走完是不是还得判断一下是不是满的(notify唤醒还是interrupt唤醒) ,那在填一个if ,那之后还是要再判断是不是 满的,循环了,所以我们直接改成循环
提炼总结:
我们在使用 wait的时候,往往都是使用 while作为条件判断的方式,目的是为了让 wait 唤醒之后,还能再确认一下,是否条件仍然满足(这是Java官方文档给出的建议)
还有一个小问题:
下面这几个东西,在下面都会进行一个判定或者修改,所以这里我们避免出现内存可见性问题,我们加上 volatile
完整代码:
import java.util.ArrayDeque;
import java.util.Queue;
/**
* @Author: iiiiiihuang
*/
//不写成泛型了,就字符串形式
public class MyBlockingQueue {
//先创建一个字符串类型的数组
private String[] data = new String[1000];
//队列的起始位置
private volatile int head = 0;
//队列的尾巴
private volatile int tail = 0;
//队列中有效元素的个数
private volatile int size = 0;
//入队列
public void put(String elem) throws InterruptedException {
synchronized (this) {
//满了
while (size == data.length) {
this.wait();
}
//没满,填元素
data[tail] = elem;
tail = (tail + 1) % data.length;
size++;
//这个notify 用来唤醒 take 中的wait
this.notify();
}
}
//出队列
public String take() throws InterruptedException {
synchronized (this) {
//空队列
while (size == 0) {
this.wait();
}
String ret = data[head];
head = (head + 1) % data.length;
size--;
//这个notify 用来唤醒 put 中的wait
this.notify();
return ret;
}
}
}
借助 阻塞队列 实现 “生产者消费者模型”
“生产者消费者模型” 肯定得有生产者,消费者,分别使用一个线程表示。(也可以是多个线程)
class Demo1 {
public static void main(String[] args) {
//阻塞队列,再生产者和消费者之间协调
MyBlockingQueue queue = new MyBlockingQueue();
//消费者
Thread t1 = new Thread(() -> {
while (true) {
try {
String result = queue.take();
System.out.println("消费元素" + result);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
//生产者
Thread t2 = new Thread(() -> {
int num = 1;
while (true) {
try {
queue.put(num + "");
System.out.println("生产元素" + num);
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t1.start();
t2.start();
}
}
sleep 加在生产者那里,而 消费者那里没有sleep,这意味着生产者生产的慢,消费者消费的快。
运行看看情况:
生产一个,就消费一个
反过来,我们把这个sleep挪到消费者那里(消费者就慢了)
运行看看:
生产者一下子就生产了很多元素 ,等到1000之后就慢了,因为队列容量1000,满了就阻塞,等着消费者消费
完整代码
import java.util.ArrayDeque;
import java.util.Queue;
/**
* @Author: iiiiiihuang
*/
//不写成泛型了,就字符串形式
public class MyBlockingQueue {
//先创建一个字符串类型的数组
private String[] data = new String[1000];
//队列的起始位置
private volatile int head = 0;
//队列的尾巴
private volatile int tail = 0;
//队列中有效元素的个数
private volatile int size = 0;
//入队列
public void put(String elem) throws InterruptedException {
synchronized (this) {
//满了
while (size == data.length) {
this.wait();
}
//没满,填元素
data[tail] = elem;
tail = (tail + 1) % data.length;
size++;
//这个notify 用来唤醒 take 中的wait
this.notify();
}
}
//出队列
public String take() throws InterruptedException {
synchronized (this) {
//空队列
while (size == 0) {
this.wait();
}
String ret = data[head];
head = (head + 1) % data.length;
size--;
//这个notify 用来唤醒 put 中的wait
this.notify();
return ret;
}
}
}
class Demo1 {
public static void main(String[] args) {
//阻塞队列,再生产者和消费者之间协调
MyBlockingQueue queue = new MyBlockingQueue();
//消费者
Thread t1 = new Thread(() -> {
while (true) {
try {
String result = queue.take();
System.out.println("消费元素:" + result);
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
//生产者
Thread t2 = new Thread(() -> {
int num = 1;
while (true) {
try {
queue.put(num + "");
System.out.println("生产元素:" + num);
num++;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t1.start();
t2.start();
}
}
定时器
什么是定时器?
日常开发中一个重要组件 ,约定一个时间,时间到达之后,执行某个代码的逻辑。
定时器非常常见,尤其是在进行网络通信的时候
客户端发出请求之后, 就要等待响应,如果服务器迟迟没有响应,怎么办?
对于客户端来说,他不能无限的等,需要有一个最大的期限,到达这个最大的期限之后,是重新再发一遍,还是彻底放弃,还是其他方式......
此时”等待的最大时间 “,就是可以通过定时器的方式来实现,
在标准库里,也是有现成的定时器的实现的:
标准库中定时器的使用
在util 包里
看看怎么使用 :
给定时器安排了一个任务,预定在 xxx 时间执行
schedule 这个方法有两个参数,一个是描述任务具体是什么 ,另一个是时间(任务在多长时间之后去执行)
此处我们使用匿名内部类的写法,继承了TimerTask,并且创建出了一个实例,目的是为了重写run方法,(通过run 描述任务的详细情况)
(实现了Runnable 的接口 为了重写run方法)
而2000 的意思是 当前安排的任务,什么时候执行 —— 此处填写的时间,就是以当前的时刻为基准,往后再推 xxxms(2000ms)的时间
我们补充完这个代码,然后运行一下:
run 方法没有立即执行,而是等了一会儿,才执行 。这就是定时器的效果
注意一下:主线程执行 schedule 方法的时候,就是把这个任务给放到了 timer 对象中了,于此同时,timer里头也包含一个线程 —— ”扫描线程“,一旦时间到,扫描线程就会执行刚才安排的任务了。
仔细观察,可以发现,整个线程其实没有结束,这就是因为 Timer 内部的线程,阻止了进程的结束。
还有,Timer里,是可以安排多个任务的。
这就相当于往定时器里面添加了 三个任务 ,我们执行看看效果:
这个使用并不复杂,重要的是我们该如何实现一个简单的定时器
实现一个简单的定时器
1.Timer里需要有一个线程,来扫描任务是否到时间,可以执行了。
2. 需要有一个数据结构,把所以的任务都保存起来
3.还需要创建一个类,通过类的对象来描述一个任务。(至少包含任务内容和时间)
关键要点是第 2 步,到底需要一个什么类型的数据结构,把所有任务都保存起来,比较好呢?
假设使用数组 (ArrayList),此时,扫描线程,就需要不停的遍历数组中的每个任务,来判定每个任务是否都到达执行时间,但是他就有了 不停遍历的开销。
所有上述这种的数据结构,效率就比较低。
所有使用优先级队列,是更好的办法!!给Timer中添加的这些任务,都是带有一个“时间”。
而且一定是时间小的 先执行。最先执行的就是时间最小的任务,如果时间最小的任务,还没到时间呢,其他任务就更不会到时间了。
优先级队列可以使用O(1)时间,来获取到时间最小的任务。
确定好数据结构类型了,接下来我们来看代码是如何实现的:
首先创建一个类来描述任务 ,执行的任务,和执行时间
那这里有一个问题?此处我们是记录一个 "相对的时间“ 还是 ”绝对的时间“?
"相对的时间“ : 2000,3000 这种时间间隔
”绝对的时间“ : 完整的时间戳
我们在上面使用标准库里面的定时器时,传入的是”相对的时间“ :
那我们保存的就是 ”相对的时间“ 吗?—— 其实这里我们保存”绝对的时间“ 更方便
这是因为 在后续扫描线程时,是先获取到当前的时间戳,再获取到任务要执行的时间戳 ,然后再对比两个时间戳,来判定当前这个任务是否要执行。所有保存”绝对的时间“ 更方便。
对保存的时间类型有了了解之后,我们接着往下写代码:
接着我们来完善一下这个类的构造方法 :(这个delay就是 schedule 方法传入的 ”相对的时间“)
time = 当前的时间 + 传入的 ”相对的时间“。
别忘了get方法,来获取时间 和 任务
我们写完描述任务的类之后,我们就可以搞自己的定时器啦:
首先使用优先级队列的数据结构来保存所有要安排的任务:
之后我们的schedule方法就可以顺理成章的出来了:
schedule方法就是把我们当前要完成的任务构造成一个任务对象,然后添加到队列里面
代码到这里,还有一个很严重的问题,就是我们要自定义优先级队列的比较方法 。
这里我们就搞个Comparable的接口,在实现一下compareTo方法
然后我们就可以来写扫描线程了:
在构造方法里搞一个扫描线程
我们先搞一个 while 循环,这是因为扫描线程,需要不停的扫描队首元素,看是否到达执行时间
然后我们就该取队首元素了,因为队首元素时间最小(优先级队列) ,注意:在拿队首元素的时候我们得先判断队列空不空
当发现队列为空的时候,我们应该阻塞等待,等到队列不为空为止,(说到这里,我们发现,这不就和阻塞队列一样吗,我们下面会解释为啥不用阻塞队列)
这里我们就要使用 wait 了,但是要想用wait 就必须搭配synchronized ,我们观察一下代码发现,我们正好需要加把锁,原因如下:
schedule 这个方法,是一个线程中的(比如是主线程中的), 给队列里添加元素
扫描线程,又是另外一个线程, 也需要操作队列
这两个是不同线程,有都要操作同一个队列,这就有线程安全问题 了,就需要加锁
首先给schedule方法里加锁:
然后在给循环里加上锁
然后就能进行wait操作了
这里的wait 需要被别的线程唤醒,什么时候唤醒呢,添加新的任务的时候 。
schedule 添加新的任务,所以在那里唤醒 :
还有我们把wait的判定条件,改为while(前面讲了)
然后我们就可以进行比较了,比较一下当前的队首元素是否可以执行了。
执行任务那里没什么好说的,我们看一下 else 那里为啥加了 wait
如果这里不加这个 wait ,那在时间 差的多的时候,就要不停的循环,虽然也是在等到,但又很忙,这样就是 忙等了,这会消耗很多CPU。
加了wait 阻塞,线程就不会在CPU上调度,就把资源让出来给别人了。
那为啥不用sleep 呢?
这是因为上面 schedule 中的notify 就可以唤醒这里的 wait,如果再添加一个新的元素(时间最小)让循环再执行一遍,重新拿到队首元素,wait的时间也就更新了,而sleep不会更新,可能会错过这个新任务,所有用 wait 更好。
之所以,我们使用 PriorityQueue,而不是 PriorityBlockingQueue,其实就是因为有两个 要处理的 wait的地方 ,使用阻塞队列版的优先级队列,不方便实现这样的两处等待。
完整代码:
import java.util.PriorityQueue;
import java.util.Timer;
/**
* @Author: iiiiiihuang
*/
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;
}
@Override
public int compareTo(MyTimerTask o) {
return (int) (this.time - o.time);
}
public long getTime() {
return time;
}
public Runnable getRunnable() {
return runnable;
}
}
public class MyTimer {
private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
//锁对象
private Object locker = new Object();
//把我们当前要完成的任务构造成一个任务对象,然后添加到队列里面
public void schedule(Runnable runnable, long delay) {
synchronized (locker) {
queue.offer(new MyTimerTask(runnable, delay));
locker.notify();
}
}
//在构造方法里搞一个扫描线程
public MyTimer() {
//创建一个扫描线程
Thread t = new Thread(() -> {
while (true) {
synchronized (locker) {
while (queue.isEmpty()) {
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
MyTimerTask task = queue.peek();//拿到队首元素
//比较一下当前的队首元素是否可以执行了。
long curTime = System.currentTimeMillis();
if(curTime >= task.getTime()){
task.getRunnable().run();
//执行完之后删掉这个任务
queue.poll();
}else {
try {
locker.wait(task.getTime() - curTime);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
});
t.start();
}
}
关注,点赞,评论,收藏,支持一下╰(*°▽°*)╯╰(*°▽°*)╯