LinkedHashMap · accessOrder · removeEldestEntry · LRU · WeakHashMap · WeakReference · ReferenceQueue · ThreadLocal · ThreadLocalMap · 开放寻址 · 0x61c88647 · 内存泄漏

HashMap 的亲戚们:同一个 Map 约定,三种取舍

三个亲戚都是“按 key 找 value”,各自在 HashMap 上改了一处,换来一样 HashMap 没有的本事。LinkedHashMap 多维护一条贯穿所有节点的双向链表,换来顺序,再加一个钩子就是 LRU;WeakHashMap 把 key 换成弱引用,key 没人用了,这一项就能被 GC 带走;ThreadLocalMap 住在每个线程里,只有本线程访问,条目又少,干脆用开放寻址省掉链表节点。改的那一处,也正是各自出坑的地方:LRU 里 get 也会改结构,WeakHashMap 的 value 不弱,ThreadLocal 的 value 跟着线程活,线程池里的线程又几乎不死。

全景:一个订单对象的一生 · 第 4 站(下一圈)地图:J01 补给 · HashMap 的下一圈上一页:HashMap:哈希、扩容、树化实验:JDK 17.0.20 实测

①为什么需要:交易靶场里冒出的三个新要求

在全景第 4 站,这一步要解决的是:商品缓存 catalog 现在是个普通 HashMap,只放不删,商品一多就涨爆(全景第 11 站已经看到 5200 单全留在堆里);可你只想记住“最近查过的那些”。同一条链路上还有两件事 HashMap 做不好:给订单挂一份风控评分,订单没人用了评分也该跟着消失;从 Controller 到 Service 到 DAO,处处都想知道“当前是哪个用户”,又不想每个方法都加一个 userId 参数。

最近查过的商品,只留 3 个 99 88 12 旧 ←————————→ 新 查了谁,谁就挪到最新; 满了,挤掉最久没用的那个。 LinkedHashMap(LRU) 给订单挂风控评分 Order#9527 → 评分 27 Order#9528 → 评分 28 订单不再被任何地方引用, 这一项自己从 Map 里消失。 WeakHashMap 当前请求的用户 http-nio-exec-8:用户 1024 http-nio-exec-9:用户 2048 一个线程处理一个请求, 链路上任何地方都能取到。 ThreadLocal(ThreadLocalMap)
需求工作要用,也是面试高频:LRU 是手写题常客,ThreadLocal 内存泄漏是 J03 要往靶场里埋的雷,Spring 的 RequestContextHolder、日志的 MDC、事务管理器绑定连接,底下都是 ThreadLocal。

②没有它会怎样:拿普通 HashMap 硬做

缓存用 HashMap只放不删

不知道谁最久没用,只能定期整个清空,清完缓存全冷;不清就一直涨,全景第 11 站那 5202 个 Order 就是这么留下来的。

附加信息用 HashMapkey 是订单

Map 自己拉着订单的强引用,订单在别处都用完了,也因为这个 Map 永远收不走。想不泄漏,就得记得在每个“用完”的地方手动 remove。

当前用户:层层传参,或者 static Map<Thread, Long>

传参:几十个方法签名都多一个 userId。全局 Map:所有线程抢一个 Map,要加锁;线程结束了条目还在。

不懂精华时的典型误用:把 ThreadLocal 当成“线程安全的全局变量”到处 set,却不 remove。Tomcat 的线程是复用的,下一个请求会读到上一个用户(⑤ 实测串号)。

③相邻方案

要什么本页旁边的选择什么时候换
有界缓存LinkedHashMap + removeEldestEntryCaffeine、Guava Cache、Redis多线程、要过期时间、要命中率统计:用 Caffeine(W-TinyLFU,命中率比纯 LRU 高);多实例共享:Redis(见 Redis 那组笔记)
key 没人用就消失WeakHashMapCaffeine weakKeys()、显式 remove多线程用 Caffeine;能明确知道生命周期就显式 remove,最好懂
每个线程一份ThreadLocal传参;ScopedValue(JDK 25 正式);TransmittableThreadLocal链路短就传参;虚拟线程、结构化并发用 ScopedValue;要把上下文带进线程池用阿里的 TTL

本页学 JDK 自带的这三个:它们是 Caffeine、Spring 上下文、MDC 的地基,读懂了它们,换成框架也知道底下发生了什么。选择规则:单线程的小缓存用 LinkedHashMap,多线程直接上 Caffeine;ThreadLocal 只在“一个线程从头到尾处理一件事”时用,而且一定 remove。

