深蓝黑背景封面:左侧大标题「有序 Map 的两种有序」,副标题「LinkedHashMap 记插入顺序,TreeMap 按 key 排序」;右侧原创示意图——上方一条哈希桶加双向链表的链条,节点按写入先后用虚线串成 3→1→2 的插入序;下方一棵小型红黑树,同一组 key 按 1→2→3 的大小顺序排布;左上标注「面试经典 · Java 后端」,右上标注「Day 06 · 11-03」 图 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 都算结构性修改。

双栏对照图:左栏 LinkedHashMap,示意哈希桶数组加一条贯穿所有节点的双向链表,标注插入序或访问序由链表维护,读写接近 O(1),额外内存是每个节点两个链表指针,适合做插入序回放与 LRU;右栏 TreeMap,示意红黑树的左右子树大小约束,标注按 key 的比较结果排序,读写 O(log n),独有 subMap、headMap、tailMap 范围视图,适合区间扫描与排行榜。中间竖线分隔,两栏底部各有一行「什么时候选它」。底部提示:先问自己要的「有序」是写入的时间序,还是 key 的大小序——这是两道题 图 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 一致性的坑,最后给选型结论。你在项目里用错过这两种「有序」吗?欢迎说说当时想要的到底是哪一种。