解密数据结构——堆 Heap

本篇博客为笔者学习"堆"数据结构时的笔记以及自身实践,包含:

  • 堆是什么:介绍堆的定义、堆的分类、堆的性质与工作原理;
  • 堆有关的操作:包括用数组实现堆、堆中插入新的元素、弹出对顶元素;
  • 堆的应用:介绍堆能解决哪些问题,包括实时数据的中位数计算、数组中的 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 21+1=3,右儿子 30 的下标为 2 ⋅ 1 + 2 = 4 2\cdot{1}+2=4 21+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(浅黄色背景)。

  1. 我们首先将新节点 8 放置于数组的末尾,即下标 6 的位置。
  2. 比较新节点 8 和父节点的大小关系,父节点下标为 6 − 1 2 = 2 \frac{6-1}{2}=2 261=2,节点值为 12。小顶堆属性要求父节点小于等于子节点,因此需要交换节点 8 和节点 12,得到第二幅图。
  3. 重复上述过程,比较节点 8 和父节点 10 的大小关系,10 大于 8,交换节点 8 和节点 10,得到第三幅图。
  4. 此时,新节点 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} 2pos1

删除堆顶元素

如果已经存在小顶堆,堆顶的元素就是最小的元素,当我们将堆顶元素弹出后,堆中第二大的元素就应该递补到堆顶,并且整棵二叉树满足小顶堆的属性。

下面是弹出堆顶元素并保持堆属性的步骤:

  1. 将数组最后一个元素与堆顶元素(根节点交换);
  2. 堆大小减一,即弹出数组尾部元素;
  3. 指针 cur 指向堆顶节点,比较 cur 节点和左右儿子,如果 cur 节点小于等于所有儿子节点,则堆已经调整完成。否则,选择两个儿子中的较小值与 cur 节点的值交换,并将 cur 指向交换了值的儿子节点。
  4. 重复步骤三,直到 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)为满足堆属性的数据结构。

将数组原地建立成堆,有两种思路:

  1. 向已建成的堆中不断插入元素:假设初始堆只有数组下标为 0 一个元素,使用堆的插入操作,将下标 1 到 n 的元素插入到堆中。
  2. 自后向前处理数组,每个数据都是从上往下堆化。假设堆大小为 n,最后一个节点的下标为 n - 1,它的父节点下标为 n − 2 2 ( n ≥ 2 ) \frac{n-2}{2}(n\ge{2}) 2n2(n2),即下标大于 n − 2 2 \frac{n-2}{2} 2n2 的节点都是叶子节点。我们只需要从下标 n − 2 2 \frac{n-2}{2} 2n2到 0,每个节点向下堆化,就能基于数组原地建堆。

第二种方式不是太好理解,我画了如下示意图:
堆化
图中展示了无序数组堆化为小顶堆的详细过程:

  • 堆中有 7 个元素,因此需要从下标为 7 − 2 2 = 2 \frac{7-2}{2}=2 272=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=211+222+...+2hh=2h+1h2h+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} 2h1,调整的总操作数为 2 h − 1 ⋅ 1 2^{h-1}\cdot{1} 2h11。以此类推,第 k 层所有节点向下堆化,需要执行的总操作为 2 k ⋅ ( h − k ) 2^{k}\cdot{(h-k)} 2k(hk)。需要进行比较的次数: 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=20h+21(h1)+...+2h11=2(h+1)(h+2)=Nlog2(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} h2h。因此,弹出整棵二叉堆,总操作树为: 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=211+222+...+2hh=2h+1h2h+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)

总结

  1. 堆排序是一个时间复杂度 O ( N l o g N ) O(N logN) O(NlogN)原地排序算法
  2. 不是稳定的排序算法,因为涉及到将堆最后一个元素交换至堆顶的操作,值相等的元素可能发生交换;
  3. 堆排序的性能总体上不如快速排序,这里有两个原因:
    • 堆排序的访问方式不能很好利用 CPU Cache,因为它是类似 i i i 2 ⋅ i + 1 2\cdot{i} + 1 2i+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

这道题的解答步骤如下:

  1. 建立小顶堆,将数组中元素 num 逐个加入小顶堆:
    (1) 如果堆中元素少于 k 个,直接添加;
    (2) 如果堆中元素等于 k 个,比较 num 和堆顶元素 top。如果 num 小于等于 top,遍历下一个数组元素;如果 num 大于 top,弹出堆顶元素,将 num 插入小顶堆。
  2. 遍历完数组中元素后,返回堆顶元素,该元素即数组的第 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. 维护一个大顶堆,一个小顶堆;大顶堆存储数据集合中较小的那一半数据,小顶堆存储较大的那一半数据。(这里约定大顶堆的元素不少于小顶堆,且大顶堆的元素个数至多比小顶堆多 1
  2. 新增元素 num,假设大顶堆堆顶为 maxHeapTop,小顶堆堆顶为 minHeapTop,分别讨论如下情况:
    • 大顶堆大小与小顶堆相同,大顶堆的元素需要增加。
      • 如果 num 小于等于 minHeapTop,说明 num 小于小顶堆中所有元素,num 插入大顶堆即可;
      • 如果 num 大于 minHeapTop,则需要弹出小顶堆堆顶,将 num 插入小顶堆,minHeapTop 插入大顶堆;
    • 大顶堆比小顶堆多一个元素,小顶堆的元素需要增加。
      • 如果 num 大于等于 maxHeapTop,说明 num 大于大顶堆中所有元素,num 插入小顶堆即可;
      • 如果 num 小于 maxHeapTop,则需要弹出大顶堆堆顶,将 num 插入大顶堆,maxHeapTop 插入小顶堆;
  3. 查看数据流中位数:
    • 如果大顶堆比小顶堆多一个元素,返回大顶堆堆顶 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)
  1. 首先创建一个 Timer 实例;
  2. 创建 TimerTask 任务,使用者需要扩展 run方法来定义任务;
  3. 以 fixed-delay 模式执行定时任务,延迟 1s 后第一次执行,随后每次执行任务完成后的 2s,再次执行该任务。

如果需要停止所有当前已安排的任务,可以使用 Timer#cancel 方法:

timer.cancel();

另外介绍下Timer类提供的两种基于周期(period)参数的任务调度模式:fixed-ratefixed-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弹出堆顶元素。

fixDownfixUp 方法分别向下和向上维护小顶堆属性,比较依据为任务预计的下一次执行时间 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)
}
  1. 从优先队列 queue 中获取执行时间戳最小的任务task,得到预期执行时间 executionTime。
  2. 获取当前时间currentTime,如果当前时间戳已经大于等于任务预期执行时间 executionTime,检查任务的 period 属性。
    • 如果 period 为 0,说明任务不需要重复执行,将 task 从优先队列中出队。
    • 如果 period 大于0,任务处于 fixed-rate 模式,下一次任务执行的时间由本轮任务的【预期开始执行的时间】确定,即executionTime + period
    • 如果 period 小于0,任务处于 fixed-delay 模式,下一次执行任务的时间由本轮任务的【实际开始执行时间】确定,即 currentTime - period
  3. 如果当前时间戳 currentTime 小于任务预期开始执行时间 executionTime,使用 Object#wait 方法等待 executionTime - currentTime 毫秒。(其它线程调用 Timer#schedual 方法加入新任务时也会通过 notify 唤醒 Timer 线程,因为此时堆顶的任务(下一个被调度的任务)已经发生了改变)
  4. 如果堆顶的任务满足 2 中的条件,则调用task.run
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值