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 参数。
②没有它会怎样:拿普通 HashMap 硬做
缓存用 HashMap只放不删
不知道谁最久没用,只能定期整个清空,清完缓存全冷;不清就一直涨,全景第 11 站那 5202 个 Order 就是这么留下来的。
附加信息用 HashMapkey 是订单
Map 自己拉着订单的强引用,订单在别处都用完了,也因为这个 Map 永远收不走。想不泄漏,就得记得在每个“用完”的地方手动 remove。
当前用户:层层传参,或者 static Map<Thread, Long>
传参:几十个方法签名都多一个 userId。全局 Map:所有线程抢一个 Map,要加锁;线程结束了条目还在。
- 缺的分别是:记住“最近用过的顺序”、不拉住 key 的 Map、每个线程一份、只有自己访问的 Map。三个亲戚各补一块。
不懂精华时的典型误用:把 ThreadLocal 当成“线程安全的全局变量”到处 set,却不 remove。Tomcat 的线程是复用的,下一个请求会读到上一个用户(⑤ 实测串号)。
③相邻方案
| 要什么 | 本页 | 旁边的选择 | 什么时候换 |
|---|---|---|---|
| 有界缓存 | LinkedHashMap + removeEldestEntry | Caffeine、Guava Cache、Redis | 多线程、要过期时间、要命中率统计:用 Caffeine(W-TinyLFU,命中率比纯 LRU 高);多实例共享:Redis(见 Redis 那组笔记) |
| key 没人用就消失 | WeakHashMap | Caffeine weakKeys()、显式 remove | 多线程用 Caffeine;能明确知道生命周期就显式 remove,最好懂 |
| 每个线程一份 | ThreadLocal | 传参;ScopedValue(JDK 25 正式);TransmittableThreadLocal | 链路短就传参;虚拟线程、结构化并发用 ScopedValue;要把上下文带进线程池用阿里的 TTL |
本页学 JDK 自带的这三个:它们是 Caffeine、Spring 上下文、MDC 的地基,读懂了它们,换成框架也知道底下发生了什么。选择规则:单线程的小缓存用 LinkedHashMap,多线程直接上 Caffeine;ThreadLocal 只在“一个线程从头到尾处理一件事”时用,而且一定 remove。
④原理:一条链、一种弱引用、一张住在线程里的表
④-1LinkedHashMap:HashMap 的每个节点,再串一条双向链表
LinkedHashMap.Entry<K,V> extends HashMap.Node<K,V>,只多了 before, after;构造参数 accessOrder 默认 false(插入顺序),设成 true 就是访问顺序。这是一个模板方法的教科书例子:HashMap 定下流程,LinkedHashMap 只填三个钩子。④-2LRU:访问顺序 + 挤掉 head
- 1
new LinkedHashMap<>(16, 0.75f, true):每次get、put已有的 key,那个节点就被挪到 tail。于是 head 永远是“最久没被碰过的”。 - 2覆盖
removeEldestEntry(eldest):每次 put 完,HashMap 调afterNodeInsertion,它拿 head 问你“删不删”,你返回size() > 3,它就删掉 head。 - 3两件事合起来就是 LRU(Least Recently Used,最近最少使用):查、放都是 O(1),因为定位靠哈希表,挪位置只改几个指针。
- 4注意:在访问顺序模式下,get 也改了链表结构(modCount 加 1),所以边遍历边 get 会抛 ConcurrentModificationException(⑥ 坑 5)。
点商品号“查一次”,看访问顺序链表怎么动、谁被挤掉。容量可以改。
模拟逻辑在 node 里跑过:“按 ⑤ 的顺序走一遍”(88、66、99、88、12、66、7,容量 3)依次挤掉 66、99、88,最后剩 [12, 66, 7],和 ⑤ 的 JDK 17 实测一致。
④-3WeakHashMap:key 用弱引用,GC 清掉以后再顺手删条目
④-4ThreadLocal:值不在 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
最少语法
// 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 →
// 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]));
== 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,换台机器可能不同,但相邻的总是隔开好几格。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 用户 B 的请求里,当前用户是 A,查到了 A 的订单。只在某些请求复现,查日志看不出规律。⑤ 第 5 段
- 根源
- Tomcat、业务线程池的线程都是复用的,上一个任务 set 的值留在线程的表里;下一个任务没 set 就直接 get。
- 做法
- 在请求入口(Filter / 拦截器)set,在
finally里 remove;不要在请求中途散落地 set。
- 现象
- 4 个线程各放 20 MB 缓冲,任务结束 GC 后还多占 84 MB。⑤ 第 6 段线程多、对象大时就是 OOM。
- 根源
- 引用链:线程池 → 线程 → threadLocals → Entry → value。key 是不是弱引用都没用,value 自己是强的。
- 做法
- 同上,finally remove;排查时在堆转储里看
ThreadLocalMap$Entry的 value(J03 会实操)。
- 现象
- value 就是订单本身(或订单的某个内部类实例、lambda 捕获了订单),GC 后 10000 个一个没少。⑤ 第 3 段
- 根源
- Map 强引用 value,value 强引用 key,key 就永远有强引用。
- 做法
- value 别引用 key;实在要引用,就把 value 也包一层
WeakReference。
- 现象
"req-1"做 key 的条目 GC 后还在,new String("req-2")的没了。⑤ 第 3 段- 根源
- 字符串字面量被常量池拉着;
Long.valueOf在 −128~127 之间返回缓存的同一个对象,也一直被缓存数组拉着。 - 做法
- WeakHashMap 只用对象身份有意义的 key(订单、会话、类加载器),别用 String、包装类。
- 现象
- 遍历 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。
- 现象
- 多个 Tomcat 线程同时 get / put,链表断开、遍历死循环、缓存里的东西莫名其妙丢了。
- 根源
- 挪一个节点要改四五个指针,访问顺序下连 get 都在写;HashMap 本身也不是线程安全的(上一页坑 2)。
- 做法
- 多线程缓存用 Caffeine;一定要 JDK 自带的就
Collections.synchronizedMap包起来,接受一把锁。
- 现象
@Async方法、CompletableFuture.supplyAsync、并行流里取当前用户,拿到 null;日志的 traceId 断了。- 根源
- 新线程有自己的表,里面没有你的值。
InheritableThreadLocal只在“创建子线程”时复制一次,线程池的线程早就建好了,不会再复制。 - 做法
- 提交任务时手动把值带过去(包装 Runnable,Spring 的
TaskDecorator);或用 TransmittableThreadLocal;新代码考虑 ScopedValue。
使用规范
一个“槽位”全局一个就够了,值本来就按线程分开。每次 new 一个 ThreadLocal,每个线程的表里就多一格。
表越来越大、key 对象到处都是。
入口统一 set,出口统一 remove。Spring 的 RequestContextFilter、MDC 的过滤器都是这个写法。
坑 1、坑 2。
LinkedHashMap 的 LRU 只用在单线程、小容量、不需要过期时间的地方。
坑 6,以及 LRU 被一次全表扫描冲掉的命中率问题。
只有“key 用对象身份、key 的生命周期由别人决定”时才合适;更多时候显式 remove 更好懂。
坑 3、坑 4,以及 GC 时机不可预测导致的测试不稳定。
只放真正的“请求上下文”:用户、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.2 | ThreadLocal、WeakHashMap 出现 |
|---|---|
| JDK 1.4 | LinkedHashMap 出现 |
| JDK 8 | ThreadLocal.withInitial;LinkedHashMap 随 HashMap 支持树化 |
| JDK 21 | 虚拟线程;LinkedHashMap 实现 SequencedMap(putFirst、putLast、reversed()) |
| JDK 25 | ScopedValue 正式 |
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)。
- ConcurrentHashMap 源码:CAS、锁桶头、多线程协助扩容(J02,补给待做页“HashMap 与 ConcurrentHashMap 源码”)
- 红黑树:插入后的旋转和变色,分步手推(J01“数据结构与算法”)
- 四种引用和 GC:软引用做缓存、虚引用和 Cleaner(J03)
- 用堆转储(jmap + MAT)揪出 ThreadLocal 泄漏(J03 的排障练习)
- Caffeine 的 W-TinyLFU 和过期策略
- 已做:ThreadLocal 与内存泄漏(泄漏用反射数 ThreadLocalMap 看到,MAT 那条还没做)· ConcurrentHashMap 与阻塞队列
↺回到⑤:改造交易靶场
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 名下。