单机环境下定时任务的基本原理和常见解决方案之最小堆原理和实现方案
背景
定时任务,顾名思义,就是在系统指定时间点执行的任务。
我们的业务系统中往往存在众多的任务需要定时或者定延迟执行,在Java开发中,定时任务是一种十分常见的功能。
业务场景 :
- 每天早上8点,统计前一天的数据并生成报表发送给相关业务方;
- 定时更新缓存数据;
- 定时清理系统中失效的数据;
单机环境下的解决方案有三种
- 1.线程sleep方案
- 2.最小堆实现方案
- 3.时间轮实现方案
1.线程sleep方案
如果让我们自己实现一个延迟/每隔5s执行的任务
最简单的方式就是在一个线程里面sleep需要延迟的时间, 如果是每隔5s执行一次,就在线程里加一个while循环
如下:
public static void main(String[] args) {
final long timeInterval = 5000;
Runnable runnable = new Runnable() {
@Override
public void run() {
while (true) {
System.out.println(Thread.currentThread().getName() + "间隔5秒执行,执行时间" + Calendar.getInstance().getTime());
try {
Thread.sleep(timeInterval);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread thread = new Thread(runnable, "定时任务1");
thread.start();
}
执行结果:
实现很简单,缺点也很明显,如果系统中需要其他业务也需要执行定时任务,那就需要再写一个while+sleep的代码。
假设系统中有大量业务需要使用定时任务,那么在上述实现下,大量的定时任务线程会不断进行时间片的切换,这样的切换对cpu的消耗是很大的。
怎么进行优化呢?
我们可以把系统中所有的定时任务都存起来,统一进行排序,然后按照执行的先后顺序依次调度不同的任务,从而减少每个线程cpu切换的消耗。 那就是最小堆实现方案
2.最小堆实现方案
我们先来讲下什么是最小堆?
堆与完全二叉树
先看下百度百科对堆的定义
堆(Heap)是计算机科学中一类特殊的数据结构,是最高效的优先级队列。堆通常是一个可以被看作一棵完全二叉树的数组对象,也叫做二叉堆。
那什么是完全二叉树呢?
满二叉树: 一个深度为k,节点个数为2^k-1的二叉树为满二叉树,即一棵树的所有非叶子节点都有左右两个叶子节点
一棵深度为k的有n个结点的二叉树,对树中的结点按从上至下、从左到右的顺序进行编号,如果编号为i(1≤i≤n)的结点与满二叉树中编号为i的结点在二叉树中的位置相同,则这棵二叉树称为完全二叉树。
下图就是一个二叉树形式的堆:
简单来说,堆是一颗被填满的二叉树,除了最底层上的元素可能是不满的,其上层的元素一定是满的,并且最底层元素必须是从左到右填入
因为有这个规律,所有我们可以用一个数组来表示这个堆(数据从index=1的位置开始存储,index=0的位置不存储数据)
不难发现,对于数组中任意位置 i 上的元素,其左子节点在 2i 位置上,右子节点在左子节点之后的位置(2i+1)上 。也就是说 对于任意位置i上的节点,其父节点一定在i/2的下标位置上,子节点一定在2i或者2i+1的位置上
这很重要,后面通过数组实现最小堆就需要用到这个结论 !!!
最小堆
最小堆,是一种经过排序的完全二叉树,其中任一非终端节点的数据值均不大于其左子节点和右子节点的值。
如图就是一个最小堆
插入数据
例如,我们加入一个新的节点,大小为18
先将新数据放在底层的叶子节点上(其实就是对应于堆数组的第一个为空的位置),然后比较新节点和其父节点的大小,如果新节点较小,则和父节点交换位置,直到新节点大于其父节点。
这种方法策略上叫上滤。如果插入的节点是新堆中最小节点,那么新节点会一直上滤到根节点处,这种插入的时间复杂度为O(logN)
删除最小数据
当删除根节点时,由于堆少了一个根节点,因此我们将堆中最后一个节点X放入到根节点中。如果X比他的子节点大,那么找出子节点中最小者,与X交换位置。直到X节点小于它的两个根节点。
这个策略称之为下滤。
使用最小堆来实现定时任务的统一调度,其核心思想就是让每次写入的定时任务都按照执行时间的先后顺序进行排序;保证在堆顶的任务的执行时间是最小的 ,也就是最早执行的。
通过自旋判断堆顶的任务是否应该执行 ,如果到达执行时间则执行,否则继续等待。为了满足最小堆的特性,每次插入、删除任务都需要重新排序;
最小堆只保证局部有序,而不保证全局有序,通过不严格排序在满足查找最小元素的同时提升插入删除的效率。
对于有序数组,查找插入位置用二分查找是O(log2n)。但是数组的插入操作为了保证有序性需要将插入位置后的元素全部后移一位,这需要O(n) 所以总的时间复杂度是O(n)
Timer定时器示例
jdk的Timer定时器底层就是采用最小堆来实现定时任务的统一调度
使用示例 :
public static void main(String[] args) {
Timer timer = new Timer();
timer.scheduleAtFixedRate(new java.util.TimerTask() {
@Override
public void run() {
System.out.println(Calendar.getInstance().getTime() + ":执行定时任务1");
}
}, 2000, 5000);
timer.schedule(new java.util.TimerTask() {
@Override
public void run() {
System.out.println(Calendar.getInstance().getTime() +"执行定时任务2");
}
}, 3000);
}
执行结果 :
Timer核心源码分析
添加新任务
private final TaskQueue queue = new TaskQueue();
public void schedule(TimerTask task, long delay) {
if (delay < 0)
throw new IllegalArgumentException("Negative delay.");
sched(task, System.currentTimeMillis()+delay, 0);
}
private void sched(TimerTask task, long time, long period) {
if (time < 0)
throw new IllegalArgumentException("Illegal execution time.");
// Constrain value of period sufficiently to prevent numeric
// overflow while still being effectively infinitely large.
if (Math.abs(period) > (Long.MAX_VALUE >> 1))
period >>= 1;
synchronized(queue) {
if (!thread.newTasksMayBeScheduled)
throw new IllegalStateException("Timer already cancelled.");
synchronized(task.lock) {
if (task.state != TimerTask.VIRGIN)
throw new IllegalStateException(
"Task already scheduled or cancelled");
//设置任务的首次执行时间,以及执行间隔和任务状态
task.nextExecutionTime = time;
task.period = period;
task.state = TimerTask.SCHEDULED;
}
//添加任务到队列
queue.add(task);
//如果最小堆的根节点为当前task,则唤醒queue
//1.新增的task刚好是最小的 -- 新增的task如果是最小的,那说明最早执行的task有变动,也需要唤醒(自旋里如果未到任务的执行时间,会wait,参见(1))
//2.新增的task是第一个 -- 之前在自旋里如果queue为空,会调用wait()等待
if (queue.getMin() == task)
queue.notify();
}
}
void add(TimerTask task) {
// Grow backing store if necessary
// 判断是否需要扩容
if (size + 1 == queue.length)
queue = Arrays.copyOf(queue, 2*queue.length);
//新加的任务默认放到数组尾部 TimerTask[] queue = new TimerTask[128];
queue[++size] = task;
//核心方法,对任务进行排序(保证最小堆的堆序特性)
fixUp(size);
}
最小堆上滤(排序实现)
//插入时k的初始值为queue数组的size(也就是最新元素的存放下标)
private void fixUp(int k) {
while (k > 1) {
int j = k >> 1;
//将新任务同队列的中部任务(其实就是父节点的任务)比较nextExecutionTime,如果新加入的任务执行时间较小(执行的比较早),则将位置与比较的任务互换 -- 上滤
if (queue[j].nextExecutionTime <= queue[k].nextExecutionTime)
break;
TimerTask tmp = queue[j]; queue[j] = queue[k]; queue[k] = tmp;
k = j;
}
}
fixUp(): 如果新任务的执行时间是最早的,那这个任务将会被移动到堆顶。
执行定时任务
定时任务的执行是通过一个新的线程来执行的,这个线程在创建Timer实例的时候创建(一个Timer底层只有一个线程执行任务)
private final TaskQueue queue = new TaskQueue();
/**
* The timer thread.
*/
private final TimerThread thread = new TimerThread(queue);
public Timer(String name) {
thread.setName(name);
thread.start();
}
class TimerThread extends Thread {
public void run() {
try {
//自旋,执行定时任务
mainLoop();
} finally {
// Someone killed this Thread, behave as if Timer cancelled
synchronized(queue) {
newTasksMayBeScheduled = false;
queue.clear(); // Eliminate obsolete references
}
}
}
}
private void mainLoop() {
while (true) {
try {
TimerTask task;
boolean taskFired;
synchronized(queue) {
// 如果队列为空,则等待 Wait for queue to become non-empty
while (queue.isEmpty() && newTasksMayBeScheduled)
queue.wait();
if (queue.isEmpty())
break; // Queue is empty and will forever remain; die
// Queue nonempty; look at first evt and do the right thing
long currentTime, executionTime;
//获取堆顶的任务,也就是最早执行的任务
task = queue.getMin();
synchronized(task.lock) {
if (task.state == TimerTask.CANCELLED) {
queue.removeMin();
continue; // No action required, poll queue again
}
currentTime = System.currentTimeMillis();
executionTime = task.nextExecutionTime;
if (taskFired = (executionTime<=currentTime)) {
if (task.period == 0) { // Non-repeating, remove
//将堆顶的任务移除,然后把堆尾的任务提到堆顶,然后再依次比较将该任务往后移,直到到达合适的位置。
queue.removeMin();
task.state = TimerTask.EXECUTED;
} else { // Repeating task, reschedule
//如果是一个定期执行的任务,则reschedule
queue.rescheduleMin(
task.period<0 ? currentTime - task.period
: executionTime + task.period);
}
}
}
// Task hasn't yet fired; wait
//如果还没有到任务的执行时间,则wait(1)
if (!taskFired)
queue.wait(executionTime - currentTime);
}
if (taskFired) // Task fired; run it, holding no locks
task.run();
} catch(InterruptedException e) {
}
}
}
移除堆顶的任务并重新排序
void removeMin() {
queue[1] = queue[size];
queue[size--] = null; // Drop extra reference to prevent memory leak
fixDown(1);
}
private void fixDown(int k) {
int j;
while ((j = k << 1) <= size && j > 0) {
//j用来记录左子节点和右子节点中执行时间较小者
if (j < size &&
queue[j].nextExecutionTime > queue[j+1].nextExecutionTime)
j++; // j indexes smallest kid
//如果当前节点比子节点都小则退出,否则交换(将较小的子节点移到父节点的位置)
if (queue[k].nextExecutionTime <= queue[j].nextExecutionTime)
break;
TimerTask tmp = queue[j]; queue[j] = queue[k]; queue[k] = tmp;
k = j;
}
}
Timer的源码比较简单,其缺点也很明显
缺点:
- 后台调度任务的线程只有一个,所以多任务是阻塞运行的,一旦其中一个任务执行周期过长将会影响到其他任务。
Timer
本身没有捕获其他异常(只捕获了InterruptedException
),一旦任务出现异常将导致后续任务也无法执行。
ScheduledThreadPoolExecutor
为了解决上述问题, JDK1.5
中的并发包中推出了 ScheduledThreadPoolExecutor
来替代 Timer
实现定时任务
原理和Timer是一样的,ScheduledThreadPoolExecutor中用自定义延迟队列DelayedWorkQueue来实现最小堆
Timer | ScheduledThreadPoolExecutor |
---|---|
单线程,多任务阻塞 | 多线程执行,任务互不影响 |
异常时任务停止 | 依赖于线程池,单个任务出现异常不影响其他任务 |
最小堆优化
在Timer的最小堆实现里,在新增节点,并对最小堆进行排序的时候,会通过交换的方式来互换值,一次交换需要3条赋值语句。如果一个节点要上滤n层,就需要3*n次交换,
但是如果我们用数组下标为0的位置来存储需要插入的节点(此时该节点原本需要插入的位置就空出来了),那么每次上滤,只需要将父节点移动到要去的空位置就行,这样只需要用n+1次赋值即可
实现如下:
public static void main(String[] args) {
addEle(30);
addEle(10);
addEle(50);
addEle(60);
addEle(70);
addEle(90);
addEle(5);
}
static Integer[] queue = new Integer[16];
static int size = 0;
private static void addEle(Integer ele) {
size++;
queue[0] = ele;
int j;
//k是当前增加元素后的size(也是目前数组最后一个元素的下标)
int k = size;
//j是k的父节点
while ((j = k >> 1) >= 0) {
if (ele >= queue[j]) {
queue[k] = ele;
break;
} else {
queue[k] = queue[j];
k = j;
}
}
System.out.println("输出元素");
for (int i = 1; i <= size; i++) {
System.out.print(queue[i] + "|");
}
System.out.println();
}
输出结果:
上面的排序也可以改为如下实现方式
for (queue[0] = ele; ele < queue[k >> 1]; k = k >> 1) {
queue[k] = queue[k >> 1];
}
queue[k] = ele;
最小堆实现方式的定时任务,其新任务写入排序的时间复杂度是O(log(n)),取任务执行的时间复杂度是O(1) ,那写入任务的时间复杂度是否可以进一步优化呢? 这就是时间轮方案 (通过数组+链表的方式让写入任务的时间复杂度接近于O(1))