乐途乐途
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
  • 学习路径
  • 第1章 Java概述与环境搭建

    • Java概述与环境搭建
    • Java语言概述
    • 解释型语言与编译型语言对比
    • JDK安装与配置
    • JDK、JRE、JVM 详解
    • HelloWorld程序详解
    • IDE 介绍
  • 第2章 标识符与基本数据类型

    • 章节导读
    • 变量概述
    • 常量概述
    • 基本类型与包装类
    • 字节型 byte
    • 短整型 short
    • 整型 int
    • 长整型 long
    • 单精度浮点型 float
    • 双精度浮点型 double
    • 字符型 char
    • 布尔型 boolean
    • 类型转换
  • 第3章 运算符与表达式

    • 章节导读
    • 算术运算符
    • 赋值运算符
    • 关系运算符
    • 逻辑运算符
    • 位运算符
    • 条件运算符
    • 运算符优先级
    • 表达式
  • 第4章 流程控制

    • 章节导读
    • 常见的程序运行流程
    • if-else 选择结构
    • switch 多分支选择
    • while 循环
    • do-while 循环
    • for 循环
    • break 与 continue
  • 第5章 数组

    • 章节导读
    • 一维数组
    • 多维数组
    • Arrays 工具类
  • 第6章 类与对象

    • 章节导读
    • 类与对象
    • 方法定义与调用
    • 构造方法
    • 封装
    • 访问修饰符
    • package 与 import
    • static 关键字
    • this 关键字
    • 参数传递 详解
    • 枚举
    • 成员内部类
    • 局部内部类
    • 静态内部类
    • 匿名内部类
  • 第7章 接口与继承

    • 章节导读
    • 继承
    • super 关键字
    • final 关键字
    • 多态
    • 向上转型与向下转型
    • 抽象类
    • 接口
    • 抽象类与接口对比
  • 第8章 注解

    • 章节导读
    • 注解基础
    • 元注解详解
    • 自定义注解
  • 第9章 常用类

    • 章节导读:Java 常用类
    • Object 类:万类之祖
    • 包装类:基本类型的对象化
    • String:不可变的字符串
    • StringBuffer:线程安全的可变字符串
    • StringBuilder:可变的字符串构建器
    • Math:数学运算工具类
    • Random:伪随机数生成器
    • 大数值运算 详解
    • 日期时间API 详解
  • 第10章 异常机制

    • 章节导读
    • 异常体系与分类
    • try-catch-finally
    • try-with-resources
    • throws 与 throw
    • 自定义异常
  • 第11章 泛型

    • 章节导读
    • 泛型基础
    • 通配符与PECS原则
    • 类型擦除
  • 第12章 集合框架

    • 章节导读
    • 集合框架概述
    • ArrayList
    • LinkedList
    • HashMap 详解
    • LinkedHashMap 详解
    • TreeMap 详解
    • HashSet
    • TreeSet 详解
    • TreeSet 与 Comparable
    • Collections 工具类详解
  • 第13章 IO流

    • 章节导读
    • IO流概述
    • 字节流
    • 字符流
    • 缓冲流
    • 转换流 详解
    • 序列化 详解
    • NIO与Files 详解
    • NIO与Files工具类
  • 第14章 多线程与并发

    • 第十六章 多线程与并发 —— 章节导读
    • 线程基础详解
    • synchronized 详解
    • Lock 与显式锁详解
    • volatile 详解
    • wait 与 notify 详解
    • ThreadLocal详解
    • 原子类详解
    • 并发工具类详解
    • 线程池详解
  • 第15章 反射

    • 章节导读
    • 反射概述与 Class 对象
    • Constructor 与对象创建
    • Field与Method详解
    • 反射应用详解
  • 第16章 JDK8新特性

    • 章节导读
    • Lambda 表达式
    • Stream API 基础
    • Stream API 高级详解
    • Optional 详解
    • 新日期时间API详解
  • 第17章 JDK9-11新特性

    • 章节导读
    • 模块化系统 — Project Jigsaw(JDK 9)
    • var 局部变量类型推断(JDK 10)
    • 集合工厂方法与增强(JDK 9 / 10 / 11)
    • 接口增强:private 方法(JDK 9)
    • Stream API 增强(JDK 9)
    • Optional 增强(JDK 9 / 10 / 11)
    • String 新增方法(JDK 11)
    • HTTP Client 与 Files 增强(JDK 11)
    • 直接运行 Java 源文件 — JEP 330(JDK 11)
  • 第18章 JDK12-17新特性

    • 章节导读
    • Switch 表达式(JDK 12 预览 / JDK 14 正式)
    • 文本块 Text Blocks(JDK 13 预览 / JDK 15 正式)
    • Records 记录类(JDK 14 预览 / JDK 16 正式)
    • 密封类 Sealed Classes(JDK 15 预览 / JDK 17 正式)
    • instanceof 模式匹配(JDK 14 预览 / JDK 16 正式)
    • Switch 模式匹配 — Pattern Matching for switch(JDK 17 预览 / JDK 21 正式)
    • Helpful NPE 与 String 增强(JDK 12 / JDK 14 / JDK 15)
    • Stream 增强(JDK 12 / JDK 16)
    • 日期时间增强 — Day Period 支持(JDK 16)
  • 第19章 JDK18-21新特性

    • 章节导读
    • 虚拟线程(JDK 19 预览 / JDK 20 第二预览 / JDK 21 正式)
    • 序列集合(JDK 21 正式)
    • Switch 模式匹配(JDK 17 预览 / JDK 18 第二预览 / JDK 20 第四预览 / JDK 21 正式)
    • Record 模式匹配(JDK 19 预览 / JDK 20 第二预览 / JDK 21 正式)
    • 未命名模式与变量(JDK 21 预览 / JDK 22 正式)
  • 第20章 JDK 22-25 新特性

    • 章节导读
    • 字符串模板(JDK 22 预览 / JDK 23 第二预览 / JDK 24 第三预览)
    • Stream Gatherers(JDK 22 预览 / JDK 24 第二预览)
    • 隐式声明类与实例方法(JDK 23 预览 / JDK 24 第二预览)
    • 原始类型模式匹配(JDK 24 预览)
  • 附录

    • Java 核心知识点
    • Java SE 专业术语
    • Java特性索引(JDK 8 → 25)

