在开发里遇到"遍历顺序"这个需求,你大概率逃不过 LinkedHashSet 和 LinkedHashMap 这两个类。明明 HashMap 和 HashSet 已经够用了,为什么还要专门搞一套"能记住插入顺序"的变体?它们的顺序又是怎么维护的?用了这么久,我踩过不少坑,也读过源码,这篇文章就把这两个类讲透,包括内部原理、经典用法和常见坑点,一次聊明白。
1. LinkedHashSet/LinkedHashMap 到底解决什么问题:三个日常场景
1.1 场景一:HashMap 把插入顺序"打乱"了
先从一个很常见的例子说起。你在系统里维护了一个菜单列表,用户登录之后要先拿一级菜单,再拿二级菜单。需求文档上明确写着"按配置顺序展示",于是你很自然地写了一个 HashMap:
java复制Map<String, String> menuMap = new HashMap<>();
menuMap.put("首页", "/home");
menuMap.put("订单", "/order");
menuMap.put("个人中心", "/profile");
menuMap.put("设置", "/settings");
等遍历打印的时候,发现输出顺序完全不是你写入的顺序,有时候是"订单"开头,有时候是"个人中心"开头。这不是 bug,这是 HashMap 的特性:数组加链表/红黑树的结构决定了它的遍历顺序取决于 key 的哈希值以及桶的扩容情况,跟插入顺序没有任何关系。
如果这个时候用 LinkedHashMap:
java复制Map<String, String> menuMap = new LinkedHashMap<>();
menuMap.put("首页", "/home");
menuMap.put("订单", "/order");
menuMap.put("个人中心", "/profile");
menuMap.put("设置", "/settings");
只要没有发生插入或者删除操作,遍历结果一定是:
code复制首页 -> /home
订单 -> /order
个人中心 -> /profile
设置 -> /settings
这就是 LinkedHashMap 存在的第一个价值:让 Map 在保持 O(1) 读写效率的同时,还能按插入顺序输出。
1.2 场景二:Set 去重之后还要保住"先后关系"
Set 的场景就更常见了。比如从数据库里查出来一批用户 ID,中间有重复的,你需要去重后按原顺序发给下游服务。HashSet 去重可以,但顺序就听天由命了。关键的是,对这批数据很可能是低频查询或离线导入,本身量不大,但去重后的顺序会被下游用来做拼装、展示,顺序一乱页面就乱了。
LinkedHashSet 就是专门解决这个问题的:
java复制List<String> rawList = Arrays.asList("A1001", "B2002", "A1001", "C3003", "B2002", "D4004");
LinkedHashSet<String> linkedSet = new LinkedHashSet<>(rawList);
System.out.println(linkedSet); // [A1001, B2002, C3003, D4004]
注意结果:重复的元素被剔除,而且第一次出现的相对顺序完整保留。
1.3 场景三:有些场景不只是顺序敏感,还是"最近访问"敏感
还有一类需求没那么直观,但非常典型,比如缓存。我们希望缓存最近访问过的数据,如果缓存满了,就把最久没访问的那个淘汰掉。这不是按插入顺序遍历,而是按访问顺序遍历。LinkedHashMap 的另一个构造函数里有个参数 accessOrder,把它设置为 true,迭代顺序就从"插入顺序"变成"访问顺序"。这是在很多业务模块里实现 LRU 缓存的高频解法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 内部实现拆解:LinkedHashMap 如何做到"记住顺序"
2.1 核心设计思路:HashMap 主结构内置双向链表
LinkedHashMap 不是什么从零设计的数据结构,它继承自 HashMap。也就是说,它在 HashMap 的哈希表基础上增加了一条双向链表,用来串联所有已有的 Entry。
这条链表才是顺序的关键。当你执行 put 时,节点会进入哈希桶,同时也会被添加到链表的尾部。遍历的时候,LinkedHashMap 并不像 HashMap 那样去遍历桶数组,而是直接遍历这条链表,所以只要链表维护得好,遍历顺序就完全可控。
这就好比我们去餐厅吃饭。HashMap 的存储像顾客按喜好分散在不同包间,你问服务员"今天所有客人都点了什么",她只能按包间号挨个去问,结果顺序听天由命。LinkedHashMap 的做法是,客人进门时服务员会发一个排队号,等需要统计的时候,只按排队号念一遍即可,又快又稳。
2.2 节点结构:HashMap.Node 上长了前后指针
LinkedHashMap 并没有重新发明一遍哈希表,而是对节点做了扩展。它的内部节点继承自 HashMap.Node:
java复制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);
}
}
看到 before 和 after 这两个指针,你就应该明白,每个节点不仅属于哈希桶结构,还属于一条额外的双向链表。那个 next 属于桶内链表(解决哈希冲突用的),before/after 属于迭代顺序链表,两者互不干扰。
这个设计是很巧妙的。哈希桶解决查找效率,双向链表解决遍历顺序,互相独立,查找仍然能 O(1) 命中。
2.3 顺序维护的时机:插入、删除、访问
要把顺序维护好,最关键的是处理好三个时机:插入、删除和访问。
插入:新节点执行完 HashMap 的 putVal 后,LinkedHashMap 会回调 afterNodeInsertion 方法。在这个方法里,新节点会被加入到双向链表的尾部。这保证了插入顺序。
如果这时 key 已经存在,HashMap 判断是"覆盖旧值",此时不会调用 afterNodeInsertion,而是调用 afterNodeAccess,把已有节点挪到链表尾部。这里有个细节:在默认 插入顺序模式下,对同一个 key 重复 put 新值,不会改变它在链表中的位置,它不会跑到尾部去。只有访问顺序模式 accessOrder=true 时才会发生挪动。
删除:无论按 key 删除还是迭代中通过 remove() 删除,节点移除时会同步把它从链表中摘除,也就是执行 before.after = after、after.before = before 这类的指针变更。
访问:LinkedHashMap 重写了 get 方法:
java复制public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)
afterNodeAccess(e); // 访问后把节点挪到尾部
return e.value;
}
只有在 accessOrder = true 的时候,get 才会调整链表顺序。这样迭代顺序就会镜像最近访问次序。
提示:LinkedHashMap 的 getOrDefault、get 等方法也都考虑了这个判断,重点还是 accessOrder 开关。
2.4 初始容量、负载因子对遍历顺序的影响
初次使用时,很多人误以为初始容量会影响遍历顺序,其实这个影响可以忽略。LinkedHashMap 的遍历顺序完全由双向链表决定,哈希表扩容、元素重新分布,链表顺序还是原有的前后关系,不会乱。容量主要影响哈希桶的长度和是否需要扩容,间接影响性能。
如果你预估数据量是 1000 条左右,直接设置初始容量为 1024,可以避免反复扩容。但要注意,这个容量设置对顺序没有影响,不要为了追求顺序特意增加容量,浪费内存而没有收益。
3. LinkedHashSet 的"真相":它其实就是 LinkedHashMap 的壳
3.1 Set 与 Map 的底层关系
很多新手会奇怪,LinkedHashSet 为什么跟 LinkedHashMap 放在一起讨论?因为 Java 标准库里的 Set 大多是基于 Map 实现的,比如 HashSet 底层就是一个 HashMap,元素作为 key 存放,value 统一为一个常量对象。LinkedHashSet 也是这个套路:它内部维护了一个 LinkedHashMap,只是 value 永远是同一个固定对象。
看到这里你就会明白,只要懂得了 LinkedHashMap 的顺序原理,LinkedHashSet 的顺序行为就顺理成章了。去重能力来自 Map 的 key 唯一性,顺序能力来自 LinkedHashMap 的双向链表。
3.2 LinkedHashSet 的构造链路与背后细节
LinkedHashSet 的构造函数其实调用了一个私有的构造方法,统一传入 LinkedHashMap:
java复制HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
LinkedHashSet 本身没有新增多少逻辑,所有顺序相关的行为都委托给 LinkedHashMap。当你调用 add() 方法时,底层调用的是 map.put(e, PRESENT),只要 put 成功,这个元素就被追加进双向链表。重复元素 put 只会覆盖 value,而 value 始终是同一个常量,实际上不会改变链表位置,因此重复元素不会打乱已有顺序。
不过有一点要留心:LinkedHashSet 继承自 HashSet,而 HashSet 的某些遍历逻辑在 LinkedHashSet 中行为不同。这不能说 bug,是设计如此,但在做集合复制时,这两种类型的顺序语义是有差异的。
3.3 去重且保序的几个实战写法
实际开发中,我经常用 LinkedHashSet 做"去重 + 保序"的过滤。比如一批待处理的任务 ID,来源有可能重复:
java复制LinkedHashSet<Integer> taskIds = new LinkedHashSet<>(taskIdSource);
for (Integer id : taskIds) {
dispatch(id);
}
也可以把 LinkedHashSet 当作链式去重的中间结构:
java复制List<Product> products = queryProductsFromDb();
LinkedHashSet<String> validCategories = new LinkedHashSet<>();
for (Product p : products) {
if (p.isVisible()) {
validCategories.add(p.getCategoryId());
}
}
这段代码比直接用 List.contains 去重要高效得多,同时顺序还能保持从数据库查询或业务拼接时的顺序,对后续展示逻辑非常友好。
4. 访问顺序实战:用 LinkedHashMap 实现一个轻量 LRU 缓存
4.1 accessOrder 参数到底如何工作
默认情况下,new LinkedHashMap() 的 accessOrder 是 false,也就是说内部链表只维护插入顺序。如果你想按访问顺序排列,需要显式使用另一个构造函数:
java复制LinkedHashMap<K, V> map = new LinkedHashMap<>(16, 0.75f, true);
第三个参数 true 表示开启访问顺序。具体表现是:每次通过 get 获取一个 key 时,如果它存在,内部会把这个节点移动到双向链表的末尾。这样一来,链表中越靠近尾部,就代表最近被使用过;越靠近头部,就代表越久没被使用。
注意,这里说的"访问"包括 get、getOrDefault、replace 等方法。单纯调用 containsKey 不会改变顺序。在实现缓存器时要针对业务调用方式测试确认,尤其是用 containsKey 做预检查逻辑时,很容易误以为顺序变了,其实没变,给你造成错觉。
4.2 手写 LRU 缓存:核心步骤与代码
LRU 的全称是 Least Recently Used,也就是淘汰最久未被访问的数据。利用 LinkedHashMap 的访问顺序,我们只需要做两件事:
第一,构造 LinkedHashMap 时把 accessOrder 设为 true。
第二,重写 removeEldestEntry 方法,让它返回一个判断条件。当插入操作完成后,如果链表元素数量超过阈值,就自动移除头部节点,也就是最久未访问的节点。
核心代码如下:
java复制class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxCapacity;
public LRUCache(int maxCapacity) {
super(16, 0.75f, true);
this.maxCapacity = maxCapacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxCapacity;
}
}
使用方式很直接:
java复制LRUCache<Integer, String> cache = new LRUCache<>(3);
cache.put(1, "Java");
cache.put(2, "Python");
cache.put(3, "Go");
System.out.println(cache.keySet()); // [1, 2, 3]
cache.get(1);
cache.put(4, "C++");
System.out.println(cache.keySet()); // [2, 3, 1, 4]
输出过程值得推敲:put 完 1、2、3 后,链表顺序是 1 -> 2 -> 3。访问 1 后,1 被移到尾部:2 -> 3 -> 1。put 4 后,4 追加到尾部,此时 size = 4,超过了 maxCapacity=3,于是 removeEldestEntry 返回 true,最久未访问的节点 2 被移除。最终链表里是 3 -> 1 -> 4。
这个机制在 Java Collections 的源码就是通过 afterNodeInsertion 实现的,里面会判断 removeEldestEntry 的返回结果,如果为 true 再移除链表的第一个节点。
4.3 使用 removeEldestEntry 的几个细节
第一,removeEldestEntry 是在 put 成功后、put 返回前被调用的。如果你重写的方法体里执行了耗时操作,会直接影响 put 方法延迟。尽量让它做简单判断,不要做重活。
第二,判断条件里使用 size() 是比较稳妥的。因为 LinkedHashMap 的 size() 返回的是当前节点数,不会受 removeEldestEntry 内部状态影响。你也可以用更复杂的条件,比如某个时间窗口内写入次数达到阈值后淘汰,但要注意业务逻辑的边界。
第三,如果扩展的 LRU 用在多线程环境,单纯的 LinkedHashMap 不是线程安全的。稳妥的做法是用 Collections.synchronizedMap 包装,或者使用 ConcurrentHashMap 并自行控制并发淘汰逻辑。不过后者已经是另一种实现了,不要强行塞进 LinkedHashMap。
5. 技术选型对比:插入顺序、访问顺序、自然排序,分别该用谁
5.1 三种顺序的语义差异
总有人把 LinkedHashMap 和 TreeMap 搞混,以为它们都是"有序的"。其实它们的"有序"完全是两码事:
| 数据结构 | HashMap | LinkedHashMap | TreeMap |
|---|---|---|---|
| 遍历顺序 | 无序 | 插入顺序或访问顺序 | key 的自然顺序或自定义比较器顺序 |
| 底层实现 | 哈希表 | 哈希表+双向链表 | 红黑树 |
| 核心操作复杂度 | O(1) | O(1) | O(log n) |
| 是否需要可比较 key | 否 | 否 | 是 |
| 典型用途 | 快速存取 | 保持顺序+快速存取 | 范围查询/排序输出 |
TreeMap 的顺序不是"你插入的顺序",而是"key 经过比较器排序后的顺序"。如果需求是"按字典序遍历 key",TreeMap 是正确选择;如果需求是"按照我操作时的先后顺序遍历",你用 TreeMap 永远做不对,必须使用 LinkedHashMap。
5.2 如何选择?一个决策思路
判断依据主要看两个点:第一,你需要的是"业务顺序"还是"比较顺序";第二,数据量级多大,查询操作的性能要求多高。
- 需要准确记住每一条数据的添加顺序,且需要频繁按 key 查找:选 LinkedHashMap。
- 需要去重并保留首次出现顺序:选 LinkedHashSet。
- 需要输出排序后的结果,或做范围查询(比如找出大于某值的所有 key):选 TreeMap。
- 只是临时存取,不关心遍历顺序:直接 HashMap/HashSet,简单高效。
有个常见的反模式是"为了排序而排序"。有些同事把所有 key 拿出来后用 Collections.sort 排序,而不使用 TreeMap,这在小数据量下能运行,但每次排序 O(n log n) 的额外开销并不划算,而且不易维护。
5.3 性能与内存开销的客观评价
LinkedHashMap 比 HashMap 多出一条双向链表,所以每个节点多了两个指针引用。这个开销在数据量特别大时不能被忽视。假设你有 100 万条记录,每条额外多两个引用,也就是 16 字节(64 位 JVM 未压缩指针下),可能多出十几 MB。这在现代应用里通常可以接受,但如果你维护的系统本身就是内存敏感型的,就不要轻易给所有 Map 换成 LinkedHashMap。
遍历方面,LinkedHashMap 直接遍历双向链表的效率很高,不需要遍历空桶。HashMap 的遍历需要遍历整个桶数组,包括很多空槽位,所以当哈希表很大但实际元素很少时,HashMap 的遍历成本其实更高。从这个角度看,LinkedHashMap 的迭代性能反而更稳定,没有空桶损耗。
6. 踩坑记录:遍历与并发遇到的问题及排查经验
6.1 迭代中删除元素的正确姿势
不管 HashSet 还是 HashMap,如果在迭代过程中直接调用 map.remove(key) 或 set.remove(element),会触发 fail-fast 机制,抛出 ConcurrentModificationException。LinkedHashMap 和 LinkedHashSet 同样继承了这一机制。
正确做法是使用迭代器的 remove 方法:
java复制Iterator<String> it = linkedSet.iterator();
while (it.hasNext()) {
String value = it.next();
if (value.startsWith("临时")) {
it.remove();
}
}
在 LinkedHashMap 的迭代中删除时,迭代器内部会同时更新链表结构,能够保证不出现并发修改异常,也不会把链表指针搞乱。这一点在业务代码中经常被踩,尤其在做"批量清理过期数据"的循环里。
6.2 keySet 的遍历顺序与 removeEldestEntry 的联动
LinkedHashMap 的 keySet、values、entrySet 返回的视图都基于链表顺序,所以遍历结果是一致的。但要注意,如果底层的 LinkedHashMap 在遍历期间触发了淘汰操作,那么遍历到的集合可能已经变化。比如一个线程正在遍历 entrySet,另一个线程插入了新数据并触发 removeEldestEntry,删除行为会直接影响迭代器正在遍历的链表。
代码层面,这种并发写导致的异常表现比较诡异,有时候是并发修改异常,有时候是数据结果差一条,很难直接定位。遇到这种问题,先检查是不是有两条线程在使用同一个 LinkedHashMap。如果是,别纠缠,直接加锁或改用线程安全结构。
6.3 序列化与深拷贝时顺序是否还能保留
LinkedHashMap 是支持序列化的,但序列化后,顺序信息是保存在那两条 before/after 引用里的,Java 默认的序列化机制会把这些关系一一还原,所以理论上顺序是保留的。但在某些 Json 库或者其他序列化中间件里,如果 Map 被中转成普通对象数组再还原,顺序就可能丢失。例如把 Map 转成 JSON 对象时,不同 Json 库对 Map 的迭代顺序保留情况不同。这一点尤其容易在缓存迁移、RPC 传输时翻车。
如果你需要跨进程保留顺序,要么把有序 key 作为列表单独传输,要么在接收端明确使用 LinkedHashMap 承载反序列化结果。别指望中间件自动把你的普通 HashMap 还原成 LinkedHashMap。
6.4 设置 accessOrder 后带来的额外认知负担
accessOrder = true 后,每次 get 会触发结构调整。调整本身的复杂度是 O(1),因为有前驱后继指针,只需做指针摘除与尾部追加即可。但频繁地 get 会造成双向链表节点不断变动,如果并发量很高,这种结构变更加上锁竞争,性能会明显下降。另一个问题是,调试时打印 keySet 的顺序会经常变化,容易误导排查方向。举个例子,你明明按顺序插入了三个 key,用户访问了第一个 key 后,再打印 keySet 就是 2, 3, 1,很多人乍一看以为数据错乱。
所以在使用 LRU 场景时,一定要把 LinkedHashMap 封装到一个独立的缓存类里,不要把 accessOrder=true 的实例直接暴露给业务层。这种"奇特的顺序语义"实在不适合让调用方直接感知,否则排查成本非常高。
6.5 代码审查时最容易被忽略的问题:value 是否为可变对象
还有一个容易被忽略的使用问题是,LinkedHashMap 维护的是 key 的唯一性和顺序,value 完全可以相同,也可以都是 null。LinkedHashSet 的底层是 Map 的 key,所以 LinkedHashSet 不接受重复元素,但可以存放 null。如果把一个可变对象当作 key 存入 LinkedHashSet 后,又修改了该对象的 hashCode 相关字段,就会导致后续的操作无法正确定位元素,甚至出现重复存储的假象。
平时我建议:放进 LinkedHashSet 或作为 LinkedHashMap key 的对象,最好是不可变对象,或者把 hashCode、equals 设计稳定。一旦顺序因为 key 的哈希变化而乱掉,LinkedHashMap 的双向链表维护得再好也没用。
7. 一些个人经验与最终建议
我的习惯是,需要顺序遍历的 Map 与 Set 场景,先问自己三个问题:数据结构最终会被谁遍历,遍历结果会不会展示给用户,中间有没有经过序列化或缓存中间件。这三个问题能过滤掉大部分顺序丢失的坑。
另外,如果数据量很小,比如不到几十条,我往往直接使用 ArrayList 加手动去重,也不见得非得引入 LinkedHashSet。因为 LinkedHashSet 虽然便捷,但对元素的 equals/hashCode 有依赖,反而容易把简单问题复杂化。当然,量级上去了,去重要求是高频操作,LinkedHashSet 的优势才真正显现出来。
实际使用中,我通常会把 LinkedHashMap 和 LinkedHashSet 封装成工厂方法,统一控制初始容量和访问顺序配置,方便后期调整。这样一个简单习惯,能省去不少因为构造参数不一致引发的隐蔽问题。如果在团队里做代码评审,我也会重点提醒大家关注"结构是否兼顾遍历顺序与查询效率",而不是止步于"能跑就行"。
