Caffeine TimerWheel时间轮 深度解析:O(1)复杂度增删和触发时间事件

Kafka以另一种简洁方式实现了时间轮,见:

Kafka 时间轮深度解析

TimerWheel(时间轮)是一种高效的数据结构,专门用于管理大量的定时事件。它的核心思想源于现实世界中的时钟,通过将时间划分为不同的"格子"(buckets),并将定时事件放入对应时间点的格子里,从而避免了对所有事件进行频繁排序的开销。

在 Caffeine 中,TimerWheel 的主要职责是管理缓存条目(Node)的过期事件。当一个条目被设置为在未来某个时间点过期时,它就被"安排"到时间轮上。时间轮会随着时间的推移而"转动",当指针扫过某个格子时,这个格子里的所有事件就被触发(即条目过期)。

TimerWheel 是 Caffeine 实现高效、可伸缩的过期策略的基石。通过精巧的​​分层时间轮算法​ ​、​​位运算​ ​和​​哨兵节点​​等技术,它能够在 O(1) 的摊销时间内完成定时事件的调度和处理,完美支撑了 Caffeine 的高性能特性(处理高并发和大量缓存条目的场景)。

核心数据结构与分层设计

Caffeine 的 TimerWheel 采用了一种更高级的​​分层时间轮(Hierarchical Timer Wheel)​​设计,以应对跨度极大的过期时间(从几秒到几天)。这通过几个关键的静态常量和成员变量来实现:

  • wheel: final Node<K, V>[][]:这是一个二维数组,是时间轮的核心。第一维代表不同的"轮",每个轮具有不同的时间精度(例如,秒轮、分钟轮、小时轮)。第二维是该轮的"桶"(buckets),每个桶代表一个时间片。
  • BUCKETS: static final int[]:定义了每个轮有多少个桶。 { 64, 64, 32, 4, 1 } 表示第一个轮有64个桶,第二个轮有64个,以此类推。
  • SPANS: static final long[]:定义了每个轮的每个桶所代表的时间跨度(以纳秒为单位)。这些值被设计为2的幂,以便使用位运算进行快速计算。例如,SPANS[0] 大约是1.07秒,SPANS[1] 大约是1.14分钟。
  • SHIFT: static final long[]:预先计算好的位移量,用于通过位运算(>>>)快速确定一个给定的过期时间应该落在哪个轮的哪个桶里。
java 复制代码
  static final long[] SPANS = {
      ceilingPowerOfTwo(TimeUnit.SECONDS.toNanos(1)), // 1.07s
      ceilingPowerOfTwo(TimeUnit.MINUTES.toNanos(1)), // 1.14m
      ceilingPowerOfTwo(TimeUnit.HOURS.toNanos(1)),   // 1.22h
      ceilingPowerOfTwo(TimeUnit.DAYS.toNanos(1)),    // 1.63d
      BUCKETS[3] * ceilingPowerOfTwo(TimeUnit.DAYS.toNanos(1)), // 6.5d
      BUCKETS[3] * ceilingPowerOfTwo(TimeUnit.DAYS.toNanos(1)), // 6.5d
  };
  static final long[] SHIFT = {
      Long.numberOfTrailingZeros(SPANS[0]),
      Long.numberOfTrailingZeros(SPANS[1]),
      Long.numberOfTrailingZeros(SPANS[2]),
      Long.numberOfTrailingZeros(SPANS[3]),
      Long.numberOfTrailingZeros(SPANS[4]),
  };

​分层逻辑​ ​:

当一个事件被调度时,比如它将在80分钟后过期。直接把它放在秒级的轮上效率很低(需要转很多圈)。分层设计会根据过期时长,将其放入一个更粗粒度的轮上,比如"小时轮"。当时间推进,"小时轮"转动一格时,这一格里的所有事件会被取出,并根据它们剩余的精确时间,重新调度到更细粒度的"分钟轮"或"秒轮"中。这个过程称为​​层级降级(cascading)​​。

时间轮工作过程的直观类比

可以想象成 多层级时钟 :

  • 秒针层 (第0层):64个桶,每桶1.07秒,转一圈≈70秒

  • 分针层 (第1层):64个桶,每桶1.14分钟,转一圈≈73分钟

  • 时针层 (第2层):32个桶,每桶1.22小时,转一圈≈39小时

  • ...更高层级以此类推

