图 1:本文封面,两种「有序」是两种语义。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题目问「有没有有顺序的 Map 实现类,它们是怎么保证有序的」。答出 LinkedHashMap 和 TreeMap 两个名字只值一半分,另一半在看清:它们说的「有序」根本不是一回事。
LinkedHashMap:记住时间序。
JDK 官方文档的定义:LinkedHashMap 在哈希表的基础上,额外维护一条贯穿所有节点的双向链表,这条链表定义了迭代顺序。默认是插入序——按 key 第一次写入的先后输出,重复 put 已存在的 key 不改变位置。读写性能接近 HashMap 的常数级,代价是每个节点多挂两个链表指针。
它还有一个开关:构造时传 accessOrder=true,迭代顺序就变成访问序——每次 get、put 都把碰过的节点挪到链表尾部。配合可以覆写的 removeEldestEntry(每次插入后问一句「要不要删掉最老的那个」),一个容量有限的 LRU 缓存就齐了,不用自己造轮子。
TreeMap:记住大小序。
TreeMap 是官方文档明确的红黑树实现,按 key 的自然序或构造时给的 Comparator 排序,containsKey、get、put、remove 都是保证的 log(n)。哈希表给不了的能力它都有:subMap、headMap、tailMap 返回区间视图,floorKey、ceilingKey 返回不大于/不小于给定值的最近 key。要「按 key 范围扫一段」「找排行榜前后邻居」,只有排序结构做得到。
一个官方文档强调的坑:TreeMap 的「相等」由 compareTo 判定,不是 equals。如果你的 Comparator 认为两个不同的 key 相等,后插入的值会覆盖前一个——排序容器的比较器必须与 equals 语义一致,否则会出现「明明 put 进去了却 get 不到」的灵异现象。
用代码把两种「有序」演示一遍。
下面这段代码在 JDK 17 下编译运行(javac OrderedMaps.java && java OrderedMaps):
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
public class OrderedMaps {
public static void main(String[] args) {
// 1) LinkedHashMap 默认插入序:链表记住写入先后,重复 put 不改位置
Map<String, Integer> insertOrder = new LinkedHashMap<>();
insertOrder.put("gamma", 3);
insertOrder.put("alpha", 1);
insertOrder.put("beta", 2);
insertOrder.put("gamma", 30); // 重新 put 已存在的 key,位置不变
System.out.println("LinkedHashMap 插入序:" + insertOrder.keySet());
// 2) accessOrder=true 变成访问序:get 会把节点挪到链表尾,天然 LRU
Map<String, Integer> lru = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
return size() > 3; // 容量 3,超出就淘汰最久未访问的
}
};
lru.put("a", 1); lru.put("b", 2); lru.put("c", 3);
lru.get("a"); // 访问 a,b 变成最久未用
lru.put("d", 4); // 触发淘汰
System.out.println("访问序 + removeEldestEntry 的 LRU:" + lru.keySet());
// 3) TreeMap 按 key 大小排序:红黑树,独有范围视图
TreeMap<String, Integer> sorted = new TreeMap<>();
sorted.put("gamma", 3);
sorted.put("alpha", 1);
sorted.put("beta", 2);
sorted.put("delta", 4);
System.out.println("TreeMap 按键排序:" + sorted.keySet());
System.out.println("subMap [beta, gamma):" + sorted.subMap("beta", "gamma").keySet());
System.out.println("floorKey(\"c\"):" + sorted.floorKey("c"));
System.out.println("tailMap 从 beta 起:" + sorted.tailMap("beta").keySet());
}
}
实测输出:
LinkedHashMap 插入序:[gamma, alpha, beta]
访问序 + removeEldestEntry 的 LRU:[c, a, d]
TreeMap 按键排序:[alpha, beta, delta, gamma]
subMap [beta, gamma):[beta, delta]
floorKey("c"):beta
tailMap 从 beta 起:[beta, delta, gamma]
两段演示各有一个值得细看的点。第一,重复 put("gamma", 30) 之后插入序纹丝不动——插入序记的是 key 第一次出现的位置。第二,LRU 那段:get("a") 把 a 挪到了尾部,b 变成最久未用,于是 put("d") 淘汰的是 b,剩下 [c, a, d]。如果注释掉那行 get,被淘汰的就会是 a。访问序下,「读」也是一种会改变结构的操作——官方文档因此提醒,accessOrder 模式下连 get 都算结构性修改。
图 2:一个记时间,一个记大小,选型先分清语义。
选型与追问。
什么时候选谁:要按写入先后回放事件流、要一个免造轮子的 LRU,选 LinkedHashMap,常数级读写;要按 key 大小遍历、做区间扫描、找最近邻,选 TreeMap,接受 log(n)。两个都不是线程安全的——官方文档都写明「此实现不是同步的」,并发场景要外部加锁或换并发容器。
「LinkedHashMap 的迭代为什么比 HashMap 还快?」它的迭代走链表,耗时与元素个数成正比;HashMap 迭代要扫整个桶数组,与容量成正比。「能不能既要排序又要 O(1)?」不能,排序的代价就是 log(n),这是比较模型的下限。「key 没有自然序怎么办?」TreeMap 构造时传 Comparator;不传而 key 又不实现 Comparable,put 时会抛 ClassCastException。
回到面试现场,建议的答题结构:先点破两种「有序」的语义差异,再各讲实现(哈希表+双向链表 / 红黑树)与复杂度,然后补 accessOrder+removeEldestEntry 这个 LRU 彩蛋和 compareTo 一致性的坑,最后给选型结论。你在项目里用错过这两种「有序」吗?欢迎说说当时想要的到底是哪一种。