④原理:一条链、一种弱引用、一张住在线程里的表

先知道这些
  • HashMap 的桶结构:数组 + 链表 + 树,(n−1) & hash 定位。见上一页。
  • GC 怎么判断对象能不能回收:从 GC Roots(线程栈上的局部变量、static 字段……)出发,顺着引用走得到的就活着。见 JVM 内存结构。
  • 模板方法:父类把流程写死,留几个空方法让子类填。见面向对象那页④-3。

④-1LinkedHashMap:HashMap 的每个节点,再串一条双向链表

桶数组(和 HashMap 一样) 0 … 2 3 … 8 … 66before | after 99before | after 88before | after head(最旧)tail(最新) HashMap 在 put / get / remove 里留了三个空钩子 afterNodeAccess(e)访问顺序时,把 e 挪到 tail afterNodeInsertion()问 removeEldestEntry(head) afterNodeRemoval(e)把 e 从链上摘掉 LinkedHashMap 覆盖这三个钩子来维护链表, 定位、扩容、树化全部复用 HashMap。 遍历走 head → tail,不走桶,所以顺序稳定。 代价:每个节点多两个引用(压缩指针下 HashMap 的 Node 32 字节 → Entry 约 40 字节,按源码估算),每次改动多维护几个指针。
JDK 17 源码:LinkedHashMap.Entry<K,V> extends HashMap.Node<K,V>,只多了 before, after;构造参数 accessOrder 默认 false(插入顺序),设成 true 就是访问顺序。这是一个模板方法的教科书例子:HashMap 定下流程,LinkedHashMap 只填三个钩子。

④-2LRU:访问顺序 + 挤掉 head

点商品号“查一次”,看访问顺序链表怎么动、谁被挤掉。容量可以改。