LinkedHashMap 详解

本章定位:集合框架中唯一能同时提供 HashMap 的 O(1) 查找性能和元素顺序维护能力的 Map 实现。LinkedHashMap 通过在 HashMap 的哈希表基础上叠加一条双向链表,实现了插入顺序(insertion-order)和访问顺序(access-order)两种遍历模式。基于访问顺序模式,只需重写 removeEldestEntry 方法即可轻松实现 LRU 缓存——这是 LinkedHashMap 最经典的工程应用。飞翔科技的最近访问员工缓存、浏览器前进后退、在线用户活跃度追踪等场景都依赖它。本章由白歌带领小崔深入 LinkedHashMap 的内核,大翔关注缓存淘汰策略的合理性,孔蓝验证访问顺序下的并发陷阱。


定义速览表

属性说明
底层数据结构HashMap(数组 + 链表 + 红黑树)+ 双向链表
默认遍历顺序插入顺序(insertion-order),即 accessOrder = false
访问顺序模式accessOrder = true 时,最近访问的元素移到链表尾部
是否允许 null是(键和值各一个 null,与 HashMap 一致)
线程安全否
时间复杂度get/put/remove 平均 O(1)(与 HashMap 相同,链表操作是 O(1) 指针调整)
实现继承HashMap<K,V> → AbstractMap<K,V> → Map<K,V>
fail-fast是(继承自 HashMap,迭代器在结构性修改时抛 ConcurrentModificationException)
内存开销比 HashMap 多(每个 Entry 额外维护 before/after 两个引用,共 16 字节)
扩容机制与 HashMap 完全一致(容量 ×2,负载因子 0.75)

接口继承体系


方法速查表

基础 Map 操作(继承自 HashMap)

