Java 后端每日学习 · 2026-09-08

Day 15

Map 阶段闯关变式:多租户消息重试调度索引的契约、状态推演、编码与源码解释。

请通过本地启动器或已部署的学习地址访问;页面加载后即使服务暂时中断,浏览器草稿与下载备份仍可使用。

Java 后端每日学习 · Day 15 · 2026-09-08

打开今日互动学习页:汇总五个课程章节,支持代码复制、复盘作答、浏览器草稿和本地 Markdown 保存。网页作答优先、聊天提交补充。

今日主题

Map 阶段闯关变式:多租户消息重试调度索引的契约、状态推演、编码与源码解释。

方向元数据

字段
directionId java-data-structures
directionSession 13
curriculumItemId challenge.map
lessonMode checkpoint
节点进入前状态 covered_unverified
完成本课后的预期状态 covered_unverified;生成第三个变式不等于闯关通过
当前方向状态 active

查看全局 Java 后端知识地图。固定同步助手对最近完整课程 Day 14 的拉取结果为 remote_missing,服务器与本地均没有 2026-09-06/04-复盘作答.md,当前任务也没有能明确映射到 Day 14 题号和问题源哈希的答案。因此答案来源为 none,不评分、不推断薄弱项或红线;challenge.map 保持 covered_unverified,今天继续在 Map 模块使用全新的消息重试数据与操作完成闯关变式。

2026-09-07 没有完整课程,不计入累计 Day 或方向课次;Day 15 直接保存在今天的日期目录中,不回填过去日期。

可验证目标

  1. 能从消息业务身份、缺失语义、重试顺序、诊断访问顺序、封闭状态域与所有权约束出发选择 Map,而不是先背实现类。
  2. 能对确定操作逐步推演事实主表、重试时间索引、状态计数、诊断窗口、API 返回值和复杂度,不依赖 HashMap 偶然顺序。
  3. 能完成 Java 21 必做编码,编译并产生规定输出,同时守住不可变复合键、比较器一致性、半开范围和稳定快照边界。
  4. 能沿 OpenJDK jdk-21+35 的公共入口、关键字段和主调用链解释 HashMapLinkedHashMapTreeMapEnumMap 以及条件更新的可观察行为。
  5. 能明确单张 Map 的条件更新、多张普通 Map 的连续写入、fail-fast 与业务事务/线程安全之间的边界。

60~75 分钟学习顺序

  1. 昨日复盘:Day 14 五题完整参考答案与仓储波次索引完整实现(约 12~14 分钟)。
  2. 核心讲解:用独立场景重组消息重试索引的 Map 决策、推演与源码因果链(约 31~32 分钟)。
  3. 编码练习:完成多租户消息重试调度索引;这是闯关通过的必交编码(约 18~20 分钟)。
  4. 复盘问题:按四个固定维度提交 Map 阶段闯关答案(约 8~9 分钟)。

总预计用时约 69~75 分钟。今天只验证 Map 模块,不提前讲 Set,也不把昨日参考答案算成大大的作答证据。

方向进度

  • 课前与课程完成后的标准课覆盖均为 10/3528.57%);今天是同一稳定闯关项的第三个变式,不重复计数。
  • 知识点覆盖保持 9/27;已评估 0、已掌握 0、待补强 0
  • 源码型知识点覆盖保持 4/9,已掌握仍为 0/9
  • Map 闯关保持“已出题待验证”:闯关已开始 1/6、通过 0/6;综合项目 0/1、方向总测 0/1
  • 无作答只表示尚未验证,不等于失败;也不能据此放行到 Set。

课程完成标准

  • 完成四部分学习,并能说明每项消息业务约束怎样影响键合同、Map 选型、顺序和所有权边界。
  • 将编码练习的 3 个 TODO 补全,以 javac --release 21 编译并运行,保存完整代码与规定输出。
  • 回答复盘页全部闯关题;回答必须映射到当天题号和问题源,不能用本文或昨日参考答案代替。
  • 不假设 HashMap 顺序稳定,不破坏 equals/hashCodeComparator 契约,不混淆活视图、只读包装与快照,也不把 fail-fast 当线程安全。

闯关放行条件

  • 总分至少 80/100;固定维度为:契约与选型 25、复杂度与状态推演 25、编码 30、源码讲解 20。
  • Java 21 编码必须成功编译、运行且产生预期结果;只写思路、缺少完整代码或输出不符均不能通过。
  • 六类红线均未命中。达到分数但编码失败或命中红线,仍视为未通过。
  • 今日课程生成后,challenge.map 仍只记 covered_unverified。只有后续取得完整、有效且满足全部条件的作答证据,才可记为 mastered 并进入 Set。

固定版本与官方资料

条件式下节预告

  • 若仍没有完整有效的闯关答案:challenge.map 保持未通过,下一完整学习日 Day 16 继续使用新数据和约束完成 Map 综合练习或闯关变式,绝不进入 Set。
  • 若出现部分有效答案且未暴露明确错误:逐题点评,challenge.map 记为 practicing,保留验证债务并继续 Map 闯关,不虚构总分。
  • 若完整答案低于 80、Java 21 编码不通过,或任何有效答案暴露明确错误或红线:challenge.map 与明确薄弱的 1~3 个知识点记为 needs_review,方向记为 needs_review;Day 16 优先换数据、约束和场景补强,再重闯。
  • 只有完整答案达到至少 80、编码编译运行符合预期且无红线:challenge.map 才记为 mastered,并依据充分证据更新相关 Map 知识点;Day 16 才可进入 ds.set.contract-algebra

返回今日索引

昨日复盘:仓储拣货波次 Map 闯关核对

复盘对象:Day 14(2026-09-06),评估项 challenge.map。建议用时:12~14 分钟。先用 1~2 分钟确认第 1 节证据与推进边界,遮住答案用 5~6 分钟口述第 2 节,再用约 6 分钟核对第 3 节三个 TODO、精确输出和复杂度。下文全部是教学参考答案,不构成大大的作答、分数、闯关通过或掌握证据

1. 作答证据、逐题评分与推进结论

  • 远端同步结果:remote_missing,服务器上没有 Day 14 的 04-复盘作答.md;本地也没有该文件。
  • Day 14 问题源哈希:sha256:d3f951abc76931f4475e31c8c996b927931e7d2f1b33afc57023032f537467ee
  • 当前任务中没有能明确映射到 Day 14、题号和上述问题源哈希的聊天答案。
  • 答案来源:none
  • 五题均为未作答、未评分;没有有效证据可给分,缺答也不能直接记为 0 分。
  • 总分:不足以评分
  • 红线:未知。没有证据证明命中,也没有证据证明已避开六类红线。
  • 薄弱知识 ID:无法从无作答证据中确定,因此不虚构错题或针对性薄弱项。
  • 状态变化:challenge.map 保持 covered_unverified,Map 闯关仍为未通过;方向保持 active
  • 推进边界:今天必须继续使用新业务数据完成 challenge.map 综合练习或闯关变式,不得进入 Set。阅读、复制或运行下方参考答案均不会改变状态。
题号 固定维度 分值 大大的有效答案 点评与得分
1 契约与选型 25 无法核对复合键、比较全序、遇见顺序、null 边界与事实来源,未评分
2 复杂度与状态推演 12 无法核对 nk 对应成本、扩容树化与 fail-fast 边界,未评分
3 复杂度与状态推演 13 无法核对半开边界、活视图、快照及多索引原子性,未评分
4 编码 30 未提交完整 Java 21 代码、编译证据和规定输出,编码维度未评分,闯关不可放行
5 源码讲解 20 无法核对固定版本的公开入口、关键字段、主路径与可观察结果,未评分
合计 100 证据不足 不足以评分

无作答时最有效的补强路线是:先独立写出第 1~3 题的约束与状态推演,再在不看参考实现的情况下完成三个 TODO,最后用第 5 题的源码路径反向解释程序输出。这只是通用复习建议,不代表已经发现大大的确定错误。

2. Day 14 五道闯关题完整参考答案

第 1 题:先把四类查询和状态不变量固定下来

  1. 事实主表使用 HashMap<WaveKey, PickingWave>WaveKey(warehouseId, waveNo) 是不可变 record,两个组件共同参与 equals/hashCode;只有仓库和波次号都相同才是同一业务键,所以 WH-A/701WH-B/701 能同时存在。键、值、仓库和阶段均不接受 null,因此本题中 get 返回 null 可以解释为缺失;若以后允许 null value,就必须配合 containsKey 消歧。
  2. 待派发索引使用 TreeMap<DispatchKey, WaveKey>DispatchKey 依次按截止分钟升序、优先级降序、仓库升序和波次号升序比较;合法键上 compareTo==0 恰好表示四个组件相等,避免同分钟波次被静默覆盖。其遇见顺序来自比较契约,不是插入顺序。
  3. 首次接收审计使用插入顺序的 LinkedHashMap<WaveKey, PickingWave>。普通读取不重排,替换既有 key 的 value 也不改变 key 的位置;它不是访问顺序缓存,不能用一次 get 推导顺序变化。
  4. 阶段计数使用 EnumMap<WaveStage, Integer>。键域封闭且按枚举声明顺序迭代,null key 被拒绝;QUEUED 减到零时删除该计数项,避免保留无意义的零值。
  5. byKey 是唯一事实来源;调度树、阶段计数和接收顺序都是可校验、可重建的派生索引。四张普通 Map 的连续写入不是事务,生产环境必须以单线程所有权、同一把锁或数据库事务包住完整状态转换,并设计重建或补偿。
  6. WeakHashMap 会让条目生命周期受键可达性和非确定性 GC 影响;IdentityHashMap== 而不是业务值相等匹配,反序列化出来的等值新键会查不到旧波次。二者都不符合稳定业务身份的事实主表合同。

这些选择没有依赖 HashMap 的偶然遍历顺序,也没有让派生索引反客为主。若业务不再需要某类查询,应删除对应派生索引以减少一致性成本,而不是为了展示集合类型保留无用状态。

第 2 题:复杂度要覆盖一次业务操作的最贵索引