运行机制 :

  1. 初始放置 :新条目根据过期时间放入对应层级的桶(如2小时后过期的放入第2层)

  2. 时间推进 : advance() 方法定期被调用,推进当前时间【桶是静止的,只是时间的指针移动,想一想 钟表的刻度 和 分针】

  3. 桶级联 :当某层的当前桶时间到达,会将桶内所有条目:

    1. 已过期的:调用 cache.evictEntry() 移除

    2. 未过期的:重新计算并放入更细粒度的下层桶

  4. 最终过期 :条目逐渐从高层级"下沉"到低层级,最终在秒针层过期

核心优势

  • 时间复杂度 :所有操作(添加/移除/过期)均为O(1)摊销复杂度

  • 内存效率 :通过分层结构避免了单一时间轮的巨大空间开销

  • 精度平衡 :高层级粗粒度+低层级细粒度,兼顾长短期过期需求

  • 并发安全 :通过哨兵节点和双向链表实现无锁化桶操作

构造与初始化

TimerWheel() 的构造函数非常直接:

java 复制代码
@SuppressWarnings({"rawtypes", "unchecked"})
TimerWheel() {
    wheel = new Node[BUCKETS.length][];
    for (int i = 0; i < wheel.length; i++) {
        wheel[i] = new Node[BUCKETS[i]];
        for (int j = 0; j < wheel[i].length; j++) {
            wheel[i][j] = new Sentinel<>();
        }
    }
}

它根据 BUCKETS 的定义创建了二维数组。最关键的一步是,每个桶(wheel[i][j])都被初始化为一个 Sentinel 对象。Sentinel 是一个特殊的 Node,它充当每个桶内双向链表的​​哨兵节点​​。使用哨兵节点可以极大地简化链表的插入和删除操作,避免了对头节点和尾节点的特殊处理。

TimerWheel 和 cache

简单来说,它们是 分工协作 的关系:

  1. TimerWheel (时间轮) :扮演着一个 高效的定时器 或 调度器 的角色。它不存储缓存的完整键值对,只关心一件事:管理和追踪缓存条目( Node )的 到期时间 。它的设计目标是以 O(1) 的摊销时间复杂度处理大量的定时事件。

  2. Cache (缓存实现) :这是 缓存的核心 。它负责存储实际的键值对数据(在一个 ConcurrentHashMap 中),并执行所有核心的缓存操作,比如读、写以及最终的驱逐(eviction)动作。

当缓存需要清理过期条目时(通常在写入操作后触发),它会调用 timerWheel.advance() 方法, advance 方法会根据流逝的时间计算出哪些时间桶(bucket)已经过期,然后调用 expire 方法来处理这些桶。

总结一下关系 :

  • Cache 是"老板",它持有并管理着所有数据。

  • TimerWheel 是老板雇佣的"秘书",专门负责管理日程表(条目何时过期)。

  • 到了预定时间,秘书( TimerWheel )会把到期的日程( Node )拿出来,并告诉老板( BoundedLocalCache ):"这个条目该处理了"。

  • 老板( cache.evictEntry() )接到通知后,亲自去执行驱逐操作。

  • 如果秘书发现某个日程还没到精确时间,或者老板处理失败,秘书会把这个日程重新放回日程表( schedule(node) ),等待下次检查。

通过这种方式,Caffeine 实现了一个高效、低锁竞争的过期策略。 TimerWheel 保证了时间管理的效率,而 BoundedLocalCache 则专注于数据的存储和最终一致性。

advance 方法

时间轮本身是被动的,它需要一个外部的"动力"来推进。这个动力就是 advance 方法。

java 复制代码
public void advance(BoundedLocalCache<K, V> cache, @Var long currentTimeNanos) {
    @Var long previousTimeNanos = nanos;
    nanos = currentTimeNanos;

    // If wrapping then temporarily shift the clock for a positive comparison. We assume that the
    // advancements never exceed a total running time of Long.MAX_VALUE nanoseconds (292 years)
    // so that an overflow only occurs due to using an arbitrary origin time (System.nanoTime()).
    if ((previousTimeNanos < 0) && (currentTimeNanos > 0)) {
        previousTimeNanos += Long.MAX_VALUE;
        currentTimeNanos += Long.MAX_VALUE;
    }

    try {
        for (int i = 0; i < SHIFT.length; i++) {
            long previousTicks = (previousTimeNanos >>> SHIFT[i]);
            long currentTicks = (currentTimeNanos >>> SHIFT[i]);
            long delta = (currentTicks - previousTicks);
            if (delta <= 0L) {
                break;
            }
            expire(cache, i, previousTicks, delta);
        }
    } catch (Throwable t) {
        nanos = previousTimeNanos;
        throw t;
    }
}

