Java 后端每日学习 · Day 21 · 2026-09-14
打开今日互动学习页 :汇总五个课程章节,支持代码复制、复盘作答、浏览器草稿和本地 Markdown 保存。网页作答优先、聊天提交补充。
今日主题
Map 阶段闯关变式:多租户会员权益卡换卡与冻结索引的契约、重键、快照与一致性。
方向元数据
字段
值
directionId
java-data-structures
directionSession
19
curriculumItemId
challenge.map
lessonMode
checkpoint
节点进入前状态
covered_unverified
完成本课后的预期状态
covered_unverified;生成第九个变式不等于闯关通过
当前方向状态
active
查看全局 Java 后端知识地图 。固定同步助手对最近完整课程 Day 20 的拉取结果为 remote_missing,服务器与本地均没有 2026-09-13/04-复盘作答.md,当前任务也没有能明确映射到 Day 20 题号及问题源哈希 sha256:b0a63d4cda496eeff0c5cf1574534fc8eb1a7fcdf4fc4b43291b81a2ed98b7b2 的答案。因此答案来源为 none,不评分、不推断薄弱项或红线;challenge.map 保持 covered_unverified,今天继续在 Map 模块完成新变式。
今天把约束换成“多租户会员权益卡换卡与冻结”:事实表保留旧卡和新卡,活动指针保证同一租户会员至多一张 ACTIVE 卡,到期队列只收录活动卡。换卡会跨两个事实键切换活动指针和排序项,冻结则保留事实但退出活动索引。大大需要用不变量约束五张 Map,并明确预检只能阻止可预见的半写,不能替代生产事务。
可验证目标
能区分不可变卡身份、租户会员身份与到期排序键,并说明 equals/hashCode、Comparator 全序和 value 替换各自承担的职责。
能逐步推演发卡、换卡、冻结、旧排序键清理、状态计数、容量 3 访问顺序缓存和稳定到期快照。
能完成 Java 21 必交编码,编译并产生规定输出;重复发卡、旧卡重复换卡或重复冻结均不能改变任何 Map 或 LRU 热度。
能沿 OpenJDK jdk-21+35 的入口、字段与主调用链解释 HashMap、TreeMap、LinkedHashMap 与 EnumMap 的可观察行为。
能区分活视图、只读包装、不可变快照、fail-fast、单键原子 API 与跨键、跨 Map 一致性。
60~75 分钟学习顺序
昨日复盘 :Day 20 五题完整参考答案与风控案件主练习完整实现(约 12~14 分钟)。
核心讲解 :用权益卡换卡与冻结串联事实、活动指针、到期队列、缓存与状态槽位(约 31~33 分钟)。
编码练习 :完成稳定到期快照、换卡与冻结;这是闯关通过的必交编码(约 18~20 分钟)。
复盘问题 :按四个固定维度提交 Map 阶段闯关答案(约 7~8 分钟)。
总预计用时约 68~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。
课程完成标准
完成四部分学习,并能说明换卡为何同时保留旧事实和新增事实、为何必须删除旧到期键,以及冻结为何要删除活动指针。
将编码练习的 3 个 TODO 补全,以 javac --release 21 编译并运行,保存完整代码与规定输出。
回答复盘页全部闯关题;答案必须映射到当天题号和问题源,不能用本文或昨日参考答案代替。
不依赖 HashMap 顺序,不破坏 equals/hashCode 或 Comparator 契约,不混淆活视图、只读包装与不可变快照,不把 fail-fast 或 ConcurrentHashMap 单键原子 API 当成跨键、跨 Map 事务。
闯关放行条件
总分至少 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。
返回今日索引
昨日复盘:多租户风控案件调分与结案索引
建议用时:12~14 分钟。大大,昨日是 Day 20(directionSession=18、challenge.map、checkpoint)的第八个闯关变式。本节先区分“大大的作答证据”和“课程参考答案”,再给出五题完整参考答案与主练习的完整 Java 21 实现。
作答证据与状态结论
固定同步助手对 2026-09-13 执行 pull-answer 的结果为 remote_missing。
本地不存在 2026-09-13/04-复盘作答.md;当前聊天也没有能明确映射到 Day 20 题号和问题源哈希 sha256:b0a63d4cda496eeff0c5cf1574534fc8eb1a7fcdf4fc4b43291b81a2ed98b7b2 的答案。
答案来源:无有效 file/chat 证据 。第 1~5 题均为“未作答”,不得给分;总分为 不足以评分 ,不是 0 分。
红线:无可判定证据 。薄弱知识 ID:无可判定证据 。没有答案不能推断大大答错,也不能虚构掌握。
状态变化:没有评估状态变化;challenge.map 保持 covered_unverified,方向保持 active。因此今天仍须留在 Map,不能进入 Set。
下文是课程参考答案,只用于复习;它不是大大的答案,绝不计入评估证据。
题号
分值
有效作答
得分
个性点评
1
25
未作答
不评分
无有效证据,无法个性点评
2
12
未作答
不评分
无有效证据,无法个性点评
3
13
未作答
不评分
无有效证据,无法个性点评
4
30
未提交完整代码、编译证据或规定输出
不评分
编码放行条件未获验证
5
20
未作答
不评分
无有效证据,无法个性点评
返回今日索引
第 1 题参考答案:事实、开放聚合、全序与缓存契约
可以用下面十条不变量完整回答:
CaseKey(tenantId, caseId) 是案件身份,AccountKey(tenantId, accountId) 是账户身份;租户字段必须参与 record 的值相等与哈希。两类键进入 Map 后都不可改变,不同租户的相同案件号或账户号不会串账。
本题在边界拒绝 null 键、null value 和空白身份,所以 facts.get(key) == null 可明确表示案件不存在。若通用 Map 允许 null value,则必须再用 containsKey 区分“缺键”和“映射到 null”。
facts: HashMap<CaseKey, RiskCase> 是唯一案件事实源。建案后事实必须保留;调分和结案用相同 CaseKey 替换为新的不可变 RiskCase value,不能原地改键,也不能因结案删除事实。
openExposure: HashMap<AccountKey, Integer> 是可重建派生聚合,值等于该账户所有 OPEN 案件的正分数之和。结案只扣本案当前分数;总分变为 0 时删除账户键,因此“键不存在”明确表示当前没有开放敞口。
reviewQueue: TreeMap<ReviewKey, CaseKey> 只容纳 OPEN 案件,并且每件开放案件恰有一项。调分改变排序字段,必须先用完整旧 ReviewKey 核对并删除旧项,再插入新项;结案只删除旧项,不插入终态案件。
ReviewKey.compareTo 依次比较风险分降序、创建序号升序、租户升序、案件号升序。最后两个身份尾字段补足全序,使合法键上 compareTo == 0 只代表同一完整排序身份,避免同分案件互相覆盖。
recentCases 是容量 3 的 access-order LinkedHashMap。创建、成功调分、成功结案和显式观察会更新热度;缓存淘汰只影响性能,不能改变事实、敞口、队列或计数。缺失、已结案调分、同分调分和重复结案必须连 LRU 顺序也不改变。
stateCounts: EnumMap<CaseState,Integer> 用封闭的枚举键域统计 OPEN/APPROVED/REJECTED;零计数不保留,所有非零计数之和应等于 facts.size()。调分不迁移计数,成功结案只从 OPEN 迁移一次。
reviewQueue.entrySet()/keySet()/values() 都是活视图;只读包装只关闭写入口,仍会随底层树变化。Top 应把当时数据投影为字符串,再用 List.copyOf 返回独立、不可修改的稳定快照。
HashMap/HashSet 的偶然遍历顺序不是优先级合同;WeakHashMap 的键可能因可达性变化消失,IdentityHashMap 按引用身份而非业务值相等,均不适合作为审计案件事实表。五张 Map 的连续写入即使预检完整也不是生产事务。
rescore 的数学语义是从账户聚合中移除旧分再加入新分;close 的语义是保留案件事实、移出开放队列并扣除本案分数。它们都只在单线程题设中按提交顺序更新多张 Map,生产环境仍需要共同锁、数据库事务或不可变聚合引用的原子替换。
返回今日索引
第 2 题参考答案:复杂度、碰撞与 fail-fast 边界
设事实数为 n、开放案件数为 q、某账户开放案件数为 m、Top 实际返回数为 k:
按完整 CaseKey 查询事实,在正常散列分布下期望 O(1),单次额外空间为 O(1);HashMap 事实总体空间为 O(n)。
成功创建需要期望 O(1) 的事实、聚合和计数操作,以及一次 TreeMap 插入,整体为 O(log q),单次额外空间 O(1)。
成功调分需要按完整旧键删除一次、按新键插入一次,仍为 O(log q),单次额外空间为 O(1);它利用已维护的账户聚合做差值更新,不应扫描该账户的 m 件案件。
成功结案需要一次 TreeMap 删除和常数次 HashMap/EnumMap/缓存操作,整体为 O(log q),单次额外空间为 O(1)。缺失、非开放或同分等提前返回分支通常只有期望 O(1) 的事实查找和 O(1) 额外空间。
非空 topSnapshot 从 TreeMap 根沿左链定位首项,再按后继遍历 k 项,并对每项做期望常数的事实与聚合查询,时间为 O(log q + k),独立快照空间为 O(k);limit == 0 在创建树迭代器前返回时为 O(1)。
EnumMap 的枚举键域固定,单次计数访问为常数级;容量固定为 3 的 access-order 最近缓存,命中、移尾、写入和一次最老项淘汰为常数或期望 O(1)。
HashMap 的 O(1) 是良好散列下的期望值,不是每次严格保证。碰撞会形成桶内链,长桶在满足 OpenJDK 21 的树化阈值和最小表容量条件后才可能树化;扩容还会迁移桶。这些阈值是 HashMap 实现细节,不是 Map 公共契约。
如果把待审案件放进 LinkedList,却不知道目标节点位置,就要先线性查找,查找加删除整体为 O(q);只有已经持有节点或迭代器位置时,局部摘链才是 O(1)。TreeMap 以完整旧 ReviewKey 定位并删除为 O(log q)。fail-fast 只会尽力检测结构性并发修改,它不提供互斥、内存可见性、复合操作原子性或一致快照,也不能作为正确性机制。
返回今日索引
第 3 题参考答案:调分、结案、缓存与旧快照推演
记 A=t-a/c-1→amy:80,seq=9、B=t-b/c-1→amy:80,seq=7、C=t-a/c-2→bob:60,seq=8、D=t-a/c-3→amy:20,seq=6。缓存顺序均从最老写到最新;表中的队列按 TreeMap 业务顺序列出:
操作后
开放账户总分
待复核队列
最近缓存(容量 2)
状态计数
创建 A
[t-a/amy=80]
[A=80]
[A=OPEN:80]
[OPEN=1]
创建 B
[t-a/amy=80, t-b/amy=80]
[B=80, A=80]
[A=OPEN:80, B=OPEN:80]
[OPEN=2]
创建 C
[t-a/amy=80, t-a/bob=60, t-b/amy=80]
[B=80, A=80, C=60]
[B=OPEN:80, C=OPEN:60]
[OPEN=3]
创建 D
[t-a/amy=100, t-a/bob=60, t-b/amy=80]
[B=80, A=80, C=60, D=20]
[C=OPEN:60, D=OPEN:20]
[OPEN=4]
取得 Top 4 快照
不变
不变
不变
不变
D 调为 95
[t-a/amy=175, t-a/bob=60, t-b/amy=80]
[D=95, B=80, A=80, C=60]
[C=OPEN:60, D=OPEN:95]
[OPEN=4]
B 结为 APPROVED
[t-a/amy=175, t-a/bob=60]
[D=95, A=80, C=60]
[D=OPEN:95, B=APPROVED:80]
[OPEN=3, APPROVED=1]
A 结为 REJECTED
[t-a/amy=95, t-a/bob=60]
[D=95, C=60]
[B=APPROVED:80, A=REJECTED:80]
[OPEN=2, APPROVED=1, REJECTED=1]
重复结案 A
全部不变
不变
[B=APPROVED:80, A=REJECTED:80]
不变
取得时的 Top 4 字符串快照为:
[t-b/c-1@amy=80, t-a/c-1@amy=80, t-a/c-2@bob=60, t-a/c-3@amy=20]
最终事实为 A=REJECTED、B=APPROVED、C=OPEN、D=OPEN;当前队列为 [D=95, C=60]。80 分并列时,B 的创建序号 7 小于 A 的 9,所以 B 在前;若分数和序号仍相同,再由租户与案件号尾字段区分,不能比较为 0 后覆盖。
t-a/amy 在创建 A 后为 80,创建 D 后为 80 + 20 = 100;D 调为 95 后用差值更新得到 100 - 20 + 95 = 175;A 结案后扣除 80,得到 175 - 80 = 95。B 属于 t-b,其结案只让 t-b/amy 归零删键,不影响 t-a/amy。Top 操作不访问缓存;D 成功调分更新其缓存 value 但仍位于尾部;B、A 依次进入容量 2 缓存并淘汰 C、D。重复结案 A 在任何 access-order get/put 前返回,因此所有状态和热度都不变。
队列视图会随调分和结案变化;只读包装仍背靠队列;上面的字符串快照已经复制,始终保持取得时的四项。多张 Map 的迁移只是单线程顺序写,不是自动事务。
返回今日索引
第 4 题参考答案:完整 Java 21 实现
下面完整保留昨日起始代码,只补全 topSnapshot、rescore、close 三个 TODO。失败或幂等分支都在缓存访问前结束;成功分支先完成旧索引核对、精确算术、计数校验、新键碰撞检查和不可变新 value 构造,之后才越过首次 Map 写入的提交线。
import java.util.ArrayList;
import java.util.Comparator;
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 TenantRiskCaseChallenge {
private enum CaseState {
OPEN,
APPROVED,
REJECTED
}
private record CaseKey(String tenantId, String caseId) {
CaseKey {
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(caseId, "caseId");
if (tenantId.isBlank() || caseId.isBlank()) {
throw new IllegalArgumentException("invalid case key");
}
}
String label() {
return tenantId + "/" + caseId;
}
}
private record AccountKey(String tenantId, String accountId) {
AccountKey {
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(accountId, "accountId");
if (tenantId.isBlank() || accountId.isBlank()) {
throw new IllegalArgumentException("invalid account key");
}
}
String label() {
return tenantId + "/" + accountId;
}
}
private record RiskCase(
CaseKey key,
String accountId,
int riskScore,
long createdSequence,
CaseState state) {
RiskCase {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(accountId, "accountId");
Objects.requireNonNull(state, "state");
if (accountId.isBlank()
|| riskScore < 1
|| riskScore > 1000
|| createdSequence < 0) {
throw new IllegalArgumentException("invalid risk case");
}
}
AccountKey accountKey() {
return new AccountKey(key.tenantId(), accountId);
}
RiskCase withScore(int newScore) {
return new RiskCase(
key, accountId, newScore, createdSequence, state);
}
RiskCase closedAs(CaseState terminalState) {
return new RiskCase(
key, accountId, riskScore, createdSequence, terminalState);
}
String cacheLabel() {
return key.label() + "=" + state + ":" + riskScore;
}
}
private record ReviewKey(
int riskScore,
long createdSequence,
String tenantId,
String caseId) implements Comparable<ReviewKey> {
static ReviewKey from(RiskCase riskCase) {
return new ReviewKey(
riskCase.riskScore(),
riskCase.createdSequence(),
riskCase.key().tenantId(),
riskCase.key().caseId());
}
@Override
public int compareTo(ReviewKey other) {
int byScore = Integer.compare(other.riskScore, riskScore);
if (byScore != 0) {
return byScore;
}
int byCreated = Long.compare(createdSequence, other.createdSequence);
if (byCreated != 0) {
return byCreated;
}
int byTenant = tenantId.compareTo(other.tenantId);
return byTenant != 0
? byTenant
: caseId.compareTo(other.caseId);
}
}
private static final class RiskIndex {
private static final int RECENT_LIMIT = 3;
private final Map<CaseKey, RiskCase> facts = new HashMap<>();
private final Map<AccountKey, Integer> openExposure = new HashMap<>();
private final NavigableMap<ReviewKey, CaseKey> reviewQueue =
new TreeMap<>();
private final LinkedHashMap<CaseKey, RiskCase> recentCases =
new LinkedHashMap<>(4, 0.75f, true) {
@Override
protected boolean removeEldestEntry(
Map.Entry<CaseKey, RiskCase> eldest) {
return size() > RECENT_LIMIT;
}
};
private final EnumMap<CaseState, Integer> stateCounts =
new EnumMap<>(CaseState.class);
private boolean open(RiskCase riskCase) {
Objects.requireNonNull(riskCase, "riskCase");
if (riskCase.state() != CaseState.OPEN) {
throw new IllegalArgumentException("new case must be open");
}
if (facts.containsKey(riskCase.key())) {
return false;
}
AccountKey account = riskCase.accountKey();
int oldExposure = openExposure.getOrDefault(account, 0);
if (oldExposure < 0) {
throw new IllegalStateException("negative open exposure");
}
int newExposure = Math.addExact(
oldExposure, riskCase.riskScore());
int newOpenCount = Math.addExact(
stateCounts.getOrDefault(CaseState.OPEN, 0), 1);
ReviewKey reviewKey = ReviewKey.from(riskCase);
if (reviewQueue.containsKey(reviewKey)) {
throw new IllegalStateException("duplicate review key");
}
// 所有可恢复校验与算术预检均已完成,下面才开始写 Map。
facts.put(riskCase.key(), riskCase);
openExposure.put(account, newExposure);
reviewQueue.put(reviewKey, riskCase.key());
stateCounts.put(CaseState.OPEN, newOpenCount);
recentCases.put(riskCase.key(), riskCase);
return true;
}
private List<String> topSnapshot(int limit) {
if (limit < 0) {
throw new IllegalArgumentException("limit must not be negative");
}
if (limit == 0) {
return List.of();
}
List<String> result = new ArrayList<>(
Math.min(limit, reviewQueue.size()));
for (Map.Entry<ReviewKey, CaseKey> item
: reviewQueue.entrySet()) {
if (result.size() == limit) {
break;
}
ReviewKey reviewKey = item.getKey();
CaseKey key = item.getValue();
RiskCase riskCase = facts.get(key);
if (riskCase == null
|| riskCase.state() != CaseState.OPEN
|| !key.equals(riskCase.key())
|| !reviewKey.equals(ReviewKey.from(riskCase))) {
throw new IllegalStateException("queue and facts disagree");
}
Integer exposure = openExposure.get(riskCase.accountKey());
if (exposure == null || exposure < riskCase.riskScore()) {
throw new IllegalStateException("invalid open exposure");
}
result.add(key.label() + "@" + riskCase.accountId()
+ "=" + riskCase.riskScore());
}
return List.copyOf(result);
}
private boolean rescore(CaseKey key, int newRiskScore) {
Objects.requireNonNull(key, "key");
if (newRiskScore < 1 || newRiskScore > 1000) {
throw new IllegalArgumentException("invalid risk score");
}
RiskCase current = facts.get(key);
if (current == null
|| current.state() != CaseState.OPEN
|| current.riskScore() == newRiskScore) {
return false;
}
if (!key.equals(current.key())) {
throw new IllegalStateException("fact key mismatch");
}
ReviewKey oldReviewKey = ReviewKey.from(current);
if (!key.equals(reviewQueue.get(oldReviewKey))) {
throw new IllegalStateException("missing old review key");
}
AccountKey account = current.accountKey();
Integer oldExposure = openExposure.get(account);
if (oldExposure == null || oldExposure < current.riskScore()) {
throw new IllegalStateException("invalid open exposure");
}
int delta = Math.subtractExact(
newRiskScore, current.riskScore());
int newExposure = Math.addExact(oldExposure, delta);
if (newExposure <= 0) {
throw new IllegalStateException("invalid new exposure");
}
int openCount = stateCounts.getOrDefault(CaseState.OPEN, 0);
if (openCount <= 0) {
throw new IllegalStateException("invalid open count");
}
RiskCase rescored = current.withScore(newRiskScore);
ReviewKey newReviewKey = ReviewKey.from(rescored);
if (reviewQueue.containsKey(newReviewKey)) {
throw new IllegalStateException("duplicate review key");
}
// 提交线之后不再执行可恢复的业务校验或精确算术。
reviewQueue.remove(oldReviewKey);
facts.put(key, rescored);
openExposure.put(account, newExposure);
reviewQueue.put(newReviewKey, key);
recentCases.put(key, rescored);
return true;
}
private boolean close(CaseKey key, CaseState terminalState) {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(terminalState, "terminalState");
if (terminalState != CaseState.APPROVED
&& terminalState != CaseState.REJECTED) {
throw new IllegalArgumentException("target must be terminal");
}
RiskCase current = facts.get(key);
if (current == null || current.state() != CaseState.OPEN) {
return false;
}
if (!key.equals(current.key())) {
throw new IllegalStateException("fact key mismatch");
}
ReviewKey oldReviewKey = ReviewKey.from(current);
if (!key.equals(reviewQueue.get(oldReviewKey))) {
throw new IllegalStateException("missing old review key");
}
AccountKey account = current.accountKey();
Integer oldExposure = openExposure.get(account);
if (oldExposure == null || oldExposure < current.riskScore()) {
throw new IllegalStateException("invalid open exposure");
}
int newExposure = Math.subtractExact(
oldExposure, current.riskScore());
if (newExposure < 0) {
throw new IllegalStateException("negative new exposure");
}
int openCount = stateCounts.getOrDefault(CaseState.OPEN, 0);
if (openCount <= 0) {
throw new IllegalStateException("invalid open count");
}
int newOpenCount = Math.subtractExact(openCount, 1);
int terminalCount = stateCounts.getOrDefault(terminalState, 0);
if (terminalCount < 0) {
throw new IllegalStateException("invalid terminal count");
}
int newTerminalCount = Math.addExact(terminalCount, 1);
RiskCase closed = current.closedAs(terminalState);
// 提交线之后只使用已经核验、计算和构造完毕的新状态。
reviewQueue.remove(oldReviewKey);
facts.put(key, closed);
if (newExposure == 0) {
openExposure.remove(account);
} else {
openExposure.put(account, newExposure);
}
if (newOpenCount == 0) {
stateCounts.remove(CaseState.OPEN);
} else {
stateCounts.put(CaseState.OPEN, newOpenCount);
}
stateCounts.put(terminalState, newTerminalCount);
recentCases.put(key, closed);
return true;
}
private String observe(CaseKey key) {
Objects.requireNonNull(key, "key");
RiskCase riskCase = facts.get(key);
if (riskCase == null) {
return "MISSING";
}
RiskCase cached = recentCases.get(key);
if (!riskCase.equals(cached)) {
recentCases.put(key, riskCase);
}
return riskCase.cacheLabel();
}
private int factCount() {
return facts.size();
}
private List<String> exposureSummary() {
return openExposure.entrySet().stream()
.sorted(Comparator.comparing(
entry -> entry.getKey().label()))
.map(entry -> entry.getKey().label()
+ "=" + entry.getValue())
.toList();
}
private List<String> queueSummary() {
return reviewQueue.entrySet().stream()
.map(entry -> entry.getValue().label()
+ "=" + entry.getKey().riskScore())
.toList();
}
private List<String> recentOrder() {
return recentCases.values().stream()
.map(RiskCase::cacheLabel)
.toList();
}
private List<String> countSummary() {
return stateCounts.entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.toList();
}
}
public static void main(String[] args) {
CaseKey aKey = new CaseKey("tenant-a", "case-1");
CaseKey bKey = new CaseKey("tenant-b", "case-1");
CaseKey cKey = new CaseKey("tenant-a", "case-2");
CaseKey dKey = new CaseKey("tenant-a", "case-3");
CaseKey eKey = new CaseKey("tenant-c", "case-9");
RiskCase a = new RiskCase(
aKey, "alice", 70, 20, CaseState.OPEN);
RiskCase b = new RiskCase(
bKey, "alice", 90, 10, CaseState.OPEN);
RiskCase c = new RiskCase(
cKey, "bob", 90, 12, CaseState.OPEN);
RiskCase d = new RiskCase(
dKey, "alice", 30, 15, CaseState.OPEN);
RiskCase e = new RiskCase(
eKey, "zoe", 60, 11, CaseState.OPEN);
RiskIndex index = new RiskIndex();
System.out.println("open-a=" + index.open(a));
System.out.println("open-b=" + index.open(b));
System.out.println("open-c=" + index.open(c));
System.out.println("open-d=" + index.open(d));
System.out.println("open-e=" + index.open(e));
System.out.println("duplicate-a=" + index.open(a));
System.out.println("observe-c=" + index.observe(cKey));
System.out.println("recent-before=" + index.recentOrder());
List<String> initialTop = index.topSnapshot(5);
System.out.println("initial-top=" + initialTop);
System.out.println("initial-exposure=" + index.exposureSummary());
System.out.println("top-zero=" + index.topSnapshot(0));
System.out.println("rescore-d=" + index.rescore(dKey, 95));
System.out.println("rescore-d-same=" + index.rescore(dKey, 95));
System.out.println("top-after-rescore=" + index.topSnapshot(5));
System.out.println("close-b="
+ index.close(bKey, CaseState.APPROVED));
System.out.println("close-b-again="
+ index.close(bKey, CaseState.REJECTED));
System.out.println("close-a="
+ index.close(aKey, CaseState.REJECTED));
System.out.println("facts=" + index.factCount());
System.out.println("final-exposure=" + index.exposureSummary());
System.out.println("final-queue=" + index.queueSummary());
System.out.println("final-counts=" + index.countSummary());
System.out.println("recent-after=" + index.recentOrder());
System.out.println("old-snapshot=" + initialTop);
}
}
运行后的精确输出为:
open-a=true
open-b=true
open-c=true
open-d=true
open-e=true
duplicate-a=false
observe-c=tenant-a/case-2=OPEN:90
recent-before=[tenant-a/case-3=OPEN:30, tenant-c/case-9=OPEN:60, tenant-a/case-2=OPEN:90]
initial-top=[tenant-b/case-1@alice=90, tenant-a/case-2@bob=90, tenant-a/case-1@alice=70, tenant-c/case-9@zoe=60, tenant-a/case-3@alice=30]
initial-exposure=[tenant-a/alice=100, tenant-a/bob=90, tenant-b/alice=90, tenant-c/zoe=60]
top-zero=[]
rescore-d=true
rescore-d-same=false
top-after-rescore=[tenant-a/case-3@alice=95, tenant-b/case-1@alice=90, tenant-a/case-2@bob=90, tenant-a/case-1@alice=70, tenant-c/case-9@zoe=60]
close-b=true
close-b-again=false
close-a=true
facts=5
final-exposure=[tenant-a/alice=95, tenant-a/bob=90, tenant-c/zoe=60]
final-queue=[tenant-a/case-3=95, tenant-a/case-2=90, tenant-c/case-9=60]
final-counts=[OPEN=3, APPROVED=1, REJECTED=1]
recent-after=[tenant-a/case-3=OPEN:95, tenant-b/case-1=APPROVED:90, tenant-a/case-1=REJECTED:70]
old-snapshot=[tenant-b/case-1@alice=90, tenant-a/case-2@bob=90, tenant-a/case-1@alice=70, tenant-c/case-9@zoe=60, tenant-a/case-3@alice=30]
关键步骤与异常安全边界:
Top 对 limit == 0 直接返回;其余情况沿树序逐项核对队列键、事实身份、OPEN 状态和账户聚合,再用 List.copyOf 冻结,不访问最近缓存。
调分在提交前完成旧队列键与旧敞口核对、差值和新敞口的 exact 算术、OPEN 计数检查、新不可变 value 构造与新键碰撞检查;之后才删旧键并更新四张 Map。
结案在提交前完成目标终态、旧队列、旧敞口、扣减结果、源/目标计数和新终态 value 的全部校验;越过提交线后只消费已经算好的局部变量。
幂等失败在任何 access-order 缓存读取之前返回,不会用一次失败请求偷偷改变 LRU。
这只能避免可恢复业务错误导致半写;普通 Map 的连续提交仍不抵抗线程交错、进程崩溃或持久化失败,生产环境必须补外层原子性边界。
设事实数为 n、开放案件数为 q、返回项数为 k:成功创建、调分、结案均由 TreeMap 更新主导,为 O(log q) 时间、单次额外空间 O(1);非空 Top 为 O(log q + k) 时间和 O(k) 快照空间,零上限提前返回为 O(1);事实与开放队列的总体空间为 O(n + q),容量 3 缓存与枚举状态槽位为常量空间。
返回今日索引
第 5 题参考答案:OpenJDK jdk-21+35 源码追踪
下面每行对应昨日题目的 4 分;字段和内部方法属于固定版本实现细节,业务代码只能依赖公开契约:
问题
公开入口
关键字段
主路径(每条至多 4 个箭头节点)
回到本题的结果或扩展点
HashMap 复合键查询、条件写入、value 替换与聚合增删
get、putIfAbsent、put、remove、compute、merge
table、size、modCount
get→getNode;put/putIfAbsent→putVal;remove→removeNode;compute/merge→桶定位→回调→更新/删除
CaseKey/AccountKey 的稳定 hashCode+equals 决定同一业务键;同键替换 value 通常不增加 size。回调返回 null 可能删映射,单 Map 复合方法不让五张 Map 成为事务。
碰撞、扩容、树化和迭代修改检测
putVal、resize、treeifyBin、entrySet().iterator()
threshold、loadFactor、TREEIFY_THRESHOLD、MIN_TREEIFY_CAPACITY、modCount
putVal→resize/treeifyBin;resize→高低链拆分;HashIterator.nextNode→expectedModCount
扩容按 oldCap 位拆链;树化同时受桶长度与表容量约束,不能猜桶序或红黑树形状。ConcurrentModificationException 是尽力检测,不是锁。
LinkedHashMap access-order 命中、已有键更新和淘汰
get、put
head、tail、accessOrder
get→getNode→afterNodeAccess;已有键 put→putVal→afterNodeAccess;新键 put→afterNodeInsertion→removeEldestEntry
成功命中或已有键更新会移到尾部;新插入后可经淘汰钩子删除最老缓存项。缓存顺序不是队列优先级,也不是事实。
TreeMap 自然序定位、重键与活视图
get、put、remove、entrySet
root、size、modCount、comparator
本题 get/remove→getEntry→ReviewKey.compareTo;put→compareTo→addEntry;entrySet.iterator→getFirstEntry→successor
本题 new TreeMap<>() 没有显式 Comparator,走 ReviewKey.compareTo 自然序分支;调分必须删完整旧键再插新键。集合视图背靠树,复制字符串列表才稳定。
EnumMap 枚举槽位与状态迁移
get、put、remove
keyType、keyUniverse、vals、size
put→typeCheck/ordinal→maskNull→写槽位;remove→isValidKey/ordinal→清槽→unmaskNull
枚举序号定位固定槽位,迭代按声明顺序;成功结案先验证旧计数,再把一次计数从 OPEN 移到目标终态。多次调用仍不是跨 Map 原子操作。
固定源码直链:
返回今日索引
补强建议
由于没有有效作答,本次不能指定“大大的薄弱点”。最值得先独立复述的是三条通用边界:可变排序字段为什么要求旧键删除和新键插入;为什么 access-order 的一次 get 也是状态变化;为什么“先预检、后连续写 Map”仍不等于生产事务。今天的新 Map 变式会继续验证这些能力,但不会把昨日参考答案冒充为作答证据。
返回今日索引
Day 21 核心讲解:多租户会员权益卡换卡与冻结索引
建议用时:31~33 分钟。今天仍是 challenge.map 的闯关变式;上一课没有有效答案,Map 闯关尚未通过,不能提前进入 Set。
返回今日索引
一、为什么需要:换卡不是覆盖一条 value
大大,一张会员权益卡至少有四种读取方式:客服按“租户 + 卡号”查历史事实,结算按“租户 + 会员”找当前唯一活动卡,运营按到期日找即将到期的活动卡,诊断页查看最近访问的三张卡。HashMap<CardKey, BenefitCard> 能快速查卡,却既不承诺到期顺序,也不能直接回答会员的活动卡指针。
更棘手的是换卡。A 卡换成 E 卡不是 facts.put(A, E):A 的身份和历史必须保留,只是状态变为 REPLACED;E 是另一条新事实,并成为该会员唯一 ACTIVE 卡。一次命令会同时改变两个事实键、一个会员指针、两个到期索引键、状态计数和缓存。若只完成其中一半,客服可能看到 E 已生效,而结算仍指向 A。
因此今天的重点不是多背几个 API,而是从不变量推导 issue、replace(old, new)、freeze 和 expiringSnapshot 的全部状态变化,并清楚区分“预检后不再发生可预见失败”与“真正事务”之间的距离。
返回今日索引
二、前置知识:Map 九项如何落到卡索引
知识点
今日用途
ds.map.contract
缺键、禁止 null、putIfAbsent/replace/remove 的条件与返回值
ds.map.equality
CardKey、MemberKey 的值相等语义和不可变性
ds.map.hashmap-structure
桶、负载因子、阈值,以及正常散列下的期望 O(1)
ds.map.hashmap-put-get-remove-source
两个事实键与活动指针的查询、插入、替换、条件删除
ds.map.hashmap-resize-treeify-iterator-source
扩容拆桶、树化边界、modCount 与 fail-fast
ds.map.compute-merge-views
状态计数、活视图、只读包装与稳定快照
ds.map.linkedhashmap-source
access-order 命中移尾和容量 3 淘汰
ds.map.treemap-source
到期全序、旧键删除、新键插入与半开范围视图
ds.map.specialized
EnumMap 三状态槽位及其他专用 Map 的适用边界
所有业务键和值都禁止 null,字符串还要拒绝空白。于是 facts.get(key) == null 可以明确表示卡不存在;若一个通用 Map 允许映射到 null,就必须再用 containsKey 区分缺键。put 返回旧值并不等于可以“先写再检查”,因为本题一旦覆盖错值,其他派生 Map 尚未迁移。
随堂检查 1
amy 在 tenant-a 和 tenant-b 各有一张活动卡,activeCardByMember 能只用 memberId 做键吗?
**即时答案:**不能。键必须是 MemberKey(tenantId, memberId);否则两个租户的同名会员会互相覆盖。两个字段都参与 equals/hashCode,并且入 Map 后不得变化。
返回今日索引
三、定义:先固定对象、状态机与五个不变量
对象全部不可变:
CardKey(tenantId, cardId) 是永久卡身份。
MemberKey(tenantId, memberId) 是租户内会员身份。
BenefitCard(key, memberId, units, expiresDay, issuedSequence, state) 是卡事实;权益单位为正,到期日和发行序号在建档后不修改。
ExpiryKey(expiresDay, issuedSequence, tenantId, cardId) 按到期日升序、发行序号升序、租户和卡号升序比较。尾部字段保证不同卡不会比较为 0。
CardState 只有 ACTIVE、REPLACED、FROZEN。后两者是终态,不再换卡或冻结。
五个容器必须始终满足:
facts: HashMap<CardKey, BenefitCard> 保存所有卡,终态卡也不删除。
activeCardByMember: HashMap<MemberKey, CardKey> 对每个存在活动卡的会员恰有一个指针;指向的事实必须为 ACTIVE 且会员一致。
expiryQueue: TreeMap<ExpiryKey, CardKey> 仅含 ACTIVE 卡,每张活动卡恰有一项。
容量 3 的 access-order LinkedHashMap<CardKey, BenefitCard> 只保存最近访问的最新卡值;淘汰不改变业务事实。
EnumMap<CardState, Integer> 记录非零状态数,所有槽位之和等于 facts.size()。
状态机也必须明确:issue 创建一张新的 ACTIVE 卡,前提是卡键不存在且会员当前没有活动卡;replace(old, new) 只接受旧 ACTIVE 卡,新卡必须是同租户同会员、全新卡键,成功后旧卡变 REPLACED、新卡为 ACTIVE;freeze 只把 ACTIVE 迁移为 FROZEN。重复换卡、重复冻结或对终态卡操作都无副作用。
返回今日索引
四、心智模型:所有会失败的事都留在提交线之前
把一个命令分成“读旧态 → 构造候选新态 → 全量预检 → 连续提交”。预检阶段不能修改任何 Map,也不能先调用最近缓存的 get:access-order 的成功命中会改变链表顺序,失败命令连 LRU 热度都不应变化。
issue 先校验对象字段、确认事实键缺失、会员没有活动指针、到期键不冲突,并用 Math.addExact 预算 ACTIVE 计数;通过后才写事实、指针、队列、计数和缓存。
replace 的预检更长:旧事实存在且为 ACTIVE;会员指针确实指向旧卡;旧到期键确实映射旧卡;新卡键在事实中不存在;新卡与旧卡属于同租户同会员;新到期键不会碰撞;ACTIVE 数保持不变、REPLACED 数可精确加一。然后提前构造旧卡的 REPLACED 新 value 与新 ACTIVE value。提交区才删除旧到期键、替换旧事实、插入新事实、切换会员指针、插入新到期键、迁移计数并刷新缓存。
freeze 先核对 ACTIVE 事实、会员指针、旧到期键和计数,预算 ACTIVE - 1、FROZEN + 1;提交后保留同一卡键的 FROZEN 新 value,删除活动指针和到期键,再迁移计数、更新缓存。若任何预检失败,所有容器和缓存次序都必须原样不动。
可以把预检结果装进只存在于栈上的“变更计划”:其中同时保存旧卡事实、旧到期键、会员键、候选终态、新卡事实、新到期键和预算后的计数。重复发卡会在发现事实键已存在或会员已有活动指针时结束;新旧卡键相同、租户或会员不同、旧卡已经终态、旧队列项缺失、新到期键比较碰撞,都会在计划完成前结束。冻结已为 REPLACED/FROZEN 的卡同样直接返回。这里的“结束”不仅是不写业务 Map,也包括不读取或更新最近缓存、不生成部分状态计数。这样大大检查一个失败分支时,只需问两个问题:提交线是否尚未越过,五个容器与 LRU 是否逐项保持原值。
越过提交线后没有正常业务失败,并不意味着原子提交。换卡仍由多个 Map 写组成,进程可能中途崩溃,另一个线程也可能读到中间态。ConcurrentHashMap 能增强单键操作的并发性质,却不会把两个事实键和三张其他 Map 自动包成事务。
随堂检查 2
为什么不能先把新卡写入 facts,再检查 REPLACED 计数是否溢出?
**即时答案:**因为 Math.addExact 可能抛出异常,此时新卡事实已经存在而活动指针、旧卡状态和队列都未更新。计数预算、键冲突和旧态核对必须先完成。
返回今日索引
五、完整数值与状态推演:四次发行、一次换卡、一次冻结
依次发行四张卡:
代号
卡与会员
权益单位
到期日
发行序号
A
tenant-a/card-1 → amy
100
30
20
B
tenant-b/card-1 → amy
200
20
10
C
tenant-a/card-2 → bob
150
20
12
D
tenant-c/card-9 → zoe
80
40
11
四次 issue(A, B, C, D) 后,facts.size() 为 4,状态计数为 ACTIVE=4。活动指针分别是 tenant-a/amy→A、tenant-b/amy→B、tenant-a/bob→C、tenant-c/zoe→D;两位 amy 因租户不同互不覆盖。到期队列为:
[B@20#10, C@20#12, A@30#20, D@40#11]
B、C 同在第 20 日,发行序号 10 的 B 在前。即使日期与序号也相同,tenant/card 尾字段仍会给出唯一顺序。发行都会把新值放入最近缓存,容量为 3,因此插入 D 后缓存由 [A,B,C] 变为 [B,C,D]。
现在复制 [20, 31) 的到期快照,得到 [B:200@20, C:150@20, A:100@30]。实现可以先取得 subMap 活视图,但必须在方法内复制为不可修改列表;取快照本身不读取最近缓存,因此缓存仍为 [B,C,D]。
接着用 E 替换 A:E 是 tenant-a/card-3 → amy,权益 120,到期日 60,发行序号 30。预检确认 A 为 ACTIVE、tenant-a/amy 指向 A、旧到期键存在、E 从未出现且同属该会员、新到期键无冲突。提交完成后:
facts 有 5 条;A 保留但为 REPLACED,E 为 ACTIVE。
activeCardByMember 的 tenant-a/amy 从 A 切到 E,其他三个指针不变。
队列变为 [B@20#10, C@20#12, D@40#11, E@60#30]。
计数为 ACTIVE=4, REPLACED=1,不是 ACTIVE 增加一。
本课约定成功换卡按变更顺序把旧卡终态值、再把新卡活动值放入缓存。由 [B,C,D] 放入 A 会淘汰 B,得 [C,D,A];再放 E 淘汰 C,得 [D,A,E]。先前快照仍保留 B、C、A 的旧字符串,不会因为 A 变为 REPLACED 而改变。
最后冻结 B。预检确认 tenant-b/amy→B 且队列中有 B;提交后 B 事实保留为 FROZEN,删除 tenant-b/amy 活动指针并移除 B 到期键。最终 facts.size()=5,活动指针 3 个,队列 [C@20#12, D@40#11, E@60#30],计数 ACTIVE=3, REPLACED=1, FROZEN=1。B 原本已被淘汰,缓存写入 B 后由 [D,A,E] 变为 [A,E,B]。再次冻结 B 返回无变化,缓存也不得重排。
返回今日索引
六、源码映射:从业务问题到 jdk-21+35 主链
以下固定使用 OpenJDK jdk-21+35。内部字段和辅助方法用于解释现象,只有公开 API 契约可以成为业务依赖。
问题
入口类 / 方法
关键字段
主调用链
扩展点与可观察结果
查卡、建档、条件替换
HashMap.get/putIfAbsent/replace/remove
table、size、threshold、modCount
get → getNode;putIfAbsent → putVal;条件删除 → removeNode
键的 equals/hashCode 决定旧卡能否再次定位
扩容、树化、遍历
HashMap.putVal/resize/treeifyBin
桶数组、链表/树节点、负载因子
超阈值 → resize;长冲突桶 → 扩容或树化
迭代器用 expectedModCount 尽力发现结构修改
到期排序与范围
TreeMap.put/remove/subMap
root、size、modCount、comparator
比较定位 → 插入/删除 → 红黑树平衡
Comparator 全序决定覆盖与遍历顺序;subMap 是活视图
最近卡缓存
LinkedHashMap.get/put
head、tail、accessOrder
HashMap 定位 → afterNodeAccess 移尾;插入 → afterNodeInsertion
覆盖 removeEldestEntry 限制容量 3
三状态计数
EnumMap.get/put
keyType、按 ordinal 定位的值数组
枚举序号 → 固定槽位
适合封闭状态集合,不适合动态业务键
以容量 64、默认负载因子 0.75 为例,阈值约为 48,第 49 个映射会触发扩容到 128。两个经扰动哈希若原来都落在桶 7,扩容时 (e.hash & oldCap) 为 0 的节点仍在 7,为 64 的节点移到 7 + 64 = 71。TREEIFY_THRESHOLD=8 和 MIN_TREEIFY_CAPACITY=64 共同影响树化:表容量不足时会优先扩容,不能仅凭碰撞数断言桶已成红黑树,更不能猜测具体树形。
compute/merge 可以简写单张 Map 的计数,但回调返回 null 会删除映射,而且回调不应结构性修改同一 Map。换卡还要核对其他容器,所以更适合先在局部变量中用 Math.addExact/subtractExact 预算,再进入提交区。keySet/values/entrySet 都是活视图;Collections.unmodifiableMap 只禁止经包装写入,仍会看见底层变化。
条件 API 的返回值可以帮助核验合同,却不是回滚机制:putIfAbsent 返回既有值表示键已被占用,replace(key, oldValue, newValue) 与 remove(key, value) 的布尔值表示当前映射是否仍匹配预期。在本题单线程模型里,完整预检后这些条件应成立;若生产并发让提交中的某一步返回 false,此前已经成功的其他 Map 写不会自动撤销。正确做法是先建立共同锁或事务边界,而不是捕获结果后继续拼凑剩余状态。
源码直链:HashMap.java 、LinkedHashMap.java 、TreeMap.java 、EnumMap.java 。调试练习:分别在 HashMap.putVal、TreeMap.put/remove、LinkedHashMap.afterNodeAccess 设断点,观察复合键查找、旧/新到期键的比较路径和一次缓存命中移尾。fail-fast 只是在 modCount 不一致时尽力报错,不提供互斥、可见性或一致快照。
随堂检查 3
把 Collections.unmodifiableNavigableMap(expiryQueue.subMap(...)) 返回给调用方,是稳定快照吗?
**即时答案:**不是。只读包装下面仍是活范围视图,后续换卡或冻结会改变调用方看到的内容。应在方法内按树序复制所需字段,再以 List.copyOf 返回。
返回今日索引
七、真实后端应用:唯一活动卡需要业务级原子边界
在会员权益服务中,消费扣减先按 MemberKey 找活动卡,再用 CardKey 读取事实;续期提醒按到期范围取稳定快照;客服检索所有历史卡;诊断缓存仅减少热点读成本。设事实卡数为 n、活动卡数为 a、快照实际返回 k:事实和活动指针查询在正常散列下为期望 O(1),队列插删为 O(log a),范围快照为 O(log a + k),缓存和 EnumMap 操作为常量级;总空间为 O(n + a)。
生产换卡通常还要校验命令幂等键和事实版本,并在数据库唯一约束、事务或同一会员串行执行器内提交。若主档与索引跨存储,可用事务消息或 outbox 推动可重放的派生更新,但不能把“最终会修好”当作允许同一请求半写的理由。恢复审计应从 facts 中筛选 ACTIVE 卡,重算会员指针、到期键集合和三状态计数,再按内容比较;审计过程不得依赖 HashMap 迭代顺序,也不能边遍历边修正式 Map。
缓存命中必须返回与事实当前状态一致的不可变值。缓存丢失、淘汰或清空只影响性能,绝不能让 REPLACED 卡重新成为活动卡。类似地,不知道节点位置时用 LinkedList 删除中间元素仍要遍历,整体不是天然 O(1),并不比 TreeMap 的完整旧键删除更神奇。
返回今日索引
八、错误示例:每个“省一步”都会制造矛盾
1. activeCardByMember 只用 memberId:跨租户同名会员互相覆盖。
2. ExpiryKey 只比较到期日:同日多卡被 TreeMap 当成同一键。
3. 让卡号、到期日或排序字段可变:HashMap 定位或 TreeMap 顺序失效。
4. 换卡直接删除旧卡:REPLACED 历史事实丢失。
5. 先插新事实再检查旧队列和计数:失败后留下两张看似活动的卡。
6. 冻结只改 facts,不删活动指针和到期键:终态卡仍可消费、仍被提醒。
7. 返回 subMap/entrySet 的只读包装并称为快照:后续写入改变旧结果。
8. 依赖 HashMap/HashSet 打印顺序,或捕获 ConcurrentModificationException 后继续遍历。
9. 把多个 ConcurrentHashMap 连续写入称为换卡事务。
WeakHashMap 的键可能因 GC 消失,IdentityHashMap 比较对象身份而非业务值,都不适合保存审计事实。access-order LinkedHashMap.get 会改变顺序,失败分支若查询缓存也不是“无副作用”。
返回今日索引
九、正确示例:独立验证全序、快照和访问顺序
下面的小程序使用另一组数据,只验证三个底层合同,不实现今日编码题的 expiringSnapshot/replace/freeze 三个 TODO,也不另写完整的 issue 服务流程。
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public final class CardOrderContractDemo {
enum CardState { ACTIVE, REPLACED, FROZEN }
record CardKey(String tenantId, String cardId) {
CardKey {
if (tenantId == null || tenantId.isBlank()
|| cardId == null || cardId.isBlank()) {
throw new IllegalArgumentException("blank card identity");
}
}
}
record CardView(
CardKey key,
String memberId,
int units,
int expiresDay,
long issuedSequence,
CardState state) {
CardView {
if (key == null || memberId == null || memberId.isBlank()
|| units <= 0 || expiresDay < 0 || issuedSequence < 0
|| state == null) {
throw new IllegalArgumentException("invalid card");
}
}
}
record ExpiryKey(int expiresDay, long issuedSequence, String tenantId, String cardId)
implements Comparable<ExpiryKey> {
@Override
public int compareTo(ExpiryKey other) {
int result = Integer.compare(expiresDay, other.expiresDay);
if (result != 0) return result;
result = Long.compare(issuedSequence, other.issuedSequence);
if (result != 0) return result;
result = tenantId.compareTo(other.tenantId);
return result != 0 ? result : cardId.compareTo(other.cardId);
}
}
static final class RecentCards extends LinkedHashMap<CardKey, CardView> {
private final int maxEntries;
RecentCards(int maxEntries) {
super(16, 0.75f, true);
this.maxEntries = maxEntries;
}
@Override
protected boolean removeEldestEntry(Map.Entry<CardKey, CardView> eldest) {
return size() > maxEntries;
}
}
private static ExpiryKey expiryKey(CardView card) {
return new ExpiryKey(card.expiresDay(), card.issuedSequence(),
card.key().tenantId(), card.key().cardId());
}
private static String identity(CardKey key) {
return key.tenantId() + "/" + key.cardId();
}
private static List<String> expiryLines(
TreeMap<ExpiryKey, CardKey> queue,
Map<CardKey, CardView> facts,
int limit) {
var lines = new ArrayList<String>();
for (CardKey key : queue.values()) {
if (lines.size() == limit) break;
CardView card = facts.get(key);
lines.add(identity(key) + "@" + card.expiresDay()
+ "#" + card.issuedSequence());
}
return List.copyOf(lines); // 脱离队列活视图,冻结此次观察结果。
}
private static List<String> snapshotLines(
TreeMap<ExpiryKey, CardKey> queue,
Map<CardKey, CardView> facts,
int limit) {
var lines = new ArrayList<String>();
for (CardKey key : queue.values()) {
if (lines.size() == limit) break;
CardView card = facts.get(key);
lines.add(identity(key) + "=" + card.memberId() + ":"
+ card.units() + "@" + card.expiresDay());
}
return List.copyOf(lines);
}
private static List<String> recentLines(RecentCards recent) {
var lines = new ArrayList<String>();
for (CardView card : recent.values()) {
lines.add(identity(card.key()) + "=" + card.state() + ":" + card.units());
}
return List.copyOf(lines);
}
public static void main(String[] args) {
var facts = new HashMap<CardKey, CardView>();
var queue = new TreeMap<ExpiryKey, CardKey>();
var recent = new RecentCards(3);
var counts = new EnumMap<CardState, Integer>(CardState.class);
var p = new CardView(new CardKey("t-x", "p-7"), "mia", 40, 18, 5,
CardState.ACTIVE);
var q = new CardView(new CardKey("t-y", "q-1"), "mia", 60, 18, 4,
CardState.ACTIVE);
var r = new CardView(new CardKey("t-x", "p-8"), "noa", 30, 27, 6,
CardState.ACTIVE);
var s = new CardView(new CardKey("t-z", "z-2"), "lee", 90, 12, 9,
CardState.ACTIVE);
for (CardView card : List.of(p, q, r, s)) {
facts.put(card.key(), card);
queue.put(expiryKey(card), card.key());
counts.merge(card.state(), 1, Math::addExact);
}
var snapshot = snapshotLines(queue, facts, 3);
recent.put(p.key(), p);
recent.put(q.key(), q);
recent.put(r.key(), r);
recent.get(p.key());
recent.put(s.key(), s);
System.out.println("FACTS_SIZE=" + facts.size());
System.out.println("EXPIRY=" + expiryLines(queue, facts, 10));
System.out.println("SNAPSHOT=" + snapshot);
System.out.println("RECENT=" + recentLines(recent));
System.out.println("COUNTS=" + counts);
}
}
预期输出:
FACTS_SIZE=4
EXPIRY=[t-z/z-2@12#9, t-y/q-1@18#4, t-x/p-7@18#5, t-x/p-8@27#6]
SNAPSHOT=[t-z/z-2=lee:90@12, t-y/q-1=mia:60@18, t-x/p-7=mia:40@18]
RECENT=[t-x/p-8=ACTIVE:30, t-x/p-7=ACTIVE:40, t-z/z-2=ACTIVE:90]
COUNTS={ACTIVE=4}
程序故意不输出 facts.entrySet(),因为 HashMap 的迭代顺序不是合同;也没有把局部演示冒充跨多个容器的原子换卡实现。
返回今日索引
十、边界总结:用六类红线完成闯关自检
不假设 HashMap 或 HashSet 顺序稳定;业务顺序必须来自明确排序结构或复制排序。
不破坏 equals/hashCode 或 Comparator 契约;身份键不可变,到期键必须补足全序。
不混淆活视图、副本、只读包装和不可变集合;稳定快照必须复制后冻结。
不把未知节点位置时的 LinkedList 中间操作说成天然 O(1)。
不把 fail-fast 当成线程安全;它不提供锁、可见性、原子性或一致快照。
不把 ConcurrentHashMap 的单键能力扩张成跨两个事实键、跨多张 Map 的自动事务。
再补三条操作口径:换卡必须保留旧事实并创建新事实,ACTIVE 数量保持不变;冻结必须保留 FROZEN 事实、退队并删除唯一活动指针;成功命令才允许刷新最近缓存。大大若能从这些不变量准确推演 A~E 的事实数、指针、队列、计数、缓存和旧快照,并沿固定源码入口解释可观察结果,才算真正具备 Map 阶段的解释、推演与应用能力。
返回今日索引
返回今日索引
编码闯关:多租户会员权益卡换卡与冻结索引
课程节点:Day 21,日期 2026-09-14,directionSession=19,curriculumItemId=challenge.map,lessonMode=checkpoint。建议用时:18~20 分钟。Map 阶段闯关必须提交完整 Java 21 代码、成功编译证据与逐行一致的运行输出;只写思路不能通过。
你要完成一个单线程会员权益卡索引。每个租户内的会员最多有一张 ACTIVE 卡;换卡要保留旧卡事实,将其替换为 REPLACED 新 value,再加入一张到期日更晚、发行序号更大的新卡。冻结则保留卡事实,但要同时移出会员活动指针与到期队列。
1. 业务背景与五张 Map
不同租户都可以有 card-1 和 amy,因此卡身份是 CardKey(tenantId, cardId),会员身份是 MemberKey(tenantId, memberId)。月权益量、到期日、发行序号与状态是事实 value,不是卡身份。
facts:HashMap<CardKey, BenefitCard>,保留所有已发行卡的当前事实;换卡和冻结均不删除旧事实。
activeCardByMember:HashMap<MemberKey, CardKey>,每个会员至多一个活动卡指针;换卡切换指针,冻结删除指针。
expiryQueue:TreeMap<ExpiryKey, CardKey>,只收录 ACTIVE 卡;按到期日、发行序号、租户和卡号依次升序形成全序。
recentCards:容量 3 的 access-order LinkedHashMap<CardKey, BenefitCard>;发卡、成功换卡、成功冻结和显式观察会更新热度,但它不是事实源。
stateCounts:EnumMap<CardState, Integer>,统计 ACTIVE/REPLACED/FROZEN 事实数,零计数不保留。
已实现的 issue 会在第一次 Map 写入前完成卡键查重、会员占用检查、到期键碰撞检查和计数精确算术。你只需补全到期快照、换卡、冻结三个位置。
2. 约束与验收边界
基线为 Java 21,仅使用 JDK;不添加依赖、日志、调试输出或吞掉异常的 try/catch。
tenantId/cardId/memberId 不能为 null 或空白;monthlyUnits 必须在 1..1000,expiresAtDay/issuedSequence 必须非负。键和事实对象发布后均不可变,Map 不接收 null 键或 null value。
issue(card) 只接受 ACTIVE 卡。完整 CardKey 重复或同租户会员已有活动卡时返回 false,五张 Map 都无副作用,缓存热度也不变。
ExpiryKey.compareTo 按 expiresAtDay、issuedSequence、tenantId/cardId 升序比较。合法对象上,比较结果为 0 必须恰好表示所有排序字段与完整卡身份都相等。
expiringSnapshot(limit) 按当前到期队列顺序取最多 limit 项;逐项交叉核对事实键、ACTIVE 状态、完整到期键和会员活动指针,返回独立、不可修改的字符串快照。limit == 0 必须在创建 TreeMap 迭代器前返回空快照;负数必须在读取业务 Map 前失败。
replace(oldKey, newCard) 只处理存在且为 ACTIVE 的旧卡;缺失或已终态返回 false。新卡必须为 ACTIVE,与旧卡属于同一租户、同一会员,使用不同且未占用的 CardKey,到期日更晚、发行序号更大。跨租户、跨会员、同键或时序不合法属于输入合同错误;新键已被事实占用时返回 false。
成功换卡前必须核对会员活动指针与旧到期键,构造旧卡终态 value 和新到期键,检查新键碰撞,验证 ACTIVE 计数并用 Math.addExact 预算 REPLACED + 1。越过第一次 Map 写入后不再执行可恢复校验或可溢出算术。
换卡提交顺序为:删旧到期键 → 旧事实换为 REPLACED → 加入新 ACTIVE 事实 → 切换活动指针 → 插入新到期键 → 更新计数 → 先把旧卡新 value、再把新卡写入缓存。ACTIVE 计数不变,REPLACED 加 1。
freeze(key) 只处理 ACTIVE 卡;缺失或已终态返回 false 且不能访问 access-order 缓存。成功时在写入前核对事实键、会员指针、旧到期键和状态计数,用精确算术预算 ACTIVE - 1/FROZEN + 1,构造 FROZEN 新 value;之后才移出指针与队列项、替换事实、迁移计数并刷新缓存。
本题是单线程模型。多次普通 Map 写入不是事务;即使将它们都换成 ConcurrentHashMap,也不会自动获得跨键、跨集合原子性。生产实现需要共同锁、数据库事务,或在局部验证后原子替换不可变聚合引用。
3. 目标拆解与实施顺序
先读 issue、三个不可变业务 record 与排序键,写下事实、活动指针、到期队列、缓存、计数的对应不变式。
完成到期快照:先处理 limit 边界,再沿 expiryQueue.entrySet() 顺序遍历,逐项交叉核对并冻结结果。
完成换卡:把输入合同、幂等返回、全量写前预检与提交区分开;严格按规定顺序刷新缓存。
完成冻结:一次迁移事实、指针、队列和两个枚举计数;重复冻结不得刷新 LRU。
以 Java 21 编译运行,逐行核对规定输出;再说明预检与生产事务边界的区别。
4. 完整可编译的 Java 21 起始代码
保存为 TenantBenefitCardChallenge.java。未补代码时仍可编译;直接运行会完成 A~D 发卡、重复卡去重、同会员占用拒绝和一次缓存观察,然后在第一个待补位置明确失败。起始代码恰好只有 3 个待补位置,当天不提供完整实现。
import java.util.ArrayList;
import java.util.Comparator;
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 TenantBenefitCardChallenge {
private enum CardState {
ACTIVE,
REPLACED,
FROZEN
}
private record CardKey(String tenantId, String cardId) {
CardKey {
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(cardId, "cardId");
if (tenantId.isBlank() || cardId.isBlank()) {
throw new IllegalArgumentException("invalid card key");
}
}
String label() {
return tenantId + "/" + cardId;
}
}
private record MemberKey(String tenantId, String memberId) {
MemberKey {
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(memberId, "memberId");
if (tenantId.isBlank() || memberId.isBlank()) {
throw new IllegalArgumentException("invalid member key");
}
}
String label() {
return tenantId + "/" + memberId;
}
}
private record BenefitCard(
CardKey key,
String memberId,
int monthlyUnits,
long expiresAtDay,
long issuedSequence,
CardState state) {
BenefitCard {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(memberId, "memberId");
Objects.requireNonNull(state, "state");
if (memberId.isBlank()
|| monthlyUnits < 1
|| monthlyUnits > 1000
|| expiresAtDay < 0
|| issuedSequence < 0) {
throw new IllegalArgumentException("invalid benefit card");
}
}
MemberKey memberKey() {
return new MemberKey(key.tenantId(), memberId);
}
BenefitCard replaced() {
return new BenefitCard(
key,
memberId,
monthlyUnits,
expiresAtDay,
issuedSequence,
CardState.REPLACED);
}
BenefitCard frozen() {
return new BenefitCard(
key,
memberId,
monthlyUnits,
expiresAtDay,
issuedSequence,
CardState.FROZEN);
}
String cacheLabel() {
return key.label() + "=" + state
+ ":" + monthlyUnits + "@" + expiresAtDay;
}
}
private record ExpiryKey(
long expiresAtDay,
long issuedSequence,
String tenantId,
String cardId) implements Comparable<ExpiryKey> {
static ExpiryKey from(BenefitCard card) {
return new ExpiryKey(
card.expiresAtDay(),
card.issuedSequence(),
card.key().tenantId(),
card.key().cardId());
}
@Override
public int compareTo(ExpiryKey other) {
int byExpiry = Long.compare(expiresAtDay, other.expiresAtDay);
if (byExpiry != 0) {
return byExpiry;
}
int bySequence = Long.compare(
issuedSequence, other.issuedSequence);
if (bySequence != 0) {
return bySequence;
}
int byTenant = tenantId.compareTo(other.tenantId);
return byTenant != 0
? byTenant
: cardId.compareTo(other.cardId);
}
}
private static final class CardIndex {
private static final int RECENT_LIMIT = 3;
private final Map<CardKey, BenefitCard> facts = new HashMap<>();
private final Map<MemberKey, CardKey> activeCardByMember =
new HashMap<>();
private final NavigableMap<ExpiryKey, CardKey> expiryQueue =
new TreeMap<>();
private final LinkedHashMap<CardKey, BenefitCard> recentCards =
new LinkedHashMap<>(4, 0.75f, true) {
@Override
protected boolean removeEldestEntry(
Map.Entry<CardKey, BenefitCard> eldest) {
return size() > RECENT_LIMIT;
}
};
private final EnumMap<CardState, Integer> stateCounts =
new EnumMap<>(CardState.class);
private boolean issue(BenefitCard card) {
Objects.requireNonNull(card, "card");
if (card.state() != CardState.ACTIVE) {
throw new IllegalArgumentException("new card must be active");
}
if (facts.containsKey(card.key())) {
return false;
}
MemberKey member = card.memberKey();
if (activeCardByMember.containsKey(member)) {
return false;
}
ExpiryKey expiryKey = ExpiryKey.from(card);
if (expiryQueue.containsKey(expiryKey)) {
throw new IllegalStateException("duplicate expiry key");
}
int activeCount = stateCounts.getOrDefault(CardState.ACTIVE, 0);
if (activeCount < 0) {
throw new IllegalStateException("invalid active count");
}
int newActiveCount = Math.addExact(activeCount, 1);
// 可恢复失败已排除,下面才进入多 Map 提交区。
facts.put(card.key(), card);
activeCardByMember.put(member, card.key());
expiryQueue.put(expiryKey, card.key());
stateCounts.put(CardState.ACTIVE, newActiveCount);
recentCards.put(card.key(), card);
return true;
}
private List<String> expiringSnapshot(int limit) {
if (limit < 0) {
throw new IllegalArgumentException("limit must not be negative");
}
// TODO 1:零上限先返回;其余沿树序核对事实与指针并冻结。
throw new UnsupportedOperationException("expiringSnapshot");
}
private boolean replace(CardKey oldKey, BenefitCard newCard) {
Objects.requireNonNull(oldKey, "oldKey");
Objects.requireNonNull(newCard, "newCard");
if (newCard.state() != CardState.ACTIVE) {
throw new IllegalArgumentException(
"replacement card must be active");
}
// TODO 2:先幂等判定与全量预检,再迁移旧事实、新事实及索引。
throw new UnsupportedOperationException("replace");
}
private boolean freeze(CardKey key) {
Objects.requireNonNull(key, "key");
// TODO 3:仅 ACTIVE 可冻结;计数和旧索引在首次写入前核对。
throw new UnsupportedOperationException("freeze");
}
private String observe(CardKey key) {
Objects.requireNonNull(key, "key");
BenefitCard card = facts.get(key);
if (card == null) {
return "MISSING";
}
BenefitCard cached = recentCards.get(key);
if (!card.equals(cached)) {
recentCards.put(key, card);
}
return card.cacheLabel();
}
private int factCount() {
return facts.size();
}
private String stateOf(CardKey key) {
BenefitCard card = facts.get(Objects.requireNonNull(key, "key"));
return card == null ? "MISSING" : card.state().name();
}
private List<String> activeSummary() {
return activeCardByMember.entrySet().stream()
.sorted(Comparator.comparing(
entry -> entry.getKey().label()))
.map(entry -> entry.getKey().label()
+ "=" + entry.getValue().label())
.toList();
}
private List<String> expirySummary() {
return expiryQueue.entrySet().stream()
.map(entry -> entry.getValue().label()
+ "=" + entry.getKey().expiresAtDay())
.toList();
}
private List<String> recentOrder() {
return recentCards.values().stream()
.map(BenefitCard::cacheLabel)
.toList();
}
private List<String> countSummary() {
return stateCounts.entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.toList();
}
}
public static void main(String[] args) {
CardKey aKey = new CardKey("tenant-a", "card-1");
CardKey bKey = new CardKey("tenant-b", "card-1");
CardKey cKey = new CardKey("tenant-a", "card-2");
CardKey dKey = new CardKey("tenant-c", "card-9");
CardKey eKey = new CardKey("tenant-a", "card-3");
BenefitCard a = new BenefitCard(
aKey, "amy", 100, 30, 20, CardState.ACTIVE);
BenefitCard b = new BenefitCard(
bKey, "amy", 200, 20, 10, CardState.ACTIVE);
BenefitCard c = new BenefitCard(
cKey, "bob", 150, 20, 12, CardState.ACTIVE);
BenefitCard d = new BenefitCard(
dKey, "zoe", 80, 40, 11, CardState.ACTIVE);
BenefitCard conflict = new BenefitCard(
new CardKey("tenant-a", "card-x"),
"amy", 90, 50, 25, CardState.ACTIVE);
BenefitCard e = new BenefitCard(
eKey, "amy", 120, 60, 30, CardState.ACTIVE);
CardIndex index = new CardIndex();
System.out.println("issue-a=" + index.issue(a));
System.out.println("issue-b=" + index.issue(b));
System.out.println("issue-c=" + index.issue(c));
System.out.println("issue-d=" + index.issue(d));
System.out.println("duplicate-a=" + index.issue(a));
System.out.println("issue-conflicting-member=" + index.issue(conflict));
System.out.println("observe-a=" + index.observe(aKey));
System.out.println("recent-before=" + index.recentOrder());
List<String> oldSnapshot = index.expiringSnapshot(4);
System.out.println("initial-expiring=" + oldSnapshot);
System.out.println("snapshot-zero=" + index.expiringSnapshot(0));
System.out.println("replace-a=" + index.replace(aKey, e));
System.out.println("replace-a-again=" + index.replace(aKey, e));
System.out.println("expiry-after-replace=" + index.expirySummary());
System.out.println("recent-after-replace=" + index.recentOrder());
System.out.println("freeze-b=" + index.freeze(bKey));
System.out.println("freeze-b-again=" + index.freeze(bKey));
System.out.println("facts=" + index.factCount());
System.out.println("state-a=" + index.stateOf(aKey));
System.out.println("state-b=" + index.stateOf(bKey));
System.out.println("active-members=" + index.activeSummary());
System.out.println("final-expiry=" + index.expirySummary());
System.out.println("final-counts=" + index.countSummary());
System.out.println("recent-after=" + index.recentOrder());
System.out.println("old-snapshot=" + oldSnapshot);
}
}
5. 三级提示
提示一:先列对应不变式
每个 ACTIVE 事实必须同时有且仅有一个会员指针和一个到期队列项;终态卡在这两张活动索引中均不存在。
换卡后旧卡和新卡都在 facts,但会员指针只能指向新卡;冻结后卡仍在 facts,会员指针必须消失。
stateCounts 非零计数之和必须等于 facts.size()。
提示二:把所有可失败步骤放到提交线之前
换卡先取旧事实;缺失或非 ACTIVE 立即返回,不要先 recentCards.get(oldKey)。再核对同租户会员、新卡时序、未占用新键、旧指针、旧队列键、新队列键与计数。
冻结先核对旧事实、指针与队列项,再用局部变量预算两个状态计数并构造终态 value。
提交区只执行对已核对键值的 remove/put,不在中途调用可溢出计算或新的业务校验。
提示三:快照与缓存顺序
limit == 0 直接返回 List.of();其余可以用 ArrayList 收集,达到上限即停止,最终以 List.copyOf 冻结。不要返回 entrySet()、keySet() 或它们的只读包装。
换卡成功时,按约定先 put(oldKey, replacedOld),再 put(newKey, newCard);两次写入都可能变更 access order,并可能触发一次最老项淘汰。
重复换卡与重复冻结都在缓存访问前返回 false,所以连热度也不能改变。
6. 预期精确输出
补全三个位置后,运行结果必须逐行一致:
issue-a=true
issue-b=true
issue-c=true
issue-d=true
duplicate-a=false
issue-conflicting-member=false
observe-a=tenant-a/card-1=ACTIVE:100@30
recent-before=[tenant-a/card-2=ACTIVE:150@20, tenant-c/card-9=ACTIVE:80@40, tenant-a/card-1=ACTIVE:100@30]
initial-expiring=[tenant-b/card-1@amy=200:exp20, tenant-a/card-2@bob=150:exp20, tenant-a/card-1@amy=100:exp30, tenant-c/card-9@zoe=80:exp40]
snapshot-zero=[]
replace-a=true
replace-a-again=false
expiry-after-replace=[tenant-b/card-1=20, tenant-a/card-2=20, tenant-c/card-9=40, tenant-a/card-3=60]
recent-after-replace=[tenant-c/card-9=ACTIVE:80@40, tenant-a/card-1=REPLACED:100@30, tenant-a/card-3=ACTIVE:120@60]
freeze-b=true
freeze-b-again=false
facts=5
state-a=REPLACED
state-b=FROZEN
active-members=[tenant-a/amy=tenant-a/card-3, tenant-a/bob=tenant-a/card-2, tenant-c/zoe=tenant-c/card-9]
final-expiry=[tenant-a/card-2=20, tenant-c/card-9=40, tenant-a/card-3=60]
final-counts=[ACTIVE=3, REPLACED=1, FROZEN=1]
recent-after=[tenant-a/card-1=REPLACED:100@30, tenant-a/card-3=ACTIVE:120@60, tenant-b/card-1=FROZEN:200@20]
old-snapshot=[tenant-b/card-1@amy=200:exp20, tenant-a/card-2@bob=150:exp20, tenant-a/card-1@amy=100:exp30, tenant-c/card-9@zoe=80:exp40]
7. 复杂度要求
设事实卡数为 n、活动卡数为 a、实际返回的到期项数为 k:
完整卡键事实查询和完整会员键指针查询在正常散列分布下期望 O(1);HashMap 碰撞、树化与扩容意味着不能把单次成本承诺为严格常数。
issue/replace/freeze 的 HashMap 部分期望 O(1),TreeMap 插入或删除为 O(log a);单次操作额外空间 O(1)。
expiringSnapshot 的树首定位与 k 项投影为 O(log a + k) 时间,独立快照额外空间 O(k);专门提前返回的 limit == 0 分支为 O(1)。
容量 3 的 access-order 缓存命中、已有键移尾、插入与一次最老项淘汰为常数或期望 O(1);EnumMap 的枚举键域固定。
不要为找会员当前活动卡扫描全部 n 条事实,也不要声称未知节点位置时 LinkedList 中间查找或删除天然为 O(1)。
8. 边界用例
至少自行验证以下情况;不得为了“通过”而捕获并忽略预期异常:
两个租户使用相同卡号和会员号,仍是不同卡事实和不同会员活动指针。
两张活动卡到期日和发行序号都相同时,租户与卡号尾字段仍形成稳定全序,不覆盖彼此。
expiringSnapshot(0) 为空;负数失败;返回列表不能 add/remove,旧快照不随换卡或冻结变化。
重复发卡、同会员活动卡冲突、缺失旧卡换卡、已终态旧卡换卡、新卡键已占用、缺失冻结和重复冻结均不得刷新 LRU。
跨租户、跨会员、同卡键、到期日不更晚、发行序号不更大的换卡请求在首次 Map 写入前拒绝。
换卡或冻结的状态计数增量溢出时,必须在首次 Map 写入前暴露异常,不能留下半更新状态。
迭代期间结构修改可能 fail-fast,但它不提供互斥、可见性、线程安全或一致快照。
Collections.unmodifiableMap(expiryQueue) 仍背靠可变底层 Map,不是不受后续迁移影响的不可变快照。
ConcurrentHashMap.compute 只能约束本 Map 的相关单键复合操作,不会自动使事实、指针、TreeMap、计数与缓存成为一个业务事务。
9. Map 九项知识覆盖
知识点 ID
本题可验证行为
ds.map.contract
null 策略、键存在性、返回值、事实与派生索引不变式
ds.map.equality
租户复合键的值相等、不可变键与全序尾字段
ds.map.hashmap-structure
事实和指针查询的期望复杂度与碰撞边界
ds.map.hashmap-put-get-remove-source
发卡、换卡、冻结的 get/containsKey/put/remove 语义
ds.map.hashmap-resize-treeify-iterator-source
扩容、树化、迭代顺序非契约与 fail-fast 边界
ds.map.compute-merge-views
精确计数、活视图、只读包装与独立快照
ds.map.linkedhashmap-source
access-order 命中移尾、已有键更新与最老项淘汰
ds.map.treemap-source
到期全序、旧键删除、新键插入与树首遍历
ds.map.specialized
EnumMap 状态槽位,以及 WeakHashMap/IdentityHashMap 不适合审计事实
10. 提交与自检清单
11. 官方资料
返回今日索引
返回今日索引
Map 阶段闯关题:多租户会员权益卡换卡与冻结索引
建议用时:约 7~8 分钟。请优先在 互动页面 作答,聊天提交仅作补充。当天不提供答案。 第 1~3、5 题提交结论与最短必要推理;第 4 题必须提交完整 Java 21 代码、编译证据和规定输出。
题号
固定维度
知识点 ID
分值
可能触发的红线
1
契约与选型
ds.map.contract、ds.map.equality、ds.map.compute-merge-views、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、ds.map.specialized
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
换卡删除旧事实、冻结仍留活动索引、失败分支刷新 LRU;把 ConcurrentHashMap 误认为跨键或跨集合自动原子
5
源码讲解
ds.map.hashmap-structure、ds.map.hashmap-put-get-remove-source、ds.map.hashmap-resize-treeify-iterator-source、ds.map.compute-merge-views、ds.map.linkedhashmap-source、ds.map.treemap-source、ds.map.specialized
20
猜测源码或红黑树形状;把实现细节当公共契约;把 fail-fast、单 Map 原子方法或多次替换当业务事务
合计
契约与选型 25;复杂度与状态推演 25;编码 30;源码讲解 20
覆盖 Map 9 个知识项
100
六类红线均需检查
闯关放行必须同时满足:总分至少 80/100、第 4 题 Java 21 完整代码成功编译运行并产生规定输出、六类红线均未命中。只达到分数、只交片段、编译失败、输出不符或命中任一红线,都不能进入 Set;应按有效证据补强并重闯。
1. 契约与方案设计:事实、活动唯一性、全序与缓存(25 分)
用最多 10 条定义 facts/activeCardByMember/expiryQueue/recentCards/stateCounts 的选型、所有权和不变量,并覆盖:CardKey 与 MemberKey 的租户边界,null 策略,不可变键与 value 替换,发卡、换卡和冻结语义,ExpiryKey 如何补足全序,旧卡为什么仍留在事实表,access-order 缓存为何不是事实,以及到期队列活视图与字符串快照的差别。解释为何 HashMap/HashSet 的偶然迭代顺序不能表示到期顺序,compute/merge 返回 null 的删映射语义,以及 WeakHashMap、IdentityHashMap 为何不适合权益卡主档。
2. 复杂度与结构推演:发卡、换卡、冻结和快照成本(12 分)
设事实卡数为 n、活动卡数为 a、实际返回到期卡数为 k。分别给出完整卡键查询、发卡、换卡、冻结、到期快照、状态计数和容量 3 最近缓存的时间与额外空间复杂度。说明 HashMap 碰撞、扩容、OpenJDK 21 树化条件为何否定“每次严格 O(1)”;比较不知道目标节点时 LinkedList 的中间查找/删除与 TreeMap 按完整旧键删除的成本,并指出 fail-fast 没有提供的互斥、可见性、原子性和一致快照性质。
3. 状态推演与代码分析:跨事实键换卡、全序和旧快照(13 分)
最近缓存容量为 2。依次发放 A=t-a/k-1→m1,units=90,exp=20,seq=8、B=t-b/k-1→m1,units=70,exp=20,seq=7、C=t-a/k-2→m2,units=40,exp=35,seq=9、D=t-c/k-9→m3,units=60,exp=25,seq=6;取得全部活动卡的到期字符串快照;再把 A 换成 E=t-a/k-3→m1,units=110,exp=50,seq=10,冻结 B,最后分别用旧 A 再次换卡、再次冻结 B。
逐步写出每次操作后的活动会员指针、到期队列、最近缓存和三状态计数,并写出最终五个事实状态、旧快照及当前队列。说明初始到期日相同时发行序号为何先决定顺序,若仍相同则租户/卡号尾字段如何防止覆盖;说明换卡为何既不是同键 value 更新,也不能删除旧卡事实,失败重试为什么连 LRU 热度也不能改变。最后区分队列活视图、只读包装和已取得的字符串快照,不得把多张 Map 的迁移称为自动事务。
4. 必交编码:完成到期快照、换卡与幂等冻结(30 分)
补全 编码练习 的 3 个待补位置,提交完整 TenantBenefitCardChallenge.java、javac --release 21 TenantBenefitCardChallenge.java 的成功证据,以及 java TenantBenefitCardChallenge 的完整且逐行一致输出。再用不超过 8 句话说明:失败分支为何必须在缓存访问和首次 Map 写入前返回;快照如何同时核对事实、排序键和活动指针并冻结;换卡如何预检两个事实键、保留旧事实、切换活动指针与旧/新排序键;冻结如何保留事实并退出两个活动索引;生产环境为何仍需要共同锁、事务或不可变聚合原子替换。
评分拆分:到期榜序核对与不可修改快照 7 分;换卡的幂等分支、双事实键、活动指针和旧/新排序键 8 分;冻结的事实保留、队列/指针/计数迁移与缓存 9 分;Java 21 编译、规定输出和生产边界说明 6 分。缺少完整代码、编译失败或输出不符时,编码维度不能视为通过,整个 Map 闯关也不得放行。
5. 源码追踪:从固定入口解释本题可观察结果(20 分)
固定版本为 OpenJDK jdk-21+35。用 5 行表格作答,每行包含“问题、公开入口、关键字段、最多 4 个箭头节点的主路径、可观察结果或扩展点”,分别覆盖:HashMap 复合键查询、putIfAbsent、同键 value 替换、get/put/remove/compute/merge 的返回与删映射语义;阈值、扩容高低链拆分、树化容量条件与迭代器结构修改检测;LinkedHashMap access-order 的 get、已有键更新和最老项淘汰钩子;TreeMap 通过 ExpiryKey.compareTo 走自然序比较定位、旧键删除、新键插入和集合活视图;EnumMap 的枚举槽位与状态计数迁移。
每行 4 分。必须从源码路径回到本题的返回值、size、到期顺序、旧键清理、快照、缓存淘汰和计数结果;不能猜红黑树具体形状,不能把内部阈值当公共契约,也不能把 fail-fast、普通 Map 或 ConcurrentHashMap 的单键能力扩张成业务顺序、线程安全或跨键、跨 Map 事务。
返回今日索引
import java.util.ArrayList;
import java.util.Comparator;
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 TenantBenefitCardChallenge {
private enum CardState {
ACTIVE,
REPLACED,
FROZEN
}
private record CardKey(String tenantId, String cardId) {
CardKey {
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(cardId, "cardId");
if (tenantId.isBlank() || cardId.isBlank()) {
throw new IllegalArgumentException("invalid card key");
}
}
String label() {
return tenantId + "/" + cardId;
}
}
private record MemberKey(String tenantId, String memberId) {
MemberKey {
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(memberId, "memberId");
if (tenantId.isBlank() || memberId.isBlank()) {
throw new IllegalArgumentException("invalid member key");
}
}
String label() {
return tenantId + "/" + memberId;
}
}
private record BenefitCard(
CardKey key,
String memberId,
int monthlyUnits,
long expiresAtDay,
long issuedSequence,
CardState state) {
BenefitCard {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(memberId, "memberId");
Objects.requireNonNull(state, "state");
if (memberId.isBlank()
|| monthlyUnits < 1
|| monthlyUnits > 1000
|| expiresAtDay < 0
|| issuedSequence < 0) {
throw new IllegalArgumentException("invalid benefit card");
}
}
MemberKey memberKey() {
return new MemberKey(key.tenantId(), memberId);
}
BenefitCard replaced() {
return new BenefitCard(
key,
memberId,
monthlyUnits,
expiresAtDay,
issuedSequence,
CardState.REPLACED);
}
BenefitCard frozen() {
return new BenefitCard(
key,
memberId,
monthlyUnits,
expiresAtDay,
issuedSequence,
CardState.FROZEN);
}
String cacheLabel() {
return key.label() + "=" + state
+ ":" + monthlyUnits + "@" + expiresAtDay;
}
}
private record ExpiryKey(
long expiresAtDay,
long issuedSequence,
String tenantId,
String cardId) implements Comparable<ExpiryKey> {
static ExpiryKey from(BenefitCard card) {
return new ExpiryKey(
card.expiresAtDay(),
card.issuedSequence(),
card.key().tenantId(),
card.key().cardId());
}
@Override
public int compareTo(ExpiryKey other) {
int byExpiry = Long.compare(expiresAtDay, other.expiresAtDay);
if (byExpiry != 0) {
return byExpiry;
}
int bySequence = Long.compare(
issuedSequence, other.issuedSequence);
if (bySequence != 0) {
return bySequence;
}
int byTenant = tenantId.compareTo(other.tenantId);
return byTenant != 0
? byTenant
: cardId.compareTo(other.cardId);
}
}
private static final class CardIndex {
private static final int RECENT_LIMIT = 3;
private final Map<CardKey, BenefitCard> facts = new HashMap<>();
private final Map<MemberKey, CardKey> activeCardByMember =
new HashMap<>();
private final NavigableMap<ExpiryKey, CardKey> expiryQueue =
new TreeMap<>();
private final LinkedHashMap<CardKey, BenefitCard> recentCards =
new LinkedHashMap<>(4, 0.75f, true) {
@Override
protected boolean removeEldestEntry(
Map.Entry<CardKey, BenefitCard> eldest) {
return size() > RECENT_LIMIT;
}
};
private final EnumMap<CardState, Integer> stateCounts =
new EnumMap<>(CardState.class);
private boolean issue(BenefitCard card) {
Objects.requireNonNull(card, "card");
if (card.state() != CardState.ACTIVE) {
throw new IllegalArgumentException("new card must be active");
}
if (facts.containsKey(card.key())) {
return false;
}
MemberKey member = card.memberKey();
if (activeCardByMember.containsKey(member)) {
return false;
}
ExpiryKey expiryKey = ExpiryKey.from(card);
if (expiryQueue.containsKey(expiryKey)) {
throw new IllegalStateException("duplicate expiry key");
}
int activeCount = stateCounts.getOrDefault(CardState.ACTIVE, 0);
if (activeCount < 0) {
throw new IllegalStateException("invalid active count");
}
int newActiveCount = Math.addExact(activeCount, 1);
// 可恢复失败已排除,下面才进入多 Map 提交区。
facts.put(card.key(), card);
activeCardByMember.put(member, card.key());
expiryQueue.put(expiryKey, card.key());
stateCounts.put(CardState.ACTIVE, newActiveCount);
recentCards.put(card.key(), card);
return true;
}
private List<String> expiringSnapshot(int limit) {
if (limit < 0) {
throw new IllegalArgumentException("limit must not be negative");
}
// TODO 1:零上限先返回;其余沿树序核对事实与指针并冻结。
throw new UnsupportedOperationException("expiringSnapshot");
}
private boolean replace(CardKey oldKey, BenefitCard newCard) {
Objects.requireNonNull(oldKey, "oldKey");
Objects.requireNonNull(newCard, "newCard");
if (newCard.state() != CardState.ACTIVE) {
throw new IllegalArgumentException(
"replacement card must be active");
}
// TODO 2:先幂等判定与全量预检,再迁移旧事实、新事实及索引。
throw new UnsupportedOperationException("replace");
}
private boolean freeze(CardKey key) {
Objects.requireNonNull(key, "key");
// TODO 3:仅 ACTIVE 可冻结;计数和旧索引在首次写入前核对。
throw new UnsupportedOperationException("freeze");
}
private String observe(CardKey key) {
Objects.requireNonNull(key, "key");
BenefitCard card = facts.get(key);
if (card == null) {
return "MISSING";
}
BenefitCard cached = recentCards.get(key);
if (!card.equals(cached)) {
recentCards.put(key, card);
}
return card.cacheLabel();
}
private int factCount() {
return facts.size();
}
private String stateOf(CardKey key) {
BenefitCard card = facts.get(Objects.requireNonNull(key, "key"));
return card == null ? "MISSING" : card.state().name();
}
private List<String> activeSummary() {
return activeCardByMember.entrySet().stream()
.sorted(Comparator.comparing(
entry -> entry.getKey().label()))
.map(entry -> entry.getKey().label()
+ "=" + entry.getValue().label())
.toList();
}
private List<String> expirySummary() {
return expiryQueue.entrySet().stream()
.map(entry -> entry.getValue().label()
+ "=" + entry.getKey().expiresAtDay())
.toList();
}
private List<String> recentOrder() {
return recentCards.values().stream()
.map(BenefitCard::cacheLabel)
.toList();
}
private List<String> countSummary() {
return stateCounts.entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.toList();
}
}
public static void main(String[] args) {
CardKey aKey = new CardKey("tenant-a", "card-1");
CardKey bKey = new CardKey("tenant-b", "card-1");
CardKey cKey = new CardKey("tenant-a", "card-2");
CardKey dKey = new CardKey("tenant-c", "card-9");
CardKey eKey = new CardKey("tenant-a", "card-3");
BenefitCard a = new BenefitCard(
aKey, "amy", 100, 30, 20, CardState.ACTIVE);
BenefitCard b = new BenefitCard(
bKey, "amy", 200, 20, 10, CardState.ACTIVE);
BenefitCard c = new BenefitCard(
cKey, "bob", 150, 20, 12, CardState.ACTIVE);
BenefitCard d = new BenefitCard(
dKey, "zoe", 80, 40, 11, CardState.ACTIVE);
BenefitCard conflict = new BenefitCard(
new CardKey("tenant-a", "card-x"),
"amy", 90, 50, 25, CardState.ACTIVE);
BenefitCard e = new BenefitCard(
eKey, "amy", 120, 60, 30, CardState.ACTIVE);
CardIndex index = new CardIndex();
System.out.println("issue-a=" + index.issue(a));
System.out.println("issue-b=" + index.issue(b));
System.out.println("issue-c=" + index.issue(c));
System.out.println("issue-d=" + index.issue(d));
System.out.println("duplicate-a=" + index.issue(a));
System.out.println("issue-conflicting-member=" + index.issue(conflict));
System.out.println("observe-a=" + index.observe(aKey));
System.out.println("recent-before=" + index.recentOrder());
List<String> oldSnapshot = index.expiringSnapshot(4);
System.out.println("initial-expiring=" + oldSnapshot);
System.out.println("snapshot-zero=" + index.expiringSnapshot(0));
System.out.println("replace-a=" + index.replace(aKey, e));
System.out.println("replace-a-again=" + index.replace(aKey, e));
System.out.println("expiry-after-replace=" + index.expirySummary());
System.out.println("recent-after-replace=" + index.recentOrder());
System.out.println("freeze-b=" + index.freeze(bKey));
System.out.println("freeze-b-again=" + index.freeze(bKey));
System.out.println("facts=" + index.factCount());
System.out.println("state-a=" + index.stateOf(aKey));
System.out.println("state-b=" + index.stateOf(bKey));
System.out.println("active-members=" + index.activeSummary());
System.out.println("final-expiry=" + index.expirySummary());
System.out.println("final-counts=" + index.countSummary());
System.out.println("recent-after=" + index.recentOrder());
System.out.println("old-snapshot=" + oldSnapshot);
}
}
互动保存需要 JavaScript。请启用 JavaScript,并通过本地启动器或已部署的学习地址访问此页面。