设事实表有 n 个波次,截止窗口命中 k 个:

  • 复合键精确查询的期望时间为 O(1);哈希分布、碰撞和桶结构会影响实际成本,因此不是“每次严格 O(1)”。
  • 首次接收先做主表条件写入,期望 O(1),再插入调度树 O(log n),枚举计数与插入顺序表更新为常数或期望 O(1),所以整次接收为 O(log n)。
  • 派发首项需要在导航树中定位并删除一个候选,主要成本为 O(log n);事实 value 替换、阶段计数和接收顺序 value 替换为常数或期望 O(1),整次仍为 O(log n)。没有候选项时,边界定位仍至多 O(log n)。
  • 范围定位后固化 k 条记录为 O(log n + k),返回的字符串快照额外占 O(k) 空间。
  • 阶段计数的枚举键域固定,单次读取或更新按 O(1) 理解;替换 acceptedOrder 中已有 value 的期望成本为 O(1),且不会改变插入顺序。
  • 四张索引只保存常数份键、值或引用,索引总空间为 O(n);一次接收或派发除索引节点外只使用 O(1) 临时空间。

固定 OpenJDK jdk-21+35 中,碰撞桶达到树化请求条件后,treeifyBin 还会检查表容量:容量小于 MIN_TREEIFY_CAPACITY=64 时优先扩容,达到门槛后长链才可能树化。超过阈值触发的 resize 要迁移或拆分已有桶,碰撞查找也可能沿链或树前进,所以 HashMap 只能给出正常分布下的期望 O(1),不能承诺每次严格 O(1)。fail-fast 只是迭代器对结构修改的尽力检测,不提供互斥、内存可见性或复合操作原子性,因此不是线程安全机制。

第 3 题:严格上界决定候选,活视图与快照随后分叉

三个波次写入调度树后的比较顺序为:

  1. WH-A/10@280#P4
  2. WH-B/9@280#P1
  3. WH-A/9@300#P2

原因是截止分钟先升序;同为 280 时优先级 4 排在优先级 1 前。[280,301) 导航活视图取得时包含以上三项,投影出的不可变字符串快照也按这个顺序保存三项当时的 QUEUED 状态。

随后执行 dispatchNext(300):上界 300 是严格排除,因此截止分钟恰为 300 的 WH-A/9 不是候选。小于 300 的两项中,树的首项是 WH-A/10@280#P4,所以它被派发。派发后的状态为:

  • 被派发键:WH-A/10
  • 调度索引顺序:WH-B/9@280#P1WH-A/9@300#P2
  • 阶段计数:QUEUED=2DISPATCHED=1,遇见顺序来自枚举声明顺序。
  • 首次接收顺序:WH-A/9WH-B/9WH-A/10;替换第三项 value 不会移动它。
  • 旧活视图当前内容:WH-B/9@280#P1WH-A/9@300#P2。派发通过该视图删除首项并写穿根 TreeMap,所以旧视图立刻反映删除;其原范围上界 301 仍包含截止 300 的项。
  • 旧快照内容仍是 WH-A/10@280#P4:QUEUEDWH-B/9@280#P1:QUEUEDWH-A/9@300#P2:QUEUED。它保存投影时的字符串,不随事实状态或调度树变化。

以上连续更新在题设的单线程所有权内维持不变量,但它们跨越四张 Map;任何一次进程失败或并发穿插都可能留下主表与派生索引不一致。因此必须在更外层提供覆盖整次转换的原子边界,不能把 putIfAbsentcomputeIfPresent 或活视图写穿误称为跨 Map 事务。

第 4 题:三个 TODO 的不变量与实现策略

  • 接收:先验证非空和初始阶段,再用复合键对 byKey 执行“缺失才放入”。旧值存在时立即返回 false,三张派生索引完全不动;成功后才维护调度树、枚举计数和首次接收表。
  • 半开窗口:用两个专用边界键创建 subMap(lower, true, upper, false),按比较顺序回查事实值,并立即投影成不可变字符串列表;后续派发既不能改变列表结构,也不能改写已有字符串。
  • 派发:用严格截止上界创建 headMap,读取并校验第一个候选;从活视图删除会写穿根调度树,再以新的不可变 record 替换事实 value、迁移计数并替换审计表 value。
  • 原子性:本题按单线程顺序执行,多张 Map 的连续成功只验证当前实现的不变量;单 Map 条件 API 不会自动把后续派生索引更新升级成并发事务。

第 3 节给出与 Day 14 起始代码一致的完整参考类、编译命令和精确输出。

第 5 题:从公开入口连接固定源码与可观察行为

固定版本为 OpenJDK jdk-21+35

要解释的问题 公开入口 关键字段 最多四个箭头节点的主路径 可观察结果或扩展点
HashMap 精确查询及新键、同键更新、删除 getputremove tablesizemodCount get→getNode→桶首/链/树匹配put→putVal→相等替换或新增remove→removeNode→摘链/树删除 等值键更新 value 且 size 不增;新键和成功删除改变结构;遇见顺序不受保证
容量阈值、扩容拆分、树化和迭代检测 put、集合视图的 iterator thresholdloadFactortablemodCountexpectedModCount putVal→size 超阈值→resize→按旧容量位拆高低链treeifyBin→容量检查→扩容或树化nextNode→检查 modCount 容量不足 64 时长链优先扩容;迭代器只尽力快速失败,不提供线程安全
LinkedHashMap 的插入/访问顺序分支与最老项钩子 getput headtailaccessOrder get→afterNodeAccess→按配置移动节点putVal→afterNodeInsertion→removeEldestEntry 插入顺序模式下读取或替换 value 不重排;访问顺序模式可移到尾部,子类可决定淘汰最老项
TreeMap 的比较定位、范围视图和写穿边界 putsubMapheadMap rootsizecomparator、范围端点 put→逐节点 compare→替换或插入并平衡subMap/headMap→范围视图→边界导航/写穿 compare==0 表示同一排序键;视图按比较顺序迭代,删除写穿根表,越界插入失败
EnumMap 的枚举键域表示与计数更新 putgetmerge keyTypekeyUniversevalssize put→typeCheck→key.ordinal→maskNull 后写槽位 枚举声明顺序稳定;同一枚举常量覆盖同一槽位,null key 被拒绝,null value 用内部哨兵区分

源码路径用于解释固定实现如何产生公开现象,不能把私有节点形状、树化阈值或 fail-fast 扩张成业务排序合同、线程安全或跨集合事务。可核对 HashMap.javaLinkedHashMap.javaTreeMap.javaEnumMap.java

3. Day 14 主练习完整可运行参考实现

下面只补全原起始代码的三个 TODO,不改变测试数据,不添加第三方依赖、日志、无恢复意义的 try/catch 或多余公开 API。实现遵循题设的单线程所有权;关键注释专门标明跨 Map 一致性边界。

import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.TreeMap;

public final class PickingWaveChallenge {
    private enum WaveStage {
        QUEUED,
        DISPATCHED
    }

    private record WaveKey(String warehouseId, long waveNo) {
        WaveKey {
            Objects.requireNonNull(warehouseId, "warehouseId");
            if (warehouseId.isBlank() || waveNo <= 0) {
                throw new IllegalArgumentException("invalid wave key");
            }
        }

        String label() {
            return warehouseId + "/" + waveNo;
        }
    }

    private record PickingWave(
            WaveKey key,
            long cutoffMinute,
            int priority,
            int cartonCount,
            WaveStage stage) {
        PickingWave {
            Objects.requireNonNull(key, "key");
            Objects.requireNonNull(stage, "stage");
            if (cutoffMinute < 0
                    || priority < 1
                    || priority > 9
                    || cartonCount <= 0) {
                throw new IllegalArgumentException("invalid wave data");
            }
        }

        PickingWave dispatched() {
            return new PickingWave(
                    key,
                    cutoffMinute,
                    priority,
                    cartonCount,
                    WaveStage.DISPATCHED);
        }

        String snapshotLine() {
            return key.label() + "@" + cutoffMinute
                    + "#P" + priority + ":" + stage + ":" + cartonCount;
        }
    }

    private record DispatchKey(
            long cutoffMinute,
            int priority,
            String warehouseId,
            long waveNo) implements Comparable<DispatchKey> {
        static DispatchKey from(PickingWave wave) {
            return new DispatchKey(
                    wave.cutoffMinute(),
                    wave.priority(),
                    wave.key().warehouseId(),
                    wave.key().waveNo());
        }

        static DispatchKey boundary(long minute) {
            return new DispatchKey(
                    minute,
                    Integer.MAX_VALUE,
                    "",
                    Long.MIN_VALUE);
        }

        @Override
        public int compareTo(DispatchKey other) {
            int byCutoff = Long.compare(cutoffMinute, other.cutoffMinute);
            if (byCutoff != 0) {
                return byCutoff;
            }
            int byPriority = Integer.compare(other.priority, priority);
            if (byPriority != 0) {
                return byPriority;
            }
            int byWarehouse = warehouseId.compareTo(other.warehouseId);
            return byWarehouse != 0
                    ? byWarehouse
                    : Long.compare(waveNo, other.waveNo);
        }
    }

    private static final class WaveIndex {
        private final Map<WaveKey, PickingWave> byKey = new HashMap<>();
        private final NavigableMap<DispatchKey, WaveKey> dispatchIndex =
                new TreeMap<>();
        private final EnumMap<WaveStage, Integer> stageCounts =
                new EnumMap<>(WaveStage.class);
        private final LinkedHashMap<WaveKey, PickingWave> acceptedOrder =
                new LinkedHashMap<>();

        private boolean enqueue(PickingWave wave) {
            Objects.requireNonNull(wave, "wave");
            if (wave.stage() != WaveStage.QUEUED) {
                throw new IllegalArgumentException(
                        "new wave must be queued");
            }
            if (byKey.putIfAbsent(wave.key(), wave) != null) {
                return false;
            }

            // 主表去重成功后才写派生索引;生产环境需要覆盖整段的原子边界。
            dispatchIndex.put(DispatchKey.from(wave), wave.key());
            stageCounts.merge(WaveStage.QUEUED, 1, Integer::sum);
            acceptedOrder.put(wave.key(), wave);
            return true;
        }