功能分析

  1. ​更新时间​:方法首先将内部时钟 nanos 更新为传入的 currentTimeNanos。
  2. ​处理时钟回绕​:System.nanoTime() 可能回绕(从正数变为负数)。代码通过将负数和正数都加上 Long.MAX_VALUE 来处理这种情况,确保时间差的计算是正确的。这是一个巧妙的设计,可以处理大约292年的连续运行时间而不会出错。
  3. ​分层处理​:TimerWheel 是分层的,每一层代表不同的时间粒度(由 SPANS 和 SHIFT 定义)。advance 方法会从最精确的轮子(i = 0)开始遍历到最粗糙的轮子。
  4. ​计算时间差(Ticks)​:对于每一层轮子,它通过位移(>>> SHIFT[i])将纳秒时间转换为该层的"滴答数"(ticks)。然后计算从上次 advance 调用到现在的滴答数差 delta。
  5. ​触发过期​ :如果 delta > 0,意味着时间至少前进了一个滴答,就需要处理这期间到期的条目。于是,它调用 expire 方法。
  6. ​提前退出​:如果某一层的 delta <= 0,说明在这一层及更粗粒度的层级上,时间并未前进一个滴答,因此可以直接 break 循环,优化性能。

expire

这是 advance 调用的核心子函数,负责处理指定轮子中到期的桶。

java 复制代码
void expire(BoundedLocalCache<K, V> cache, int index, long previousTicks, long delta) {
    Node<K, V>[] timerWheel = wheel[index];
    int mask = timerWheel.length - 1;

    // We assume that the delta does not overflow an integer and cause negative steps. This can
    // occur only if the advancement exceeds 2^61 nanoseconds (73 years).
    int steps = Math.min(1 + (int) delta, timerWheel.length);
    int start = (int) (previousTicks & mask);
    int end = start + steps;

    for (int i = start; i < end; i++) {
        Node<K, V> sentinel = timerWheel[i & mask];
        Node<K, V> prev = sentinel.getPreviousInVariableOrder();
        @Var Node<K, V> node = sentinel.getNextInVariableOrder();
        sentinel.setPreviousInVariableOrder(sentinel);
        sentinel.setNextInVariableOrder(sentinel);

        while (node != sentinel) {
            Node<K, V> next = node.getNextInVariableOrder();
            node.setPreviousInVariableOrder(null);
            node.setNextInVariableOrder(null);

            try {
                if (((node.getVariableTime() - nanos) > 0)
                    || !cache.evictEntry(node, RemovalCause.EXPIRED, nanos)) {
                    schedule(node);
                }
                node = next;
            } catch (Throwable t) {
                // ... existing code ...
            }
        }
    }
}

功能分析

  1. ​确定范围​:计算需要处理的桶的范围。start 是上次处理的最后一个桶的下一个,steps 是需要处理的桶的数量(等于 delta + 1,但不能超过整个轮子的大小)。
  2. ​遍历到期桶​:循环遍历从 start 到 end 的所有桶。
  3. ​清空桶​:对于每个桶,它首先将哨兵节点的 next 和 prev 指针都指向自己,从而在O(1)时间内将整个桶的链表"清空"。
  4. ​处理节点​:然后遍历刚刚从桶中取出的链表中的每一个节点(node)。
  5. ​重新调度或驱逐​
    • 检查是否真的过期:((node.getVariableTime() - nanos) > 0) 检查节点的精确过期时间是否仍然在未来。如果一个事件被放在一个粗粒度的轮子里,当这个轮子的桶到期时,事件的精确时间可能还未到。在这种情况下,需要将它重新调度到更精确的轮子中。
    • 尝试驱逐:如果节点确实到期了,就调用 cache.evictEntry() 来尝试驱逐它。evictEntry 返回 false 可能意味着该条目已被用户手动删除或因其他原因(如容量限制)被驱逐。
    • 重新调度:如果节点尚未到期,或者驱逐失败(意味着它仍然"存活"),则调用 schedule 方法将其重新放入时间轮的正确位置。

schedule

schedule 负责将一个节点放入时间轮。

java 复制代码
public void schedule(Node<K, V> node) {
    Node<K, V> sentinel = findBucket(node.getVariableTime());
    link(sentinel, node);
}

Node<K, V> findBucket(@Var long time) {
    long duration = Math.max(0L, time - nanos);
    if (duration == 0L) {
        time = nanos;
    }

    int length = wheel.length - 1;
    for (int i = 0; i < length; i++) {
        if (duration < SPANS[i + 1]) {
            long ticks = (time >>> SHIFT[i]);
            int index = (int) (ticks & (wheel[i].length - 1));
            return wheel[i][index];
        }
    }
    return wheel[length][0];
}

