日常开发中,只要提到键值对存储,大家闭着眼睛都会new HashMap()。毕竟它的查找和插入速度几乎无敌,时间复杂度逼近 O(1)。
但HashMap有个致命的“薛定谔的顺序”问题:你怎么存进去的,和它怎么遍历出来的,完全是两码事。如果业务场景要求“怎么存进去的,就怎么取出来”(比如渲染有序菜单、记录任务先后顺序),该怎么办?
今天,我们要请出 Java 集合框架里的隐藏大佬——LinkedHashMap。用一句话概括它:它就是一个能记住元素顺序的 HashMap。
核心机制:它是如何记住顺序的?(是什么)
很多初学者觉得LinkedHashMap很高深,其实它的底层公式极其简单:
核心公式:LinkedHashMap = HashMap + 双向链表
场景化降维:想象一个巨大的露天停车场(HashMap的哈希桶数组)。为了最高效利用空间,管理员让每辆进来的车根据车牌号算出一个数字,直接停到对应的车位上。这导致车辆在整个停车场里是完全散乱分布的,你根本不知道哪辆车是先来的,哪辆是后来的。
这时候,换了一个聪明的管理员(LinkedHashMap)。他依然让车子随便按哈希值停,但他手里拿了一根红色的长绳子。 第一辆车进来,绳子的一头系在第一辆车上;第二辆进来,绳子从第一辆车拉过去系在第二辆车上……以此类推。 最后,无论这些车在停车场里停得多么天女散花,只要你顺着这根红绳子走(双向链表),就能完美还原所有车辆入场的绝对顺序!
两大杀手锏与极简实战(怎么用)
LinkedHashMap不仅能记住“插入顺序”,它还隐藏了另一个大招:“访问顺序”。
1. 插入顺序(默认模式:先进先出)
默认情况下,它就像排队买喜茶,先来的排前面,后来的排后面。
2. 访问顺序(Access Order)
这是LinkedHashMap的灵魂特性。当你在构造函数中将accessOrder参数设置为true时,它的排序规则就变了。
场景化降维:就像你手机后台的“最近打开应用列表”。你刚才打开了微信,微信就排在最前面;此时你切出去刷了一下抖音,抖音就被顶到了最前面,而太久没点开的 App 则会沉底,甚至被系统杀后台淘汰掉。 只要你对某个元素执行了get()或put()操作,这个元素就会被立刻“摘”下来,重新挂到双向链表的尾部。
来看看极简的代码实战:
Jav
import java.util.LinkedHashMap; import java.util.Map; public class LinkedHashMapDemo { public static void main(String[] args) { // 划重点:第三个参数 true 代表开启“访问顺序”模式 // 初始容量 16,负载因子 0.75f,开启访问顺序 LinkedHashMap<String, String> appCache = new LinkedHashMap<>(16, 0.75f, true); appCache.put("App1", "微信"); appCache.put("App2", "抖音"); appCache.put("App3", "淘宝"); System.out.println("初始顺序:"); System.out.println(appCache.keySet()); // 模拟用户刚刚打开了“微信” appCache.get("App1"); System.out.println("访问微信后的顺序:"); System.out.println(appCache.keySet()); } } /* 运行输出结果: 初始顺序: [App1, App2, App3] 访问微信后的顺序: [App2, App3, App1] <-- 重点!被访问的微信(App1)被移动到了链表最尾部 */高光时刻:几行代码手撕 LRU 缓存(重点细节)
一道常见的代码题:“请设计一个 LRU(最近最少使用)缓存机制”。 如果你从头手写双向链表加 HashMap,很容易在指针的断开与重连中搞出 NullPointerException。但有了LinkedHashMap,你只需要几行代码就能完成绝杀。
JDK 老司机在LinkedHashMap内部预留了一个优雅的钩子方法:removeEldestEntry()。每次执行put()之后,系统都会调用这个方法,问你要不要删掉最老的那个元素(也就是链表头部的元素)。默认它返回false,你只需要重写它即可。
Java
import java.util.LinkedHashMap; import java.util.Map; // 继承 LinkedHashMap,泛型指定 K 和 V public class SimpleLRUCache<K, V> extends LinkedHashMap<K, V> { private final int capacity; // 缓存最大容量 public SimpleLRUCache(int capacity) { // 容量设置,负载因子默认,开启访问顺序 true super(capacity, 0.75f, true); this.capacity = capacity; } // 重写钩子方法:当当前容量超过设定的最大容量时,返回 true,触发淘汰机制 @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > capacity; } public static void main(String[] args) { SimpleLRUCache<Integer, String> lru = new SimpleLRUCache<>(3); lru.put(1, "数据A"); lru.put(2, "数据B"); lru.put(3, "数据C"); System.out.println("填满3个后: " + lru.keySet()); // [1, 2, 3] lru.get(1); // 访问数据A,数据A移动到尾部 lru.put(4, "数据D"); // 插入新数据,容量超载,淘汰最久未使用的头部数据(此时是2) System.out.println("访问1并插入4后: " + lru.keySet()); } } /* 运行输出结果: 填满3个后: [1, 2, 3] 访问1并插入4后: [3, 1, 4] <-- 重点!最久未被访问的2被淘汰,最近访问的1和新插入的4都在后面 */源码浅析:JDK 老司机的优雅设计(知其然更知其所以然)
LinkedHashMap为什么能做到这么无缝的切换?其实它本质上就是稍微魔改了一点点HashMap的底层节点。
普通的HashMap节点(Node)只有next指针(用来解决哈希冲突的单链表)。 而LinkedHashMap搞了个静态内部类Entry,继承了HashMap.Node,并强行塞入了两个新指针:
Java
// LinkedHashMap.Entry 的极简示意 static class Entry<K,V> extends HashMap.Node<K,V> { Entry<K,V> before, after; // 就是这俩兄弟,串起了双向链表 Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); } }底层逻辑闭环:在HashMap的源码里,如果你眼尖,会发现有几个空方法:afterNodeAccess()、afterNodeInsertion()。 这在设计模式里叫模板方法模式。HashMap把骨架搭好,留几个空方法。LinkedHashMap则重写了这几个方法。
每次
get()完,触发afterNodeAccess(),把节点剪切到链表尾部。每次
put()完,触发afterNodeInsertion(),调用removeEldestEntry()决定要不要干掉链表头。
避坑指南与高频面试对比
1. 性能陷阱
虽然增删改查依然维持在 O(1),但天下没有免费的午餐。因为每个节点多了before和after两个引用指针,LinkedHashMap的内存消耗比普通的 HashMap 要大。而且每次操作都需要额外维护双向链表的指针变动,极端高并发或极致性能要求的场景下,插入速度会略微受影响。
2. 并发噩梦
它是非线程安全的!千万不要在多线程环境下直接拿上面的SimpleLRUCache来用。如果有并发需求,请使用Collections.synchronizedMap包装一下,或者在生产环境直接拥抱工业级的本地缓存框架:Guava Cache 或 Caffeine(基于 W-TinyLFU,吊打传统 LRU)。