查:

    模拟逻辑在 node 里跑过:“按 ⑤ 的顺序走一遍”(88、66、99、88、12、66、7,容量 3)依次挤掉 66、99、88,最后剩 [12, 66, 7],和 ⑤ 的 JDK 17 实测一致。

    ④-3WeakHashMap:key 用弱引用,GC 清掉以后再顺手删条目

    四种引用,从强到弱:强、软(内存不够才清)、弱(下次 GC 就清)、虚(只用来收通知) 业务代码局部变量、alive 列表 Order#9527key 强引用 Entry extends WeakReference referent(弱)→ key value(强)→ "风控评分 27" hash、next(还是拉链法) 弱引用 ReferenceQueueGC 把清掉的条目放这 1 业务代码不再引用 Order#9527,它只剩 Entry 的弱引用 → 下次 GC 回收它,清空 referent,把 Entry 放进队列。 2 条目还在桶里!下次你调 get / put / size,expungeStaleEntries() 才从队列里取出它,从桶里摘掉。 3 value 是强引用:value 里要是拉着 key(比如 value 就是订单本身),key 永远有强引用,永远清不掉(⑤ 实测)。 实测 放 10000 个订单,只留一半的强引用,GC 后 size() = 5000。

    ④-4ThreadLocal:值不在 ThreadLocal 里,在每个线程自己的表里

    CURRENT_USERstatic final ThreadLocal<Long>只带一个哈希值,不存数据 Thread exec-8threadLocals → Thread exec-9threadLocals → 另一张表 exec-8 的 ThreadLocalMap:table[16],开放寻址 _ _ USER _ _ MDC TX _ … TX 本该在 5,被占了,往后挪到 6 一个 Entry extends WeakReference<ThreadLocal> key → CURRENT_USER(弱) value → 1024L(强) 没有 hash 字段、没有 next 指针: 撞了就放下一格,不挂链表 get():取 Thread.currentThread().threadLocals,用 threadLocalHashCode & (len−1) 定位, key 对不上就看下一格,直到遇到空格。顺路遇到 key 已被 GC 清空的格子,就把它清理掉。 只有本线程访问这张表,不需要任何锁。
    JDK 17 源码:每创建一个 ThreadLocal,全局计数器加 0x61c88647 作为它的哈希值;表初始 16 格,阈值是长度的 2/3,清理过期条目后 size 仍不小于阈值的 3/4 就翻倍。实测 一个线程里连续建 7 个 ThreadLocal,落在 16 格表的第 1、3、5、8、10、12、14 格,一个都没撞,没有一个需要往后挪。图里 TX 被挤到第 6 格是示意。

    ④-5为什么这样设计

    选的:数据挂在 Thread 上Thread.threadLocals

    • 只有本线程读写,不用锁
    • 线程结束,整张表跟着 Thread 对象一起被回收
    • 条目少(通常个位数),开放寻址没有节点对象,一个数组搞定
    • 哈希值按 0x61c88647(黄金分割比 × 2³²)递增,连续创建的 ThreadLocal 在 2 的幂长度的表里分得最均匀,线性探测很少走第二步

    没选的:ThreadLocal 里放一个 Map<Thread, V>最直觉的做法

    • 所有线程抢同一个 Map,要加锁或用并发 Map
    • Map 拉着 Thread 对象,线程结束了也收不走,要手动清
    • 每次 get 都要先拿到当前线程再查一个大 Map
    为什么 key 弱、value 不弱?key 弱:你把某个 ThreadLocal 对象扔了(比如它是某个已卸载类的字段),线程里的表不应该拉着它不放。value 不能弱:你 set 进去的用户 id、数据库连接,可能只有这张表引用着,一弱就被 GC 收走,get 时就没了。代价就是:线程不死、又不 remove,value 就一直活着。普通线程活不了多久,问题不大;可线程池的线程几乎永远不死,这就是 ThreadLocal 内存泄漏和串号的根。

    最少语法

    // LRU
    Map<Long, Product> recent = new LinkedHashMap<>(16, 0.75f, true) {
        protected boolean removeEldestEntry(Map.Entry<Long, Product> e) { return size() > 3; }
    };
    // 附加信息,key 用对象身份
    Map<Order, String> notes = new WeakHashMap<>();
    // 当前用户
    static final ThreadLocal<Long> CURRENT_USER = new ThreadLocal<>();
    try { CURRENT_USER.set(1024L); handle(); } finally { CURRENT_USER.remove(); }
    ThreadLocal<SimpleDateFormat> fmt = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

    ⑤亲眼看到:三个亲戚在交易靶场里各干一件事

    一个 Demo,6 段:LinkedHashMap 两种顺序、LRU 缓存、WeakHashMap 和它的两个坑、ThreadLocalMap 的格子、线程池里的串号、线程池里挂着的大对象。第 4 段用反射读线程的表,要加 --add-opens java.base/java.lang=ALL-UNNAMED。完整代码:study-labs/java/javase/hashmap-relatives →

    1Demo.java 节选,JDK 17
    // 2. LRU:最多记 3 个最近查过的商品
    static final class RecentProducts
            extends LinkedHashMap<Long, Product> {
      RecentProducts(int max) { super(16, 0.75f, true); … }
      protected boolean removeEldestEntry(Map.Entry<…> e) {
        return size() > max;
      }
    }
    for (long id : new long[]{88, 66, 99, 88, 12, 66, 7})
      if (recent.get(id) == null) recent.put(id, p(id));
    
    // 3. WeakHashMap:只留一半订单的强引用
    Map<Order, String> notes = new WeakHashMap<>();
    for (i = 0; i < 10_000; i++) {
      Order o = new Order(9527 + i);
      notes.put(o, "风控评分 " + i % 100);
      if (i % 2 == 0) alive.add(o);
    }
    System.gc();
    // 坑:value 就是 key
    selfRef.put(o, o);
    // 坑:字面量做 key
    literal.put("req-1", …);  literal.put(new String("req-2"), …);
    
    // 5. 线程池只有 1 个线程
    pool.submit(() -> CURRENT_USER.set(1024L));      // 忘了 remove
    pool.submit(() -> CURRENT_USER.get());           // 下一个请求
    
    // 6. 4 个线程各挂 20 MB
    pool4.submit(() -> buffer.set(new byte[20 << 20]));
    2运行 JDK 17.0.20,2 核 ARM,-Xmx256m
    == 1. 放 88、66、99,再查 88 ==
      HashMap(按桶)       :[66, 99, 88]
      插入顺序(默认)      :[88, 66, 99]
      访问顺序(accessOrder):[66, 99, 88]
    == 2. LRU,容量 3 ==
      查 88、66、99:没命中     [88, 66, 99]
      查 88:命中               [66, 99, 88]
      查 12:挤掉 66            [99, 88, 12]
      查 66:挤掉 99            [88, 12, 66]
      查 7 :挤掉 88            [12, 66, 7]
    == 3. WeakHashMap ==
      放进 10000 个,size() = 10000
      丢掉一半订单的引用,GC 之后 size() = 5000
      坑:value 就是 key,GC 之后 size() = 10000
      坑:字面量 key 还在吗?true;new String 的还在吗?false
    == 4. ThreadLocalMap 的格子 ==
      table 长 16,有东西的:1 3 5 8 10 12 14(都在本位)
    == 5. 线程池里忘了 remove ==
      请求 A(用户 1024)在 pool-1-thread-1 上,没 remove
      请求 B(匿名用户)在 pool-1-thread-1 上,读到 1024
      请求 C 用 try/finally,处理用户 2048
      请求 D 读到当前用户 = null
    == 6. 4 个线程各挂 20 MB ==
      任务都结束了,GC 后多占 84 MB
      每个线程上 remove 之后,多占 0 MB
      线程池关掉后,多占 0 MB

    第 2 段的输出合并了几行。第 4 段的格子位置取决于 JVM 里一共建过多少个 ThreadLocal,换台机器可能不同,但相邻的总是隔开好几格。

    到这里已经能交差了:交易靶场有了一个 3 格的 LRU 商品缓存、会自己清理的订单附加信息、一个“当前用户”上下文,而且你亲眼看到了不 remove 的后果。
    已知还没处理的细节:LRU 多线程用(⑥-1)、访问顺序下遍历时 get(⑥-1)、WeakHashMap 的 value 引用 key(⑥-1)、异步线程拿不到 ThreadLocal(⑥-1)、虚拟线程和 ScopedValue(⑥-3)。

    ⑥细节·规范·优化

    第一圈

    注意事项:不知道就会出事故

    坑 1线程池里没 remove:下一个请求读到上一个用户← ④-5 value 跟着线程活
    现象
    用户 B 的请求里,当前用户是 A,查到了 A 的订单。只在某些请求复现,查日志看不出规律。⑤ 第 5 段
    根源
    Tomcat、业务线程池的线程都是复用的,上一个任务 set 的值留在线程的表里;下一个任务没 set 就直接 get。
    做法
    在请求入口(Filter / 拦截器)set,在 finally 里 remove;不要在请求中途散落地 set。
    坑 2线程池里挂大对象:内存涨了又找不到谁拿着← ④-5 value 是强引用
    现象
    4 个线程各放 20 MB 缓冲,任务结束 GC 后还多占 84 MB。⑤ 第 6 段线程多、对象大时就是 OOM。
    根源
    引用链:线程池 → 线程 → threadLocals → Entry → value。key 是不是弱引用都没用,value 自己是强的。
    做法
    同上,finally remove;排查时在堆转储里看 ThreadLocalMap$Entry 的 value(J03 会实操)。
    坑 3WeakHashMap 的 value 拉着 key:永远清不掉← ④-3 value 是强引用
    现象
    value 就是订单本身(或订单的某个内部类实例、lambda 捕获了订单),GC 后 10000 个一个没少。⑤ 第 3 段
    根源
    Map 强引用 value,value 强引用 key,key 就永远有强引用。
    做法
    value 别引用 key;实在要引用,就把 value 也包一层 WeakReference。
    坑 4WeakHashMap 的 key 是字面量、小整数:永远不会被回收← ④-3 只要还有强引用
    现象
    "req-1" 做 key 的条目 GC 后还在,new String("req-2") 的没了。⑤ 第 3 段
    根源
    字符串字面量被常量池拉着;Long.valueOf 在 −128~127 之间返回缓存的同一个对象,也一直被缓存数组拉着。
    做法
    WeakHashMap 只用对象身份有意义的 key(订单、会话、类加载器),别用 String、包装类。
    坑 5访问顺序的 LinkedHashMap:边遍历边 get 也抛 CME← ④-2 get 会挪节点
    现象
    遍历 keySet,在循环里 recent.get(k) 取 value,抛 ConcurrentModificationException。
    根源
    访问顺序下 get 把节点挪到 tail,是结构修改,modCount 加 1;迭代器检测到就 fail-fast(JDK 文档原话:merely querying the map with get is a structural modification)。
    做法
    遍历用 entrySet() 直接拿 value。
    坑 6LinkedHashMap 做多线程缓存← ④-1 改链表是好几步
    现象
    多个 Tomcat 线程同时 get / put,链表断开、遍历死循环、缓存里的东西莫名其妙丢了。
    根源
    挪一个节点要改四五个指针,访问顺序下连 get 都在写;HashMap 本身也不是线程安全的(上一页坑 2)。
    做法
    多线程缓存用 Caffeine;一定要 JDK 自带的就 Collections.synchronizedMap 包起来,接受一把锁。
    坑 7换个线程,ThreadLocal 就没了← ④-4 数据在线程自己的表里
    现象
    @Async 方法、CompletableFuture.supplyAsync、并行流里取当前用户,拿到 null;日志的 traceId 断了。
    根源
    新线程有自己的表,里面没有你的值。InheritableThreadLocal 只在“创建子线程”时复制一次,线程池的线程早就建好了,不会再复制。
    做法
    提交任务时手动把值带过去(包装 Runnable,Spring 的 TaskDecorator);或用 TransmittableThreadLocal;新代码考虑 ScopedValue。
    第二圈

    使用规范

    ThreadLocal 声明成 static final

    一个“槽位”全局一个就够了,值本来就按线程分开。每次 new 一个 ThreadLocal,每个线程的表里就多一格。

    表越来越大、key 对象到处都是。

    谁 set 谁 remove,写在 finally 里

    入口统一 set,出口统一 remove。Spring 的 RequestContextFilter、MDC 的过滤器都是这个写法。

    坑 1、坑 2。

    多线程缓存用 Caffeine

    LinkedHashMap 的 LRU 只用在单线程、小容量、不需要过期时间的地方。

    坑 6,以及 LRU 被一次全表扫描冲掉的命中率问题。

    WeakHashMap 慎用

    只有“key 用对象身份、key 的生命周期由别人决定”时才合适;更多时候显式 remove 更好懂。

    坑 3、坑 4,以及 GC 时机不可预测导致的测试不稳定。

    别拿 ThreadLocal 当隐式参数到处传

    只放真正的“请求上下文”:用户、traceId、租户、事务连接。业务数据走参数。

    代码读不懂、单测难写、异步时丢失。

    第三圈

    深处

    为什么是 0x61c88647

    0x61c88647 约等于 2³² × (1 − 0.618…),也就是黄金分割点。每个新 ThreadLocal 的哈希值都比上一个多这么多,再和 15 按位与,得到的下标会把 16 个格子依次填得很均匀(这叫斐波那契哈希)。所以开放寻址虽然“撞了就往后找”,实际几乎不撞(⑤ 第 4 段 7 个全在本位)。

    HashMap 不能这么做:它的 key 是任意对象,哈希值由 key 自己决定;ThreadLocalMap 的 key 只会是 ThreadLocal,哈希值由它自己分配。

    虚拟线程和 ScopedValue

    JDK 21 的虚拟线程可以开到上百万个,每个都可以有自己的 ThreadLocalMap,ThreadLocal 里放的大对象就会乘上一百万。所以 JDK 在虚拟线程之外,又做了 ScopedValue(JDK 21 预览,JDK 25 正式):值只在一段代码范围内有效,出了范围自动失效,不可修改,子任务可以直接继承,天然没有“忘了 remove”这回事。

    靶场在 J01 用 JDK 21,但还是用 ThreadLocal:Spring、日志框架目前都建立在它上面。

    版本差异

    JDK 1.2ThreadLocal、WeakHashMap 出现
    JDK 1.4LinkedHashMap 出现
    JDK 8ThreadLocal.withInitial;LinkedHashMap 随 HashMap 支持树化
    JDK 21虚拟线程;LinkedHashMap 实现 SequencedMap(putFirst、putLast、reversed())
    JDK 25ScopedValue 正式

    JDK 1.2 / 1.4 两行按各类的 Javadoc “@since” 写,没有在本机核对。

    LRU 不是缓存的终点

    纯 LRU 只看“最近有没有被碰”:一次批量导出把 10 万个冷门商品扫一遍,就把真正的热门商品全挤出去了。Caffeine 用的 W-TinyLFU 多记一份“最近一段时间被访问的频率”,新来的要比被挤掉的更常用才能进来,所以命中率更高。Redis 的 allkeys-lfu 也是同一个思路(见 Redis 过期删除与内存淘汰那页)。

    面试官会怎么问
    手写一个 LRU 缓存。

    最快:继承 LinkedHashMap,构造时 accessOrder = true,覆盖 removeEldestEntry 返回 size() > capacity。追问“不许用 LinkedHashMap”:HashMap<K, Node> 加一个自己写的双向链表,get 时把节点挪到尾,put 满了删头,两个操作都是 O(1)(④-1、④-2)。

    ThreadLocal 为什么会内存泄漏?key 为什么是弱引用?

    值存在线程的 ThreadLocalMap 里,value 是强引用,线程池的线程不死、又不 remove,value 就一直活着。key 用弱引用是为了 ThreadLocal 对象本身能被回收,之后 get / set 顺路清理 key 为 null 的条目;但如果再也没人调用,value 还是留着。所以正确做法是 finally remove(④-5,坑 1、2)。

    ThreadLocalMap 为什么用开放寻址,不用拉链法?

    每个线程里的条目很少,只有本线程访问;哈希值由 ThreadLocal 自己按黄金分割递增分配,分得很均匀,几乎不撞;开放寻址不用节点对象,一个数组就够(④-4、⑥-3)。

    WeakHashMap 的条目是什么时候被删的?

    GC 回收 key 后把 Entry 放进 ReferenceQueue;下一次调用 get、put、size 等方法时,expungeStaleEntries 才从队列里取出并从桶里删除(④-3)。

    下一圈

    ↺回到⑤:改造交易靶场

    1. 一个当前用户 Filter

    在全景的 Spring Boot 订单服务里加一个 Filter:从请求头 X-User-Id 取用户,CURRENT_USER.set,在 finally 里 remove;OrderService 里用它代替 cmd.userId()。再故意删掉 finally,连发不同用户的请求,复现串号。

    用到:坑 1、规范 2

    2. 不用 LinkedHashMap 手写 LRU

    HashMap<Long, Node> 加自己写的双向链表(带哨兵头尾节点),实现 get / put,跑 ⑤ 第 2 段同样的序列,输出要一模一样。

    用到:④-1、④-2;也是面试题

    3. 修掉 WeakHashMap 的自引用

    把⑤ 第 3 段的 selfRef.put(o, o) 改成 value 只存订单号,或者包一层 WeakReference,确认 GC 后 size 变成 0。

    用到:坑 3

    自测

    1. 用一句话说出三个亲戚各改了 HashMap 的哪一处。

    LinkedHashMap 给节点加 before / after 串成链,换来顺序和 LRU;WeakHashMap 让条目继承 WeakReference 指向 key,换来“key 没人用就消失”;ThreadLocalMap 把表挂在线程上、用开放寻址,换来无锁和省节点。

    2. 用普通 HashMap 做商品缓存,最疼的是什么?

    不知道谁最久没用,只能整个清空或者一直涨(②)。

    3. 线程池里读到上一个用户,根源是哪条原理?

    值存在线程自己的 ThreadLocalMap 里,value 是强引用、跟着线程活;线程被复用,上一个任务没 remove,值就留给了下一个任务(④-4、④-5)。

    4. 预测:把⑤ 第 2 段的 accessOrder 改回 false(插入顺序),同样那串查询,最后剩哪三个?

    插入顺序下 get 不挪位置。88、66、99 放满;查 88 命中但不动;查 12 挤掉最早插入的 88 → [66, 99, 12];查 66 命中不动;查 7 挤掉 66 → [99, 12, 7]。这就退化成了 FIFO(先进先出),不是 LRU(④-2)。

    5. 预测:⑤ 第 3 段如果不调 System.gc(),也不调任何 notes 的方法,过一会儿 size 会自己变成 5000 吗?

    size() 本身就会先调 expungeStaleEntries,所以只要 GC 已经发生过,调 size() 时就是 5000。但“过一会儿”GC 不一定发生:堆还宽裕时 JVM 可能很久都不做 GC,那时调 size() 还是 10000。弱引用只保证“GC 时可以清”,不保证“什么时候清”(④-3)。

    6. 放回全景:这一页的三样东西能放进全景的哪几站?做错了整条链路会怎样?

    第 4 站的 catalog 可以换成 LRU,商品再多内存也有上限,代价是冷门商品要重新查库;第 8 站的 userOrders 如果改成 WeakHashMap 也不对:key 是用户 id(Long),put 时用的那个 Long 对象通常没有别人再引用,GC 一来条目就没了,用户的订单记录随机消失;何况你要的本来就是按用户长期保留,该做的是有界缓存或者干脆查库。“当前用户”放 ThreadLocal 是在第 1 站 Tomcat 线程拿到请求后、第 3 站进代理前就 set 好;如果没在 finally 里 remove,第 1 站复用的 exec-8 线程会把上一个用户带进下一单,第 7 站扣的是 A 的库存,订单却记在 B 名下。