功能分析

  1. ​schedule​ :它的逻辑很简单,调用 findBucket 找到正确的桶,然后调用 link 将节点添加到该桶的双向链表的末尾。
  2. ​findBucket​ :这是决定节点存放位置的关键。
    • 它首先计算出节点的过期时间与当前时间的差值 duration。
    • 然后,它从最精确的轮子开始查找,if (duration < SPANS[i + 1]) 这个判断是为了找到能容纳这个 duration 的最精确的轮子。
    • 一旦找到合适的轮子(i),它就会计算出在该轮子中的 ticks 和桶的索引 index。
    • 如果一个事件的过期时间非常遥远,超过了除最后一个轮子之外所有轮子的最大跨度,它将被放入最后一个轮子的第一个桶中,作为一个"蓄水池"。

总结

advance 方法通过一个级联的、分层的过程来管理定时事件:

  1. advance 启动过程,根据当前时间计算出每个时间轮需要前进的"滴答数"。
  2. expire 负责处理这些到期的"滴答"对应的桶,将桶中的所有事件取出。
  3. 对于取出的每个事件,expire 会判断它是否真的到期。如果到期,则尝试驱逐;如果尚未到期(即"级联"),则调用 schedule。
  4. schedule 通过 findBucket 为事件计算出新的、更精确的位置,并将其放入相应的桶中。

这个设计确保了添加、删除和处理过期事件的平摊时间复杂度都是 O(1),因为处理一个桶的成本被分摊到了该桶所代表的整个时间跨度上。

getExpirationDelay()

返回 到下一个事件触发 的 时间

getExpirationDelaypeekAhead 协同实现高效延迟估算:

  • ​分层扫描​:从细到粗,优先处理最紧急事件。
  • ​精确与保守结合​:精确计算当前层级延迟,保守估计更高层级延迟。
  • ​立即返回​:避免不必要计算,减少 CPU 空转。

此机制使 Caffeine 的过期调度器能"智能"休眠,在保证及时性的同时最小化资源消耗。

java 复制代码
@SuppressWarnings({"IntLongMath", "Varifier"})
public long getExpirationDelay() {
  for (int i = 0; i < SHIFT.length; i++) {
    Node<K, V>[] timerWheel = wheel[i];
    long ticks = (nanos >>> SHIFT[i]);
    
    long spanMask = SPANS[i] - 1;
    int start = (int) (ticks & spanMask);
    int end = start + timerWheel.length;
    int mask = timerWheel.length - 1;
    
    for (int j = start; j < end; j++) {
      Node<K, V> sentinel = timerWheel[(j & mask)];
      Node<K, V> next = sentinel.getNextInVariableOrder();
      if (next == sentinel) {
        continue;
      }
      
      long buckets = (j - start);
      @Var long delay = (buckets << SHIFT[i]) - (nanos & spanMask);
      delay = (delay > 0) ? delay : SPANS[i];

      for (int k = i + 1; k < SHIFT.length; k++) {
        long nextDelay = peekAhead(k);
        delay = Math.min(delay, nextDelay);
      }
      return delay;
    }
  }
  return Long.MAX_VALUE;
}

工作流程分析

  1. ​分层遍历(外层循环)​ ​:for (int i = 0; i < SHIFT.length; i++)

    • 从最精确的时间轮(i=0,通常为秒级)开始,逐级遍历到最粗糙的时间轮。
    • ​核心思想​:找到所有层级中第一个非空桶,其条目是下一个最可能过期的。
  2. ​桶内扫描(内层循环)​ ​:for (int j = start; j < end; j++)

    • 对当前层级 i 的时间轮,从当前时间 nanos 对应的桶(start)开始扫描一圈。
    • ticks = (nanos >>> SHIFT[i]):计算当前时间在当前轮子下的"滴答数"。
    • start = (int) (ticks & spanMask):通过位掩码计算当前"滴答数"对应的桶索引。
  3. ​寻找非空桶​ ​:if (next == sentinel)

    • 检查哨兵节点 sentinel 的下一个节点是否为自身,若为空则继续扫描。
  4. ​计算延迟(核心逻辑)​​:

    • buckets = (j - start):计算目标桶与当前桶的距离。
    • delay = (buckets << SHIFT[i]) - (nanos & spanMask)
      • (buckets << SHIFT[i]):将桶距离转换为纳秒(目标桶起始时间 - 当前桶起始时间)。
      • (nanos & spanMask):当前时间在当前桶内已经过的时间。
    • delay = (delay > 0) ? delay : SPANS[i]:若 delay ≤ 0(目标桶为当前桶),则保守设置为 SPANS[i]
  5. ​展望更高层级(调用 peekAhead)​ ​:for (int k = i + 1; k < SHIFT.length; k++)

    • 检查比当前轮更粗糙的层级(k > i),通过 peekAhead 获取预估延迟。
    • delay = Math.min(delay, nextDelay):取最小延迟,确保不遗漏任何过期事件。
  6. ​返回结果​​:

    • 找到非空桶后立即返回 delay(最近的过期时间)。
    • 若所有桶为空,返回 Long.MAX_VALUE(无待过期条目)。