方法描述时间复杂度
put(K key, V value)插入键值对,维护链表顺序平均 O(1)
get(Object key)获取值,accessOrder 模式下移动节点到尾部平均 O(1)
remove(Object key)删除键值对,从链表中移除节点平均 O(1)
containsKey(Object key)判断是否包含指定键平均 O(1)
containsValue(Object value)判断是否包含指定值(沿双向链表遍历,比 HashMap 更高效)O(n)
size()返回键值对数量O(1)
clear()清空所有键值对,重置链表头尾指针O(n)

LinkedHashMap 特有构造器

构造方法描述
LinkedHashMap()默认:插入顺序,初始容量 16,负载因子 0.75
LinkedHashMap(int initialCapacity)指定初始容量
LinkedHashMap(int initialCapacity, float loadFactor)指定初始容量和负载因子
LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)关键构造器——accessOrder=true 启用访问顺序模式
LinkedHashMap(Map<? extends K, ? extends V> m)用已有 Map 初始化,插入顺序

可重写的回调方法(模板方法模式)

方法描述默认行为
removeEldestEntry(Map.Entry<K,V> eldest)每次 put/putAll 后调用,返回 true 则移除最老条目返回 false(不移除)
afterNodeAccess(Node<K,V> e)get/put(更新)后调用,accessOrder 模式下将节点移到链表尾部—
afterNodeInsertion(boolean evict)插入新节点后调用,会触发 removeEldestEntry 检查—
afterNodeRemoval(Node<K,V> e)删除节点后调用,从双向链表中移除节点—

核心原理:双向链表 + HashMap

内部结构全景

LinkedHashMap 的每一个 Entry 节点同时存在于两个数据结构中:

两层结构的职责分工:

  • HashMap 哈希表:负责快速查找——通过 hashCode 定位桶,通过 equals 确认键
  • 全局双向链表:负责维护遍历顺序——head 指向最老的条目,tail 指向最新的条目

LinkedHashMap.Entry 节点结构

// JDK 8 源码(简化)
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);  // next 用于 HashMap 桶内链表
    }
}

每个 Entry 节点包含 6 个字段:hash、key、value、next(桶内链表)、before(双向链表前驱)、after(双向链表后继)。

两种遍历顺序

插入顺序模式下,双向链表记录的是元素首次插入的顺序——即使后续更新已存在的 key,其在链表中的位置也不会改变。

访问顺序模式下,每次调用 get 或 put(更新已存在 key)都会将该节点从链表中摘除,重新插入到链表尾部。这样链表头部始终是最久未访问的元素,链表尾部是最近访问的元素——这正是 LRU 算法的核心数据结构。


核心原理:LRU 缓存实现

accessOrder 模式下的节点移动源码

// JDK 8 LinkedHashMap.afterNodeAccess 源码(简化注释版)
void afterNodeAccess(Node<K,V> e) {
    LinkedHashMap.Entry<K,V> last;
    // 仅在 accessOrder 为 true 且 e 不是尾部节点时才移动
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e;
        LinkedHashMap.Entry<K,V> b = p.before, a = p.after;
        p.after = null;

        // 步骤1:从当前位置摘除
        if (b == null)        head = a;
        else                  b.after = a;
        if (a != null)        a.before = b;
        else                  last = b;

        // 步骤2:插入到链表尾部
        if (last == null)     head = p;
        else {
            p.before = last;
            last.after = p;
        }
        tail = p;
        ++modCount;  // 修改计数(导致迭代器 fail-fast)
    }
}

关键点:afterNodeAccess 会修改 modCount,这意味着在访问顺序模式下,遍历过程中调用 get 会触发 ConcurrentModificationException——这是最常见的陷阱之一。

removeEldestEntry 与 LRU 缓存模板

// LRU 缓存的标准模板
class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int maxCapacity;

    public LRUCache(int maxCapacity) {
        // 16: 初始容量, 0.75f: 负载因子, true: 访问顺序模式
        super(16, 0.75f, true);
        this.maxCapacity = maxCapacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 当 size 超过最大容量时,允许移除最老的条目
        return size() > maxCapacity;
    }
}

