Java 后端每日学习 · Day 16 · 2026-09-09
打开今日互动学习页 :汇总五个课程章节,支持代码复制、复盘作答、浏览器草稿和本地 Markdown 保存。网页作答优先、聊天提交补充。
今日主题
Map 阶段闯关变式:多商户对账差异升级索引的契约、状态推演、编码与源码解释。
方向元数据
字段
值
directionId
java-data-structures
directionSession
14
curriculumItemId
challenge.map
lessonMode
checkpoint
节点进入前状态
covered_unverified
完成本课后的预期状态
covered_unverified;生成第四个变式不等于闯关通过
当前方向状态
active
查看全局 Java 后端知识地图 。固定同步助手对最近完整课程 Day 15 的拉取结果为 remote_missing,服务器与本地均没有 2026-09-08/04-复盘作答.md,当前任务也没有能明确映射到 Day 15 题号和问题源哈希的答案。因此答案来源为 none,不评分、不推断薄弱项或红线;challenge.map 保持 covered_unverified,今天继续在 Map 模块使用全新的对账差异数据与升级操作完成闯关变式。
directionSession=14 原本命中每七课次的阶段复习节奏,但未通过的模块闯关拥有更高优先级。本课用综合变式承担阶段回顾,不跨到 Set,也不把重复出题计作新的标准课覆盖。
可验证目标
能从对账差异的复合身份、缺失语义、升级顺序、诊断访问顺序、封闭状态域与所有权约束出发选择 Map,而不是先背实现类。
能对确定操作逐步推演事实主表、升级时间索引、状态计数、诊断缓存、API 返回值和复杂度,不依赖 HashMap 偶然顺序。
能完成 Java 21 必做编码,编译并产生规定输出,同时守住不可变复合键、比较器全序、半开范围、严格上界和稳定快照边界。
能沿 OpenJDK jdk-21+35 的公共入口、关键字段和主调用链解释 HashMap、LinkedHashMap、TreeMap、EnumMap 以及条件更新的可观察行为。
能明确单张 Map 的条件更新、多张普通 Map 的连续写入、fail-fast 与业务事务或线程安全之间的边界。
60~75 分钟学习顺序
昨日复盘 :Day 15 五题完整参考答案与消息重试索引完整实现(约 12~14 分钟)。
核心讲解 :用独立场景重组对账差异升级索引的 Map 决策、推演与源码因果链(约 31~32 分钟)。
编码练习 :完成多商户对账差异升级索引;这是闯关通过的必交编码(约 18~20 分钟)。
复盘问题 :按四个固定维度提交 Map 阶段闯关答案(约 8~9 分钟)。
总预计用时约 69~75 分钟 。今天只验证 Map 模块,不提前讲 Set,也不把昨日参考答案算成大大的作答证据。
方向进度
课前与课程完成后的标准课覆盖均为 10/35(28.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/hashCode 或 Comparator 契约,不混淆活视图、只读包装与快照,也不把 fail-fast 当线程安全。
闯关放行条件
总分至少 80/100;固定维度为:契约与选型 25、复杂度与状态推演 25、编码 30、源码讲解 20。
Java 21 编码必须成功编译、运行且产生预期结果;只写思路、缺少完整代码或输出不符均不能通过。
六类红线均未命中。达到分数但编码失败或命中红线,仍视为未通过。
今日课程生成后,challenge.map 仍只记 covered_unverified。只有后续取得完整、有效且满足全部条件的作答证据,才可记为 mastered 并进入 Set。
固定版本与官方资料
条件式下节预告
若仍没有完整有效的闯关答案:challenge.map 保持未通过,下一完整学习日继续使用新数据和约束完成 Map 综合练习或闯关变式,绝不进入 Set。
若出现部分有效答案且未暴露明确错误:逐题点评,challenge.map 记为 practicing,保留验证债务并继续 Map 闯关,不虚构总分。
若完整答案低于 80、Java 21 编码不通过,或任何有效答案暴露明确错误或红线:challenge.map 与明确薄弱的 1~3 个知识点记为 needs_review,方向记为 needs_review;下一课优先换数据、约束和场景补强,再重闯。
只有完整答案达到至少 80、编码编译运行符合预期且无红线:challenge.map 才记为 mastered,并依据充分证据更新相关 Map 知识点;下一课才可进入 ds.set.contract-algebra。
返回今日索引
昨日复盘:多租户消息重试 Map 闯关核对
复盘对象:Day 15(2026-09-08),评估项 challenge.map。建议用时:12~14 分钟。先用 1~2 分钟确认第 1 节证据与推进边界,遮住答案用 5~6 分钟口述第 2 节,再用约 6 分钟核对第 3 节三个 TODO、精确输出和复杂度。下文全部是教学参考答案,不构成大大的作答、分数、闯关通过或掌握证据 。
1. 作答证据、逐题评分与推进结论
固定同步助手拉取 Day 15 作答的结果为 remote_missing,服务器上没有 2026-09-08/04-复盘作答.md;本地也没有该文件。
Day 15 问题源哈希:sha256:45be77e5674cd43acea0fe807bf8cdcaeb5a3ddd22209eba8ffc067a8e3ae90e。
当前任务中没有能明确映射到 Day 15、题号和上述问题源哈希的聊天答案。
答案来源:none。
五题均为未作答、未评分 ;没有有效证据可给分,缺答也不能直接记为 0 分。
总分:不足以评分 。
红线:未知 。没有证据证明命中,也没有证据证明已避开六类红线。
薄弱知识 ID:无法从无作答证据中确定 ,因此不虚构错题或针对性薄弱项。
状态变化:challenge.map 保持 covered_unverified,Map 闯关仍为未通过 ;方向保持 active。
推进边界:今天继续 challenge.map 的第四个变式“多商户对账差异升级索引”,不得进入 Set 。阅读、复制或运行下方参考答案均不会改变状态。
题号
固定维度
分值
大大的有效答案
点评与得分
1
契约与选型
25
无
无法核对复合键、调度全序、缓存淘汰、null 与快照所有权,未评分
2
复杂度与状态推演
12
无
无法核对 n、k 的成本、扩容树化和 fail-fast 边界,未评分
3
复杂度与状态推演
13
无
无法核对严格上界、访问热度、活视图、快照与多索引原子性,未评分
4
编码
30
无
未提交完整 Java 21 代码、编译证据和规定输出,编码维度未评分,闯关不可放行
5
源码讲解
20
无
无法核对固定版本的公开入口、关键字段、主路径与可观察结果,未评分
合计
100
证据不足
不足以评分
无作答时可先独立重做第 1~3 题,再在不看参考实现的情况下完成三个 TODO,最后用第 5 题的源码路径反向解释运行结果。这只是通用复习路线,不表示已经发现大大的确定错误。
2. Day 15 五道闯关题完整参考答案
第 1 题:从身份、顺序和所有权推出四张 Map
事实主表使用 HashMap<MessageKey, RetryMessage>。不可变 MessageKey(tenantId, messageId) 的两个组件共同参与 equals/hashCode,只有租户与消息号都相同才是同一业务键;不同租户的 msg-7 可以并存。键和值均禁止 null,所以本题中 get 返回 null 可解释为缺失;若未来允许 null value,则必须配合 containsKey 消歧。
重试调度使用 TreeMap<RetryKey, MessageKey>。RetryKey 依次按重试分钟升序、优先级降序、租户升序、消息号升序比较;合法键上 compareTo==0 恰好代表四个组件相同,不会让同一分钟的不同消息相互覆盖。调度遇见顺序来自比较合同,不来自 HashMap。
状态计数使用 EnumMap<RetryState, Integer>。键域封闭,遇见顺序是枚举声明顺序,null key 被拒绝;WAITING 减到零时删除该计数项。
诊断缓存使用容量为 3 的 access-order LinkedHashMap<MessageKey, RetryMessage>。成功命中、更新已有键或插入都会影响热度,超限只淘汰最久未访问的缓存项;缓存不是事实来源,淘汰不能删除 byKey 或调度项。
范围查询在所有者内部使用 TreeMap 活视图,跨边界返回时立即投影为独立不可变字符串列表。只读包装仅阻止经包装引用修改,仍可能随原容器变化,不能冒充快照。
byKey 是唯一事实来源,调度树、计数和诊断缓存均为可重建投影。四张普通 Map 的连续更新不是事务;生产环境必须由单线程所有者、共同锁、不可变聚合状态交换或数据库事务覆盖完整转换。
WeakHashMap 会让条目生命周期取决于键可达性和非确定性 GC;IdentityHashMap 用 == 判断键,等值但不同实例的反序列化业务键会查不到旧消息。二者均不符合稳定业务身份的事实表合同。
这七条先定义了“谁是真相、什么构成同一条消息、每种顺序从哪里来”,再选择实现类;没有依赖一次打印的偶然顺序,也没有让有界缓存反过来决定消息是否存在。
第 2 题:整次业务操作由最贵的树索引步骤主导
设事实表有 n 条消息,到期窗口命中 k 条:
复合键精确查询的期望时间为 O(1),但不是每次严格 O(1)。
首次接收包含事实表条件写入、调度树插入、枚举计数和诊断缓存写入;树插入 O(log n) 主导,所以整次为 O(log n)。
领取首项需要在 TreeMap 中定位并删除候选,主要成本为 O(log n);事实 value 替换、计数迁移和诊断缓存更新为常数或期望 O(1),整次仍为 O(log n)。
范围边界定位与固化 k 项合计 O(log n + k),独立字符串快照额外占 O(k) 空间。
EnumMap 只有两个状态槽位,单次计数读写按 O(1) 理解。
容量固定为 3 的诊断缓存,命中、插入和一次最老项淘汰的期望时间为 O(1);输出缓存顺序最多遍历 3 项,在本题固定容量下为 O(1)。若容量改为变量 c,输出就是 O(c)。
事实表和导航索引随消息数增长,整体索引空间为 O(n);状态表与固定上限缓存为 O(1)。
固定 OpenJDK jdk-21+35 中,新增节点超过负载阈值会触发 resize,这次操作需要处理旧 table 的节点;碰撞还可能沿链或树查找。碰撞桶达到树化请求条件时,若表容量小于 MIN_TREEIFY_CAPACITY=64,treeifyBin 会优先扩容,容量达到门槛后才可能树化。因此正常分布下的期望 O(1) 不是单次严格保证。迭代器比较 expectedModCount 与 modCount 只是尽力暴露结构修改误用,不提供互斥、内存可见性或复合操作原子性,所以 fail-fast 不是线程安全机制。
第 3 题:先逐步推诊断热度,再区分视图与快照
用 A、B、C、D 分别表示:
A:tenant-a/m1@420#P2
B:tenant-b/m1@400#P1
C:tenant-a/m2@400#P5
D:tenant-c/m9@430#P4
诊断缓存容量为 2。按顺序接收 A、B、C 后,热度变化为 [A]→[A,B]→[B,C],第三次插入淘汰最久未访问的 A。接着诊断访问 A 时缓存未命中,只返回 MISS,不会把事实表中的 A 自动装回缓存,因此仍为 [B,C]。接收 D 后插入尾部并淘汰 B,得到 [C,D]。
此时调度树的比较顺序为 [C@400#P5, B@400#P1, A@420#P2, D@430#P4]。取得 [400,421) 活视图及快照时,两者均按 [C,B,A] 排列;D 在范围外。重复接收 A 时,byKey.putIfAbsent 命中旧值并立即返回,既不能重复增加 WAITING,也不能借重复请求刷新缓存,所以缓存仍为 [C,D]。
执行 claimNext(420) 时,上界 420 严格排除 A;在分钟 400 的候选中 P5 的 C 排在 P1 的 B 前,因此领取 tenant-a/m2。领取后的结论是:
调度索引顺序:tenant-b/m1@400#P1、tenant-a/m1@420#P2、tenant-c/m9@430#P4。
状态计数:WAITING=3、CLAIMED=1。
诊断缓存:领取后以新 value 更新已有 C,access-order 将它移到尾部,最终为 [D,C],即 [tenant-c/m9, tenant-a/m2]。
A 和 B 虽已被缓存淘汰,仍然存在于事实主表;缓存淘汰没有事实删除语义。
旧 [400,421) 活视图当前包含 [B,A],因为删除 C 写穿根 TreeMap,且范围上界 421 仍包含分钟 420 的 A。
旧不可变快照仍保留 [C@400#P5:WAITING, B@400#P1:WAITING, A@420#P2:WAITING],因为它保存复制时刻的字符串。
单次 putIfAbsent、computeIfPresent 或视图删除只约束当前 Map 的当前操作;事实替换、导航删除、计数迁移和缓存热度更新跨越四张 Map。题设用单线程顺序便于验证不变量,但生产中的并发穿插或进程中断仍可能产生部分更新,必须由更外层的一致性边界处理。
第 4 题:三个 TODO 的实现策略
接收:先检查 message 非空且为 WAITING,再由复合键对 byKey 做“缺失才写入”。重复时立即返回,调度、计数和诊断热度均不变;首次接收成功后才维护三个派生索引。
到期快照:使用两个排在同分钟全部合法业务键之前的边界键创建 subMap(lower, true, upper, false),依比较顺序回查事实并固化字符串;索引失配立即暴露,返回列表不再背靠内部 Map。
领取:在严格截止上界的 headMap 中取首项,核对事实存在、状态和导航键;以新 record 替换事实 value,从活视图删除导航项,迁移计数,并用领取后 value 更新 access-order 缓存。
缓存淘汰:更新或插入最多触发一次 removeEldestEntry,只影响缓存;被淘汰消息仍由事实表管理。
原子性:以上步骤在练习的单线程所有权内按顺序完成,不代表跨 Map 自动原子;生产环境仍需共同锁、聚合状态交换或持久化事务。
第 3 节给出与 Day 15 起始代码一致的完整 Java 21 参考实现与规定输出。
第 5 题:固定源码路径必须回到可观察合同
固定版本为 OpenJDK jdk-21+35:
要解释的问题
公开入口
关键字段
最多四个箭头节点的主路径
可观察结果或扩展点
HashMap 精确查询及新键、同键更新、删除
get、put、remove
table、size、modCount
get→getNode→桶首/链/树匹配;put→putVal→相等替换或新增;remove→removeNode→摘链/树删除
等值键更新返回旧值且 size 不增;新键和成功删除改变结构;遇见顺序不受保证
容量阈值、扩容拆分、树化与结构修改检测
put、集合视图的 iterator
threshold、loadFactor、table、modCount、expectedModCount
putVal→size 超阈值→resize→按旧容量位拆高低链;treeifyBin→容量检查→扩容或树化;nextNode→比较 modCount
容量不足 64 时长链优先扩容;迭代器只尽力快速失败,不提供线程安全
LinkedHashMap access-order 命中、已有键更新与淘汰钩子
get、put
head、tail、accessOrder
get→afterNodeAccess→节点移到尾部;putVal→afterNodeAccess/afterNodeInsertion→removeEldestEntry
access-order 中成功读取和已有键更新会影响热度;插入后子类可淘汰最老项,但不会删除另一张事实表
TreeMap 比较定位、subMap/headMap 与写穿边界
put、subMap、headMap
root、size、comparator、范围端点
put→逐节点 compare→替换或插入并平衡;subMap/headMap→范围视图→边界导航/写穿
compare==0 视为同一排序键;视图按比较顺序迭代,删除写穿根表,越界写入失败
EnumMap 枚举键域表示与计数更新
put、get、merge
keyType、keyUniverse、vals、size
put→typeCheck→key.ordinal→maskNull 后写槽位
遇见顺序为枚举声明顺序;同一常量覆盖同一槽位,null key 被拒绝,null value 以内部哨兵区分
这些路径解释的是固定实现怎样产生 API 可观察结果,不能把私有节点形状、树化阈值、当前打印顺序或 fail-fast 扩张成业务排序合同、线程安全或跨集合事务。可核对 HashMap.java 、LinkedHashMap.java 、TreeMap.java 与 EnumMap.java 。
3. Day 15 主练习完整可运行参考实现
下面只补全原起始代码的三个 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 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) {
Objects.requireNonNull(message, "message");
if (message.state() != RetryState.WAITING) {
throw new IllegalArgumentException(
"new message must be waiting");
}
if (byKey.putIfAbsent(message.key(), message) != null) {
return false;
}
// 事实去重成功后才维护投影;生产环境需要覆盖整段的原子边界。
retrySchedule.put(RetryKey.from(message), message.key());
stateCounts.merge(RetryState.WAITING, 1, Integer::sum);
diagnostics.put(message.key(), message);
return true;
}
private List<String> dueSnapshot(
long fromInclusive,
long toExclusive) {
if (fromInclusive > toExclusive) {
throw new IllegalArgumentException(
"fromInclusive must be <= toExclusive");
}
NavigableMap<RetryKey, MessageKey> window =
retrySchedule.subMap(
RetryKey.boundary(fromInclusive), true,
RetryKey.boundary(toExclusive), false);
return window.values().stream()
.map(key -> Objects.requireNonNull(
byKey.get(key),
"retry schedule must reference an existing message"))
.map(RetryMessage::snapshotLine)
.toList();
}
private String claimNext(long beforeExclusive) {
if (beforeExclusive < 0) {
throw new IllegalArgumentException(
"beforeExclusive must be >= 0");
}
NavigableMap<RetryKey, MessageKey> candidates =
retrySchedule.headMap(
RetryKey.boundary(beforeExclusive), false);
Map.Entry<RetryKey, MessageKey> first = candidates.firstEntry();
if (first == null) {
return "NONE";
}
MessageKey key = first.getValue();
RetryMessage current = byKey.get(key);
if (current == null
|| current.state() != RetryState.WAITING
|| !RetryKey.from(current).equals(first.getKey())) {
throw new IllegalStateException(
"retry schedule is inconsistent with fact map");
}
RetryMessage claimed = byKey.computeIfPresent(
key,
(ignored, existing) -> existing.claimed());
// 从子视图删除会写穿根 TreeMap;后续同步其余派生投影。
candidates.remove(first.getKey());
int waiting = stateCounts.merge(
RetryState.WAITING, -1, Integer::sum);
if (waiting == 0) {
stateCounts.remove(RetryState.WAITING);
}
stateCounts.merge(RetryState.CLAIMED, 1, Integer::sum);
diagnostics.put(key, claimed);
return key.label();
}
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);
}
}
三个待补位置的关键步骤
accept 在任何写入前验证参数和 WAITING,再以复合键让 byKey.putIfAbsent 决定是否首次出现。重复时立即返回,避免重复计数、调度覆盖或把重复请求变成缓存刷新后门;成功后才维护三个投影。
dueSnapshot 用同一分钟内排在全部合法业务键之前的边界键构造半开范围。包含下界边界会收进下界分钟,排除上界边界会排除上界整分钟;回查缺失明确暴露索引不一致,toList() 固化的字符串列表不再背靠内部 Map。
claimNext 从严格上界视图取得第一项,同时验证事实状态与完整导航键。事实以新 record 替换,子视图删除写穿根树,计数从 WAITING 迁移到 CLAIMED;diagnostics.put 更新领取后的 value,并按 access-order 调整热度。
精确编译与运行输出
javac --release 21 MessageRetryChallenge.java
java MessageRetryChallenge
运行输出必须为:
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-a/msg-7 升温,第四次接收淘汰 tenant-b/msg-7 的缓存项但不删除事实;领取又把 tenant-a/msg-8 移到缓存尾部。旧快照仍保留领取前的 WAITING 字符串。
时间与空间复杂度
accept 的事实表条件写入期望 O(1),调度树插入 O(log n),计数与缓存更新为常数或期望 O(1),整次为 O(log n)。
dueSnapshot 的边界定位和 k 项投影为 O(log n + k),稳定字符串列表额外占 O(k)。
claimNext 的候选定位和树删除为 O(log n),事实替换、状态迁移和缓存更新为常数或期望 O(1),整次为 O(log n)。
attemptOf 与诊断命中的期望时间为 O(1);scheduleOrder 为 O(n);诊断缓存输出与状态摘要因键域和容量固定均按 O(1) 理解。
事实表与调度树的总空间为 O(n),固定状态表和有界缓存为 O(1)。这些期望结论不等于最坏碰撞、并发安全或跨 Map 事务保证。
边界复核
不同租户的同号消息互不覆盖;完全相同的复合键重复接收返回 false,且不得刷新缓存热度。
[320,320) 返回空快照;下界大于上界在业务读取或写入前失败。
claimNext(300) 返回 NONE;严格上界 320 不包含重试分钟恰为 320 的消息。
相同分钟、相同优先级的不同租户或消息号仍由完整比较器区分,不能静默覆盖。
快照列表不可增删,领取后的事实替换也不会改写旧字符串;导航视图则随根树变化。
缓存淘汰只释放诊断投影,不删除事实或调度;缓存未命中也不能据此宣称消息不存在。
生产环境必须选择覆盖四张 Map 的共同所有权、锁、事务或补偿边界;fail-fast 不提供这些能力。
4. 今日变式前的最短自检
能否在 60 秒内说明四张 Map 的职责,并指出为什么诊断缓存不是事实来源?
能否从分钟、优先级、租户和消息号推出调度全序,而不查看 HashMap 的打印结果?
能否逐步推演容量为 2 的 access-order 缓存,区分命中、未命中、已有键更新和淘汰?
能否解释旧活视图与旧快照在领取后的不同内容,以及严格上界 420 的作用?
能否解释接收和领取为什么整体是 O(log n),而非只看事实主表回答 O(1)?
能否明确承认没有有效作答,所以 challenge.map 仍是 covered_unverified,今天必须继续 Map 而不能进入 Set?
如有一项含糊,先换一组商户键、升级时间和缓存访问序列重新推演,再开始今天的对账差异变式。这里的完整参考答案始终只用于教学核对,不会被记录为大大的答案证据。
返回今日索引
返回今日索引
Day 16 核心讲解:Map 闯关变式——多商户对账差异升级索引
评估项:challenge.map 方向课次:14 模式:checkpoint 建议用时:31~32 分钟 版本基线:Java 21、OpenJDK jdk-21+35
Day 15 没有有效作答,challenge.map 保持 covered_unverified,方向保持 active。虽然 directionSession=14 命中阶段复习节奏,但未通过的 Map 闯关门禁优先,因此本课继续用新场景验证 Map,不进入 Set。下方演示只隔离验证集合合同,不包含今日编码题 3 个 TODO 的完整实现。
1. 为什么需要:对账差异不是一张“万能 Map”能解释的
支付平台每天接收多个商户的账单。相同差异单号可能出现在不同商户下;差异要按截止分钟和严重级别升级;运营希望查看最近诊断过的差异;监控还要统计 OPEN 与 ESCALATED 数量。若只用一张 HashMap,精确查询虽方便,却没有范围导航或优先级顺序合同。若把所有需求粗暴拆成四张 Map,又会引入“事实已升级、旧调度键仍残留、计数未迁移”的多索引一致性问题。
真正的起点不是集合类名,而是业务不变量:什么字段定义同一差异,什么字段允许变化,缺失怎样表示,升级顺序由谁承诺,缓存淘汰能否删除事实,跨层返回的是联动视图还是稳定报告。只有这些问题有答案,才能分别选择 HashMap、TreeMap、LinkedHashMap 和 EnumMap,并说明每次状态转换的返回值、复杂度与失败边界。
九个 Map 知识项在本题形成一条证据链:ds.map.contract 约束存在性、null 和方法返回值;ds.map.equality 固定差异身份;ds.map.hashmap-structure 及两组 HashMap 源码专题解释桶、替换、删除、扩容、树化和迭代检测;ds.map.compute-merge-views 解释条件转换、计数和所有权;LinkedHashMap、TreeMap 与 specialized Map 则分别提供访问顺序、导航全序和封闭枚举等窄语义。闯关要求这些结论互相吻合,而不是九段背诵。
2. 前置知识:把对账规则翻译成七条可执行约束
设事实身份为 DifferenceKey(merchantId, ticketNo)。差额、严重级别、升级截止分钟与状态都会变化,所以它们属于 value。调度树另用 UpgradeKey(dueMinute, severity, merchantId, ticketNo):先按截止分钟升序,同分钟严重级别降序,再以商户和差异单号升序补足全序。
先固定七条约束:
DifferenceKey 不可变,equals/hashCode 使用相同的商户与单号字段;不同商户的同号差异必须并存。
事实表禁止 null key 和 null value,因此 get()==null 才能直接解释为缺失;若将来允许 null value,必须同时用 containsKey 消歧。
UpgradeKey.compareTo()==0 必须表示四个排序组件均相同。只比较分钟或“分钟 + 严重级别”会让不同差异在 TreeMap 中互相覆盖。
byKey 是唯一事实来源;升级树、诊断缓存和状态计数是可校验、可重建的派生投影。
范围查询先得到 TreeMap 活视图;越过服务边界前要投影并复制。只读包装仍可能跟随原 Map,不能冒充不可变快照。
诊断缓存明确采用 access-order 且容量有界;淘汰只丢缓存引用,不能删除事实或调度项。重复上报也不能偷偷刷新热度。
四张 Map 的连续写入不构成事务;并发、进程失败或持久化失败必须由外层锁、单线程所有者、不可变聚合交换或数据库事务处理。
复杂度要针对完整动作:事实精确读写通常是期望 O(1);升级树单项插入、删除和首项导航是 O(log n);范围定位并复制 k 项是 O(log n + k);诊断缓存的命中、写入和一次淘汰通常是期望 O(1);枚举键域固定,状态计数按常数规模理解。因此一次登记或升级包含 TreeMap 操作时,整体通常是 O(log n),不能因为其中有 HashMap 就只答 O(1)。
随堂检查 1:Comparator 比较截止分钟和严重级别,但不比较商户、差异单号;它仍是一个能排序的比较器,为什么业务上却错误?
**即时答案:**TreeMap 把比较结果为 0 的两个键视为同一排序键。同一分钟、同严重级别的不同差异会互相替换,导致事实表有两条而调度树只剩一条。必须加入稳定的商户和单号作为最终区分量,使“比较为 0”与本索引所需的键等价一致。
3. 定义:一个事实空间,三个查询投影
角色
推荐实现
它承诺什么
它不承诺什么
差异事实
HashMap<DifferenceKey, Difference>
按值相等的复合身份精确定位
业务顺序、最坏情况恒定 O(1)、跨 Map 事务
升级调度
TreeMap<UpgradeKey, DifferenceKey>
比较顺序、首项、邻近与范围查询
事实真实性、并发业务事务
诊断窗口
access-order LinkedHashMap
最近访问顺序及可定义的最老项淘汰钩子
持久事实、自动线程安全
状态计数
EnumMap<DifferenceState, Integer>
封闭枚举键域与声明顺序
与事实表自动同步
Map 的返回值也是状态证据。put 返回旧值,可区分新增与替换;putIfAbsent 返回已有值,可阻止重复上报继续修改派生索引;remove(key, value) 只有当前映射仍匹配时才删除;computeIfPresent 只在事实存在时转换;merge 适合累计单个枚举槽位。需要注意,映射函数返回 null 可能表示“不建立映射”或“删除映射”,具体要按 API 合同推演,不能把 null 当普通新值草草处理。
这四张 Map 仍不是四份同等权威的数据。升级树中的 key 必须能回查到事实表中的 OPEN 差异;状态计数总和应与事实状态投影一致;缓存中不存在某项只表示它不热,不表示事实不存在。验收应把这些关系写成不变量,并为派生索引提供校验或重建路径。
4. 心智模型:身份键与调度键必须分开推演
第一层是稳定身份。商户、差异单号共同回答“这还是不是同一笔差异”;差额从 800 变 900、严重级别从 2 变 4、截止时间延期,都不应改变事实 key。Java record 能生成基于组件的值相等与哈希,但组件自身也必须稳定,不能把可变集合或可变数组塞进 record 后就假设整个 key 安全。
第二层是派生排序。UpgradeKey 包含截止分钟和严重级别,是事实在某一时刻投影出的排序键。若严重级别或截止时间改变,不能只替换事实 value:TreeMap 不会自动知道旧键代表的数据已变化。正确状态机要保存或重建旧 UpgradeKey,先移除旧投影,再插入新投影,并由外层一致性边界覆盖全过程。直接修改已在 TreeMap 中参与比较的可变键更危险,会破坏树的查找路径。
第三层是观察顺序。TreeMap 的遇见顺序来自比较器;诊断缓存的顺序来自成功访问;HashMap 没有业务顺序。一个 API 若要返回“升级队列”,只能使用排序索引或显式排序后的副本;若要返回“最近诊断”,才使用访问顺序缓存。两个顺序不能互相替代。
第四层是所有权。subMap/headMap/entrySet 是背靠根 Map 的视图,适合索引所有者内部写穿;跨线程或跨层返回应复制不可变字段。Collections.unmodifiableMap(root) 仅阻止调用者经包装引用修改,拥有 root 的代码仍可改变内容;Map.copyOf(root) 创建独立、不可修改的映射副本,但复制是浅层的,value 可变时仍要另做隔离,而且其 null 限制也必须提前满足。
专用实现要从语义判断。WeakHashMap 让条目生命周期受 key 可达性和 GC 影响,不适合作为财务差异事实;IdentityHashMap 用 == 区分实例,两个反序列化得到的等值业务键会被当作不同差异;EnumMap 的枚举 ordinal 键域恰好适合有限状态。所谓“专用”不是普遍更快,而是合同更窄。
5. 完整数值与数据状态推演:从桶扩容到一次升级
先做 HashMap 结构推演。假设 table 容量为 8、负载因子 0.75、threshold=6,当前 size=5。差异 A 的扰动后 hash 为 2,差异 B 为 10;容量 8 时二者都落在桶 2,因为 7 & 2=2、7 & 10=2。
步骤
操作
返回值
size 与结构
1
put(keyA, openA)
null
新增 A,size=6,尚未超过阈值
2
put(equalA, revisedA)
openA
equals 命中并替换 value,size 仍为 6
3
put(keyB, openB)
null
新增 B,size=7,超过阈值后触发 resize
4
容量扩至 16
无业务返回值
2 & 8=0 的 A 留桶 2;10 & 8!=0 的 B 去桶 10
5
remove(equalA)
revisedA
用相同 hash/equals 找到并摘除 A,size=6
扩容要处理旧 table 节点,碰撞查找也可能沿链或树前进,所以“HashMap 每次严格 O(1)”是错的。固定 OpenJDK 21 中,碰撞桶发起树化请求后还要检查 table 容量;小于 MIN_TREEIFY_CAPACITY=64 时优先扩容,容量足够才可能树化。树化改善严重碰撞,却不能修复相等合同或可变 key。
再推演业务数据。按上报顺序接收三条 OPEN 差异:
A:merchant-a/D-31@620#S2:+870
B:merchant-b/D-31@600#S4:-260
C:merchant-a/D-32@600#S1:+120
事实表有 3 条,因为 A 与 B 的单号相同但商户不同。升级树按“分钟升序、严重级别降序、商户、单号”排列为 [B@600#S4, C@600#S1, A@620#S2];状态表为 {OPEN=3}。诊断缓存容量为 2,连续接收 A、B、C 后淘汰 A,顺序为 [B,C]。先查看 B 得 [C,B],再查看 C 得 [B,C]。
此时以一个内容相等的新 DifferenceKey("merchant-a","D-31") 重复上报 A。事实表的条件写返回已有值,整个业务分支必须立即结束:size、升级树、状态计数和缓存热度全都不变,A 也不能借重复请求重新进入缓存。
接着取得 [600,621) 的导航活视图,并投影为稳定字符串快照 S=[B,C,A]。执行“升级严格早于 620 的首项”:A 恰好在 620,被上界排除;B 与 C 都在 600,但 B 严重级别更高,所以 B 被升级。事实 B 变为 ESCALATED,旧调度键移除,计数变为 {OPEN=2, ESCALATED=1},诊断缓存写入 B 后由 [B,C] 变成 [C,B]。旧活视图现在是 [C,A],旧快照 S 仍保存升级前的 [B,C,A] 与 OPEN 字段。
若事实 B 已改状态,调度键也已删除,但计数更新前进程退出,系统就出现半完成状态。单线程只排除同进程内交错,不能抵抗崩溃;几个成功的 put/remove/merge 也不会自动成为事务。生产方案必须把完整转换置于共同边界内,或承认派生索引可从权威事实和事件日志重建。
随堂检查 2:先取得升级树的 subMap,再删除 B;旧视图和此前的 List.copyOf(...) 会各自怎样变化?
**即时答案:**subMap 是联动视图,会立即少掉 B;先前复制的独立 List 保持复制时刻内容。若只是 unmodifiableMap(subMap),仍然背靠同一根树,只是不能经包装引用写入,根树变化仍会被观察到。
6. 源码映射:从业务问题沿固定入口追到底
固定源码为 OpenJDK jdk-21+35,每条源码回答都应包含“问题 → 入口类/方法 → 关键字段 → 主调用链 → 扩展点 → 调试练习 → 公开结论”。
问题
入口类 / 方法
关键字段
主调用链(最多四节点)
扩展点与可观察结论
等值键为何替换且 size 不增
HashMap.put/get/remove
table、size、modCount
put→putVal→桶定位→相等替换/新增;get→getNode→链/树匹配
newNode/afterNodeAccess;替换返回旧值,新增和结构删除才改结构
何时扩容、树化或快速失败
HashMap.put、视图迭代器
threshold、loadFactor、modCount、expectedModCount
putVal→size 检查→resize→低高链拆分;treeifyBin→容量检查→树化/扩容
TreeNode 路径;fail-fast 只尽力检测误用,不提供线程安全
条件转换、计数和视图怎样落地
putIfAbsent/computeIfPresent/merge/entrySet
当前 Map 的桶、size、修改计数
公共入口→单键查找→回调结果→替换/新增/删除
回调函数和集合视图;保证只限当前 Map,不扩张成跨索引事务
诊断访问为何移动且何时淘汰
LinkedHashMap.get/put
head、tail、accessOrder
get→afterNodeAccess→摘链→接到尾部;putVal→afterNodeInsertion→钩子
removeEldestEntry;访问顺序与插入顺序是不同合同
升级首项与范围为何稳定有序
TreeMap.put/subMap/headMap
root、comparator、范围端点
put→逐节点 compare→插入/替换→平衡;范围入口→NavigableSubMap→边界导航
Comparator;比较为 0 表示同一排序键,范围视图背靠根树
枚举计数为何按声明顺序
EnumMap.put/get/merge
keyType、keyUniverse、vals、size
put→typeCheck→ordinal→写数组槽位
封闭键域;null key 被拒绝,同常量覆盖同槽位
官方源码入口可核对 HashMap.get/getNode 、HashMap.put/putVal 、HashMap.resize/treeifyBin 、HashMap.merge 、LinkedHashMap.afterNodeAccess 、TreeMap.getFloorEntry 与 EnumMap.get/put 。
调试时可在 HashMap.putVal 的相等分支与新增分支分别停住,比较 size、modCount 和返回值;把容量设小,在 resize 中观察旧容量位拆分;切换 LinkedHashMap 的 accessOrder,比较 get 后 head/tail;在 UpgradeKey 比较器中观察 B、C 同分钟为何仍不为 0;在 TreeMap 范围视图删除一项,确认根树同步变化。源码里的阈值和节点形状只是固定实现证据,不能升级成业务合同。
7. 后端应用:升级状态机必须保护旧投影与新投影
真实对账服务首先在接口边界校验商户、差异号、截止时间、严重级别和金额格式。重复上报应返回已有事实或幂等结果,不能重复计数。若开放“调整截止时间/严重级别”,服务必须根据旧事实构造旧 UpgradeKey,移除旧投影后再插入新键;只改事实 value 会留下幽灵调度项,只插新键又会造成一条事实对应多个候选。
财务差异通常要跨重启保留,因此数据库更适合作为权威事实。状态从 OPEN 升级为 ESCALATED 时,可用版本号或条件更新防止重复升级,并在同一事务中维护持久调度数据;内存 TreeMap 与诊断缓存作为加速投影,在提交后更新,失败时可从数据库或事件日志重建。读取多个投影时也应先取得同一版本的聚合状态,避免拼出从未同时存在的事实与计数。
下面的 Java 21 程序只分别验证相等键替换、TreeMap 全序/视图、访问顺序和枚举计数,没有实现今日主练习的登记、快照或升级方法:
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.Set;
import java.util.TreeMap;
public final class ReconciliationContractsDemo {
private enum DifferenceState {
OPEN,
ESCALATED
}
private record DifferenceKey(String merchantId, String ticketNo) {
DifferenceKey {
Objects.requireNonNull(merchantId, "merchantId");
Objects.requireNonNull(ticketNo, "ticketNo");
if (merchantId.isBlank() || ticketNo.isBlank()) {
throw new IllegalArgumentException("blank difference key");
}
}
String label() {
return merchantId + "/" + ticketNo;
}
}
private record UpgradeKey(
long dueMinute,
int severity,
String merchantId,
String ticketNo) implements Comparable<UpgradeKey> {
@Override
public int compareTo(UpgradeKey other) {
int byMinute = Long.compare(dueMinute, other.dueMinute);
if (byMinute != 0) {
return byMinute;
}
int bySeverity = Integer.compare(other.severity, severity);
if (bySeverity != 0) {
return bySeverity;
}
int byMerchant = merchantId.compareTo(other.merchantId);
return byMerchant != 0
? byMerchant
: ticketNo.compareTo(other.ticketNo);
}
}
public static void main(String[] args) {
DifferenceKey a = new DifferenceKey("merchant-a", "D-31");
DifferenceKey equalA = new DifferenceKey("merchant-a", "D-31");
DifferenceKey b = new DifferenceKey("merchant-b", "D-31");
DifferenceKey c = new DifferenceKey("merchant-a", "D-32");
Map<DifferenceKey, String> facts = new HashMap<>();
System.out.println("first-old=" + facts.put(a, "OPEN:+870"));
System.out.println("replace-old="
+ facts.put(equalA, "OPEN:+900"));
facts.put(b, "OPEN:-260");
facts.put(c, "OPEN:+120");
System.out.println("facts-size=" + facts.size());
System.out.println("a-now=" + facts.get(a));
UpgradeKey orderA = new UpgradeKey(620, 2, "merchant-a", "D-31");
UpgradeKey orderB = new UpgradeKey(600, 4, "merchant-b", "D-31");
UpgradeKey orderC = new UpgradeKey(600, 1, "merchant-a", "D-32");
NavigableMap<UpgradeKey, DifferenceKey> schedule = new TreeMap<>();
schedule.put(orderA, a);
schedule.put(orderB, b);
schedule.put(orderC, c);
Set<Map.Entry<UpgradeKey, DifferenceKey>> live = schedule.entrySet();
List<String> snapshot = live.stream()
.map(entry -> entry.getValue().label()
+ "@" + entry.getKey().dueMinute()
+ "#S" + entry.getKey().severity())
.toList();
System.out.println("schedule=" + snapshot);
schedule.remove(orderB);
System.out.println("live-after-remove=" + live.stream()
.map(entry -> entry.getValue().label())
.toList());
System.out.println("snapshot-stable=" + snapshot);
LinkedHashMap<DifferenceKey, 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(DifferenceKey::label)
.toList());
EnumMap<DifferenceState, Integer> counts =
new EnumMap<>(DifferenceState.class);
counts.merge(DifferenceState.OPEN, 3, Integer::sum);
counts.merge(DifferenceState.OPEN, -1, Integer::sum);
counts.merge(DifferenceState.ESCALATED, 1, Integer::sum);
System.out.println("counts=" + counts);
}
}
预期输出:
first-old=null
replace-old=OPEN:+870
facts-size=3
a-now=OPEN:+900
schedule=[merchant-b/D-31@600#S4, merchant-a/D-32@600#S1, merchant-a/D-31@620#S2]
live-after-remove=[merchant-a/D-32, merchant-a/D-31]
snapshot-stable=[merchant-b/D-31@600#S4, merchant-a/D-32@600#S1, merchant-a/D-31@620#S2]
recent-after-read=[merchant-a/D-31, merchant-a/D-32, merchant-b/D-31]
counts={OPEN=2, ESCALATED=1}
程序刻意不把四张 Map 包装成一个业务服务,因此不能作为三个 TODO 的答案,更不能证明跨 Map 原子性。它只提供可复核的集合合同证据。
8. 错误示例:可变排序字段最容易制造幽灵候选
factKey = ticketNo // 漏商户,跨商户覆盖
scheduleKey = mutableDifference // severity 改变后破坏树路径
schedule.compare = dueMinuteOnly // 同分钟不同差异比较为 0
report = unmodifiableMap(schedule) // 只读包装仍随根树变化
facts.computeIfPresent(key, escalate)
counts.merge(ESCALATED, 1, sum) // 中途失败不回滚旧调度项
catch ConcurrentModificationException // 错把 fail-fast 当并发控制
即使单元样例恰好输出正确,这套方案也没有稳定身份、完整全序或共同事务边界。把事实表换成 ConcurrentHashMap 只能改变该容器部分操作的并发性质,不会自动修复 TreeMap,也不会让多键、多集合更新成为原子事务。
9. 正确示例:用十个问题审查一次状态转换
先问身份字段是否稳定;再问 null 是否有唯一语义;确认事实来源与派生索引;为 UpgradeKey 补足全序;区分比较顺序与访问顺序;说明缓存淘汰不删事实;列出旧视图和新快照的所有权;给出每个 Map 方法的返回值和无副作用分支;从固定源码入口解释结构结果;最后选择覆盖“事实替换 + 旧调度删除 + 计数迁移 + 缓存更新”的外层一致性边界。
如果截止分钟或严重级别可以调整,还要把“删除旧 UpgradeKey、校验没有冲突、插入新 UpgradeKey”作为一个明确状态转换。读多写少且数据受控时,可先复制完整聚合状态并一次交换引用;需要持久化和崩溃恢复时,更适合数据库事务、版本条件更新和可重建内存投影。结构选型解决查询能力,一致性方案解决业务原子性,两者不能混为一谈。
随堂检查 3:把 byKey 换成 ConcurrentHashMap,并对每个事实键使用 compute,能否保证事实、升级树和计数永远一致?
**即时答案:**不能。单键 compute 的边界不包含 TreeMap、EnumMap、另一个事实键或数据库。完整升级仍需共同锁、单线程所有者、不可变聚合交换或持久化事务;fail-fast 也不能补上这些保证。
10. 边界总结:六类红线与本题门禁
HashMap、HashSet 没有稳定业务顺序;升级顺序必须来自完整 Comparator,诊断顺序必须来自明确的 LinkedHashMap 模式。
equals/hashCode 使用同一组不可变身份字段;Comparator 为 0 必须符合调度键等价,已入树的比较字段不能变化。
集合视图背靠原容器;只读包装不等于不可变集合;跨边界历史报告必须复制成稳定快照。
本题没有用 LinkedList 代替升级索引;即使保存为链表,在不知道节点位置时中间查找或删除仍为 O(n),不能声称天然 O(1)。
fail-fast 只是尽力发现结构修改误用,不提供互斥、可见性或线程安全。
ConcurrentHashMap 不会让跨键或跨集合操作自动原子;一致性属于更外层的状态机、锁或事务。
challenge.map 只有在完整答案至少 80 分、Java 21 编码成功且无红线时才能通过。生成或阅读本课仍只是 covered_unverified;阶段复习不能绕过 Map 闯关门禁。
一句话收束:事实键守住“是谁”,调度键守住“何时先处理”,快照守住“看到哪个时刻”,外层事务守住“多张索引一起变化”。
官方资料:Java SE 21 Map 、HashMap 、LinkedHashMap 、NavigableMap 、EnumMap 、WeakHashMap 、IdentityHashMap 。
返回今日索引
返回今日索引
编码闯关:多商户对账差异升级索引
评估项:challenge.map。建议用时:18~20 分钟。本题完整代码、Java 21 编译结果与运行输出必须提交;只写思路不能通过 Map 阶段闯关。
你要为支付对账平台完成一个单线程内存索引。平台既要按“商户 + 差异单号”精确定位对账差异,又要按“升级截止分钟 + 严重级别”挑出应进入人工处理的差异,还要维护状态计数与一个容量受限的诊断缓存。本题不复用消息重试的字段或动作:差异金额允许正负但不能为零,核心状态变化是把最早到期的 OPEN 差异升级为 ESCALATED,缓存只用于诊断热度。
1. 业务背景
不同商户可能生成相同 ticketNo,单独用差异单号会串商户;事实键必须同时包含 merchantId 与 ticketNo,并且入表后不可改变。升级顺序先按 dueMinute 升序;同一分钟严重级别越高越先处理;仍相同时再按商户和差异单号形成全序。这个顺序是可验收的业务合同,不能从 HashMap 的一次打印顺序猜出来。
索引各自承担一个职责:
byKey:唯一事实来源,以不可变 DifferenceKey 精确查询差异。
upgradeSchedule:只保存仍为 OPEN 的升级候选,支持时间窗口与最早候选。
stateCounts:用 EnumMap 统计封闭状态域 OPEN/ESCALATED。
diagnostics:容量为 3 的访问顺序 LinkedHashMap;登记、诊断命中与升级后的 value 更新会改变热度,超限只淘汰缓存项,不删除事实或调度项。
后三张都是派生投影。练习在单线程所有权下顺序执行,以便验证不变量;事实表的 putIfAbsent 最多约束当前 Map 的一个键,随后更新 TreeMap、EnumMap 与 LinkedHashMap 不会自动组成事务。真实系统应由同一事件所有者、共同锁或数据库事务包住完整状态转换,并准备派生索引重建或补偿。
2. 约束与验收边界
基线为 Java 21;只使用 JDK 集合,不引入依赖、日志或无法恢复问题的 try/catch。
DifferenceKey 是不可变 record;merchantId、ticketNo 均非空白,不同商户的同号差异必须并存。
新登记差异只能为 OPEN;dueMinute 非负,severity 限定 1..5,deltaCents 可以正或负但不能为 0。
重复复合键返回 false,并且不能重复写调度树、增加计数或刷新诊断缓存热度。
UpgradeKey.compareTo 已给出:截止分钟升序、严重级别降序、商户升序、差异单号升序;在合法业务键上,比较为 0 与四个组件相等一致。
dueSnapshot(fromInclusive, toExclusive) 必须从 TreeMap 的 [fromInclusive,toExclusive) 活视图生成不可变字符串快照,不能扫描 HashMap,也不能把内部视图直接返回。
escalateNext(beforeExclusive) 只考虑截止分钟严格小于上界的 OPEN 候选;没有候选返回 NONE,有候选则升级比较顺序第一项并从调度树移除。
diagnostics 是 access-order 且上限为 3;诊断未命中不改变缓存,命中和已有 key 的 value 更新会把该 key 移到尾部。缓存淘汰绝不能反向删除事实。
禁止依赖 HashMap 遇见顺序、破坏键或 Comparator 合同、混淆活视图与快照、把 fail-fast 当线程安全,或把跨 Map 连续更新称为事务。
3. 目标拆解与实施顺序
完成登记:先校验外部输入与初始状态,再让事实表决定复合键是否首次出现;重复时立即结束。
完成时间窗:验证上下界,用两个专用边界键取得半开导航视图,并按比较顺序固化差异字段。
完成升级:在严格上界视图中找到首项,回查并校验事实,再同步事实 value、调度树、状态计数和诊断缓存。
使用 Java 21 编译运行,逐行核对精确输出;保存补全后的完整代码、编译证据与输出,作为编码维度证据。
说明正负差异金额、相同单号跨商户、严格上界、缓存淘汰及跨 Map 原子性各自的合同边界。
4. 完整可编译的 Java 21 起始代码
保存为 ReconciliationDifferenceChallenge.java。未补代码时仍可通过编译;直接运行会在第一次登记时以 TODO 1: register 明确失败。起始代码只有 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 ReconciliationDifferenceChallenge {
private enum DifferenceState {
OPEN,
ESCALATED
}
private record DifferenceKey(String merchantId, String ticketNo) {
DifferenceKey {
Objects.requireNonNull(merchantId, "merchantId");
Objects.requireNonNull(ticketNo, "ticketNo");
if (merchantId.isBlank() || ticketNo.isBlank()) {
throw new IllegalArgumentException(
"merchantId and ticketNo must not be blank");
}
}
String label() {
return merchantId + "/" + ticketNo;
}
}
private record ReconciliationDifference(
DifferenceKey key,
long dueMinute,
int severity,
long deltaCents,
DifferenceState state) {
ReconciliationDifference {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(state, "state");
if (dueMinute < 0
|| severity < 1
|| severity > 5
|| deltaCents == 0) {
throw new IllegalArgumentException(
"invalid reconciliation difference");
}
}
ReconciliationDifference escalated() {
return new ReconciliationDifference(
key,
dueMinute,
severity,
deltaCents,
DifferenceState.ESCALATED);
}
String snapshotLine() {
return key.label() + "@" + dueMinute
+ "#S" + severity + ":" + state + ":" + deltaCents;
}
}
private record UpgradeKey(
long dueMinute,
int severity,
String merchantId,
String ticketNo) implements Comparable<UpgradeKey> {
static UpgradeKey from(ReconciliationDifference difference) {
return new UpgradeKey(
difference.dueMinute(),
difference.severity(),
difference.key().merchantId(),
difference.key().ticketNo());
}
static UpgradeKey boundary(long minute) {
return new UpgradeKey(
minute,
Integer.MAX_VALUE,
"",
"");
}
@Override
public int compareTo(UpgradeKey other) {
int byMinute = Long.compare(dueMinute, other.dueMinute);
if (byMinute != 0) {
return byMinute;
}
int bySeverity = Integer.compare(other.severity, severity);
if (bySeverity != 0) {
return bySeverity;
}
int byMerchant = merchantId.compareTo(other.merchantId);
return byMerchant != 0
? byMerchant
: ticketNo.compareTo(other.ticketNo);
}
}
private static final class DifferenceIndex {
private static final int DIAGNOSTIC_LIMIT = 3;
private final Map<DifferenceKey, ReconciliationDifference> byKey =
new HashMap<>();
private final NavigableMap<UpgradeKey, DifferenceKey> upgradeSchedule =
new TreeMap<>();
private final EnumMap<DifferenceState, Integer> stateCounts =
new EnumMap<>(DifferenceState.class);
private final LinkedHashMap<
DifferenceKey, ReconciliationDifference> diagnostics =
new LinkedHashMap<>(4, 0.75f, true) {
@Override
protected boolean removeEldestEntry(
Map.Entry<
DifferenceKey,
ReconciliationDifference> eldest) {
return size() > DIAGNOSTIC_LIMIT;
}
};
private boolean register(ReconciliationDifference difference) {
// 校验初始状态并以事实复合键去重,再维护三个派生投影。
throw new UnsupportedOperationException("TODO 1: register");
}
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 escalateNext(long beforeExclusive) {
if (beforeExclusive < 0) {
throw new IllegalArgumentException(
"beforeExclusive must be >= 0");
}
// 升级首个到期候选并同步事实与派生投影。
throw new UnsupportedOperationException("TODO 3: escalateNext");
}
private String deltaOf(DifferenceKey key) {
ReconciliationDifference difference = byKey.get(
Objects.requireNonNull(key, "key"));
return difference == null
? "MISSING"
: Long.toString(difference.deltaCents());
}
private String diagnosticState(DifferenceKey key) {
ReconciliationDifference difference = diagnostics.get(
Objects.requireNonNull(key, "key"));
return difference == null ? "MISS" : difference.state().name();
}
private List<String> scheduleOrder() {
return upgradeSchedule.entrySet().stream()
.map(entry -> entry.getValue().label()
+ "@" + entry.getKey().dueMinute()
+ "#S" + entry.getKey().severity())
.toList();
}
private List<String> diagnosticOrder() {
return diagnostics.keySet().stream()
.map(DifferenceKey::label)
.toList();
}
private List<String> countSummary() {
return stateCounts.entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.toList();
}
}
public static void main(String[] args) {
DifferenceKey merchantAFirst =
new DifferenceKey("merchant-a", "D-41");
DifferenceKey merchantBFirst =
new DifferenceKey("merchant-b", "D-41");
DifferenceKey merchantASecond =
new DifferenceKey("merchant-a", "D-42");
DifferenceKey merchantDFirst =
new DifferenceKey("merchant-d", "D-9");
ReconciliationDifference first = new ReconciliationDifference(
merchantAFirst, 720, 2, 1_500, DifferenceState.OPEN);
ReconciliationDifference second = new ReconciliationDifference(
merchantBFirst, 700, 4, -800, DifferenceState.OPEN);
ReconciliationDifference third = new ReconciliationDifference(
merchantASecond, 700, 5, 250, DifferenceState.OPEN);
ReconciliationDifference fourth = new ReconciliationDifference(
merchantDFirst, 740, 3, 4_000, DifferenceState.OPEN);
DifferenceIndex index = new DifferenceIndex();
System.out.println("REGISTER first=" + index.register(first));
System.out.println("REGISTER second=" + index.register(second));
System.out.println("REGISTER third=" + index.register(third));
System.out.println("REGISTER fourth=" + index.register(fourth));
System.out.println("DIAG evicted-first="
+ index.diagnosticState(merchantAFirst));
System.out.println("DIAG access-second="
+ index.diagnosticState(merchantBFirst));
System.out.println("REGISTER duplicate=" + index.register(first));
System.out.println("BY-KEY merchant-a/D-41="
+ index.deltaOf(merchantAFirst));
System.out.println("BY-KEY merchant-b/D-41="
+ index.deltaOf(merchantBFirst));
System.out.println("DIAG before=" + index.diagnosticOrder());
List<String> beforeEscalation = index.dueSnapshot(700, 721);
System.out.println("WINDOW before=" + beforeEscalation);
System.out.println("COUNTS before=" + index.countSummary());
System.out.println("ESCALATE before-700="
+ index.escalateNext(700));
System.out.println("ESCALATE before-720="
+ index.escalateNext(720));
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=" + beforeEscalation);
}
}
直接编译运行:
javac --release 21 ReconciliationDifferenceChallenge.java
java ReconciliationDifferenceChallenge
5. 三个待补位置的验收条件
位置 1:登记事实并拒绝重复副作用
difference 为 null 时在边界失败;任何 Map 写入前拒绝非 OPEN 的新差异。
对 byKey 以 DifferenceKey 做一次“缺失才写入”;旧值存在时立刻返回 false,不能改变调度树、计数或缓存热度。
事实新增后才写 UpgradeKey.from(difference)、合并增加 OPEN 计数,并把差异加入访问顺序诊断缓存。
说明 putIfAbsent 只保护事实表中的当前键,后续三次派生写入仍需外层原子边界。
位置 2:半开导航视图转稳定快照
用两个 UpgradeKey.boundary 和四参数 subMap 表达 [fromInclusive,toExclusive);不得遍历 byKey 后自行过滤排序。
按导航视图顺序,用 DifferenceKey 回查事实并生成 snapshotLine();派生键找不到事实时必须明确暴露不变量错误。
返回列表不可增删,元素是复制时刻生成的字符串;后续状态替换或调度删除不能改写旧内容。
[x,x) 合法且为空;只有下界大于上界才在读取任何业务状态前失败。
位置 3:严格上界内升级首个候选
用 headMap(UpgradeKey.boundary(beforeExclusive), false) 建立严格上界活视图;无候选返回 NONE 且全部 Map 不变。
首项必须回查事实,确认仍为 OPEN,并核对 UpgradeKey.from(current) 与导航键一致;失配时抛出明确状态错误。
以新的不可变 record 替换事实 value,从活视图删除首项以写穿根 TreeMap;OPEN 减一且归零时移除,再增加 ESCALATED。
将升级后的 value 放入诊断缓存;已有键移动到访问顺序尾部,若未来插入已被淘汰的键,超限也只能淘汰缓存最老项。最后返回复合键标签。
6. 三级提示
一级提示:先让事实表决定是否首次登记
输入与初始状态校验完成后,先对 byKey 做单键条件写。只要已经存在旧值就结束;任何派生 Map 都不应被重复请求触碰。
二级提示:严重级别降序如何影响边界键
同一分钟内,严重级别越大越靠前。边界键用高于合法值的严重级别,因此排在该分钟全部业务键之前:包含下界边界可收进下界整分钟,排除上界边界可排除上界整分钟。
三级提示:调度顺序与诊断热度分属两个合同
TreeMap 首项由截止时间与严重级别全序决定;LinkedHashMap 的尾部表示最近成功访问或更新。升级既要从导航活视图删除候选,也要用新 value 访问诊断缓存,但不能把这两个顺序混为一谈。
7. 精确预期输出
三个待补位置完成后,实际输出必须逐行一致:
REGISTER first=true
REGISTER second=true
REGISTER third=true
REGISTER fourth=true
DIAG evicted-first=MISS
DIAG access-second=OPEN
REGISTER duplicate=false
BY-KEY merchant-a/D-41=1500
BY-KEY merchant-b/D-41=-800
DIAG before=[merchant-a/D-42, merchant-d/D-9, merchant-b/D-41]
WINDOW before=[merchant-a/D-42@700#S5:OPEN:250, merchant-b/D-41@700#S4:OPEN:-800, merchant-a/D-41@720#S2:OPEN:1500]
COUNTS before=[OPEN=4]
ESCALATE before-700=NONE
ESCALATE before-720=merchant-a/D-42
COUNTS after=[OPEN=3, ESCALATED=1]
SCHEDULE after=[merchant-b/D-41@700#S4, merchant-a/D-41@720#S2, merchant-d/D-9@740#S3]
DIAG after=[merchant-d/D-9, merchant-b/D-41, merchant-a/D-42]
SNAPSHOT unchanged=[merchant-a/D-42@700#S5:OPEN:250, merchant-b/D-41@700#S4:OPEN:-800, merchant-a/D-41@720#S2:OPEN:1500]
验收时还要解释:两个商户的 D-41 为什么互不覆盖;同为 700 分钟时 S5 为什么先于 S4;严格上界 720 为什么排除恰为 720 的差异;被诊断缓存淘汰的第一条为什么仍能从事实表读到;命中第二条与升级第三条分别怎样改变缓存顺序;旧快照为什么继续显示 OPEN。这组输出不构成对任意 HashMap 遍历顺序的保证。
8. 复杂度要求
byKey 精确查询与单键条件写的期望时间为 O(1);碰撞、扩容和桶树化意味着不能承诺每次严格 O(1)。
upgradeSchedule 的插入、删除和边界导航为 O(log n);定位范围并复制 k 项为 O(log n + k)。
stateCounts 的枚举键域固定,单次读取与更新按常数规模理解。
diagnostics 的命中、已有键移动、插入和一次最老项淘汰期望为 O(1);其上限固定为 3,输出缓存顺序按本题也是常数规模。
主表与升级树的总空间为 O(n);枚举计数和有界缓存为 O(1),窗口快照额外占 O(k)。
登记和成功升级均只做常数次 Map 操作,整体由 TreeMap 主导为 O(log n);这只是复杂度结论,不是跨 Map 原子性证明。
9. 边界用例
补全后至少自行验证:
merchant-a/D-41 与 merchant-b/D-41 并存,正负差异金额保留各自符号。
相同复合键重复登记不增加 OPEN、不重排导航树,也不能让已淘汰缓存项重新进入缓存。
同一分钟严重级别高者优先;分钟和级别均相同时,商户与单号仍形成全序,比较为 0 不会误覆盖不同差异。
[720,720) 返回空快照;下界大于上界时不读取或修改业务状态。
escalateNext(700) 返回 NONE;上界 720 不包含截止恰为 720 的差异。
对快照执行新增应失败;升级后旧快照的状态与金额文本保持不变,而旧导航视图会反映删除。
缓存未命中不影响事实;命中或已有 value 更新改变访问顺序;容量淘汰不删除事实或调度候选。
生产环境必须给四张 Map 一个明确的共同所有权、锁或事务边界;fail-fast 和单 Map 条件 API 都不能代替它。
10. 闯关提交与自检
11. Java 21 与固定源码资料
返回今日索引
返回今日索引
Map 阶段闯关题:多商户对账差异升级索引
建议用时:约 8~9 分钟。请优先在 互动页面 作答,聊天提交仅作补充。当天不提供答案。 第 1~3、5 题只提交结论和最短必要推理;第 4 题必须提交完整 Java 21 代码、编译证据和规定输出。
题号
固定维度
知识点 ID
分值
可能触发的红线
1
契约与选型
ds.map.contract、ds.map.equality、ds.map.linkedhashmap-source、ds.map.treemap-source、ds.map.specialized
25
假设 HashMap/HashSet 顺序稳定;破坏 equals/hashCode 或 Comparator;混淆访问顺序与调度顺序
2
复杂度与状态推演
ds.map.hashmap-structure、ds.map.hashmap-put-get-remove-source、ds.map.hashmap-resize-treeify-iterator-source、ds.map.treemap-source
12
把平均复杂度当绝对保证;认为未知节点位置时 LinkedList 中间操作天然 O(1);把 fail-fast 当线程安全
3
复杂度与状态推演
ds.map.contract、ds.map.compute-merge-views、ds.map.linkedhashmap-source、ds.map.treemap-source
13
混淆活视图、只读包装与独立快照;把缓存淘汰当事实删除;把连续写多张 Map 当事务
4
编码
ds.map.contract、ds.map.equality、ds.map.compute-merge-views、ds.map.linkedhashmap-source、ds.map.treemap-source、ds.map.specialized
30
复合键、比较器、范围、缓存热度或计数错误;把单 Map 条件更新扩张成跨 Map 原子性
5
源码讲解
ds.map.hashmap-structure、ds.map.hashmap-put-get-remove-source、ds.map.hashmap-resize-treeify-iterator-source、ds.map.linkedhashmap-source、ds.map.treemap-source、ds.map.specialized
20
猜测源码或把实现细节当合同;把 fail-fast 当线程安全;误认为 ConcurrentHashMap 跨键或跨集合自动原子
合计
契约与选型 25;复杂度与状态推演 25;编码 30;源码讲解 20
覆盖 Map 9 个知识项
100
闯关放行必须同时满足:总分至少 80/100、第 4 题 Java 21 代码编译运行且产生规定输出、六类红线均未命中。只达到分数、只交代码片段、编译失败或命中任一红线,都不能进入 Set 模块;应依据有效证据补强并重闯。
1. 契约与方案设计:从差异身份推出四张 Map(25 分)
对账平台要求按“商户 + 差异单号”精确查询、按截止分钟和严重级别升级、按有限状态计数,并维护容量为 3 的诊断访问缓存。用最多 7 条说明四张 Map 的选型和事实/派生关系,同时定义:复合键相等与 null 边界、排序键全序、正负差异金额的归属、access-order 与淘汰语义、导航视图及对外快照所有权。解释为什么 HashMap 或 HashSet 的遇见顺序不能排升级队列,为什么缓存淘汰不能删事实,以及 WeakHashMap、IdentityHashMap 为什么不适合事实主表。
2. 复杂度与结构推演:不要只报最快一步(12 分)
设事实表有 n 条差异,窗口命中 k 条。给出精确查询、首次登记、最早候选升级、导航范围转快照、状态计数、诊断缓存命中/更新/淘汰的时间复杂度,以及总索引空间和快照额外空间。说明 HashMap 在碰撞、扩容和固定 OpenJDK 21 树化容量门槛下为什么不能承诺每次严格 O(1);再比较用 TreeMap 定位候选与用 LinkedList 在不知道节点位置时查找并删除中间候选的成本,并说明 fail-fast 缺少哪些线程安全保证。
3. 状态推演与代码分析:缓存重新进入和活视图分叉(13 分)
诊断缓存容量为 2,依次登记四条 OPEN 差异:merchant-a/T1@900#S2、merchant-b/T1@880#S4、merchant-a/T2@880#S5、merchant-c/T9@920#S3。在登记第三条后诊断访问第二条;登记第四条后取得 [880,901) 的导航活视图并投影成不可变字符串快照;随后尝试重复登记第一条,再执行 escalateNext(900)。
逐步写出诊断缓存每次登记、命中、淘汰、重复请求和升级后的访问顺序;再写被升级键、升级后的导航顺序、OPEN/ESCALATED 计数、被淘汰项是否仍在事实表、旧活视图当前内容和旧快照内容。说明严格上界 900、同分钟严重级别降序、升级一个已从缓存淘汰的键重新进入缓存时的淘汰,以及多张 Map 更新为什么不具备自动事务性。不得用 HashMap 次序推导任何结果。
4. 必交编码:完成对账差异升级索引(30 分)
补全 编码练习 的全部 3 个待补位置,提交完整 ReconciliationDifferenceChallenge.java、javac --release 21 ReconciliationDifferenceChallenge.java 的成功结果,以及 java ReconciliationDifferenceChallenge 的完整且逐行一致输出。再用不超过 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 分。必须把源码路径收束回本题的返回值、size、顺序、范围和状态结果,不能把方法名清单当答案;不得猜红黑树具体形状,也不得把实现阈值、fail-fast、普通 Map 或 ConcurrentHashMap 的单键能力扩张成业务排序合同、线程安全或跨键、跨 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 ReconciliationDifferenceChallenge {
private enum DifferenceState {
OPEN,
ESCALATED
}
private record DifferenceKey(String merchantId, String ticketNo) {
DifferenceKey {
Objects.requireNonNull(merchantId, "merchantId");
Objects.requireNonNull(ticketNo, "ticketNo");
if (merchantId.isBlank() || ticketNo.isBlank()) {
throw new IllegalArgumentException(
"merchantId and ticketNo must not be blank");
}
}
String label() {
return merchantId + "/" + ticketNo;
}
}
private record ReconciliationDifference(
DifferenceKey key,
long dueMinute,
int severity,
long deltaCents,
DifferenceState state) {
ReconciliationDifference {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(state, "state");
if (dueMinute < 0
|| severity < 1
|| severity > 5
|| deltaCents == 0) {
throw new IllegalArgumentException(
"invalid reconciliation difference");
}
}
ReconciliationDifference escalated() {
return new ReconciliationDifference(
key,
dueMinute,
severity,
deltaCents,
DifferenceState.ESCALATED);
}
String snapshotLine() {
return key.label() + "@" + dueMinute
+ "#S" + severity + ":" + state + ":" + deltaCents;
}
}
private record UpgradeKey(
long dueMinute,
int severity,
String merchantId,
String ticketNo) implements Comparable<UpgradeKey> {
static UpgradeKey from(ReconciliationDifference difference) {
return new UpgradeKey(
difference.dueMinute(),
difference.severity(),
difference.key().merchantId(),
difference.key().ticketNo());
}
static UpgradeKey boundary(long minute) {
return new UpgradeKey(
minute,
Integer.MAX_VALUE,
"",
"");
}
@Override
public int compareTo(UpgradeKey other) {
int byMinute = Long.compare(dueMinute, other.dueMinute);
if (byMinute != 0) {
return byMinute;
}
int bySeverity = Integer.compare(other.severity, severity);
if (bySeverity != 0) {
return bySeverity;
}
int byMerchant = merchantId.compareTo(other.merchantId);
return byMerchant != 0
? byMerchant
: ticketNo.compareTo(other.ticketNo);
}
}
private static final class DifferenceIndex {
private static final int DIAGNOSTIC_LIMIT = 3;
private final Map<DifferenceKey, ReconciliationDifference> byKey =
new HashMap<>();
private final NavigableMap<UpgradeKey, DifferenceKey> upgradeSchedule =
new TreeMap<>();
private final EnumMap<DifferenceState, Integer> stateCounts =
new EnumMap<>(DifferenceState.class);
private final LinkedHashMap<
DifferenceKey, ReconciliationDifference> diagnostics =
new LinkedHashMap<>(4, 0.75f, true) {
@Override
protected boolean removeEldestEntry(
Map.Entry<
DifferenceKey,
ReconciliationDifference> eldest) {
return size() > DIAGNOSTIC_LIMIT;
}
};
private boolean register(ReconciliationDifference difference) {
// 校验初始状态并以事实复合键去重,再维护三个派生投影。
throw new UnsupportedOperationException("TODO 1: register");
}
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 escalateNext(long beforeExclusive) {
if (beforeExclusive < 0) {
throw new IllegalArgumentException(
"beforeExclusive must be >= 0");
}
// 升级首个到期候选并同步事实与派生投影。
throw new UnsupportedOperationException("TODO 3: escalateNext");
}
private String deltaOf(DifferenceKey key) {
ReconciliationDifference difference = byKey.get(
Objects.requireNonNull(key, "key"));
return difference == null
? "MISSING"
: Long.toString(difference.deltaCents());
}
private String diagnosticState(DifferenceKey key) {
ReconciliationDifference difference = diagnostics.get(
Objects.requireNonNull(key, "key"));
return difference == null ? "MISS" : difference.state().name();
}
private List<String> scheduleOrder() {
return upgradeSchedule.entrySet().stream()
.map(entry -> entry.getValue().label()
+ "@" + entry.getKey().dueMinute()
+ "#S" + entry.getKey().severity())
.toList();
}
private List<String> diagnosticOrder() {
return diagnostics.keySet().stream()
.map(DifferenceKey::label)
.toList();
}
private List<String> countSummary() {
return stateCounts.entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.toList();
}
}
public static void main(String[] args) {
DifferenceKey merchantAFirst =
new DifferenceKey("merchant-a", "D-41");
DifferenceKey merchantBFirst =
new DifferenceKey("merchant-b", "D-41");
DifferenceKey merchantASecond =
new DifferenceKey("merchant-a", "D-42");
DifferenceKey merchantDFirst =
new DifferenceKey("merchant-d", "D-9");
ReconciliationDifference first = new ReconciliationDifference(
merchantAFirst, 720, 2, 1_500, DifferenceState.OPEN);
ReconciliationDifference second = new ReconciliationDifference(
merchantBFirst, 700, 4, -800, DifferenceState.OPEN);
ReconciliationDifference third = new ReconciliationDifference(
merchantASecond, 700, 5, 250, DifferenceState.OPEN);
ReconciliationDifference fourth = new ReconciliationDifference(
merchantDFirst, 740, 3, 4_000, DifferenceState.OPEN);
DifferenceIndex index = new DifferenceIndex();
System.out.println("REGISTER first=" + index.register(first));
System.out.println("REGISTER second=" + index.register(second));
System.out.println("REGISTER third=" + index.register(third));
System.out.println("REGISTER fourth=" + index.register(fourth));
System.out.println("DIAG evicted-first="
+ index.diagnosticState(merchantAFirst));
System.out.println("DIAG access-second="
+ index.diagnosticState(merchantBFirst));
System.out.println("REGISTER duplicate=" + index.register(first));
System.out.println("BY-KEY merchant-a/D-41="
+ index.deltaOf(merchantAFirst));
System.out.println("BY-KEY merchant-b/D-41="
+ index.deltaOf(merchantBFirst));
System.out.println("DIAG before=" + index.diagnosticOrder());
List<String> beforeEscalation = index.dueSnapshot(700, 721);
System.out.println("WINDOW before=" + beforeEscalation);
System.out.println("COUNTS before=" + index.countSummary());
System.out.println("ESCALATE before-700="
+ index.escalateNext(700));
System.out.println("ESCALATE before-720="
+ index.escalateNext(720));
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=" + beforeEscalation);
}
}
互动保存需要 JavaScript。请启用 JavaScript,并通过本地启动器或已部署的学习地址访问此页面。