peekAhead()

java 复制代码
long peekAhead(int index) {
  long ticks = (nanos >>> SHIFT[index]);
  Node<K, V>[] timerWheel = wheel[index];
  
  long spanMask = SPANS[index] - 1;
  int mask = timerWheel.length - 1;
  int probe = (int) ((ticks + 1) & mask);
  
  Node<K, V> sentinel = timerWheel[probe];
  Node<K, V> next = sentinel.getNextInVariableOrder();
  return (next == sentinel) ? Long.MAX_VALUE : (SPANS[index] - (nanos & spanMask));
}
  1. ​定位下一个桶​probe = (int) ((ticks + 1) & mask)
    • 仅检查下一个待处理的桶(ticks + 1)。
  2. ​检查是否为空​next = sentinel.getNextInVariableOrder()
    • 若下一个桶为空,返回 Long.MAX_VALUE
  3. ​返回预估延迟​
    • 若非空,返回 SPANS[index] - (nanos & spanMask)(当前桶剩余时间)。
    • ​保守估计​:假设下一个桶的条目会在当前桶结束后立刻过期。

为什么 peekAhead 只检查下一个桶?

peekAhead 的作用是为更高层级的轮( k > i )提供一个 快速且保守的延迟估算 。它只检查下一个桶是基于以下事实:

  • 精确轮次整个跨度不超过下一层级的一个Span。

  • 时间轮的级联机制 :当时间轮通过 advance() 方法向前推进时,如果一个高层级轮的桶过期了,里面的所有节点都会被取出,并根据它们精确的过期时间重新调度( schedule )到更低、更精确的层级轮中。

  • 有序性保证 :这个级联机制保证了,对于任何一个 k 层的轮,所有比"当前时间格"更早的节点都已经被移动到了 k-1 层或更低的层级。因此,在 k 层轮中,我们唯一需要担心的、可能比 i 层( i < k )结果更早的,就是 紧邻当前时间点的下一个桶 中的节点。

时间说明

首先,最关键的一点是, TimerWheel 内部的时间戳 nanos (通常来自 System.nanoTime() ) 是一个 单调递增 的值,就像汽车的里程表一样,它只会前进,不会倒退或循环。它不是我们日常生活中"今天下午3点"和"明天下午3点"那样的循环时间。

所以,即使最细粒度的秒轮盘(wheel[0])转了一圈又一圈,底层的 nanos 时间戳已经变得非常大了。系统通过比较节点自身的过期时间 node.getVariableTime() 和当前的 timerWheel.nanos 来判断是否到期,这是一个绝对时间的比较,因此不会混淆。

桶的索引是通过 (时间戳 >>> SHIFT) & mask 计算的,这确实是一个循环操作。一个桶会被一次又一次地重复使用。

那么,如何保证一个旧的、很久以前的事件不会和一个新的、即将到期的事件混在同一个桶里呢?

答案就在 advance 方法中。每次调用 advance(currentTimeNanos) 时,它会计算出从上次推进到的时间 previousTimeNanos 到 currentTimeNanos 之间经过了多少"滴答"(ticks)。然后,它会 遍历并处理这期间所有过期的桶 。

  • 处理过程 :对于每个过期的桶,它会清空里面的所有节点。

    • 如果节点真的到期了( node.getVariableTime() <= currentTimeNanos ),就执行驱逐操作。

    • 如果节点还没到期(这通常发生在从高层轮盘级联下来时),就调用 schedule 方法,根据它最新的过期时间把它重新放入更精确的、更底层的轮盘中。

这个"清空并级联"的机制保证了当一个桶即将被"循环使用"时,它里面所有陈旧的事件都已经被处理完毕了。因此,桶里永远只会有在当前时间周期内有效的事件。