        private List<String> cutoffSnapshot(
                long fromInclusive,
                long toExclusive) {
            if (fromInclusive > toExclusive) {
                throw new IllegalArgumentException(
                        "fromInclusive must be <= toExclusive");
            }
            NavigableMap<DispatchKey, WaveKey> window =
                    dispatchIndex.subMap(
                            DispatchKey.boundary(fromInclusive), true,
                            DispatchKey.boundary(toExclusive), false);
            return window.values().stream()
                    .map(key -> Objects.requireNonNull(
                            byKey.get(key),
                            "dispatch index must reference an existing wave"))
                    .map(PickingWave::snapshotLine)
                    .toList();
        }

        private String dispatchNext(long beforeExclusive) {
            if (beforeExclusive < 0) {
                throw new IllegalArgumentException(
                        "beforeExclusive must be >= 0");
            }
            NavigableMap<DispatchKey, WaveKey> candidates =
                    dispatchIndex.headMap(
                            DispatchKey.boundary(beforeExclusive), false);
            Map.Entry<DispatchKey, WaveKey> first = candidates.firstEntry();
            if (first == null) {
                return "NONE";
            }

            WaveKey key = first.getValue();
            PickingWave current = byKey.get(key);
            if (current == null || current.stage() != WaveStage.QUEUED) {
                throw new IllegalStateException(
                        "dispatch index is inconsistent with fact map");
            }

            candidates.remove(first.getKey());
            PickingWave dispatched = byKey.computeIfPresent(
                    key,
                    (ignored, existing) -> existing.dispatched());
            int queued = stageCounts.merge(
                    WaveStage.QUEUED, -1, Integer::sum);
            if (queued == 0) {
                stageCounts.remove(WaveStage.QUEUED);
            }
            stageCounts.merge(WaveStage.DISPATCHED, 1, Integer::sum);
            acceptedOrder.put(key, dispatched);
            return key.label();
        }

        private int cartonsOf(WaveKey key) {
            PickingWave wave = byKey.get(Objects.requireNonNull(key, "key"));
            return wave == null ? -1 : wave.cartonCount();
        }

        private List<String> dispatchOrder() {
            return dispatchIndex.entrySet().stream()
                    .map(entry -> entry.getValue().label()
                            + "@" + entry.getKey().cutoffMinute()
                            + "#P" + entry.getKey().priority())
                    .toList();
        }

        private List<String> acceptedOrder() {
            return acceptedOrder.keySet().stream()
                    .map(WaveKey::label)
                    .toList();
        }

        private List<String> countSummary() {
            return stageCounts.entrySet().stream()
                    .map(entry -> entry.getKey() + "=" + entry.getValue())
                    .toList();
        }
    }

    public static void main(String[] args) {
        WaveKey warehouseAFirst = new WaveKey("WH-A", 701);
        WaveKey warehouseBFirst = new WaveKey("WH-B", 701);
        WaveKey warehouseASecond = new WaveKey("WH-A", 702);
        WaveKey warehouseCFirst = new WaveKey("WH-C", 88);

        PickingWave first = new PickingWave(
                warehouseAFirst, 480, 2, 12, WaveStage.QUEUED);
        PickingWave second = new PickingWave(
                warehouseBFirst, 450, 1, 20, WaveStage.QUEUED);
        PickingWave third = new PickingWave(
                warehouseASecond, 450, 3, 8, WaveStage.QUEUED);
        PickingWave fourth = new PickingWave(
                warehouseCFirst, 510, 5, 6, WaveStage.QUEUED);

        WaveIndex index = new WaveIndex();
        System.out.println("ENQUEUE first=" + index.enqueue(first));
        System.out.println("ENQUEUE second=" + index.enqueue(second));
        System.out.println("ENQUEUE third=" + index.enqueue(third));
        System.out.println("ENQUEUE fourth=" + index.enqueue(fourth));
        System.out.println("ENQUEUE duplicate=" + index.enqueue(first));
        System.out.println("BY-KEY WH-A/701="
                + index.cartonsOf(warehouseAFirst));
        System.out.println("BY-KEY WH-B/701="
                + index.cartonsOf(warehouseBFirst));

        List<String> beforeDispatch = index.cutoffSnapshot(450, 500);
        System.out.println("WINDOW before=" + beforeDispatch);
        System.out.println("COUNTS before=" + index.countSummary());
        System.out.println("ACCEPTED before=" + index.acceptedOrder());

        System.out.println("DISPATCH before-480="
                + index.dispatchNext(480));
        System.out.println("COUNTS after=" + index.countSummary());
        System.out.println("SCHEDULE after=" + index.dispatchOrder());
        System.out.println("ACCEPTED after=" + index.acceptedOrder());
        System.out.println("SNAPSHOT unchanged=" + beforeDispatch);
    }
}

三个待补位置的关键步骤

  1. enqueue 在第一次写入前校验非空和 QUEUED,随后让 byKey.putIfAbsent 决定复合键是否首次出现。重复键立刻返回,保证调度树、阶段计数和接收顺序均无副作用;成功后才一次维护三个派生索引。
  2. cutoffSnapshot 用“同一分钟内排在所有合法业务键之前”的边界键表达半开范围。下界包含边界且真实键都在它之后,所以收进下界分钟;上界排除边界且该分钟真实键都在它之后,所以整分钟被排除。Stream.toList() 返回不可修改列表,元素又是当时生成的字符串,因此后续状态替换不会回写旧快照。
  3. dispatchNext 从严格上界视图取得首项,先回查事实来源并验证仍为 QUEUED。从视图删除会写穿根树;事实表用已有条件更新 API 生成 DISPATCHED record,随后迁移枚举计数并替换 acceptedOrder 的 value,key 的首次接收位置保持不动。

精确编译与运行输出

javac --release 21 PickingWaveChallenge.java
java PickingWaveChallenge

运行输出必须为:

ENQUEUE first=true
ENQUEUE second=true
ENQUEUE third=true
ENQUEUE fourth=true
ENQUEUE duplicate=false
BY-KEY WH-A/701=12
BY-KEY WH-B/701=20
WINDOW before=[WH-A/702@450#P3:QUEUED:8, WH-B/701@450#P1:QUEUED:20, WH-A/701@480#P2:QUEUED:12]
COUNTS before=[QUEUED=4]
ACCEPTED before=[WH-A/701, WH-B/701, WH-A/702, WH-C/88]
DISPATCH before-480=WH-A/702
COUNTS after=[QUEUED=3, DISPATCHED=1]
SCHEDULE after=[WH-B/701@450#P1, WH-A/701@480#P2, WH-C/88@510#P5]
ACCEPTED after=[WH-A/701, WH-B/701, WH-A/702, WH-C/88]
SNAPSHOT unchanged=[WH-A/702@450#P3:QUEUED:8, WH-B/701@450#P1:QUEUED:20, WH-A/701@480#P2:QUEUED:12]

两个仓库的 701 同时存在,证明复合键没有串仓;同一分钟内 P3P1 之前,来自明确比较契约。严格上界 480 没有派发截止恰为 480 的项;旧快照仍显示复制时刻的 QUEUED;派发只替换已接收 key 的 value,没有改变首次接收顺序。

时间与空间复杂度

  • enqueue 的主表条件写入期望 O(1),调度索引插入 O(log n),整次为 O(log n)。
  • cutoffSnapshot 的边界定位加命中项投影为 O(log n + k),稳定字符串快照额外空间为 O(k)。
  • dispatchNext 的候选定位和树删除为 O(log n),事实替换、两类计数与审计 value 替换为常数或期望 O(1),整次为 O(log n)。
  • cartonsOf 期望 O(1);dispatchOrder 遍历剩余调度项为 O(n);acceptedOrder 遍历全部接收项为 O(n);countSummary 的枚举键域固定,按 O(1) 理解。
  • 四张索引的总空间为 O(n)。以上期望复杂度不等于最坏碰撞、并发安全或跨 Map 事务保证。

边界复核

  • 两个仓库的同号波次互不覆盖;完全相同的复合键重复接收返回 false,三张派生索引不变。
  • [480,480) 返回空快照;下界大于上界在读取或写入业务状态前失败。
  • dispatchNext(0) 返回 NONE;上界 480 严格排除截止恰为 480 的波次。
  • 相同分钟、相同优先级的不同仓库或波次号仍能由完整比较器区分,不能只比较分钟和优先级。
  • 快照列表不可增删,后续派发不会改写旧字符串;导航活视图则会跟随根树。
  • acceptedOrder 采用插入顺序,普通读取和已有 value 替换均不重排。
  • 生产环境需要明确覆盖四张 Map 的所有权、锁、事务或补偿边界;fail-fast 不提供这些能力。

4. 今日重闯前的最短自检

  • 能否在 60 秒内说明四张 Map 的职责,并指出 byKey 才是事实来源?
  • 能否从截止分钟、优先级、仓库和波次号推出全序,而不借助 HashMap 的打印结果?
  • 能否推演旧活视图与旧快照在派发后的不同内容,并解释 300 严格上界?
  • 能否解释为什么接收、派发整体是 O(log n),而不是看到主表就回答 O(1)?
  • 能否沿 put→putValtreeifyBin/resizeafterNodeAccess、TreeMap 范围视图和 EnumMap ordinal 槽位各说出一个可观察结果?
  • 能否明确承认当前没有有效作答,所以 challenge.map 仍是 covered_unverified,绝不能提前进入 Set?

如有一项含糊,先用新的复合键、时间边界和状态数据重新推演,再做今天的闯关变式。这里的完整参考答案始终只用于教学核对,不会被记录为大大的答案证据。

返回今日索引

返回今日索引

Day 15 核心讲解:Map 闯关变式 3——多租户消息重试调度索引

评估项:challenge.map  方向课次:13  模式:checkpoint  建议用时:31~33 分钟  版本基线:Java 21、OpenJDK jdk-21+35

challenge.map 当前仍为 covered_unverified,尚无足够证据放行到 Set。本讲用“会话幂等键 + 重试时间索引 + 访问顺序诊断缓存 + EnumMap 状态计数”完成独立推演;讲解程序的数据与动作不同于今日编码闯关,不包含主练习待补位置的完整实现。

1. 为什么需要:重试系统最怕“单张表看起来正确”

消息投递失败后,后端通常既要按租户和幂等号精确定位任务,又要找到“当前最应该重试”的任务,还要展示最近诊断记录并统计有限状态。把这些需求全部塞进一张 HashMap 会立即遇到三个问题:它没有按时间范围导航的合同;遍历顺序不能承担调度优先级;一个 value 同时承载事实、排序与统计后,很难说明失败到一半时哪些数据可信。

反过来,简单地摆出四张 Map 也不够。若幂等键可变,事实任务会在桶中“失联”;若 TreeMap 比较器只比较重试分钟,同一分钟的不同租户会静默覆盖;若把 keySet() 当快照返回,调用者看到的历史报告会随底表变化;若维护四张 Map 的连续写入在第三步失败,单次 put 成功并不能证明业务转换完整。Map 闯关检验的正是这条闭环:从业务不变量推出键合同和实现选型,再用状态、复杂度、源码与可运行代码相互验证。

九个 Map 知识项在这里各有位置:ds.map.contract 管缺失、null 与返回值;ds.map.equality 管幂等身份;ds.map.hashmap-structure 以及 put/get/remove、resize/treeify/iterator 两组源码知识解释精确索引的结构行为;ds.map.compute-merge-views 约束条件更新、计数和所有权;LinkedHashMap、TreeMap 与 specialized Map 分别负责明确顺序、导航查询和特殊键语义。少掉任一环,都可能得到“样例输出对,但业务合同错”的方案。

2. 前置知识:先把消息需求写成六条不变量

设一次投递任务的业务身份为“租户 ID + 会话幂等号”。消息正文、失败次数、下次重试分钟和当前状态都会变化,因此都属于 value,而不能进入事实 key。今天先约定:事实表禁止 null key 和 null value;只接受有限枚举状态;重试时间相同时按优先级降序,再按租户和幂等号升序形成全序。

由此得到六条可验证不变量:

  1. RetryKey(tenantId, idempotencyKey) 必须不可变,equalshashCode 使用同一组身份字段;不同租户可以复用相同幂等号。
  2. byKey.get(key)==null 只有在 value 禁止为 null 时才能直接表示缺失;若业务允许 null value,就必须以 containsKey 消歧。
  3. RetryOrder(nextMinute, priority, tenantId, idempotencyKey) 的比较结果为 0 时,必须确实代表同一个排序键,不能只比较分钟。
  4. 重试调度的遇见顺序来自 TreeMap 的比较合同;最近诊断顺序来自 access-order LinkedHashMap;二者都不能从 HashMap 的一次打印结果猜测。
  5. 范围视图适合在索引所有者内部继续操作;跨层返回必须投影并复制成稳定结果。只读包装会阻止经包装引用写入,却仍可能随原容器变化,不能冒充不可变快照。
  6. byKey 是事实来源,调度树、诊断缓存和状态计数都是可重建投影。跨 Map 原子性必须由外层所有者、共同锁、不可变状态交换或持久化事务提供。

复杂度也要绑定业务动作。事实表精确查询通常按期望 O(1) 讨论;向重试树插入或删除为 O(log n);定位范围再复制命中的 k 项为 O(log n + k);访问顺序缓存的常规读取与移动为期望 O(1);枚举域大小固定时,状态计数可按常数规模理解。一次登记同时写入事实表和重试树,因此整体由树操作主导为 O(log n),不能只挑最快的 HashMap 操作回答。

随堂检查 1:equals 比较租户和幂等号,hashCode 只使用租户,一定违反合同吗?

**即时答案:**不一定违反“相等对象必须同哈希”的最低合同,但会让同租户任务大量碰撞,散列质量很差。若 hashCode 反而加入 nextMinute,而 equals 不比较它,等值 key 更新重试时间后可能得到不同哈希,才是确定违约。正确方案是两者都围绕同一组不可变身份字段。

3. 定义:四维闯关证据要落到同一套事实

固定维度 分值 本场景必须证明的内容 对应 Map 知识
契约与选型 25 幂等身份、null、调度全序、诊断顺序、专用 Map 的适用边界 contract、equality、LinkedHashMap、TreeMap、specialized
复杂度与状态推演 25 返回值、size、桶/阈值、范围结果、活视图、状态迁移 structure、put/get/remove、resize/treeify/iterator、views
编码 30 Java 21 编译运行,重复登记、半开边界、调度和稳定快照均符合预期 compute/merge/views 与实现协作
源码讲解 20 问题→公开入口→关键字段→主链→扩展点→可观察结论 固定 jdk-21+35 锚点

“事实”表示任务当前业务状态;“索引”表示为了某种查询而保存的投影。HashMap<RetryKey, RetryTask> 可以是事实来源,TreeMap<RetryOrder, RetryKey> 只是按重试规则排列仍待处理的键,LinkedHashMap 只是受控容量内的诊断访问窗口,EnumMap 只是状态计数。若调度树中存在一个 key,而事实表已经没有对应任务,应暴露不变量破坏并修复来源流程,不能默默把派生表提升为第二真相源。

组合 API 也有边界。putIfAbsent 可表达事实表“缺失才登记”,computeIfPresent 可表达“存在才转换”,merge 可表达一个状态槽位上的累计;它们简化的是当前 Map 的单键分支。普通 HashMap 并不因使用这些 API 而线程安全;即使换成 ConcurrentHashMap,单键原子 API 也不会自动覆盖另一个键、另一张 Map 或数据库写入。

4. 心智模型:身份、查询、顺序、所有权四层分开

第一层问“什么变化后仍是同一条任务”。重试次数从 1 变 2、状态从 WAITINGCLAIMED、下次分钟从 115 变 130,任务仍由租户与幂等号定位,所以这些可变字段只能进入不可变 value 的新版本,不能修改已入表 key。Java record 很适合表达不可变复合键,但 record 只替你生成基于组件的相等与哈希,组件本身仍应具有稳定值语义。

第二层问“调用者做精确、邻近还是范围查询”。精确幂等判断适合 HashMap;“取早于某时刻的首项”和 [from,to) 窗口适合 NavigableMap/TreeMap;封闭状态适合 EnumMap。WeakHashMap 的条目可能随 key 可达性与 GC 消失,不适合可靠重试事实;IdentityHashMap== 判断键,两个反序列化得到的等值幂等键会被视为不同实例,也不适合领域身份。

第三层问“顺序由什么合同产生”。TreeMap 按 Comparator 或自然顺序遇见;插入顺序 LinkedHashMap 记录首次加入顺序;访问顺序 LinkedHashMap 在成功访问后把条目移动到尾部。HashMap 只承诺映射关系,不承诺业务顺序。比较器还必须形成全序:若只比较 nextMinutepriority,同一分钟同优先级的两个任务会得到 0,后一次 put 会替换前一次映射。

第四层问“引用由谁拥有”。subMapheadMapkeySetvaluesentrySet 通常是背靠原 Map 的视图,适合内部联动操作;List.copyOf(...) 或将不可变字段投影后调用 toList() 才能隔离后续结构变化。Collections.unmodifiableMap(root) 只是禁止经这个包装器修改,持有 root 的一方仍可改变它,因此它与真正不可变副本不是同一概念。

5. 完整数值与数据状态推演:三条重试任务怎样变化

先看 HashMap 的结构层。假设 table 容量为 32、负载因子 0.75、threshold=24,初始 size=23。令任务 A 的扰动后 hash 为 6,任务 B 为 38;二者在容量 32 时都落桶 6,因为 31 & 6 = 631 & 38 = 6

步骤 操作 公开结果 结构状态
1 put(keyA, taskA) 返回 null 新增节点,size=24,尚未超过阈值
2 put(equalA, taskA2) 返回旧 taskA hash/equals 命中并替换 value,size 仍为 24
3 put(keyB, taskB) 返回 null 新增节点,size=25,超过阈值并触发扩容
4 容量扩至 64 无业务返回值 6 & 32 = 0 留桶 6;38 & 32 != 0 移到桶 38
5 remove(equalA) 返回 taskA2 按同一合同找到并摘除 A,size=24

一次 resize 需要处理旧 table 的节点,因此“每次严格 O(1)”不成立。若碰撞桶达到树化请求门槛,固定 OpenJDK 21 还会检查 MIN_TREEIFY_CAPACITY=64;容量不足会优先扩容,容量达到条件后才可能树化。树化只改善严重碰撞下的桶内查找,无法修复会变的 key 或错误的相等合同。

再看业务层。按接收顺序写入三条 WAITING 任务:

  • A:tenant-a/idem-7@120#P2
  • B:tenant-b/idem-7@115#P1
  • C:tenant-a/idem-8@115#P5

复合幂等键使 A 与 B 虽然都叫 idem-7 仍能并存。调度比较规则是分钟升序、优先级降序、租户与幂等号升序,所以重试树确定为 [C@115#P5, B@115#P1, A@120#P2];这不是插入顺序,也不是 HashMap 顺序。EnumMap{WAITING=3},access-order 诊断缓存初始为 [A,B,C]

接着用一个内容相等的新 RetryKey("tenant-a","idem-7") 再登记 A。putIfAbsent 返回旧值,事实 size 仍为 3;正确业务流程必须立即结束,不能再次给 WAITING 加一或重写调度树。随后取得 [115,121) 的导航活视图,并投影出字符串快照 S=[C,B,A]。读取 B 的诊断详情后,access-order 缓存变为 [A,C,B]

现在执行“领取严格早于 120 的第一项”。半开上界排除 A;C 在 115 分钟且优先级最高,因此被领取。事实 value 替换为 CLAIMED,调度树移除 C,计数变为 {WAITING=2, CLAIMED=1},诊断缓存访问 C 后变为 [A,B,C]。先前活视图当前只剩 [B,A],因为它背靠调度树;快照 S 仍是领取前的 [C,B,A],因为字符串已复制。每个顺序都来自明确合同,没有一个结论需要猜 HashMap 桶次序。

最后检查故障点:若事实已改为 CLAIMED,删除调度键成功,但计数更新前进程退出,三张投影就互相矛盾。单线程只能避免同进程内并发交错,不能避免进程失败;多张普通 Map 连续写也不是事务。真实服务应根据故障模型选择共同锁和补偿、单线程事件所有者、不可变聚合状态一次交换,或数据库事务与可重建索引。

随堂检查 2:取得 headMap(boundary(120), false) 后,底层 TreeMap 删除首项,旧 headMap 会自动少一项吗?旧的 List.copyOf(headMap.values()) 呢?

**即时答案:**headMap 是联动视图,会反映底表删除;先前创建的独立不可变 List 保留复制时刻内容。只读包装若仍背靠原对象,则会像视图一样看到原对象变化,不能和副本混为一谈。

6. 源码映射:按问题追主链,而不是背文件名

固定版本为 OpenJDK jdk-21+35。源码题可用下面六列组织答案:

要解释的问题 公开入口 / 方法 关键字段 主调用链(最多四个节点) 扩展点 应回到的公开结论
精确查询、同键替换与删除 HashMap.get/put/remove tablesizemodCount get→getNode→桶首/树/链匹配put→putVal→替换或新增remove→removeNode→摘除 newNode/afterNodeAccess 供关联实现参与 相等键替换返回旧值且 size 不增;顺序无保证
扩容、树化和迭代检测 put、视图 iterator thresholdloadFactormodCountexpectedModCount putVal→size 检查→resize→低高链拆分treeifyBin→容量检查→扩容或树化 TreeNode 路径 fail-fast 是尽力检测结构修改,不是互斥或可见性保证
条件更新与视图 putIfAbsent/compute/merge/keySet 当前 Map 的 table 与修改计数 公共 API→单键查找→回调/替换/新增→结构计数 回调函数、集合视图 只约束当前 Map;回调不可随意结构修改同一 Map;视图背靠底表
最近访问顺序 LinkedHashMap.get/put headtailaccessOrder get→afterNodeAccess→摘链→接到尾部 removeEldestEntry 插入顺序与访问顺序是两种明确模式,不等于线程安全缓存
重试导航与半开窗口 TreeMap.put/subMap/headMap rootcomparator、范围边界 put→逐节点 compare→替换/插入→fixAfterInsertionsubMap→范围视图→边界导航 自定义 Comparator compare==0 视为同一排序键;视图写穿且遇见顺序由比较器决定
枚举状态槽位 EnumMap.get/put/merge keyTypekeyUniversevalssize put→typeCheck→ordinal→写数组槽位 固定枚举键域 遇见顺序为枚举声明顺序;null key 被拒绝

可直接核对这些官方锚点:HashMap.get/getNodeHashMap.put/putValHashMap.resize/treeifyBinHashMap.computeIfAbsentLinkedHashMap.afterNodeAccessTreeMap.getFloorEntryEnumMap.get/put

调试练习可以在 HashMap.putVal 的“相等键”与“新增节点”分支各停一次,观察 size/modCount 的差异;把初始容量改小后在 resize 观察旧容量位如何拆分节点;在 LinkedHashMap.afterNodeAccess 比较 access-order 开关;在 TreeMap 比较器处观察 C、B 同分钟为何不比较为 0。每次都要把内部现象收束回 API 合同,不能把阈值、节点形状或当前遍历次序升级成跨版本业务承诺。

专用 Map 也应从语义而非速度判断。WeakHashMap.Entry 的弱键生命周期不适合可靠任务;IdentityHashMap.hash/get 使用实例身份,不适合反序列化业务键;EnumMap 的枚举域恰好适合状态计数。所谓 specialized 不是“更高级”,而是身份、生命周期或键域合同更窄。

7. 真实后端应用:事实、调度和诊断必须有共同边界

在真实消息网关中,请求入口先校验租户、幂等号、优先级和时间范围,再尝试建立事实任务。成功登记后才派生调度条目与状态计数;重复幂等请求返回已有任务标识,不重复增加计数。调度线程只从有序索引领取候选,但领取前仍回查事实状态,避免陈旧派生项成为事实。

读取路径也需要单一版本视图。若 API 先从事实 HashMap 读状态,再从另一个时刻的 TreeMap 读调度位置,即使两次单独读取都没有异常,也可能拼出不存在于任何时刻的组合结果。读多写少且数据有界时,可以先构造完整不可变聚合状态,再一次发布新引用;写多或需要跨进程恢复时,通常应让数据库事务、队列表的条件更新和可重建索引承担一致性,而不是在内存里手写脆弱补偿。

访问顺序 LinkedHashMap 只能作为小型诊断缓存,并应明确容量、淘汰和并发所有权。它不是消息事实,也不是持久重试队列。若 key 数量无界、重试任务必须跨重启保留,正确结论应是换用持久化存储,而不是继续给 Map 叠加功能。

下面的 Java 21 程序把四种集合合同彼此隔离验证:它没有 acceptdueSnapshotclaimNext 业务方法,也没有实现多索引状态转换,因此不会给出今日编码闯关三个待补位置的答案:

import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.TreeMap;

public final class RetryContractsDemo {
    private enum RetryState {
        WAITING,
        CLAIMED,
        SUCCEEDED,
        DEAD
    }

    private record RetryKey(String tenantId, String idempotencyKey) {
        RetryKey {
            Objects.requireNonNull(tenantId, "tenantId");
            Objects.requireNonNull(idempotencyKey, "idempotencyKey");
            if (tenantId.isBlank() || idempotencyKey.isBlank()) {
                throw new IllegalArgumentException("blank retry key");
            }
        }

        String label() {
            return tenantId + "/" + idempotencyKey;
        }
    }

    private record RetryOrder(
            long nextMinute,
            int priority,
            String tenantId,
            String idempotencyKey) implements Comparable<RetryOrder> {
        static RetryOrder boundary(long minute) {
            return new RetryOrder(minute, Integer.MAX_VALUE, "", "");
        }

        @Override
        public int compareTo(RetryOrder other) {
            int byMinute = Long.compare(nextMinute, other.nextMinute);
            if (byMinute != 0) {
                return byMinute;
            }
            int byPriority = Integer.compare(other.priority, priority);
            if (byPriority != 0) {
                return byPriority;
            }
            int byTenant = tenantId.compareTo(other.tenantId);
            return byTenant != 0
                    ? byTenant
                    : idempotencyKey.compareTo(other.idempotencyKey);
        }
    }

    public static void main(String[] args) {
        RetryKey a = new RetryKey("tenant-a", "idem-7");
        RetryKey equalA = new RetryKey("tenant-a", "idem-7");
        RetryKey b = new RetryKey("tenant-b", "idem-7");
        RetryKey c = new RetryKey("tenant-a", "idem-8");

        Map<RetryKey, String> facts = new HashMap<>();
        System.out.println("first-old=" + facts.put(a, "WAITING@120"));
        System.out.println("replace-old="
                + facts.put(equalA, "WAITING@130"));
        facts.put(b, "WAITING@115");
        facts.put(c, "WAITING@115");
        System.out.println("facts-size=" + facts.size());
        System.out.println("a-now=" + facts.get(a));

        NavigableMap<RetryOrder, RetryKey> schedule = new TreeMap<>();
        RetryOrder orderA = new RetryOrder(120, 2, "tenant-a", "idem-7");
        RetryOrder orderB = new RetryOrder(115, 1, "tenant-b", "idem-7");
        RetryOrder orderC = new RetryOrder(115, 5, "tenant-a", "idem-8");
        schedule.put(orderA, a);
        schedule.put(orderB, b);
        schedule.put(orderC, c);
        System.out.println("schedule-before=" + schedule.entrySet().stream()
                .map(entry -> entry.getValue().label()
                        + "@" + entry.getKey().nextMinute()
                        + "#P" + entry.getKey().priority())
                .toList());

        NavigableMap<RetryOrder, RetryKey> liveWindow = schedule.subMap(
                RetryOrder.boundary(115), true,
                RetryOrder.boundary(121), false);
        List<String> snapshot = liveWindow.entrySet().stream()
                .map(entry -> entry.getValue().label()
                        + "@" + entry.getKey().nextMinute()
                        + "#P" + entry.getKey().priority())
                .toList();
        System.out.println("window-snapshot=" + snapshot);
        schedule.remove(orderC);
        System.out.println("live-after-remove="
                + liveWindow.entrySet().stream()
                .map(entry -> entry.getValue().label()
                        + "@" + entry.getKey().nextMinute()
                        + "#P" + entry.getKey().priority())
                .toList());
        System.out.println("snapshot-stable=" + snapshot);

        LinkedHashMap<RetryKey, Integer> recent =
                new LinkedHashMap<>(8, 0.75f, true);
        recent.put(a, 1);
        recent.put(b, 1);
        recent.put(c, 1);
        recent.get(b);
        System.out.println("recent-after-read=" + recent.keySet().stream()
                .map(RetryKey::label)
                .toList());

        EnumMap<RetryState, Integer> counts =
                new EnumMap<>(RetryState.class);
        counts.merge(RetryState.WAITING, 3, Integer::sum);
        counts.merge(RetryState.WAITING, -1, Integer::sum);
        counts.merge(RetryState.CLAIMED, 1, Integer::sum);
        System.out.println("counts=" + counts);
    }
}

预期输出:

first-old=null
replace-old=WAITING@120
facts-size=3
a-now=WAITING@130
schedule-before=[tenant-a/idem-8@115#P5, tenant-b/idem-7@115#P1, tenant-a/idem-7@120#P2]
window-snapshot=[tenant-a/idem-8@115#P5, tenant-b/idem-7@115#P1, tenant-a/idem-7@120#P2]
live-after-remove=[tenant-b/idem-7@115#P1, tenant-a/idem-7@120#P2]
snapshot-stable=[tenant-a/idem-8@115#P5, tenant-b/idem-7@115#P1, tenant-a/idem-7@120#P2]
recent-after-read=[tenant-a/idem-7, tenant-a/idem-8, tenant-b/idem-7]
counts={WAITING=2, CLAIMED=1}

这段程序只分别展示相等键替换、导航活视图与快照、访问顺序和枚举累计,未实现接收去重、领取候选或跨索引维护。它不能作为生产事务方案,也没有替大大完成今日主练习:闯关仍需提交当日题目要求的完整代码、编译证据和规定输出。

8. 错误示例:把偶然现象误当业务保证

key = idempotencyKey                       // 漏租户,跨租户串任务
schedule.put(nextMinute, key)              // 同分钟任务互相覆盖
display = facts.keySet()                   // HashMap 次序无合同,且是活视图
readOnly = unmodifiableMap(facts)          // 原 facts 改动仍会透出
facts.computeIfPresent(key, claim)
schedule.remove(order)                     // 两步不是跨 Map 事务
catch ConcurrentModificationException      // 把 fail-fast 错当线程安全

这套方案既破坏身份,又丢失全序,还泄露所有权。把 HashMap 改成 ConcurrentHashMap,只能改变单容器并发能力;“更新事实键 + 删除调度键 + 修改计数”仍然不是跨键或跨集合原子操作。

9. 正确示例:把方案写成可审查的九步闭环

先声明租户与幂等号组成不可变身份;再声明 value 禁止 null,使缺失语义明确;用 HashMap 承担精确事实查询;用包含所有区分量的比较键建立 TreeMap 全序;用 access-order LinkedHashMap 承担有界诊断访问;用 EnumMap 承担封闭状态计数;将导航活视图在所有权边界前投影成独立快照;用具体返回值、size、顺序和边界数据完成状态推演;最后从固定 OpenJDK 入口追到字段与主分支,并把内部现象收束回公开合同。

生产写路径还要多一步:选择覆盖全部投影的共同一致性边界。若采用不可变聚合状态交换,先基于旧状态构造候选副本,全部校验与派生更新成功后一次发布;任一步失败就丢弃候选,读者继续看到旧状态。若使用数据库,则事实状态转换和持久调度行应处于同一事务,内存索引可以在提交后刷新或从事实重建。选择哪一种取决于读写比、规模与故障模型,而不是 Map 类名本身。

随堂检查 3:把事实表和调度表都换成 ConcurrentHashMap,再分别用 compute,一次领取就自动跨两张表原子了吗?

**即时答案:**没有。ConcurrentHashMap 的保证围绕单张 Map 中相关键的操作,不会建立跨键、跨集合或跨存储事务。必须由共同锁、单线程所有者、不可变状态交换或持久化事务覆盖完整领取流程。

10. 边界总结:六条红线一次守住

  1. 不依赖 HashMapHashSet 的偶然顺序;调度用 TreeMap 比较合同,诊断用明确的 LinkedHashMap 模式。
  2. equals/hashCode 围绕同一组不可变身份字段;Comparator 返回 0 必须符合领域排序键等价,不能让同分钟任务静默覆盖。
  3. keySet/values/entrySet/subMap/headMap 是视图;只读包装仍可能随底表变化;真正跨边界的历史结果应是独立不可变快照。
  4. 本课不以 LinkedList 作为调度索引;即使后续使用 LinkedList,在不知道节点位置时做中间查找或删除仍需 O(n),不能声称天然 O(1)。
  5. fail-fast 只是尽力暴露结构修改误用,不提供互斥、可见性、原子性或线程安全。
  6. ConcurrentHashMap 的单键原子 API 不会自动覆盖多个键、多个集合或数据库;跨 Map 一致性属于更外层业务边界。

一句话收束:用不可变幂等身份守住事实,用明确比较合同守住调度,用副本守住所有权,再由外层事务边界守住多索引一致性。

官方资料:Java SE 21 MapHashMapLinkedHashMapNavigableMapEnumMapWeakHashMapIdentityHashMap

返回今日索引

返回今日索引

编码闯关:多租户消息重试调度索引

评估项:challenge.map。建议用时:18~20 分钟。本题完整代码、Java 21 编译结果与运行输出必须提交;只写思路不能通过 Map 阶段闯关。

你要为消息投递平台完成一个单线程内存索引。系统既要按“租户 + 消息号”精确定位消息,又要按“下次重试分钟 + 优先级”挑出到期任务,还要维护状态计数和一个有容量上限的诊断缓存。今天的身份、缓存淘汰与领取操作都不同于退款和仓储场景:核心动作是从到期导航视图领取最早候选,并让访问顺序缓存体现诊断热度。

1. 业务背景

不同租户可以产生相同 messageId,所以单独使用消息号会串租户;事实键必须同时包含 tenantIdmessageId,进入 Map 后不能改变。调度先按 nextAttemptMinute 升序;同一分钟内高优先级先领取;仍相同时再按租户和消息号形成全序。这个顺序来自业务合同,不能由 HashMap 某次遍历结果代替。

索引承担四种职责:

  • byKey:唯一事实来源,用不可变 MessageKey 精确查找消息。
  • retrySchedule:仍在等待领取的导航索引,支持半开时间窗和最早候选。
  • stateCounts:在封闭的消息状态枚举域内统计数量。
  • diagnostics:容量为 3 的访问顺序 LinkedHashMap;接收、诊断命中或状态替换都会改变热度,超限淘汰最久未访问项,但绝不删除事实主表。

后三张 Map 都是可由事实或业务日志重建的派生投影。练习按单线程顺序执行,便于核对不变量;putIfAbsent 只约束事实表的一个键,随后对导航树、计数和缓存的写入不会自动组成事务。生产环境必须由外层单线程所有者、共同锁或数据库事务保护完整状态转换,并定义重建或补偿策略。

2. 约束与验收边界

  • 基线为 Java 21;只使用 JDK 集合,不添加依赖、日志或不能恢复问题的 try/catch
  • MessageKey 是不可变 record;tenantIdmessageId 均非空白。不同租户的同号消息必须同时存在。
  • 新消息只允许处于 WAITINGnextAttemptMinute 非负,优先级限定 1..9attempt 为正数。
  • 重复复合键返回 false,并且不能再次修改导航索引、状态计数或诊断缓存,也不能借重复请求刷新缓存热度。
  • RetryKey.compareTo 已给出:先比较重试分钟,再让高优先级排前,最后比较租户与消息号。对合法业务键,比较为 0 与四个组件相等一致。
  • 时间窗必须由 TreeMap.subMap 建立活视图,再立即投影成不可变字符串快照;返回结果不能继续背靠内部 Map。
  • claimNext(beforeExclusive) 只考虑重试分钟严格小于上界的 WAITING 消息;领取导航视图中的首项,成功后改为 CLAIMED 并移出重试索引。没有候选项返回 NONE
  • diagnostics 明确采用访问顺序且容量为 3;命中 diagnosticState、更新已有 value 或插入新项都会影响访问顺序,淘汰只影响缓存。
  • 禁止依赖 HashMap 遇见顺序,禁止破坏 equals/hashCode 或比较器合同,禁止把 fail-fast 当线程安全,也禁止把单 Map 条件更新扩张为跨 Map 原子保证。

3. 目标拆解与实施顺序

  1. 完成接收:在第一次写入前验证状态,以复合键对事实表去重;只有事实新增成功才维护三个派生索引。
  2. 完成到期快照:验证范围,用两个边界键取得 [fromInclusive, toExclusive) 活视图,按调度顺序固化当前字段。
  3. 完成领取:用严格截止上界取得到期活视图,选择首项后回查事实来源,再同步导航项、不可变事实 value、状态计数与诊断缓存。
  4. 用 Java 21 编译运行,逐行比对精确输出;保存补全后的完整代码、编译命令和输出,作为 30 分编码证据。
  5. 解释复合键、比较全序、访问顺序、快照所有权和跨索引原子性边界。

4. 完整可编译的 Java 21 起始代码

保存为 MessageRetryChallenge.java。未补代码时仍能通过编译;直接运行会在第一次接收时以 TODO 1: accept 明确失败。起始代码只有 3 个待补位置,当天不提供完整实现。

import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.TreeMap;

public final class MessageRetryChallenge {
    private enum RetryState {
        WAITING,
        CLAIMED
    }

    private record MessageKey(String tenantId, String messageId) {
        MessageKey {
            Objects.requireNonNull(tenantId, "tenantId");
            Objects.requireNonNull(messageId, "messageId");
            if (tenantId.isBlank() || messageId.isBlank()) {
                throw new IllegalArgumentException(
                        "tenantId and messageId must not be blank");
            }
        }

        String label() {
            return tenantId + "/" + messageId;
        }
    }

    private record RetryMessage(
            MessageKey key,
            long nextAttemptMinute,
            int priority,
            int attempt,
            RetryState state) {
        RetryMessage {
            Objects.requireNonNull(key, "key");
            Objects.requireNonNull(state, "state");
            if (nextAttemptMinute < 0
                    || priority < 1
                    || priority > 9
                    || attempt <= 0) {
                throw new IllegalArgumentException("invalid retry message");
            }
        }

        RetryMessage claimed() {
            return new RetryMessage(
                    key,
                    nextAttemptMinute,
                    priority,
                    attempt,
                    RetryState.CLAIMED);
        }

        String snapshotLine() {
            return key.label() + "@" + nextAttemptMinute
                    + "#P" + priority + ":A" + attempt + ":" + state;
        }
    }

    private record RetryKey(
            long nextAttemptMinute,
            int priority,
            String tenantId,
            String messageId) implements Comparable<RetryKey> {
        static RetryKey from(RetryMessage message) {
            return new RetryKey(
                    message.nextAttemptMinute(),
                    message.priority(),
                    message.key().tenantId(),
                    message.key().messageId());
        }

        static RetryKey boundary(long minute) {
            return new RetryKey(
                    minute,
                    Integer.MAX_VALUE,
                    "",
                    "");
        }

        @Override
        public int compareTo(RetryKey other) {
            int byMinute = Long.compare(
                    nextAttemptMinute,
                    other.nextAttemptMinute);
            if (byMinute != 0) {
                return byMinute;
            }
            int byPriority = Integer.compare(other.priority, priority);
            if (byPriority != 0) {
                return byPriority;
            }
            int byTenant = tenantId.compareTo(other.tenantId);
            return byTenant != 0
                    ? byTenant
                    : messageId.compareTo(other.messageId);
        }
    }

    private static final class RetryIndex {
        private static final int DIAGNOSTIC_LIMIT = 3;

        private final Map<MessageKey, RetryMessage> byKey = new HashMap<>();
        private final NavigableMap<RetryKey, MessageKey> retrySchedule =
                new TreeMap<>();
        private final EnumMap<RetryState, Integer> stateCounts =
                new EnumMap<>(RetryState.class);
        private final LinkedHashMap<MessageKey, RetryMessage> diagnostics =
                new LinkedHashMap<>(4, 0.75f, true) {
                    @Override
                    protected boolean removeEldestEntry(
                            Map.Entry<MessageKey, RetryMessage> eldest) {
                        return size() > DIAGNOSTIC_LIMIT;
                    }
                };

        private boolean accept(RetryMessage message) {
            // 校验初始状态,以复合键去重,再维护三个派生索引。
            throw new UnsupportedOperationException("TODO 1: accept");
        }

        private List<String> dueSnapshot(
                long fromInclusive,
                long toExclusive) {
            if (fromInclusive > toExclusive) {
                throw new IllegalArgumentException(
                        "fromInclusive must be <= toExclusive");
            }
            // 从半开范围活视图生成稳定、不可变的消息快照。
            throw new UnsupportedOperationException("TODO 2: dueSnapshot");
        }

        private String claimNext(long beforeExclusive) {
            if (beforeExclusive < 0) {
                throw new IllegalArgumentException(
                        "beforeExclusive must be >= 0");
            }
            // 领取首个到期候选并一致维护事实与派生索引。
            throw new UnsupportedOperationException("TODO 3: claimNext");
        }

        private int attemptOf(MessageKey key) {
            RetryMessage message = byKey.get(
                    Objects.requireNonNull(key, "key"));
            return message == null ? -1 : message.attempt();
        }

        private String diagnosticState(MessageKey key) {
            RetryMessage message = diagnostics.get(
                    Objects.requireNonNull(key, "key"));
            return message == null ? "MISS" : message.state().name();
        }

        private List<String> scheduleOrder() {
            return retrySchedule.entrySet().stream()
                    .map(entry -> entry.getValue().label()
                            + "@" + entry.getKey().nextAttemptMinute()
                            + "#P" + entry.getKey().priority())
                    .toList();
        }

        private List<String> diagnosticOrder() {
            return diagnostics.keySet().stream()
                    .map(MessageKey::label)
                    .toList();
        }

        private List<String> countSummary() {
            return stateCounts.entrySet().stream()
                    .map(entry -> entry.getKey() + "=" + entry.getValue())
                    .toList();
        }
    }

    public static void main(String[] args) {
        MessageKey tenantAFirst = new MessageKey("tenant-a", "msg-7");
        MessageKey tenantBFirst = new MessageKey("tenant-b", "msg-7");
        MessageKey tenantASecond = new MessageKey("tenant-a", "msg-8");
        MessageKey tenantCFirst = new MessageKey("tenant-c", "msg-1");

        RetryMessage first = new RetryMessage(
                tenantAFirst, 320, 2, 2, RetryState.WAITING);
        RetryMessage second = new RetryMessage(
                tenantBFirst, 300, 1, 3, RetryState.WAITING);
        RetryMessage third = new RetryMessage(
                tenantASecond, 300, 4, 1, RetryState.WAITING);
        RetryMessage fourth = new RetryMessage(
                tenantCFirst, 330, 5, 2, RetryState.WAITING);

        RetryIndex index = new RetryIndex();
        System.out.println("ACCEPT first=" + index.accept(first));
        System.out.println("ACCEPT second=" + index.accept(second));
        System.out.println("ACCEPT third=" + index.accept(third));
        System.out.println("DIAG access-first="
                + index.diagnosticState(tenantAFirst));
        System.out.println("ACCEPT fourth=" + index.accept(fourth));
        System.out.println("ACCEPT duplicate=" + index.accept(first));
        System.out.println("BY-KEY tenant-a/msg-7="
                + index.attemptOf(tenantAFirst));
        System.out.println("BY-KEY tenant-b/msg-7="
                + index.attemptOf(tenantBFirst));
        System.out.println("DIAG before=" + index.diagnosticOrder());

        List<String> beforeClaim = index.dueSnapshot(300, 321);
        System.out.println("WINDOW before=" + beforeClaim);
        System.out.println("COUNTS before=" + index.countSummary());
        System.out.println("CLAIM before-300=" + index.claimNext(300));
        System.out.println("CLAIM before-320=" + index.claimNext(320));
        System.out.println("COUNTS after=" + index.countSummary());
        System.out.println("SCHEDULE after=" + index.scheduleOrder());
        System.out.println("DIAG after=" + index.diagnosticOrder());
        System.out.println("SNAPSHOT unchanged=" + beforeClaim);
    }
}

直接编译运行:

javac --release 21 MessageRetryChallenge.java
java MessageRetryChallenge

5. 三个待补位置的验收条件

位置 1:接收、复合键去重与缓存热度

  • messagenull 时在边界失败;第一次写 Map 前拒绝非 WAITING 状态。
  • MessageKeybyKey 做一次“缺失才写入”的单 Map 条件更新;旧值存在时立刻返回 false,其余索引和缓存热度必须保持不变。
  • 首次接收成功后,以 RetryKey.from(message) 写入导航索引;合并增加 WAITING;最后写入访问顺序诊断缓存并返回 true
  • 必须说明:主表一次条件写只保护主表当前键,不会给后续三张 Map 自动附加事务性。

位置 2:到期活视图与稳定快照

  • 使用两个 RetryKey.boundary 和四参数 subMap 精确表达 [fromInclusive, toExclusive),不能线性扫描 byKey
  • 按范围视图的比较顺序,以 MessageKey 回查事实来源并调用 snapshotLine() 固化字段;索引失配必须明确失败。
  • 返回列表不可增删;后续领取和事实 value 替换不能改变已经返回的字符串。
  • 允许两个边界相等并返回空快照;只有下界大于上界才失败。

位置 3:领取最早候选并维护派生索引

  • 使用边界键建立严格小于 beforeExclusiveheadMap 活视图;没有候选时返回 NONE,所有 Map 保持不变。
  • 读取视图首项后,确认事实仍存在、状态是 WAITING,并且其 RetryKey 与导航键一致;不变量破坏时明确失败,不能吞掉问题。
  • 用新的 immutable record 替换事实 value;从活视图删除首项以写穿原 TreeMap;将 WAITING 减一且归零移除,再增加 CLAIMED
  • 用领取后的 value 更新诊断缓存;已有键应移动到访问顺序尾部,超限淘汰只作用于缓存。返回被领取的复合键标签。

6. 三级提示

一级提示:事实去重必须先于派生写入

先让 byKey 决定这个复合键是否首次出现;重复时立即返回。导航树、枚举计数和诊断缓存只有在事实新增成功后才能变化。

二级提示:同一分钟为何使用极高优先级边界

比较器让高优先级排在前面。边界键使用高于合法业务值的优先级,因此它排在该分钟所有真实键之前:包含下界边界便能收进下界分钟,排除上界边界便能排除上界分钟。

三级提示:领取与缓存不是同一种顺序

领取候选来自 TreeMap 的比较顺序;诊断缓存则是访问顺序。导航子视图的删除会写穿根树,而对缓存中已有键执行 put 会把该键移动到尾部。二者都不能从 HashMap 遍历顺序推导。

7. 精确预期输出

三个待补位置完成后,实际输出必须逐行一致:

ACCEPT first=true
ACCEPT second=true
ACCEPT third=true
DIAG access-first=WAITING
ACCEPT fourth=true
ACCEPT duplicate=false
BY-KEY tenant-a/msg-7=2
BY-KEY tenant-b/msg-7=3
DIAG before=[tenant-a/msg-8, tenant-a/msg-7, tenant-c/msg-1]
WINDOW before=[tenant-a/msg-8@300#P4:A1:WAITING, tenant-b/msg-7@300#P1:A3:WAITING, tenant-a/msg-7@320#P2:A2:WAITING]
COUNTS before=[WAITING=4]
CLAIM before-300=NONE
CLAIM before-320=tenant-a/msg-8
COUNTS after=[WAITING=3, CLAIMED=1]
SCHEDULE after=[tenant-b/msg-7@300#P1, tenant-a/msg-7@320#P2, tenant-c/msg-1@330#P5]
DIAG after=[tenant-a/msg-7, tenant-c/msg-1, tenant-a/msg-8]
SNAPSHOT unchanged=[tenant-a/msg-8@300#P4:A1:WAITING, tenant-b/msg-7@300#P1:A3:WAITING, tenant-a/msg-7@320#P2:A2:WAITING]

还要解释:两个租户的 msg-7 为什么不会覆盖;同为 300 分钟时为什么 P4 先于 P1;严格上界 320 为什么排除恰为 320 的消息;诊断首次访问和领取更新分别怎样改变缓存顺序;被缓存淘汰的 tenant-b/msg-7 为什么仍能通过事实主表查询;旧快照为什么保持 WAITING。精确输出只验证这组数据,绝不授权依赖任意 HashMap 顺序。

8. 复杂度要求

  • byKey 的精确查询与单键条件写平均为 O(1);碰撞、树化和扩容意味着不能承诺每次严格 O(1)
  • retrySchedule 单项插入、删除和首项导航为 O(log n);范围定位后固化 k 项合计 O(log n + k)
  • stateCounts 的键域只有两个枚举常量,可按常数规模理解。
  • diagnostics 的命中、写入和一次最老项淘汰平均为 O(1),且最多保留 3 项;输出缓存顺序按固定上限是常数规模。
  • 主表与导航索引随消息数增长,总空间为 O(n);状态表和有界缓存为 O(1),稳定窗口快照额外占 O(k)
  • 一次接收或领取只执行常数次索引操作,整体由 TreeMap 主导为 O(log n);这不等于跨 Map 原子操作。

9. 边界用例

补全后至少自行验证:

  1. tenant-a/msg-7tenant-b/msg-7 同时存在,重试次数互不串租户。
  2. 重复接收不增加 WAITING,不改变调度顺序,也不刷新诊断缓存热度。
  3. 同一分钟高优先级排前;相同分钟和优先级仍由租户、消息号形成全序,比较为零不会覆盖不同业务键。
  4. [320,320) 为空;下界大于上界时在读取或写入状态前失败。
  5. claimNext(300) 返回 NONE;上界 320 不包含下一次重试恰为 320 的消息。
  6. 领取后对返回快照执行新增操作会失败;快照内容也不随事实状态改变。
  7. 访问顺序缓存超限只淘汰最久未访问缓存项,不删除事实或调度项;重复接收不能成为刷新热度的后门。
  8. 若迁移到多线程或数据库,指出一个覆盖四张 Map 更新的明确所有权或事务边界,而不是依赖 fail-fast。

10. 闯关提交与自检

  • 提交补全后的完整 MessageRetryChallenge.java,不是只交三个代码片段。
  • 提交 javac --release 21 成功证据与完整运行输出。
  • 三个待补位置全部完成,没有新增绕过验收的特殊分支。
  • 复合键不可变且包含租户;没有用可变字段或单独消息号作为事实键。
  • 比较器包含所有业务区分量,没有依赖 HashMap 的偶然顺序。
  • 能区分 TreeMap 比较顺序、访问顺序缓存、活视图和稳定快照。
  • 诊断缓存淘汰没有误删事实;重复请求没有意外刷新缓存。
  • 没有把多个 Map 更新、fail-fast 或单 Map 条件 API 描述成线程安全事务。
  • 没有无意义的 try/catch、调试日志、公开辅助 API 或第三方依赖。
  • 能解释时间、空间复杂度以及至少 4 个边界用例。

11. Java 21 与固定源码资料

返回今日索引

返回今日索引

Map 阶段闯关题:多租户消息重试调度索引

建议用时:约 8~9 分钟。请优先在 互动页面 作答,聊天提交仅作补充。当天不提供答案。 第 1~3、5 题只写结论和最短必要推理;第 4 题必须提交完整代码、Java 21 编译证据和完整输出。

闯关放行必须同时满足:总分至少 80/100、第 4 题代码用 Java 21 成功编译并产生规定输出、六类红线均未命中。只达到分数、只交代码片段、编译失败或命中任一红线,都不能进入 Set 模块;应先按有效证据补强并重闯。

题号 固定维度 知识点 ID 分值 可能触发的红线
1 契约与选型 ds.map.contractds.map.equalityds.map.linkedhashmap-sourceds.map.treemap-sourceds.map.specialized 25 假设 HashMap 顺序稳定;破坏 equals/hashCode 或 Comparator;混淆插入顺序与访问顺序
2 复杂度与状态推演 ds.map.hashmap-structureds.map.hashmap-put-get-remove-sourceds.map.hashmap-resize-treeify-iterator-sourceds.map.treemap-source 12 把平均复杂度写成绝对保证;把 fail-fast 当线程安全
3 复杂度与状态推演 ds.map.contractds.map.compute-merge-viewsds.map.linkedhashmap-sourceds.map.treemap-source 13 混淆活视图、只读包装与稳定快照;把缓存淘汰当事实删除;把多步更新当事务
4 编码 ds.map.contractds.map.equalityds.map.compute-merge-viewsds.map.linkedhashmap-sourceds.map.treemap-sourceds.map.specialized 30 复合键、比较器、半开范围、LRU 热度或计数错误;把跨 Map 更新误称自动原子
5 源码讲解 ds.map.hashmap-structureds.map.hashmap-put-get-remove-sourceds.map.hashmap-resize-treeify-iterator-sourceds.map.linkedhashmap-sourceds.map.treemap-sourceds.map.specialized 20 猜测源码结论;把实现细节当公共契约;把 fail-fast 当线程安全
合计 契约与选型 25;复杂度与状态推演 25;编码 30;源码讲解 20 覆盖 Map 9 个知识项 100

1. 契约与方案设计:先写不变量,再选 Map(25 分)

消息平台需要:按“租户 + 消息号”精确定位、按下次重试分钟和优先级调度、按有限状态计数,并维护容量为 3 的最近诊断访问缓存。用最多 7 条写出四张 Map 的具体选型及事实/派生关系,同时定义复合键相等性、调度键全序、访问顺序与淘汰语义、null 边界和快照所有权。最后解释为什么缓存淘汰不能删除事实,以及 WeakHashMapIdentityHashMap 为什么都不适合作为事实主表;不能只列类名。

2. 复杂度与结构推演:给平均结论补上退化条件(12 分)

设事实表有 n 条消息,到期窗口命中 k 条。分别给出:复合键查询、首次接收、领取首项、范围定位并固化快照、状态计数、诊断缓存命中/插入/淘汰、输出缓存顺序的时间复杂度,以及索引总空间与快照额外空间。再说明固定 OpenJDK 21 中 HashMap 的扩容、碰撞和树化容量门槛为什么否定“每次严格 O(1)”,并解释结构修改检测为什么不能提供线程安全。

3. 状态推演与代码分析:比较顺序、缓存热度和稳定快照(13 分)

按顺序接收三个 WAITING 消息:tenant-a/m1@420#P2tenant-b/m1@400#P1tenant-a/m2@400#P5;诊断缓存容量为 2。随后依次执行:诊断访问第一条、接收 tenant-c/m9@430#P4、取得 [400,421) 的 TreeMap 活视图并投影为不可变字符串快照、尝试重复接收第一条、执行 claimNext(420)

写出:被领取的复合键;领取后调度索引顺序;WAITING/CLAIMED 计数;诊断缓存从每一步到最终的访问顺序;被淘汰项是否仍在事实表;旧活视图当前包含的键与旧快照内容。说明上界 420 的效果、重复接收为什么不能刷新缓存,以及四张普通 Map 的连续更新为什么仍不是事务。所有顺序必须由 Comparator 或 access-order 合同推出,不能猜 HashMap 次序。

4. 必交编码:完成消息重试索引(30 分)

补全 编码练习 的全部 3 个待补位置,提交:完整 MessageRetryChallenge.javajavac --release 21 MessageRetryChallenge.java 的成功结果;java MessageRetryChallenge 的完整且逐行一致输出;再用不超过 5 句话分别说明重复接收、半开范围、严格领取上界、诊断缓存淘汰和跨 Map 原子性边界。

评分拆分:接收与复合键不变量 8 分;范围活视图转稳定快照 7 分;领取、状态迁移与访问顺序缓存 9 分;Java 21 编译、规定输出和边界说明 6 分。缺少完整代码、编译失败或输出不符时,本维度不得视为通过,整个 Map 闯关也不得放行。

5. 源码追踪:从公开入口解释可观察结果(20 分)

固定版本为 OpenJDK jdk-21+35。用 5 行表格作答,每行依次写“要解释的问题、公开入口、关键字段、最多 4 个箭头节点的主路径、可观察结果或扩展点”,分别覆盖:HashMap 的精确查询以及新键/同键更新/删除;容量阈值、扩容拆分、树化门槛与迭代器结构修改检测;LinkedHashMap 的 access-order 命中/已有键更新/插入后最老项钩子;TreeMap 的比较定位、subMap/headMap 范围视图和写穿边界;EnumMap 的枚举键域表示与计数更新。

每行 4 分。必须自行追踪源码并从入口回到本题可观察结果,不能把文件名或方法名清单当作调用链答案;不得猜红黑树的具体形状,也不得把内部阈值、fail-fast 或单次 Map 操作扩张成业务排序合同、线程安全或跨集合事务保证。

返回今日索引

课后作答

复盘问题与编码作答

草稿保存在当前浏览器;点击保存或按 ⌘/Ctrl + S 后,才会写入当天的 04-复盘作答.md

1. 契约与方案设计:先写不变量,再选 Map(25 分)

消息平台需要:按“租户 + 消息号”精确定位、按下次重试分钟和优先级调度、按有限状态计数,并维护容量为 3 的最近诊断访问缓存。用最多 7 条写出四张 Map 的具体选型及事实/派生关系,同时定义复合键相等性、调度键全序、访问顺序与淘汰语义、null 边界和快照所有权。最后解释为什么缓存淘汰不能删除事实,以及 WeakHashMapIdentityHashMap 为什么都不适合作为事实主表;不能只列类名。

2. 复杂度与结构推演:给平均结论补上退化条件(12 分)

设事实表有 n 条消息,到期窗口命中 k 条。分别给出:复合键查询、首次接收、领取首项、范围定位并固化快照、状态计数、诊断缓存命中/插入/淘汰、输出缓存顺序的时间复杂度,以及索引总空间与快照额外空间。再说明固定 OpenJDK 21 中 HashMap 的扩容、碰撞和树化容量门槛为什么否定“每次严格 O(1)”,并解释结构修改检测为什么不能提供线程安全。

3. 状态推演与代码分析:比较顺序、缓存热度和稳定快照(13 分)

按顺序接收三个 WAITING 消息:tenant-a/m1@420#P2tenant-b/m1@400#P1tenant-a/m2@400#P5;诊断缓存容量为 2。随后依次执行:诊断访问第一条、接收 tenant-c/m9@430#P4、取得 [400,421) 的 TreeMap 活视图并投影为不可变字符串快照、尝试重复接收第一条、执行 claimNext(420)

写出:被领取的复合键;领取后调度索引顺序;WAITING/CLAIMED 计数;诊断缓存从每一步到最终的访问顺序;被淘汰项是否仍在事实表;旧活视图当前包含的键与旧快照内容。说明上界 420 的效果、重复接收为什么不能刷新缓存,以及四张普通 Map 的连续更新为什么仍不是事务。所有顺序必须由 Comparator 或 access-order 合同推出,不能猜 HashMap 次序。

4. 必交编码:完成消息重试索引(30 分)

补全 编码练习 的全部 3 个待补位置,提交:完整 MessageRetryChallenge.javajavac --release 21 MessageRetryChallenge.java 的成功结果;java MessageRetryChallenge 的完整且逐行一致输出;再用不超过 5 句话分别说明重复接收、半开范围、严格领取上界、诊断缓存淘汰和跨 Map 原子性边界。

评分拆分:接收与复合键不变量 8 分;范围活视图转稳定快照 7 分;领取、状态迁移与访问顺序缓存 9 分;Java 21 编译、规定输出和边界说明 6 分。缺少完整代码、编译失败或输出不符时,本维度不得视为通过,整个 Map 闯关也不得放行。

5. 源码追踪:从公开入口解释可观察结果(20 分)

固定版本为 OpenJDK jdk-21+35。用 5 行表格作答,每行依次写“要解释的问题、公开入口、关键字段、最多 4 个箭头节点的主路径、可观察结果或扩展点”,分别覆盖:HashMap 的精确查询以及新键/同键更新/删除;容量阈值、扩容拆分、树化门槛与迭代器结构修改检测;LinkedHashMap 的 access-order 命中/已有键更新/插入后最老项钩子;TreeMap 的比较定位、subMap/headMap 范围视图和写穿边界;EnumMap 的枚举键域表示与计数更新。

每行 4 分。必须自行追踪源码并从入口回到本题可观察结果,不能把文件名或方法名清单当作调用链答案;不得猜红黑树的具体形状,也不得把内部阈值、fail-fast 或单次 Map 操作扩张成业务排序合同、线程安全或跨集合事务保证。

返回今日索引

可选:编码作答

尚未保存