工作原理:每次 put 新条目后,HashMap 会回调 afterNodeInsertion,该方法内部调用 removeEldestEntry。如果返回 true,则自动移除双向链表头部(即 head 指向的)条目——在访问顺序模式下,这就是最久未使用的条目。


完整代码示例

示例一:LRU 缓存——飞翔科技最近访问员工缓存

import java.util.*;

/**
 * 场景:飞翔科技的员工管理系统需要缓存最近查询的 5 个员工信息。
 * 白歌要求使用 LinkedHashMap 实现 LRU 淘汰策略:
 *  - 缓存满时自动淘汰最久未访问的员工
 *  - 再次访问已有员工时将其标记为"最近使用"
 * 小崔负责实现,孔蓝编写测试用例验证淘汰逻辑。
 */
class EmployeeCache extends LinkedHashMap<String, String> {
    private final int maxSize;

    public EmployeeCache(int maxSize) {
        super(16, 0.75f, true);  // accessOrder = true(访问顺序模式)
        this.maxSize = maxSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
        boolean shouldRemove = size() > maxSize;
        if (shouldRemove) {
            System.out.println("  [淘汰] " + eldest.getKey() + " → " + eldest.getValue()
                + "(最久未使用)");
        }
        return shouldRemove;
    }
}

public class FeiXiangLRUCache {
    public static void main(String[] args) {
        System.out.println("=== 飞翔科技员工缓存(容量=5,LRU 策略) ===\n");

        EmployeeCache cache = new EmployeeCache(5);

        // 1. 预热缓存:加载 5 名员工
        System.out.println("1. 预热缓存(添加 5 名员工):");
        cache.put("E001", "大翔-CEO");
        cache.put("E002", "白歌-架构师");
        cache.put("E003", "小崔-后端开发");
        cache.put("E004", "孔蓝-测试工程师");
        cache.put("E005", "Frank-市场专员");
        printCache(cache);
        // 预期:E001 → E002 → E003 → E004 → E005

        // 2. 访问 E001(大翔被移到尾部)
        System.out.println("\n2. 查询 E001(大翔)后:");
        System.out.println("  大翔信息: " + cache.get("E001"));
        printCache(cache);
        // 预期:E002 → E003 → E004 → E005 → E001

        // 3. 访问 E003(小崔被移到尾部)
        System.out.println("\n3. 查询 E003(小崔)后:");
        System.out.println("  小崔信息: " + cache.get("E003"));
        printCache(cache);
        // 预期:E002 → E004 → E005 → E001 → E003

        // 4. 添加第 6 条 → 触发淘汰(最久未使用的 E002 白歌被移除)
        System.out.println("\n4. 新增 E006(超出容量):");
        cache.put("E006", "黄俪-前端开发");
        printCache(cache);
        // 预期:E004 → E005 → E001 → E003 → E006(E002 被淘汰)

        // 5. 再次访问 E004,再添加第 7 条
        System.out.println("\n5. 查询 E004(孔蓝)后,新增 E007:");
        cache.get("E004");
        cache.put("E007", "韩信-运维工程师");
        printCache(cache);
        // E005 最久未使用,被淘汰

        // 6. 最终缓存快照
        System.out.println("\n=== 最终缓存内容 ===");
        int i = 1;
        for (Map.Entry<String, String> entry : cache.entrySet()) {
            System.out.printf("  %d. %s → %s%n", i++, entry.getKey(), entry.getValue());
        }
    }

    private static void printCache(LinkedHashMap<String, String> cache) {
        System.out.println("  当前缓存: " + cache.keySet());
    }
}
=== 飞翔科技员工缓存(容量=5,LRU 策略) ===

1. 预热缓存(添加 5 名员工):
  当前缓存: [E001, E002, E003, E004, E005]

2. 查询 E001(大翔)后:
  大翔信息: 大翔-CEO
  当前缓存: [E002, E003, E004, E005, E001]