理解了以上两点, peekAhead 的逻辑就清晰了。 peekAhead 的目的是为了快速、保守地估算一个上层(更粗粒度)时间轮中的下一个事件还有多久。

为什么它只检查"下一个"桶( (ticks + 1) & mask )就足够了呢?

因为 advance() 已经保证了,任何位于 当前桶或之前桶 的事件,如果它们的精确过期时间已经到来,那么它们早就被级联到下层轮盘了。换句话说,一个粗粒度轮盘(比如"小时"轮)的当前桶里,不可能存在一个"本分钟"就该过期的事件,它一定已经被移动到"分钟"或"秒"轮里了。

因此,当 getExpirationDelay 在检查完"秒"轮后,想知道"分钟"轮里会不会有更早的事件时,它只需要 peekAhead 一下"分钟"轮的下一个桶就够了。这提供了一个安全的、最差情况下的延迟估计。 getExpirationDelay 会取所有轮盘估算出的延迟中的最小值,从而确保找到全局的最小延迟,让调度器可以精确地休眠。

总结 :

  • 单调时间 : nanos 保证了时间的绝对前进性。

  • 循环索引 :桶通过 & mask 被循环使用。

  • advance() :是时间轮的核心驱动力,通过"级联"机制,确保了在桶被复用前,旧事件已被妥善处理,从而保证了时间的正确性。

  • peekAhead() :是一个基于 advance() 所建立的安全性的优化,它通过快速查看下一个可能的时间槽来提供一个保守的延迟估计,避免了全面搜索的高昂代价。

Node<K, V>:缓存条目的核心抽象

Node 是一个抽象类,定义了 Caffeine 缓存中条目(Entry)的基本特征和行为,是键值对及其元数据的"集装箱"。

​核心职责与功能​​:

  1. ​数据承载​​:

    • getKey():获取键(Key)
    • getValue():获取值(Value)
      这些是 Node 作为缓存条目的基本职责
  2. ​生命周期管理​​:

    • isAlive():节点是否存活(在哈希表和淘汰策略中都有效)
    • isRetired():节点是否已从哈希表中移除(等待从淘汰策略中清理)
    • isDead():节点是否已彻底死亡(从哈希表和淘汰策略中都已移除)
    • retire(), die():改变节点生命周期状态的方法
      这些状态精确追踪了缓存条目从创建到销毁的全过程
  3. ​权重(Weight)管理​​:

    • getWeight(), setWeight():支持基于权重的淘汰策略,允许为不同条目设置不同"成本"
  4. ​多种排序/淘汰策略支持​​:

    • 通过实现多个接口维护多套双向链表指针:
      • ​访问顺序 (AccessOrder)​get/setPrevious/NextInAccessOrder()等方法,支持LRU/LIRS等策略
      • ​写入顺序 (WriteOrder)​get/setPrevious/NextInWriteOrder()等方法,支持FIFO等策略
      • ​可变顺序 (Variable order)​get/setPrevious/NextInVariableOrder()get/setVariableTime()等方法,支持基于时间的过期策略(如TimerWheel)

​实现特点​ ​:

Node 是抽象类,默认方法抛出UnsupportedOperationException,具体子类根据缓存策略动态实现功能,体现高度灵活的设计。


Sentinel<K, V>:时间轮中的"哨兵"

Sentinel 是一个静态内部类,它继承自 Node,但重写了所有方法,使其成为一个空操作或返回默认值。每个 Sentinel 实例代表一个桶内双向链表的头部。它的 nextprev 指针在初始化时都指向自己,形成一个空的循环链表。当新节点加入时,会被插入到 sentinel.prevsentinel 之间,极大地简化了边界条件的处理。

​定义​ ​:
TimerWheel内部的static final类,继承自Node,作为特殊的哨兵节点(非实际缓存条目)。

​核心职责与功能​​:

  1. ​链表头/尾节点​​:

    • 在TimerWheel中,每个"桶"(Bucket)是一个由Sentinel引领的双向链表
    • 构造函数Sentinel() { prev = next = this; }创建空循环链表(当桶为空时,哨兵节点的指针指向自身)
  2. ​简化链表操作​​:

    • 哨兵模式优势:链表永不"空"(至少含Sentinel节点),统一插入/删除操作逻辑,无需空指针判断
    • 重写get/setPrevious/NextInVariableOrder()方法,使其能作为链表节点操作
  3. ​无实际数据​​:

    • 重写数据相关方法:
      • getKey()/getValue()返回null
      • getKeyReference()/getValueReference()抛出异常
      • isAlive()/isRetired()/isDead()固定返回false
        明确表明其仅作为结构性工具

