定时器的实现&&线程池基础
文章目录
前言
上一篇学习了基于线程安全的单例模式、基于生产者消费者模型的阻塞队列的简单实现,这一篇继续学习基于生产者消费者模式的定时器的简单实现和使用,以及线程池的一些基础知识。
一、什么是定时器?
简单来说,定时器 Timer 就类似于一个闹钟,给定时器(闹钟)设定一个任务 TimerTask ,约定这个任务在多长时间之后执行。Timer 类提供了一个核心接口,schedule(安排) 指定一个任务交给定时器,在一定时间之后再去执行这个任务。Timer中要包含一个TimerTask类,每个TimerTask就表示一个具体的任务实例。
看一下简单的使用方法吧💁♀️💁♀️💁♀️
public class UseTimer {
public static void main(String[] args) {
Timer timer=new Timer();//定时器
TimerTask task=new TimerTask() {
@Override
public void run() {
System.out.println("闹钟一响,我以为是阎王来拿我命了");
}
};
// timer.schedule(task,1000);//定时器任务调度(多长时间后执行)
timer.schedule(task,1000,2000);//(定时器任务调度,多长时间后执行,隔多久执行一次)
while(true){
//这个死循环是为了表示定时器任务并不是主线程在执行
}
}
}
主线程(main线程)一直在死循环,然后定义了一个闹钟,作为子线程(timer)去执行任务。
- timer.schedule(task,1000):定时器任务调度(任务,多长时间后执行)
- timer.schedule(task,1000,2000):定时器任务调度(任务,多长时间后执行,隔多久执行一次)
用法非常简单,接下来让我研究研究如何简单的实现一个定时器吧🙋♀️
二、简单的实现
1.原理
(1)数据结构
拿闹钟来类比定时器,我们经常会定多个闹钟,必然是哪个时间最早,哪个闹钟最先响,闹钟的任务是有优先级的,所以类比到定时器这里也是一样的,定时器任务也是有优先级的,根据延时执行的时间来进行优先级排序,延时最短的任务最先执行,很容易就想到优先级阻塞队列(PriorityBlockingQueue)。
(2)内部逻辑
实现 1 V 1 的定时器(1个定时器就执行一个任务)非常容易,让线程休眠既定的时间即可。但Java内部的定时器 Timer 的实现方式是一个定时器执行多个任务。,那就让定时器内部去创建线程执行任务就好啦,我们只管定闹钟✌
(3)代码来啦
MyTimer类(定时器)
import javafx.concurrent.Worker;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.PriorityBlockingQueue;
/**
* @author Lvvvvvv
* @date 2022/07/27 21:07
**/
public class MyTimer {
//优先级阻塞队列(用来放那些需要执行的任务)
private final PriorityBlockingQueue<MyTimerTask> queue=new PriorityBlockingQueue<>();
public MyTimer(){
Work work=new Work();
work.run();
}
class Work extends Thread{
@Override
public void run() {
while(true){
MyTimerTask task=null;
try {
task=queue.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
long now=System.currentTimeMillis();
long delay=task.runTime-now;
if(delay<=0){
task.run();
}else{
try {
sleep(delay);
} catch (InterruptedException e) {
e.printStackTrace();
}
task.run();
}
}
}
}
//负责调度,就是将任务(以及任务的延时时间)放进阻塞队列
public void schedule(MyTimerTask task, long delay) {
// 该方法非工作线程调用
task.runTime = System.currentTimeMillis() + delay;
queue.put(task);
}
}
MyTimerTask类(任务)
/**
* @author Lvvvvvv
* @date 2022/07/27 21:11
**/
//不同任务,要以延时时间的长短来比较优先级,所以 MyTimerTask 这个类要具有可比较能力
public abstract class MyTimerTask implements Comparable<MyTimerTask> {
long runTime;//这个任务在什么时候执行
public abstract void run();//抽象方法,需要执行什么任务,重写 run 方法即可
@Override
public int compareTo(MyTimerTask o) {
if(this.runTime>o.runTime){
return 1;
}else if(this.runTime<o.runTime){
return -1;
}else{
return 0;
}
}
}
主方法
public class Main {
public static void main(String[] args) {
MyTimerTask t1 = new com.UseTimer.ImplMyTimer.MyTimerTask() {
@Override
public void run() {
System.out.println("5s 之后执行");
}
};
MyTimerTask t2 = new com.UseTimer.ImplMyTimer.MyTimerTask() {
@Override
public void run() {
System.out.println("4s 之后执行");
}
};
MyTimerTask t3 = new com.UseTimer.ImplMyTimer.MyTimerTask() {
@Override
public void run() {
System.out.println("3s 之后执行");
}
};
MyTimerTask t4 = new com.UseTimer.ImplMyTimer.MyTimerTask() {
@Override
public void run() {
System.out.println("2s 之后执行");
}
};
MyTimerTask t5 = new MyTimerTask() {
@Override
public void run() {
System.out.println("1s 之后执行");
}
};
MyTimer myTimer = new MyTimer();
myTimer.schedule(t1, 5000);
myTimer.schedule(t2, 4000);
myTimer.schedule(t3, 3000);
myTimer.schedule(t4, 2000);
myTimer.schedule(t5, 1000);
}
}
看看运行结果吧:
2、改进版
上面只是一个简单的实现,相当于把每一个任务都视为一次性任务,那么周期性任务需要怎么写呢?
改进:
- 给任务类(MyTimerTask类)增加一个属性 flag 作为标志位;
- flag==0:一次性任务;
- flag==1:周期性任务;
- 如果task.flag==0,那么执行完就结束了;
- 如果task.flag==1,那么执行完继续把这个任务放进优先级队列即可;
当然啦还有改进的空间,我写的代码的逻辑是,如果没有到达延时时间,就sleep,但是当休眠期间来了一个新任务,这个任务的延时时间还恰巧小于当前休眠时间,按理来说新来的任务就应该被执行,但目前的代码还做不到这一点。因为 sleep 就是严格按照延时时间进行休眠的所以,在这儿就可以用到昨天学到的 wait() 和 notify() 策略。
3、再次改进版
利用wait() 和 notify()
只改 MyTimer 类就可以啦:
public class MyTimer {
// 这里是普通属性,不是静态属性
// 优先级队列,要求元素具备比较能力
private final PriorityBlockingQueue<MyTimerTask> queue = new PriorityBlockingQueue<>();
private final Object newTaskComing = new Object();
public MyTimer() {
Worker worker = new Worker();
worker.start();
}
// 不能使用静态内部类,否则看不到外部类的属性
class Worker extends Thread {
@Override
public void run() {
while (true) {
MyTimerTask task = null;
try {
task = queue.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
// task 应该有个应该执行的时刻(不能记录 delay)
long now = System.currentTimeMillis();
long delay = task.runAt - now;
if (delay <= 0) {
task.run();
} else {
try {
// Thread.sleep(delay); // 5s
// 应该在两种条件下醒来:
// 1. 有新的任务过来了(任务可能比当前最小的任务更靠前)
// 2. 没有新任务来,但到了该执行该任务的时候了
synchronized (newTaskComing) {
newTaskComing.wait(delay);
}
// 如果当前时间已经在要执行任务的时间之后了
// 说明任务的执行时间已过,所以应该去执行任务了
// 否则,先把这个任务放回去(因为时间还没到),再去取最小的任务
if (System.currentTimeMillis() >= task.runAt) {
task.run();
} else {
queue.put(task);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
public void schedule(MyTimerTask task, long delay) {
// 该方法非工作线程调用
task.runAt = System.currentTimeMillis() + delay;
queue.put(task);
synchronized (newTaskComing) {
newTaskComing.notify();
}
}
}
4、总结一个面试热题
wait() 和 sleep() 的区别
wait() | sleep() | |
---|---|---|
语义不同 | 等待 | 休眠 |
结束条件不同 | 被唤醒(notify)正常结束;线程被中断异常结束;等待超时异常结束 | 时间到了才能结束 |
所属类不同 | Object 类下的普通方法 | Thread 类下的静态方法 |
和锁的关系 | 会释放当前对象的锁 | 与锁无关 |
三、线程池(面试热点)
1、线程池是什么
在多线程开发中,线程的创建和销毁都是有成本的。
将多线程环境比喻成开饭店,来一个客人雇一个厨师,给这个客人做完菜之后就辞退厨师,明显是没有效率的且毫无意义的。
那么回到Java中:
- 来新任务
- 创建新的线程(浪费成本且毫无意义)
- 任务执行结束
- 销毁当前线程(浪费成本且毫无意义)
所以Java中的线程池机制,就是提前创建好若干个线程 (按需创建) ,来了新任务,就交给储备的线程去处理。
2、线程池管理模式
(1)Java中提供的线程池
(2)构造方法概述
构造方法:
参数列表:
1️⃣int corePoolSize: 要保留在池中的线程数,即使它们处于空闲状态(核心线程);
2️⃣int maximumPoolSize: 线程池中允许的最大线程数;
3️⃣long keepAliveTime: 当阻塞队列已满且还有任务进来时,这是多余的空闲线程(临时线程)在终止之前等待新任务的最长时间;
4️⃣TimeUnit unit: 参数keepAliveTime的时间单位;
5️⃣BlockingQueue workQueue: 当核心线程数量达到上限且线程池中没有空闲的核心线程时,将新来的任务放入阻塞队列中;
6️⃣ThreadFactory threadFactory: 创建新线程时要使用的工厂;
7️⃣RejectedExecutionHandler handler: 由于达到线程边界和队列容量而被阻止执行时使用的处理程序;
通俗的概括一下:
将线程池比喻成一个公司,线程池中的一个个线程就像公司中的一个个员工,从这个角度出发,再理解理解构造方法的参数列表:
1、int corePoolSize: 公司正式员工数量上限(核心线程数量上限);
2、int maximumPoolSize: 公司所有员工数量上限(正式员工数量上限+临时员工数量上限);
3、long keepAliveTime: 临时员工没事儿可干最长时限(临时线程空闲的最长时限);
4、TimeUnit unit: 参数keepAliveTime的时间单位;
5、BlockingQueue<> workQueue: 正式员工数量达到上限且没有空闲的正式员工时,来了新任务就先排队等待处理(阻塞队列);
6、ThreadFactory threadFactory: 招聘员工(创建线程);
7、RejectedExecutionHandler handler: 所有员工(正式+临时)都有任务并且排队任务的总数量也达到上限,公司就采取一定的拒绝策略。
(3)线程池的流程运转原理
图比一大段文字好理解我觉着,再用公司的模式对比着线程池来消化消化这张图吧。
公司 | 线程池 |
---|---|
一开始,公司没有员工 | 一开始,线程池没有线程 |
随着任务的不断提交 | 随着任务的不断提交 |
在正式员工数量小于正式员工数量上限的前提下,不断雇佣新的正式员工 | 在核心线程数量小于核心线程数量上限的前提下,不断创建核心线程 |
此时正式员工数量达到上限且任务持续提交 | 此时核心线程数量到达上限且任务持续提交 |
让任务排队等待处理直到排队任务数量到达上限 | 将任务放入阻塞队列直到阻塞队列已满 |
雇佣临时员工 | 创建临时线程 |
总员工数量(正式员工+临时员工)达到上限 | 总线程数量(核心线程+临时线程)达到上限 |
拒绝策略 | 拒绝策略 |
(4)拒绝策略
(5)一个细节问题
线程池构造方法中参数long keepAliveTime和 TimeUnit unit共同来决定临时线程的空闲状态下的存活时间,那么当任务没有那么多的时候,有的核心线程也一直处于空闲状态,有没有什么方法能够让长时间处于空闲状态的核心线程也结束掉呢?
参考官方文档,就能得出结论:默认情况下,仅当存在多个 corePoolSize 线程时,才应用保持活动状态策略。但是方法 allowCoreThreadTimeOut(boolean) 也可用于将此超时策略应用于核心线程,只要 keepAliveTime 值不为零。
(6)创建一个线程池
上面学习了线程池构造方法参数列表中各个参数的意义以及线程池工作流程,那么就简简单单使用一下线程池吧👇
public class Demo {
public static void main(String[] args) {
//阻塞队列,用来放持续提交的任务,数量为 1
BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1);
//线程工厂,用来创建线程,所以可以在这个方法中对线程进行命名之类的操作
ThreadFactory tf = new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "公司员工");
return t;
}
};
//创建一个线程池对象 service
ExecutorService service = new ThreadPoolExecutor(
3, // 核心线程数量上限:3
9, // 总线程数量上限:9
10, TimeUnit.SECONDS,//这俩用来规定临时线程空闲状态下的存活时间
queue, //阻塞队列,核心线程数量达到上限且任务持续提交时,把新来的任务放在这里
tf, //创建线程的线程工厂
new ThreadPoolExecutor.AbortPolicy() //拒绝策略(采用默认模式,即拒绝当前任务)
);
// 定义任务,就让这个任务 sleep 365天,目的是为了方便我们查看线程池中的线程情况
Runnable task = new Runnable() {
@Override
public void run() {
try {
TimeUnit.DAYS.sleep(365);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
// 把任务提交给线程池对象(公司)
Scanner s = new Scanner(System.in);
for (int i = 1; i < 100; i++) {
s.nextLine();
//execute :在将来的某个时间执行给定的任务
service.execute(task);
System.out.println(i);
}
}
}
先不看运行结果,结合代码分析:
- 阻塞队列最大值:1
- 核心线程数量上限:3
- 总线程数量上限:9
- 所以,临时线程数量上限:6(9-3)
- 采取拒绝策略,即所有线程处于工作状态且阻塞队列已满时,拒绝当前提交的任务
所以,在 for 循环中,读入一行就提交一次任务,当我们不断提交任务时,线程池中最多能放 10 个任务。 (为了方便我们查看到底提交了多少个任务,每次提交任务后都打印一下 i 的值)
运行结果:
jconsole查看线程池中线程情况:
结果分析:
因为我们创建线程池的时候规定,总线程数量上限为9,所以当任务持续提交时,线程池中最多有九个线程,这与我们在 jconsole 中看到的结果一致;此外,阻塞队列还允许放一个任务,所以线程池中最多能有10个任务,10个任务提交后,当任务继续提交,执行拒绝策略( RejectedExecutionException ),这与我们的运行结果一致。
(7)一些可以直接使用的线程池策略
对于初学者来说,使用线程池的时候不用每次都是用上面提到的构造方法去创建线程池,使用已经定义好的几个创建线程池的方法就可以啦🥰
在Executors类下面提供了几个创建线程池的常见方法,但不太建议在实际项目中使用,因为策略太固定了
-
①ExecutorService service1=Executors.newFixedThreadPool(10):创建固定大小的线程池,源码中可以看到,这个方法只有核心线程,没有临时线程
-
②ExecutorService service2=Executors.newCachedThreadPool():源码中可以看到,这个方法临时线程(数量无上限),没有核心线程
-
③ExecutorService service3=Executors.newSingleThreadExecutor():单一线程池(源码中可以看到,里面只允许有一个线程)
结合源码,可以看到,这几个创建线程池的方法锁采用的策略比较固定,所以才说不适合在实际项目中用这几个方法。
总结
- 定时器,自己实现了一个简单的定时器;
- 什么是线程池
- 线程池构造方法中参数列表概述
- 线程池的使用
明天继续🙋♀️