3. 查询 E003(小崔)后:
  小崔信息: 小崔-后端开发
  当前缓存: [E002, E004, E005, E001, E003]

4. 新增 E006(超出容量):
  [淘汰] E002 → 白歌-架构师(最久未使用)
  当前缓存: [E004, E005, E001, E003, E006]

5. 查询 E004(孔蓝)后,新增 E007:
  [淘汰] E005 → Frank-市场专员(最久未使用)
  当前缓存: [E001, E003, E006, E004, E007]

=== 最终缓存内容 ===
  1. E001 → 大翔-CEO
  2. E003 → 小崔-后端开发
  3. E006 → 黄俪-前端开发
  4. E004 → 孔蓝-测试工程师
  5. E007 → 韩信-运维工程师

示例二:插入顺序 vs 访问顺序——两种模式的行为对比

import java.util.*;

/**
 * 场景:白歌让小崔写一个对比 Demo,直观展示 LinkedHashMap
 * 在插入顺序和访问顺序两种模式下对同一组操作的不同行为。
 * 孔蓝用这个 Demo 给新人讲解 LinkedHashMap 的核心特性。
 */
public class LinkedHashMapOrderComparison {
    public static void main(String[] args) {
        System.out.println("═══════════════════════════════════════");
        System.out.println("   LinkedHashMap 两种顺序模式对比");
        System.out.println("═══════════════════════════════════════\n");

        // ========== 模式一:插入顺序(默认) ==========
        System.out.println("--- 模式一:插入顺序(accessOrder = false,默认) ---");
        LinkedHashMap<String, Integer> insertionMap = new LinkedHashMap<>();
        insertionMap.put("大翔", 80000);
        insertionMap.put("白歌", 35000);
        insertionMap.put("小崔", 12000);
        insertionMap.put("孔蓝", 15000);

        System.out.println("初始状态:        " + insertionMap.keySet());
        insertionMap.get("大翔");              // 访问不改变顺序
        System.out.println("get(大翔) 后:     " + insertionMap.keySet());
        insertionMap.put("白歌", 36000);       // 更新已存在的 key 不改变顺序
        System.out.println("put(白歌)更新后:  " + insertionMap.keySet());
        insertionMap.put("Frank", 22000);      // 新 key 追加到尾部
        System.out.println("put(Frank)新增后: " + insertionMap.keySet());
        System.out.println("  结论:顺序始终等于首次插入的顺序\n");

        // ========== 模式二:访问顺序 ==========
        System.out.println("--- 模式二:访问顺序(accessOrder = true) ---");
        LinkedHashMap<String, Integer> accessMap =
            new LinkedHashMap<>(16, 0.75f, true);
        accessMap.put("大翔", 80000);
        accessMap.put("白歌", 35000);
        accessMap.put("小崔", 12000);
        accessMap.put("孔蓝", 15000);

        System.out.println("初始状态:        " + accessMap.keySet());
        accessMap.get("大翔");                 // 访问 → 移到尾部
        System.out.println("get(大翔) 后:     " + accessMap.keySet());
        accessMap.put("白歌", 36000);          // 更新 → 移到尾部
        System.out.println("put(白歌)更新后:  " + accessMap.keySet());
        accessMap.put("Frank", 22000);         // 新 key 追加到尾部
        System.out.println("put(Frank)新增后: " + accessMap.keySet());
        System.out.println("  结论:被访问/更新的 key 移到尾部,头部始终是最久未访问的\n");

        // ========== 补充验证:两种模式下的 containsValue 行为 ==========
        System.out.println("--- 补充验证:containsValue 对顺序的影响 ---");
        LinkedHashMap<String, Integer> testMap =
            new LinkedHashMap<>(16, 0.75f, true);
        testMap.put("A", 1);
        testMap.put("B", 2);
        testMap.put("C", 3);

        System.out.println("containsValue 前: " + testMap.keySet());
        testMap.containsValue(2);  // containsValue 遍历但不触发 afterNodeAccess
        System.out.println("containsValue 后: " + testMap.keySet());
        System.out.println("  结论:containsValue 不改变访问顺序(不触发节点移动)");
    }
}
═══════════════════════════════════════
   LinkedHashMap 两种顺序模式对比
