🚀 Java 巩固进阶 · 第10天
主题:Map 接口深度解析 ------ 键值对的高效艺术
📅 进度概览 :掌握 Java 中最灵活的数据结构。
💡 核心价值:
- 动态数据承载 :SpringBoot 中接收前端动态参数 (
Map<String, Object>)、MyBatis 多参数传递、Redis Hash 结构映射。- 高性能查找:O(1) 的查询效率,是缓存、索引、计数器的基石。
- 并发安全 :理解
HashMap的非线程安全特性,掌握ConcurrentHashMap的高并发方案。- 代码简化 :利用 Java 8
computeIfAbsent等新特性,消除冗长的判空逻辑。
一、Map 接口核心特性:键值对的契约
Map 是双列集合,核心规则:Key 唯一,Value 可重复。
- null 值支持 :
HashMap允许 1 个 null Key 和多个 null Value;TreeMap不允许 null Key (无法比较)。 - 无索引:只能通过 Key 访问 Value,或通过 Entry 遍历。
- 等价性 :Key 的重复判断逻辑与
HashSet完全一致(hashCode+equals或compareTo)。
⚡ 核心方法速查 (含 Java 8 新特性)
| 方法 | 作用 | 进阶用法/注意 |
|---|---|---|
V put(K key, V value) |
添加/修改 | 返回旧 Value,若为 null 表示新增 |
V get(Object key) |
获取 Value | Key 不存在返回 null (注意:null 也可能是真实值) |
V getOrDefault(K key, V default) |
安全获取 | 推荐 :避免 NPE,如 map.get("age", 0) |
V putIfAbsent(K key, V value) |
不存在才放 | 原子操作,常用于简易分布式锁或缓存填充 |
V computeIfAbsent(K key, Func) |
计算并放入 | 神器:解决"先查后插"的竞态条件和冗长代码 |
V merge(K key, V value, BiFunc) |
合并值 | 神器:用于计数累加、列表合并 |
Set<Map.Entry<K,V>> entrySet() |
获取键值对集合 | 遍历最高效的方式 |
二、HashMap:绝对的主力军
1. 底层原理深度揭秘 (Java 8+)
- 结构 :
数组 (Node[]) + 链表 + 红黑树。 - 哈希计算 :
(key.hashCode() ^ (key.hashCode() >>> 16)) & (capacity - 1)。- 高位异或:让高位也参与运算,减少哈希冲突(尤其当容量较小时)。
- 位运算 :要求容量必须是 2 的幂次方 ,这样
&运算等同于%但更快。
- 树化阈值 :
- 链表长度 > 8 且 数组长度 ≥ 64 → 转为红黑树。
- 链表长度 < 6 → 退化为链表。
- 为什么是 8? 泊松分布统计结果,概率极低,平衡时间与空间。
- 扩容机制 :
- 阈值 :
capacity * loadFactor(默认 16 * 0.75 = 12)。 - 扩容:新容量 = 旧容量 * 2。
- Rehash :Java 8 优化了扩容逻辑,元素要么在原位置,要么在
原位置 + 旧容量处,无需重新计算 hash。
- 阈值 :
2. ⚠️ 生产环境避坑指南
- 初始容量设定 :
- 若预估存放 1000 个元素,应设为
1000 / 0.75 + 1 ≈ 1334,并自动扩容到最近的 2 的幂 (2048)。 - 错误做法 :
new HashMap<>(1000)→ 实际容量 1024 → 存到 768 个就开始扩容 → 多次扩容消耗 CPU。 - 正确做法 :
new HashMap<>((int)(expectedSize / 0.75f) + 1)。
- 若预估存放 1000 个元素,应设为
- Key 的选择 :
- 必须使用不可变类 (如
String,Integer) 或字段不可变的对象作为 Key。 - 严禁 在放入 Map 后修改 Key 中影响
hashCode的字段,否则该元素将永久丢失 (无法 get,无法 remove)。
- 必须使用不可变类 (如
3. 🚀 Java 8 优雅编程示例
场景:统计列表中每个单词出现的次数。
java
// ❌ 传统写法 (冗长)
for (String word : words) {
if (map.containsKey(word)) {
map.put(word, map.get(word) + 1);
} else {
map.put(word, 1);
}
}
// ✅ 进阶写法 1:merge (推荐)
for (String word : words) {
map.merge(word, 1, Integer::sum);
// 含义:若存在,则执行 Integer::sum(旧值, 1);若不存在,则放入 1
}
// ✅ 进阶写法 2:computeIfAbsent (适用于复杂对象初始化)
// 比如:Map<String, List<User>> userGroups
List<User> list = map.computeIfAbsent(groupId, k -> new ArrayList<>());
list.add(newUser);
// 避免了先 get 判断 null,再 put 的两步操作,且线程更安全(相对)
三、LinkedHashMap:顺序的守护者
- 原理 :在 HashMap 基础上,维护了一条双向链表,记录插入顺序(或访问顺序)。
- 应用场景 :
- LRU 缓存 :重写
removeEldestEntry方法,自动淘汰最久未使用的元素(Spring Cache 默认实现原理之一)。 - 配置读取 :保持
application.yml中的配置顺序。 - JSON 序列化:FastJSON/Jackson 默认按插入顺序输出 JSON 字段,依赖 LinkedHashMap。
- LRU 缓存 :重写
四、TreeMap:有序的专家
- 原理 :基于红黑树 ,Key 必须实现
Comparable或传入Comparator。 - 特性 :
- Key 不能为 null。
- 遍历时 Key 天然有序。
- 支持范围查询:
subMap(fromKey, toKey),headMap(toKey),tailMap(fromKey)。
- 陷阱 :去重逻辑完全依赖
compareTo返回 0。若两个对象业务上不等(equals=false),但排序相等(compareTo=0),后者会被覆盖!务必在比较器中加入主键(ID)作为最终排序依据。
五、并发安全:HashMap 的致命弱点
1. HashMap 在多线程下的风险
- 数据覆盖 :多线程同时
put,可能导致某个线程的写入丢失。 - 死循环 (Java 7) :扩容时链表形成环,
get操作导致 CPU 100% (Java 8 已修复此问题,但仍不安全)。 - 结论 :严禁 在多线程环境下直接使用
HashMap。
2. SpringBoot 中的线程安全方案
| 方案 | 实现 | 特点 | 适用场景 |
|---|---|---|---|
| ConcurrentHashMap | new ConcurrentHashMap<>() |
分段锁 (Java 7) / CAS + synchronized (Java 8)。锁粒度细,并发度极高。 | 高并发首选:本地缓存、计数器、共享配置 |
| Synchronized Map | Collections.synchronizedMap(new HashMap<>()) |
全局锁 (synchronized)。性能差。 |
低并发,遗留系统兼容 |
| Hashtable | new Hashtable<>() |
全表锁,方法全同步。已过时。 | ❌ 禁止使用 |
java
// ✅ 推荐:高并发安全 Map
Map<String, Integer> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("key", 1);
// 支持原子操作
concurrentMap.computeIfAbsent("key", k -> 0);
六、🎯 今日实战任务:高并发商品计数器
背景:模拟电商大促场景,统计各商品的点击量。
需求步骤
- 定义商品类
Product:- 属性:
id(Long),name(String)。 - 重写
hashCode和equals(基于 id)。
- 属性:
- 功能实现 :
- 场景 A (基础 Map) :使用
HashMap<Product, Integer>统计点击量。- 模拟单线程添加 1000 次点击,使用
merge方法累加。 - 打印点击量最高的前 3 个商品(需转为 List 排序)。
- 模拟单线程添加 1000 次点击,使用
- 场景 B (并发挑战) :
- 创建
HashMap和ConcurrentHashMap两个容器。 - 开启 10 个线程,每个线程对同一批 100 个商品随机点击 1000 次。
- 对比最终总数:
HashMap是否出现数据丢失(总数 < 10 * 100 * 1000)?ConcurrentHashMap是否准确?
- 创建
- 场景 C (有序输出) :
- 使用
LinkedHashMap存储,验证遍历顺序是否与插入顺序一致(按首次点击时间)。 - 使用
TreeMap存储,按商品 ID 升序输出统计结果。
- 使用
- 场景 A (基础 Map) :使用
- 进阶要求 :
- 尝试在遍历
HashMap的同时,另一个线程进行remove操作,观察是否抛出ConcurrentModificationException。 - 使用
Iterator安全删除。
- 尝试在遍历
💡 代码提示
java
// 线程安全累加
ConcurrentHashMap<Product, Long> counter = new ConcurrentHashMap<>();
// 模拟多线程点击
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
executor.submit(() -> {
for (Product p : productList) {
// merge 是线程安全的吗?对于 ConcurrentHashMap 是的!
counter.merge(p, 1L, Long::sum);
}
});
}
executor.shutdown();
📝 第10天 · 核心总结
-
Map 选型铁律:
- 默认首选 :
HashMap(单线程,最快)。 - 保序需求 :
LinkedHashMap(插入顺序/访问顺序)。 - 排序需求 :
TreeMap(Key 自然排序/定制排序)。 - 并发需求 :
ConcurrentHashMap(唯一推荐,高性能线程安全)。 - 禁选 :
Hashtable(过时),Collections.synchronizedMap(性能差)。
- 默认首选 :
-
性能调优:
- 预估容量,设置合适的
initialCapacity,避免频繁扩容。 - Key 必须不可变或放入后不修改关键字段。
- 遍历优先使用
entrySet。
- 预估容量,设置合适的
-
现代 Java 风格:
- 拒绝
if-contains-put模式,拥抱merge,computeIfAbsent,getOrDefault。 - 利用 Stream API 处理 Map 数据 (
entrySet().stream())。
- 拒绝
-
SpringBoot 实践:
- Controller 接收动态参数用
Map<String, Object>。 - 本地缓存用
ConcurrentHashMap(或 Caffeine/Guava Cache)。 - JSON 返回时,Map 的顺序可能影响前端展示,需注意选择 LinkedHashMap。
- Controller 接收动态参数用