键值对模型
Map 存的是“键到值”的映射:key 不能重复,value 可以重复。最常用的 HashMap 有三个特点:无序、允许一个 null key、允许多个 null value。需要顺序时用 LinkedHashMap(保持插入顺序)或 TreeMap(按 key 排序)。
很多人把 Map 当成“高级数组”来用,这没问题,但要记住它按键查找,不按下标查找。想把 Map 用明白,核心是理解 key 的 hashCode 和 equals。
几个常用实现的差别很大:
| 实现 | 特点 | 适用场景 |
|---|---|---|
HashMap |
无序,查询最快 | 绝大多数键值查询 |
LinkedHashMap |
保持插入顺序 | 需要按放入顺序遍历 |
TreeMap |
按 key 排序 | 需要排序或范围查询 |
ConcurrentHashMap |
线程安全 | 多线程读写 |
哈希表的直觉是“按 key 算出一个位置,直接去那个位置取”。位置算得越均匀,冲突越少,查询越接近 O(1)。所以 HashMap 的性能本质上取决于 key 的 hashCode 写得均不均匀,而不是你存了多少条数据。
常用操作
import java.util.HashMap;
import java.util.Map;
public class HashMapDemo {
public static void main(String[] args) {
Map<String, Integer> stock = new HashMap<>(16);
stock.put("apple", 10);
stock.put("banana", 5);
stock.put("apple", 8); // 覆盖旧值
System.out.println(stock.get("apple")); // 8
System.out.println(stock.getOrDefault("pear", 0)); // 0
System.out.println(stock.containsKey("banana")); // true
stock.remove("banana");
for (Map.Entry<String, Integer> entry : stock.entrySet()) {
System.out.println(entry.getKey() + "=" + entry.getValue());
}
}
}
遍历用 entrySet,不要 keySet() 之后再 get(key),后者相当于每个元素多查一次。JDK 8 之后还可以用 forEach,但遍历中要删除元素时仍然推荐 Iterator.remove 或 removeIf。
putIfAbsent 和 computeIfAbsent 能省掉“先 get、判空、再 put”的三行代码。后者适合 value 需要计算或查库的场景,但不要在 lambda 里做远程调用,因为它会持有桶锁,其他线程可能被一起拖住。merge 则适合计数累加,比如统计每个商品的销量。
为什么 key 要重写 equals 和 hashCode
HashMap 定位一个 key 分两步:先用 hashCode 算出一个桶的位置,再用 equals 在桶里比较。如果自定义类只重写了 equals 没重写 hashCode,两个“逻辑相等”的对象会被散到不同桶里,get 永远拿不到,表现就是“明明 put 进去了却查不出来”。
反过来,只重写 hashCode 不重写 equals,桶内的比较依然过不了。所以这两个方法必须成对重写,标准做法是用 IDEA 自动生成,或者用 Objects.hash(...)。这也是为什么 String、Integer 适合当 key:它们不可变,且已经正确实现了这两个方法。
并发场景不要用 HashMap
JDK 8 的 HashMap 在并发 put 时不会再形成 JDK 7 那种环形链表,但依然可能丢数据、size 不准、扩容时读到中间状态。多线程读写请直接换 ConcurrentHashMap。
换的时候注意差异:ConcurrentHashMap 的 key 和 value 都不允许为 null,所以 get 返回 null 时无法区分“不存在”和“值就是 null”。业务上要么约定不用 null,要么用 containsKey 辅助判断。
常见坑
1. get 返回 null 就以为不存在
Map 里可能真的存了 null 值。要区分就配 containsKey,或者干脆约定 value 不允许为 null,用 getOrDefault 统一兜底。
2. 用可变对象当 key
Map<List<String>, Integer> map = new HashMap<>();
List<String> key = new ArrayList<>();
map.put(key, 1);
key.add("x"); // 内容变了,hashCode 也变了
System.out.println(map.get(key)); // null
key 的字段被修改后,hash 值变化,原位置再也找不到。key 优先用不可变类型;自定义类把字段设为 final,并且不要在放进 Map 之后修改。
3. 遍历时删除
增强 for 里 map.remove(key) 会抛 ConcurrentModificationException。用 Iterator.remove(),或者 map.entrySet().removeIf(e -> e.getValue() == 0)。
4. 初始容量拍脑袋
默认容量 16、负载因子 0.75,超过 12 个元素就扩容到 32,并重新分配所有桶。已知要存 1000 条,初始容量给 1000 / 0.75 + 1,大约是 1334,能少扩容好几次。
5. 以为遍历顺序是插入顺序
HashMap 的遍历顺序由 hash 决定,和插入顺序无关,换 JDK 版本都可能变。需要顺序就用 LinkedHashMap,需要按 key 排序用 TreeMap。
6. 用 HashMap 存大量数据还频繁遍历
Map 本身适合按 key 查,不适合当列表遍历。既要查又要按顺序遍历,说明数据模型可能选错了。
排查步骤
get为 null 时,先打印 key、key.hashCode()和map.containsKey(key);- 自定义 key 检查是否同时重写了
equals和hashCode; - 多线程场景先换
ConcurrentHashMap,看问题是否消失; - 遍历删除统一改成
removeIf或迭代器; - 数据量大时指定初始容量,观察扩容次数和 GC 频率。
评论