═══════════════════════════════════════

--- 模式一:插入顺序(accessOrder = false,默认) ---
初始状态:        [大翔, 白歌, 小崔, 孔蓝]
get(大翔) 后:     [大翔, 白歌, 小崔, 孔蓝]
put(白歌)更新后:  [大翔, 白歌, 小崔, 孔蓝]
put(Frank)新增后: [大翔, 白歌, 小崔, 孔蓝, Frank]
  结论:顺序始终等于首次插入的顺序

--- 模式二:访问顺序(accessOrder = true) ---
初始状态:        [大翔, 白歌, 小崔, 孔蓝]
get(大翔) 后:     [白歌, 小崔, 孔蓝, 大翔]
put(白歌)更新后:  [小崔, 孔蓝, 大翔, 白歌]
put(Frank)新增后: [小崔, 孔蓝, 大翔, 白歌, Frank]
  结论:被访问/更新的 key 移到尾部,头部始终是最久未访问的

--- 补充验证:containsValue 对顺序的影响 ---
containsValue 前: [A, B, C]
containsValue 后: [A, B, C]
  结论:containsValue 不改变访问顺序(不触发节点移动)

示例三:LinkedHashMap 作为简易 FIFO 缓存(插入顺序模式)

import java.util.*;

/**
 * 场景:飞翔科技的"操作日志缓冲区"需要按插入顺序保留最近 100 条日志,
 * 超出容量时按 FIFO(先进先出)淘汰——即淘汰最早写入的日志。
 * 这是 LinkedHashMap 在插入顺序模式下的另一个经典应用。
 */
class FIFOCache<K, V> extends LinkedHashMap<K, V> {
    private final int maxSize;

    public FIFOCache(int maxSize) {
        // accessOrder = false(默认),插入顺序模式
        super(16, 0.75f, false);
        this.maxSize = maxSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        boolean shouldRemove = size() > maxSize;
        if (shouldRemove) {
            System.out.println("  [FIFO淘汰] " + eldest.getKey() + " → " + eldest.getValue());
        }
        return shouldRemove;
    }
}

public class FeiXiangFIFOLogBuffer {
    public static void main(String[] args) {
        System.out.println("=== 飞翔科技操作日志缓冲区(容量=4,FIFO 策略) ===\n");

        FIFOCache<Integer, String> logBuffer = new FIFOCache<>(4);

        // 模拟操作日志写入
        logBuffer.put(1, "大翔 登录系统");
        logBuffer.put(2, "白歌 创建项目");
        logBuffer.put(3, "小崔 提交代码");
        logBuffer.put(4, "孔蓝 执行测试");

        System.out.println("初始 4 条日志: " + logBuffer.keySet());

        // 第 5 条日志 → 触发 FIFO 淘汰(第 1 条被移除)
        System.out.println("\n新增第 5 条日志:");
        logBuffer.put(5, "Frank 发送周报");
        System.out.println("当前日志序号: " + logBuffer.keySet());

        // 第 6 条日志 → 淘汰第 2 条
        System.out.println("\n新增第 6 条日志:");
        logBuffer.put(6, "黄俪 更新页面");
        System.out.println("当前日志序号: " + logBuffer.keySet());

        // 展示完整日志内容
        System.out.println("\n=== 当前日志缓冲 ===");
        for (Map.Entry<Integer, String> entry : logBuffer.entrySet()) {
            System.out.println("  [" + entry.getKey() + "] " + entry.getValue());
        }
    }
}
=== 飞翔科技操作日志缓冲区(容量=4,FIFO 策略) ===

初始 4 条日志: [1, 2, 3, 4]

新增第 5 条日志:
  [FIFO淘汰] 1 → 大翔 登录系统
当前日志序号: [2, 3, 4, 5]

新增第 6 条日志:
  [FIFO淘汰] 2 → 白歌 创建项目
当前日志序号: [3, 4, 5, 6]