Node 与 Sentinel 的关系

角色 比喻 功能 设计意义
Node "士兵" 存储实际缓存数据和元信息,参与淘汰策略 实现缓存核心功能
Sentinel "哨兵" 不存储数据,仅作为链表头节点简化操作(如TimerWheel中的桶管理) 提升链表操作效率,降低边界条件处理

在TimerWheel中,wheel数组的每个槽位存放一个Sentinel实例。当Node因过期时间被调度时,会插入对应桶的Sentinel管理的链表中。

Traverser:抽象的遍历器基类

TimerWheel 实现了 Iterable<Node<K, V>> 接口,提供了两种遍历方式:

  • AscendingIterator:从最快要过期的节点(在最细粒度的轮的当前桶中)开始,向最晚过期的节点遍历。它大致按照过期时间的升序进行。
  • DescendingIterator:与前者相反,从最晚要过期的节点(在最粗粒度的轮中)开始,向最早要过期的节点遍历。

这两个迭代器都设计为​​快速失败(fail-fast)​ ​的,如果在遍历过程中时间轮被 advance 修改,会抛出 ConcurrentModificationException

Traverser 是一个抽象内部类,它定义了在 TimerWheel 的多层级结构中进行遍历所需要的通用逻辑和基本框架。它实现了 Iterator<Node<K, V>> 接口,但将具体的遍历方向(升序或降序)和移动逻辑交给了子类实现。

核心设计与功能:

  1. ​并发修改检测​​:

    • final long expectedNanos;:在构造时,它会记录下当前 TimerWheel 的时间戳 nanos。
    • hasNext() 方法会在每次调用时检查 nanos != expectedNanos。如果时间轮的时间被 advance() 方法改变了,就意味着其内部结构可能已经发生了变化(节点被移除或重新调度),此时迭代器会抛出 ConcurrentModificationException,这是一种快速失败(fail-fast)机制,保证了迭代的安全性。
  2. ​迭代器基本逻辑​​:

    • 它实现了标准的 hasNext()next() 方法。hasNext() 的核心是调用 computeNext() 来预先查找并缓存下一个节点到 next 字段。next() 方法则返回缓存的节点并将其清空。
    • computeNext() 是遍历的核心,它在一个循环中不断尝试:
      1. 在当前桶内移动( traverse(node))。
      2. 如果当前桶遍历完,就移动到下一个桶(goToNextBucket())。
      3. 如果当前层级的轮子遍历完,就移动到下一个轮子(goToNextWheel())。
      4. 如果所有轮子都遍历完,则返回 null,表示迭代结束。
java 复制代码
    @Nullable Node<K, V> computeNext() {
      @Var var node = (current == null) ? sentinel() : current;
      for (;;) {
        node = traverse(node);
        if (node != sentinel()) {
          return node;
        } else if ((node = goToNextBucket()) != null) {
          continue;
        } else if ((node = goToNextWheel()) != null) {
          continue;
        }
        return null;
      }
    }

​抽象方法(留给子类实现)​​:Traverser 将具体的移动策略定义为一系列抽象方法,这使得它可以同时支持升序和降序两种遍历方式:

  • abstract boolean isDone();:判断整个迭代是否已经完成。
  • abstract Node<K, V> sentinel();:获取当前位置的哨兵节点,用于判断一个桶是否遍历完毕。
  • abstract Node<K, V> traverse(Node<K, V> node);:定义了在桶的链表中如何移动。升序迭代器会调用 node.getNextInVariableOrder(),而降序则会调用 getPreviousInVariableOrder()
  • abstract @Nullable Node<K, V> goToNextBucket();:移动到下一个桶。
  • abstract @Nullable Node<K, V> goToNextWheel();:移动到下一个时间轮。

AscendingIterator:具体的升序迭代器

AscendingIterator 是 Traverser 的一个具体实现,它提供了按时间​​升序​ ​(从最近到最远)遍历 TimerWheel 中所有节点的能力。TimerWheel 的 iterator() 方法返回的就是这个类的实例。

