文章目录
本篇博客为笔者学习"堆"数据结构时的笔记以及自身实践,包含:
- 堆是什么:介绍堆的定义、堆的分类、堆的性质与工作原理;
- 堆有关的操作:包括用数组实现堆、堆中插入新的元素、弹出对顶元素;
- 堆的应用:介绍堆能解决哪些问题,包括实时数据的中位数计算、数组中的 Top K 问题以及定时任务。
希望我的博客能对大家深入理解堆数据结构有所帮助,让我们开始吧!
"堆"是什么
堆是一棵完全二叉树,完全二叉数要求除了最后一层,其他层的节点个数都是满的,最后一层节点全部靠左。堆中任何给定节点,需要满足如下性质:
- 大顶堆属性:节点值总是大于等于它的子节点,并且根节点的值是所有节点中最大的。
- 小顶堆属性:节点值总是小于等于它的子节点,并且根节点的值是所有节点中最小的
下图中有四棵二叉树,上层左侧的二叉树为小顶堆,上层右侧为大顶堆。作为对比,下层的两棵二叉树均不是堆,原因是下层左侧并非完全二叉树,节点 23 没有靠左排列;下层右侧的二叉树中,节点 23 小于左儿子 47,不满足大顶堆属性。
如何实现堆
堆是一个完全二叉树,完全二叉树是一种压缩率很高的数据结构,从上到下除了最底层叶子节点外都是排满的,没有空节点。即使最底层叶子节点层也需要从左往右排列。因此,可以从上到下,从左至右地将节点数据存储在数组中。
下面这幅图清晰展现了如何使用数组实现堆:
相信聪明的你已经找到了堆的存储规律:
- 数组下标为 i i i 节点,左儿子节点的数组下标为 2 i + 1 2 i + 1 2i+1 ,右儿子节点的数组下标为 2 i + 2 2 i + 2 2i+2。例如:25 节点在数组中的下标为 1,它的左儿子 48 的下标为 2 ⋅ 1 + 1 = 3 2\cdot{1}+1=3 2⋅1+1=3,右儿子 30 的下标为 2 ⋅ 1 + 2 = 4 2\cdot{1}+2=4 2⋅1+2=4。
- 已知节点下标为 j j j,它的父节点的下标为 j 2 \frac{j}{2} 2j。例如:23 节点下标 5,父节点 12 下标为 5 2 = 2 \frac{5}{2}=2 25=2。
堆有关的操作
如果要基于任意一个无序数组建立堆,我们需要知道如何将数组 改造为满足堆属性的数据结构——通常称为堆化(heapify),以及如何在增加和删除元素时 维护堆的属性。
向堆中插入一个元素
假设已经存在一个数组,满足小顶堆的属性,我们如何向堆中添加新元素?
这里我画了一张过程分解图,假定在小顶堆中插入的新节点 8(浅黄色背景)。
- 我们首先将新节点 8 放置于数组的末尾,即下标 6 的位置。
- 比较新节点 8 和父节点的大小关系,父节点下标为 6 − 1 2 = 2 \frac{6-1}{2}=2 26−1=2,节点值为 12。小顶堆属性要求父节点小于等于子节点,因此需要交换节点 8 和节点 12,得到第二幅图。
- 重复上述过程,比较节点 8 和父节点 10 的大小关系,10 大于 8,交换节点 8 和节点 10,得到第三幅图。
- 此时,新节点 8 已经是根节点,不存在父节点,所以第三幅图就是插入新节点后堆结构的最终状态。
按照上述思路,我将过程翻译为了代码,插入逻辑实现在insert
方法:
void MinHeap::insert(int node) {
if(this->heapSize == this->maxSize) {
return;
}
// node添加到数组的尾部, pos 记录新节点所处的下标, parent 为新节点当前的父节点
int pos = this->heapSize++, parent = (pos - 1) / 2;
array[pos] = node;
while (parent >= 0) {
// 父节点已经小于等于新节点, 无须再调整
if(array[parent] <= array[pos]) break;
// 交换父节点和新节点的值
std::swap(array[parent], array[pos]);
pos = parent;
parent = (pos - 1) / 2;
}
}
代码要点:
- 如果堆中元素个数达到堆容量最大值
maxSize
,直接返回; - 先将节点插入到数组 array 的尾部,即下标
heapSize
处;pos
记录插入节点下标,parent 为父节点下标。 - while 循环不断比较父节点和新节点的大小,如果父节点大于新节点,则交换
array[parent]
和array[pos]
,同时更新插入节点下标 pos 为 parent,parent 更新为 p o s − 1 2 \frac{pos-1}{2} 2pos−1。
删除堆顶元素
如果已经存在小顶堆,堆顶的元素就是最小的元素,当我们将堆顶元素弹出后,堆中第二大的元素就应该递补到堆顶,并且整棵二叉树满足小顶堆的属性。
下面是弹出堆顶元素并保持堆属性的步骤:
- 将数组最后一个元素与堆顶元素(根节点交换);
- 堆大小减一,即弹出数组尾部元素;
- 指针 cur 指向堆顶节点,比较 cur 节点和左右儿子,如果 cur 节点小于等于所有儿子节点,则堆已经调整完成。否则,选择两个儿子中的较小值与 cur 节点的值交换,并将 cur 指向交换了值的儿子节点。
- 重复步骤三,直到 cur 指向的节点没有儿子 或 cur 节点为叶子节点。
只看文字描述可能不利于理解,下面我用图片展示弹出堆顶元素的过程:
- 首先,交换堆顶元素 10 和数组中最后一个元素 52。堆 size 也要从原先的 7 更新为 6,此时原根节点 10 已经弹出。
- 随后,cur 指针指向根节点 52,比较 cur 和两个儿子节点,较小的儿子为节点 12。交换 52 和 12 的值,cur 指向儿子节点;
- 重复上述过程,cur 只有一个儿子节点 24 小于 52,进行交换和 cur 指针更新;
- 最后,cur 节点没有儿子,调整完成。
最后,我将文字描述的步骤翻译为代码,pop
方法弹出小顶堆堆顶元素:
int MinHeap::pop() {
if(this->heapSize == 0) {
return INT_MIN;
}
int top = array[0];
std::swap(array[0], array[--this->heapSize]);
int cur = 0; // 初始指向根节点
while (cur < this->heapSize) {
int left = 2 * cur + 1, right = 2 * cur + 2;
int nodeIdx = cur; // 记录 cur 和两个儿子中值最小的节点
if(left < this->heapSize && array[left] < array[cur]) {
nodeIdx = left;
}
if(right < this->heapSize && array[right] < array[nodeIdx]){
nodeIdx = right;
}
if(nodeIdx == cur) break; // 不存在儿子节点 或 cur 节点小于等于儿子节点
std::swap(array[cur], array[nodeIdx]); // 交换节点值
cur = nodeIdx; // 更新 cur 指针
}
return top;
}
堆的构造——堆化
在介绍堆的插入和删除操作时,我都会强调一个前提:当前数组已经满足小(大)顶堆的属性,本节我将介绍如何将一个无序数组堆化(heapify)为满足堆属性的数据结构。
将数组原地建立成堆,有两种思路:
- 向已建成的堆中不断插入元素:假设初始堆只有数组下标为 0 一个元素,使用堆的插入操作,将下标 1 到 n 的元素插入到堆中。
- 自后向前处理数组,每个数据都是从上往下堆化。假设堆大小为 n,最后一个节点的下标为 n - 1,它的父节点下标为 n − 2 2 ( n ≥ 2 ) \frac{n-2}{2}(n\ge{2}) 2n−2(n≥2),即下标大于 n − 2 2 \frac{n-2}{2} 2n−2 的节点都是叶子节点。我们只需要从下标 n − 2 2 \frac{n-2}{2} 2n−2到 0,每个节点向下堆化,就能基于数组原地建堆。
第二种方式不是太好理解,我画了如下示意图:
图中展示了无序数组堆化为小顶堆的详细过程:
- 堆中有 7 个元素,因此需要从下标为 7 − 2 2 = 2 \frac{7-2}{2}=2 27−2=2的节点(67)开始向下堆化,节点 67 与最小的儿子节点 (idx=6 val=2) 进行值交换。
- 下标为 2 的节点标识的子树已经完成堆化,从后向前,下一个需要调整下标为 1 的节点(32),与左儿子(idx=3 val=16) 值交换,完成调整。
- 最后调整下标 0 的节点(根节点),过程和删除元素操作中自顶向下堆化的过程相同,这里不再赘述。
堆化过程的代码实现如下:
void MinHeap::heapify() {
// 堆为空或只有一个元素, 无需调整
if(this->heapSize < 2) return;
int lastParent = (this->heapSize - 2) / 2;
for(int i = lastParent; i >= 0; i--)
down(i);
}
void MinHeap::down(int idx) {
int lastParent = (this->heapSize - 2) / 2;
if(idx > lastParent || idx < 0) return;
int cur = idx;
while (cur < this->heapSize) {
int left = 2 * cur + 1, right = 2 * cur + 2;
int nodeIdx = cur;
if(left < this->heapSize && array[left] < array[cur]) {
nodeIdx = left;
}
if(right < this->heapSize && array[right] < array[nodeIdx]){
nodeIdx = right;
}
if(nodeIdx == cur) break; // 不存在儿子节点 或 cur 小于等于儿子节点
std::swap(array[cur], array[nodeIdx]); // 交换节点值
cur = nodeIdx; // 更新 cur 指针
}
}
heapify
方法从堆中 最后一个非叶子节点 开始,向前遍历节点,遍历到的每个节点执行down
操作——自顶向下堆化。因为抽取出了down
方法,前一节中的pop
方法也可以简化为:
int MinHeap::pop() {
if(this->heapSize == 0) {
return INT_MIN;
}
int top = array[0];
std::swap(array[0], array[--this->heapSize]);
down(0);
return top;
}
这里我不打算详细介绍【逐个插入节点的方式】构造堆,因为这种方式的性能低于【从后向前依次向下堆化】,下面是这两种建堆方式的时间复杂度分析。
假设,堆的根节点高度为 h,叶子节点高度为 0,这个堆是满二叉树:
-
满二叉树的高度 h 和节点数目 N 满足: 2 0 + 2 1 + . . . + 2 h = N h = l o g 2 ( N + 1 ) − 1 2^{0} + 2^{1} +...+2^{h}=N\newline h=log_{2}{(N+1)}-1 20+21+...+2h=Nh=log2(N+1)−1
-
采用第一种方式进行堆化,需要进行比较和交换的次数为: S = 2 1 ⋅ 1 + 2 2 ⋅ 2 + . . . + 2 h ⋅ h = 2 h + 1 ⋅ h − 2 h + 1 + 2 = ( N + 1 ) l o g 2 ( N + 1 ) − 2 N S=2^{1}\cdot{1} + 2^{2}\cdot{2} + ... + 2^{h}\cdot{h}=2^{h+1}\cdot{h}-2^{h+1}+2=(N+1)log_{2}{(N+1)}-2N S=21⋅1+22⋅2+...+2h⋅h=2h+1⋅h−2h+1+2=(N+1)log2(N+1)−2N
采用大 O 计数法,时间复杂度为 O ( N l o g N ) O(N logN) O(NlogN) 。
-
采用第二种方式进行堆化,叶子节点均位于 h 层,只需要从 h - 1 层开始向下堆化,第 h-1 层节点个数为 2 h − 1 2^{h-1} 2h−1,调整的总操作数为 2 h − 1 ⋅ 1 2^{h-1}\cdot{1} 2h−1⋅1。以此类推,第 k 层所有节点向下堆化,需要执行的总操作为 2 k ⋅ ( h − k ) 2^{k}\cdot{(h-k)} 2k⋅(h−k)。需要进行比较的次数: S = 2 0 ⋅ h + 2 1 ⋅ ( h − 1 ) + . . . + 2 h − 1 ⋅ 1 = 2 ( h + 1 ) − ( h + 2 ) = N − l o g 2 ( N + 1 ) S=2^{0}\cdot{h} + 2^{1}\cdot{(h-1)} + ... + 2^{h-1}\cdot{1}=2^{(h+1)}-(h+2)=N-log_2{(N+1)} S=20⋅h+21⋅(h−1)+...+2h−1⋅1=2(h+1)−(h+2)=N−log2(N+1)大O计数法,时间复杂度为 O ( N ) O(N) O(N) 。
因此,从后向前遍历非叶子节点,每个节点依次向下堆化,这种方式的时间复杂度为 O ( N ) O(N) O(N),性能更优。
堆排序
基于堆进行排序分为两个步骤:建堆、排序。建堆的方法我在【堆的构造——堆化】中已经介绍,本章介绍已经建堆的数组,如何转化为有序数组。
针对满足堆属性的数组排序,过程类似前文介绍的【删除堆顶元素】操作,我先给出我的经验总结——堆排序就是 不断弹出满足堆属性的数组的堆顶元素,直到堆为空的过程。如果数组需要排序为 顺序,需要先建立 大顶堆;如果排序为 逆序,则建立 小顶堆。
下图我展示了基于 小顶堆,将数组转化为 逆序 排列的过程:
- 首先,弹出堆顶元素 10,弹出方法是将堆中最后一个元素 23 交换至堆顶,heap size 减一,然后堆顶向下堆化,直到满足堆属性。注意到,原先的堆顶元素已经位于数组 idx=5 的位置。
- 继续重复上述过程,依次弹出堆顶元素 12、23、25、30、48,这些元素在弹出时都会先交换至堆的最后一个元素,然后再减小 heap size。因此,当堆中元素全部弹出,数组将按照逆序排列。
上述过程翻译为代码:
步骤一:创建一个 MinHeap 对象,设置数组为 array;
步骤二:使用 MinHeap#heapify
方法,基于数组 array 建立小顶堆;(heapify 方法实现参考【堆的构造】小节)
步骤三:调用 MinHeap#pop
方法,逐个弹出堆顶元素。(pop 方法的实现参考【删除堆顶元素】小节)
void heapSort(int array[], int size) {
MinHeap minHeap(1024);
minHeap.setArray(array, size);
// 建立小顶堆
minHeap.heapify();
for(int i = 0; i < size; i++)
minHeap.pop();
}
我们来分析下堆排序的复杂度:
-
假设堆的根节点高度为 h,叶子节点高度为 0,这个堆是满二叉树。满二叉树的高度 h 和节点数目 N 满足: 2 0 + 2 1 + . . . + 2 h = N h = l o g 2 ( N + 1 ) − 1 2^{0} + 2^{1} +...+2^{h}=N\newline h=log_{2}{(N+1)}-1 20+21+...+2h=Nh=log2(N+1)−1
-
弹出堆顶时,交换至根节点的元素向下调整,最多下降 h 层 (0 至 h 层),h 层总共存在元素 2 h 2^h 2h 个,因此操作总数为 h ⋅ 2 h h\cdot{2^h} h⋅2h。因此,弹出整棵二叉堆,总操作树为: S = 2 1 ⋅ 1 + 2 2 ⋅ 2 + . . . + 2 h ⋅ h = 2 h + 1 ⋅ h − 2 h + 1 + 2 = ( N + 1 ) l o g 2 ( N + 1 ) − 2 N S=2^{1}\cdot{1} + 2^{2}\cdot{2} + ... + 2^{h}\cdot{h}=2^{h+1}\cdot{h}-2^{h+1}+2=(N+1)log_{2}{(N+1)}-2N S=21⋅1+22⋅2+...+2h⋅h=2h+1⋅h−2h+1+2=(N+1)log2(N+1)−2N 采用大 O 计数法,时间复杂度为 O ( N l o g N ) O(N logN) O(NlogN) 。考虑到建堆的时间复杂度为 O ( N ) O(N) O(N) ,因此堆排序的总时间复杂度为 O ( N l o g N ) O(N logN) O(NlogN)。
总结:
- 堆排序是一个时间复杂度 O ( N l o g N ) O(N logN) O(NlogN) 的 原地排序算法;
- 它不是稳定的排序算法,因为涉及到将堆最后一个元素交换至堆顶的操作,值相等的元素可能发生交换;
- 堆排序的性能总体上不如快速排序,这里有两个原因:
- 堆排序的访问方式不能很好利用 CPU Cache,因为它是类似 i i i 到 2 ⋅ i + 1 2\cdot{i} + 1 2⋅i+1 的跳跃式访问;而快排中访问相邻元素,满足缓存的局部性原理。
- 快速排序中元素交换的次数不大于数组的逆序度。堆排序的交换次数(swap) 多于快速排序,堆排序首先需要建堆,这会将原先有序数组的顺序打乱,逆序度增加。
堆能解决什么问题?
数组中第 K 小(大)元素
本节以 Leetcode 215. 数组中的第 K 个最大元素 为示例,介绍如何使用堆获取数组中第 K 小(大)的元素。题目描述如下:
给定整数数组
nums
和整数k
,请返回数组中第k
个最大的元素。
示例 1:
输入:[3,2,1,5,6,4],
k = 2
输出: 5
示例 2:
输入:[3,2,3,1,2,4,5,5,6]
, k = 4
输出: 4
这道题的解答步骤如下:
- 建立小顶堆,将数组中元素 num 逐个加入小顶堆:
(1) 如果堆中元素少于 k 个,直接添加;
(2) 如果堆中元素等于 k 个,比较 num 和堆顶元素 top。如果 num 小于等于 top,遍历下一个数组元素;如果 num 大于 top,弹出堆顶元素,将 num 插入小顶堆。 - 遍历完数组中元素后,返回堆顶元素,该元素即数组的第 K 个最大值。
完整代码实现如下:
int findKthLargest(vector<int>& nums, int k) {
priority_queue<int, vector<int>, greater<int>> minHeap;
for(int i = 0; i < nums.size(); i++){
if(minHeap.size() < k){
minHeap.push(nums[i]);
continue;
}
int top = minHeap.top();
if(top < nums[i]){
minHeap.pop();
minHeap.push(nums[i]);
}
}
return minHeap.top();
}
数据流的中位数
堆可以求动态数据流中的中位数,中位数的含义是有序数组排在中间的数。
- 如果有序数组中存在 2n + 1 个元素,中位数就是下标为 n 的元素(下标0起始);
- 如果包含 2n + 2 个元素,中位数为下标 n 和 n+1 的两个元素的平均值。
对于静态数据集合,求取中位数的方法是先排序,然后直接访问数组中对应下标的元素即可。如果是动态数据集合(支持添加元素),中位数会不停的变动,如果每次都需重新排序效率就太低了。
此时,可以利用堆高效实现动态数据集合求中位数:
- 维护一个大顶堆,一个小顶堆;大顶堆存储数据集合中较小的那一半数据,小顶堆存储较大的那一半数据。(这里约定大顶堆的元素不少于小顶堆,且大顶堆的元素个数至多比小顶堆多 1)
- 新增元素 num,假设大顶堆堆顶为 maxHeapTop,小顶堆堆顶为 minHeapTop,分别讨论如下情况:
- 大顶堆大小与小顶堆相同,大顶堆的元素需要增加。
- 如果 num 小于等于 minHeapTop,说明 num 小于小顶堆中所有元素,num 插入大顶堆即可;
- 如果 num 大于 minHeapTop,则需要弹出小顶堆堆顶,将 num 插入小顶堆,minHeapTop 插入大顶堆;
- 大顶堆比小顶堆多一个元素,小顶堆的元素需要增加。
- 如果 num 大于等于 maxHeapTop,说明 num 大于大顶堆中所有元素,num 插入小顶堆即可;
- 如果 num 小于 maxHeapTop,则需要弹出大顶堆堆顶,将 num 插入大顶堆,maxHeapTop 插入小顶堆;
- 大顶堆大小与小顶堆相同,大顶堆的元素需要增加。
- 查看数据流中位数:
- 如果大顶堆比小顶堆多一个元素,返回大顶堆堆顶 maxHeapTop;
- 如果大顶堆与小顶堆具有同样数目的元素,返回平均值 (minHeapTop + maxHeapTop) / 2
Leetcode 题目连接:LCR 160. 数据流的中位数
题目完整代码题解如下:
class MedianFinder {
// 1. 小顶堆和大顶堆
priority_queue<int, vector<int>, greater<int>> minHeap;
priority_queue<int, vector<int>, less<int>> maxHeap;
public:
/** initialize your data structure here. */
MedianFinder() {
}
// 2. 动态数据集合添加新元素
void addNum(int num) {
if(maxHeap.size() == 0){
maxHeap.push(num);
return;
}
if(maxHeap.size() > minHeap.size()) {
int maxHeapTop = maxHeap.top();
if(maxHeapTop > num){
// 弹出 maxHeap 堆顶, 加入到minHeap中
maxHeap.pop();
minHeap.push(maxHeapTop);
maxHeap.push(num);
} else{
minHeap.push(num);
}
} else {
int minHeapTop = minHeap.top();
if(minHeapTop < num){
minHeap.pop();
minHeap.push(num);
maxHeap.push(minHeapTop);
} else {
maxHeap.push(num);
}
}
}
// 3. 查询动态数据集合的中位数
double findMedian() {
if(maxHeap.size() == minHeap.size()) {
return ((double)(maxHeap.top() + minHeap.top())) / 2.0;
}
return maxHeap.top();
}
};
定时器 Java Timer
java.util.Timer
类是用于任务调度的一个工具,它可以让你在未来的某一时刻执行一次任务,或者定期重复执行任务。
使用示例
Timer timer = new Timer("Timer"); // (1)
TimerTask task = new TimerTask() {
@Override
public void run() {
System.out.println("当前时间: " + new Date() +" 线程名称:" +
Thread.currentThread().getName());
}
}; // (2)
System.out.println("当前时间: " + new Date() +" 线程名称:"
+ Thread.currentThread().getName());
long delay = 1000L;
// 定时任务延迟1s执行, 每隔周期2s执行一次
timer.schedule(task, delay, 2000L); // (3)
- 首先创建一个
Timer
实例; - 创建 TimerTask 任务,使用者需要扩展
run
方法来定义任务; - 以 fixed-delay 模式执行定时任务,延迟 1s 后第一次执行,随后每次执行任务完成后的 2s,再次执行该任务。
如果需要停止所有当前已安排的任务,可以使用 Timer#cancel
方法:
timer.cancel();
另外介绍下Timer
类提供的两种基于周期(period)参数的任务调度模式:fixed-rate 和 fixed-delay。这两种调度模式的主要区别在于任务执行的准确性和间隔方式。
- Fixed-Rate:该模式下,任务按照指定的周期准时执行,周期的计算是基于任务的预期开始时间。如果安排了一个每 5 秒执行一次的任务,
Timer
确保新一轮任务的预期开始执行时间nextExecutionTime
为上一轮任务的nextExecutionTime + 5s
。即使上一轮任务的实际执行晚于它的预期执行时间,也不会影响下一轮任务的预期开始时间。(可能导致任务在较短时间内重复执行)timer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { // 任务代码 } }, delay, period);
- Fixed-Delay:与 fixed-rate 不同,fixed-delay 调度以上一轮任务的实际开始执行时间为基准,计算下一轮任务的开始执行时间。
timer.schedule(new TimerTask() { @Override public void run() { // 任务代码 } }, delay, period);
TaskQueue
Timer 内部使用TaskQueue
的类存放定时任务,它是⼀个基于小顶堆实现的优先队列。
queue
是任务数组,add
方法添加定时任务就是往小顶堆中加入元素,getMin
方法获取堆顶的定时任务、removeMin
弹出堆顶元素。
fixDown
和 fixUp
方法分别向下和向上维护小顶堆属性,比较依据为任务预计的下一次执行时间 nextExecutionTime
。
class TaskQueue {
// 平衡二叉堆: 下一次执行时间最小的任务存放在queue[1]
private TimerTask[] queue = new TimerTask[128];
private int size = 0;
int size() { return size; }
// 添加任务到优先队列
void add(TimerTask task) {
// Grow backing store if necessary
if (size + 1 == queue.length)
queue = Arrays.copyOf(queue, 2*queue.length);
queue[++size] = task;
fixUp(size);
}
// 获取堆顶元素
TimerTask getMin() {
return queue[1];
}
// 弹出堆顶元素
void removeMin() {
queue[1] = queue[size];
queue[size--] = null; // Drop extra reference to prevent memory leak
fixDown(1);
}
// 定时任务重新调度的时间为newTime
void rescheduleMin(long newTime) {
queue[1].nextExecutionTime = newTime;
fixDown(1);
}
//...
// 从下标k开始向上调整
private void fixUp(int k) {
while (k > 1) {
int j = k >> 1;
if (queue[j].nextExecutionTime <= queue[k].nextExecutionTime)
break;
TimerTask tmp = queue[j]; queue[j] = queue[k]; queue[k] = tmp;
k = j;
}
}
private void fixDown(int k) {
int j;
while ((j = k << 1) <= size && j > 0) {
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;
}
}
// 将数组queue堆化
void heapify() {
for (int i = size/2; i >= 1; i--)
fixDown(i);
}
}
可以看出 TaskQueue
是一个以定时任务 TimerTask
为元素的标准小顶堆实现。
使用 Timer#schedual
方法提交定时任务,调用的是 sched 方法,该方法将定时任务添加到任务队列 queue 中,如果堆顶元素是新添加的任务,则使用Object#notify
方法唤醒正在等待的定时器线程。
// Timer#sched
// task为定时任务, time是任务首次执行的时间戳, period为任务执行的周期
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) {
//...
queue.add(task);
// 获取堆顶的元素, 如果堆顶为新添加的任务, 则需要唤醒Timer线程
if (queue.getMin() == task)
queue.notify();
}
}
Timer线程执行mainLoop方法
定时器Timer线程的主要逻辑在 mainLoop
方法中:
// TimerThread#mainLoop
private void mainLoop() {
while (true) {
try {
TimerTask task;
boolean taskFired;
synchronized(queue) {
/* 等待任务队列非空。当threadReaper被回收时将newTasksMayBeScheduled设置为false
从而Timer线程优雅地退出mainLoop */
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(); // (1) 获取堆顶元素, 即执行时刻最小的任务
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 {
/* (2) 设置周期任务的重新调度时间, 更新堆顶元素的优先级, 再fixDown
period小于0, 下一轮任务的执行时间由本轮任务的【实际开始执行的时间】确定
period大于0, 下一次任务的执行时间由本轮任务【预期开始执行的时间】确定
*/
queue.rescheduleMin(
task.period<0 ? currentTime - task.period
: executionTime + task.period);
}
}
}
// 最近任务还需等待一段时间执行
if (!taskFired) // Task hasn't yet fired; wait
queue.wait(executionTime - currentTime);
}// synchronized(queue)
// 任务到达了运行时间, 由Timer线程运行, 此阶段已经释放了queue的锁, 不影响添加任务
if (taskFired)
task.run();
} // try
catch(InterruptedException e) { }
}// while(true)
}
- 从优先队列 queue 中获取执行时间戳最小的任务task,得到预期执行时间 executionTime。
- 获取当前时间currentTime,如果当前时间戳已经大于等于任务预期执行时间 executionTime,检查任务的 period 属性。
- 如果 period 为 0,说明任务不需要重复执行,将 task 从优先队列中出队。
- 如果 period 大于0,任务处于 fixed-rate 模式,下一次任务执行的时间由本轮任务的【预期开始执行的时间】确定,即
executionTime + period
; - 如果 period 小于0,任务处于 fixed-delay 模式,下一次执行任务的时间由本轮任务的【实际开始执行时间】确定,即
currentTime - period
;
- 如果当前时间戳 currentTime 小于任务预期开始执行时间 executionTime,使用
Object#wait
方法等待executionTime - currentTime
毫秒。(其它线程调用Timer#schedual
方法加入新任务时也会通过 notify 唤醒 Timer 线程,因为此时堆顶的任务(下一个被调度的任务)已经发生了改变) - 如果堆顶的任务满足 2 中的条件,则调用
task.run
。