=== 当前日志缓冲 ===
  [3] 小崔 提交代码
  [4] 孔蓝 执行测试
  [5] Frank 发送周报
  [6] 黄俪 更新页面

易错场景

反例一:accessOrder 模式下遍历时 get 导致 ConcurrentModificationException

孔蓝在测试 LRU 缓存时写了一段遍历代码,结果抛出了异常:

// ❌ 错误:accessOrder 模式下遍历时调用 get
LinkedHashMap<String, String> map = new LinkedHashMap<>(16, 0.75f, true);
map.put("A", "1");
map.put("B", "2");
map.put("C", "3");

for (String key : map.keySet()) {
    if (key.equals("A")) {
        map.get("B");  // get 触发 afterNodeAccess → 修改 modCount → 迭代器检测到并发修改!
    }
}
// 抛出 ConcurrentModificationException

原理:afterNodeAccess 方法会执行 ++modCount,而增强 for 循环底层使用的迭代器在每次 next() 时都会检查 modCount 是否与创建时一致。不一致则立即抛出 ConcurrentModificationException。

纠正:

// ✅ 方案一:遍历前收集需要访问的 key
List<String> keysToAccess = new ArrayList<>();
for (String key : map.keySet()) {
    if (key.equals("A")) keysToAccess.add("B");
}
for (String key : keysToAccess) {
    map.get(key);
}

// ✅ 方案二:使用迭代器的 remove 或显式索引遍历
// ✅ 方案三:使用 ConcurrentHashMap + 自定义 LRU 实现(多线程场景)

反例二:未重写 removeEldestEntry 导致缓存无限增长

小崔第一次实现 LRU 缓存时忘了最重要的一步:

// ❌ 错误:构造时设置了 accessOrder=true,但忘记重写 removeEldestEntry
LinkedHashMap<String, String> cache = new LinkedHashMap<>(16, 0.75f, true);
// ... 疯狂 put ...
// 结果:缓存永远不会自动淘汰,等同于普通 LinkedHashMap!

原理:removeEldestEntry 的默认实现永远返回 false(JDK 文档明确说明)。不重写它,accessOrder=true 只是改变了遍历顺序,但不会触发任何淘汰行为。LRU 缓存的"淘汰"机制完全由 removeEldestEntry 提供。

纠正:

// ✅ 即使是最简单的 LRU 缓存也必须重写 removeEldestEntry
LinkedHashMap<String, String> cache = new LinkedHashMap<String, String>(16, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
        return size() > 100;  // 最大 100 条
    }
};

反例三:插入顺序模式下 put 已有 key 误以为会改变顺序

// ❌ 误区:认为 put 已有 key 会将该 key 移到尾部
LinkedHashMap<String, String> map = new LinkedHashMap<>();  // 默认插入顺序
map.put("A", "1");
map.put("B", "2");
map.put("A", "updated");  // 更新已存在 key
System.out.println(map.keySet());  // [A, B] —— A 仍在 B 前面!

// 很多人误以为输出应该是 [B, A]

原理:在插入顺序模式下,put 一个已存在的 key 时,LinkedHashMap 会先找到已有节点,更新其 value,但不会改变该节点在双向链表中的位置。这是因为插入顺序的语义是"记录首次插入时的顺序"。

反例四:多线程环境下使用 LinkedHashMap 的 LRU 缓存

// ❌ 错误:多个线程同时访问/修改同一个 LinkedHashMap
final LinkedHashMap<String, String> cache = new LinkedHashMap<String, String>(16, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
        return size() > 100;
    }
};

// 线程1:查询
new Thread(() -> cache.get("key1")).start();
// 线程2:插入(可能触发淘汰)
new Thread(() -> cache.put("key2", "value2")).start();
// 线程3:遍历
new Thread(() -> {
    for (String key : cache.keySet()) { /* ... */ }
}).start();

// 结果:数据不一致、ConcurrentModificationException、甚至死循环(链表指针损坏)

纠正:

// ✅ 方案一:使用 Collections.synchronizedMap 包装
Map<String, String> syncCache = Collections.synchronizedMap(cache);