AscendingIterator 通过实现 Traverser 的抽象方法来定义其升序遍历行为:

  1. ​状态管理​​:

    • int wheelIndex:当前遍历到哪个时间轮(0 是最精确的,比如秒级)。
    • int steps:在当前时间轮上已经移动了多少个桶。
  2. ​方法实现​​:

    • isDone():当 wheelIndex 等于 wheel.length 时,表示所有层级的时间轮都已遍历完毕,迭代结束。
    • sentinel():根据当前的 wheelIndexbucketIndex() 计算结果,返回对应桶的 Sentinel 节点。
    • traverse(Node<K, V> node):核心的升序移动逻辑,它返回 node.getNextInVariableOrder(),即沿着链表的 next 指针方向前进。
    • goToNextBucket():将 steps 加一,如果还没超出当前轮的桶数,就返回新位置的哨兵节点。
    • goToNextWheel():将 wheelIndex 加一,如果还没超出时间轮的总层数,就将 steps 重置为 0,并返回新轮子中第一个桶的哨兵节点。
  3. ​bucketIndex() 的计算逻辑​ ​:

    这是 AscendingIterator 最复杂的逻辑之一。它需要计算出在当前时间 nanos 下,应该从哪个桶开始遍历。

    java 复制代码
    int ticks = (int) (nanos >>> SHIFT[wheelIndex]);
    int bucketMask = wheel[wheelIndex].length - 1;
    int bucketOffset = (ticks & bucketMask) + 1;
    return (bucketOffset + steps) & bucketMask;
    • 它首先计算出当前时间 nanos 在当前层级 wheelIndex 对应的"滴答数" ticks
    • bucketOffset 计算出当前时间所在的桶的下一个位置作为遍历的起点。
    • steps 记录了从起点开始已经走过的步数。
    • 通过 (bucketOffset + steps) & bucketMask 确保索引在桶数组的范围内循环。

int bucketOffset = (ticks & bucketMask) + 1;

  • ticks & bucketMask : 这行代码通过位运算计算出当前 ticks 对应的桶索引。例如,如果当前轮有 64 个桶,这个操作会返回 ticks % 64 的结果,范围是 0 到 63。

  • +1 : 这是 AscendingIterator (升序迭代器)的关键所在。迭代器需要遍历的是 未来 的事件。因此,它不从当前时间所在的桶开始,而是从 下一个桶 开始。 +1 操作就实现了这个"跳到下一个桶"的逻辑。

  • 目的 :计算出升序遍历的 起始桶索引 。

return (bucketOffset + steps) & bucketMask;

  • steps : 这是 AscendingIterator 的一个成员变量,记录了在当前时间轮中已经向前遍历了多少个桶。初始值为 0。

  • bucketOffset + steps : 将起始桶索引与已经走过的步数相加,得到当前应该访问的桶的逻辑位置。

  • & bucketMask : 再次使用位掩码进行取模运算。这确保了即使 bucketOffset + steps 的结果超过了桶的总数,也能正确地"回绕"(wrap around)到时间轮的开头,实现循环遍历。

  • 目的 :计算出本次迭代需要检查的 最终桶索引 。

DescendingIterator 的目标是 降序 遍历,即从最遥远的未来向当前时间回溯。它的 bucketOffset 计算没有 + 1 ,并且它是 - steps 。

  • 它的起点就是"当前桶"。

  • 它通过 - steps 从"当前桶"开始 向前 (即向时间戳更小的方向)回溯,实现降序遍历。

总结

Traverser 和 AscendingIterator 的设计体现了​​模板方法模式​​:

  • ​Traverser(模板)​ :定义了迭代的整体算法骨架(hasNextnextcomputeNext),但将易变的部分(如遍历方向、如何切换桶和轮子)抽象成方法。
  • ​AscendingIterator(具体实现)​:继承 Traverser 并实现了这些抽象方法,从而定义了具体的升序遍历行为。

这种结构使得代码逻辑清晰,复用性高。如果需要一个降序迭代器(DescendingIterator),只需再创建一个 Traverser 的子类并反向实现那些抽象方法即可,而无需重写整个迭代逻辑。

相关推荐
Christo36 小时前
TFS-1996《The Possibilistic C-Means Algorithm: Insights and Recommendations》
人工智能·算法·机器学习
xzkyd outpaper7 小时前
Java中协变逆变的实现与Kotlin中的区别
java·kotlin
fuyongliang1237 小时前
Linux shell 脚本基础 003
java·服务器·前端
地平线开发者8 小时前
理想汽车智驾方案介绍专题 3 MoE+Sparse Attention 高效结构解析
人工智能·算法·自动驾驶
小蒜学长8 小时前
汽车专卖店管理系统的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·汽车
pusue_the_sun9 小时前
C语言强化训练(1)
c语言·开发语言·算法
catcfm10 小时前
Java学习笔记-泛型
java·笔记·学习
老华带你飞11 小时前
社区互助|基于SSM+vue的社区互助平台的设计与实现(源码+数据库+文档)
java·前端·数据库·vue.js·小程序·毕设·社区互助平台