// ✅ 方案二:使用 ConcurrentHashMap + 自定义 LRU 实现
// ✅ 方案三:使用 Guava Cache(内置线程安全的 LRU)

面试考点

Q1:LinkedHashMap 与 HashMap 的区别?遍历性能如何?

维度LinkedHashMapHashMap
底层结构HashMap + 双向链表数组 + 链表 + 红黑树
遍历顺序可预测(插入顺序或访问顺序)不可预测(取决于哈希分布)
内存占用更大(每个节点多 2 个引用)更小
get/put 性能基本相同(链表操作是 O(1))相同
遍历性能更快(沿双向链表遍历所有节点,只遍历有效节点,跳过空桶)需要遍历 table 数组的所有桶(包括空桶)
null 键/值允许允许

关键认知:LinkedHashMap 的遍历性能实际优于 HashMap——因为它只需沿双向链表遍历,时间复杂度 O(size);而 HashMap 需要遍历整个 table 数组(含空桶),时间复杂度 O(capacity)。

Q2:LinkedHashMap 如何实现 LRU 缓存?核心步骤是什么?

实现 LRU 缓存只需三步: ① 构造方法:传入 accessOrder = true,启用访问顺序模式。 ② 重写 removeEldestEntry:当 size() > maxCapacity 时返回 true,触发自动淘汰。 ③ 正常使用:get/put 操作会自动将访问的条目移到链表尾部;新增条目时若超出容量,afterNodeInsertion 回调 removeEldestEntry,从链表头部移除最久未使用的条目。

额外说明:removeEldestEntry 是特意设计为 protected 方法的——它是模板方法模式中的"钩子方法",供子类扩展。JDK 文档明确说明该方法的存在意义就是为 LRU 缓存提供扩展点。

Q3:插入顺序和访问顺序有何区别?各自适用场景?

插入顺序(accessOrder=false,默认):遍历顺序等于元素首次插入的顺序;put 已有 key 不改变位置。适用于需要保留"首次出现顺序"的场景,如配置项覆盖(后来的不改变位置)、操作历史记录、FIFO 队列缓存。

访问顺序(accessOrder=true):遍历顺序按最近访问排序;get 或 put(更新已有 key)将条目移到链表尾部。适用于 LRU 缓存、最近使用列表、用户活跃度追踪等场景。

核心区别在于 afterNodeAccess 回调:访问顺序模式下每次访问都会触发节点重新排队,同时增加 modCount(导致迭代器 fail-fast)。

Q4:LinkedHashMap 的 containsValue 为什么比 HashMap 快?

HashMap 的 containsValue 需要双重循环:外层遍历 table 数组的每个桶,内层遍历每个桶内的链表/红黑树节点——时间复杂度 O(capacity + size),因为必须检查所有桶(包括空桶)。

LinkedHashMap 重写了 containsValue,改为沿双向链表遍历——时间复杂度 O(size),因为双向链表只连接有效节点,跳过了所有空桶。

这是"维护遍历顺序"带来的附加收益:双向链表本身就是一张"有效节点的索引表"。

Q5:LinkedHashMap 的 Entry 节点为什么同时有 next 和 before/after 指针?

next 指针服务于 HashMap 的桶内冲突解决——当两个 key 的 hash 值落在同一个桶时,它们通过 next 指针形成链表(或红黑树,此时为 TreeNode)。before/after 指针服务于 LinkedHashMap 的全局顺序维护——无论节点落在哪个桶,它们都通过 before/after 串成一条全局有序链表。

这两个链表服务于完全不同的目的,互相独立但也互相关联:删除节点时,既要断开桶内链表(修改 next),也要断开全局双向链表(修改 before/after),同时还要处理红黑树退化、rehash 迁移等复杂情况。LinkedHashMap 通过重写 newNode、afterNodeRemoval 等模板方法,在 HashMap 的钩子中悄悄完成了这些额外维护工作——这正是模板方法设计模式的经典应用。

上一页
HashMap 详解
下一页
TreeMap 详解