1. 概述
本文将深入讲解 Java 中 HashMap
的使用方法及其底层实现机制。作为开发中最常用的集合类之一,掌握其核心原理对写出高性能、少踩坑的代码至关重要。
HashMap
有一个“老前辈”叫 Hashtable
,两者功能相似但线程安全性、性能表现差异明显。关于 HashMap
与 Hashtable
的详细对比,可参考其他专题文章。本文聚焦 HashMap
本身。
2. 基本用法
HashMap
的本质是 键值对映射(Key-Value Mapping)。每个键(Key)唯一对应一个值(Value),通过键可以快速查找到对应的值。
你可能会问:为什么不直接用 List
存储?关键在于 性能。在 List
中查找元素,时间复杂度为 O(n),即使排序后用二分查找也需 O(log n)。而 HashMap
在平均情况下,插入和查找的时间复杂度仅为 **O(1)**,这是它被广泛使用的核心原因。
下面我们通过代码示例快速上手。
2.1. 示例类准备
先定义一个用于演示的 Product
类:
public class Product {
private String name;
private String description;
private List<String> tags;
// standard getters/setters/constructors
public Product addTagsOfOtherProduct(Product product) {
this.tags.addAll(product.getTags());
return this;
}
}
2.2. put:插入元素
创建一个以 String
为键、Product
为值的 HashMap
,并插入数据:
Map<String, Product> productsByName = new HashMap<>();
Product eBike = new Product("E-Bike", "A bike with a battery");
Product roadBike = new Product("Road bike", "A bike for competition");
productsByName.put(eBike.getName(), eBike);
productsByName.put(roadBike.getName(), roadBike);
✅ 插入操作是幂等的,重复 put 会覆盖旧值。
2.3. get:获取元素
通过键获取对应的值:
Product nextPurchase = productsByName.get("E-Bike");
assertEquals("A bike with a battery", nextPurchase.getDescription());
⚠️ 如果键不存在,get()
返回 null
:
Product nextPurchase = productsByName.get("Car");
assertNull(nextPurchase);
⚠️ 如果插入相同键的新值,旧值会被覆盖:
Product newEBike = new Product("E-Bike", "A bike with a better battery");
productsByName.put(newEBike.getName(), newEBike);
assertEquals("A bike with a better battery", productsByName.get("E-Bike").getDescription());
2.4. null 作为键
HashMap
允许使用 null
作为键,且仅允许一个 null
键:
Product defaultProduct = new Product("Chocolate", "At least buy chocolate");
productsByName.put(null, defaultProduct);
Product nextPurchase = productsByName.get(null);
assertEquals("At least buy chocolate", nextPurchase.getDescription());
✅ get(null)
可以正确返回 null
键对应的值。
2.5. 同一对象多个键
同一个 Product
实例可以被多个不同的键引用:
productsByName.put(defaultProduct.getName(), defaultProduct);
assertSame(productsByName.get(null), productsByName.get("Chocolate"));
✅ 两个键指向的是同一个对象实例。
2.6. remove:删除元素
通过键删除键值对:
productsByName.remove("E-Bike");
assertNull(productsByName.get("E-Bike"));
2.7. 检查键或值是否存在
- ✅
containsKey(key)
:检查键是否存在,时间复杂度 O(1)
productsByName.containsKey("E-Bike");
- ❌
containsValue(value)
:检查值是否存在,时间复杂度 **O(n)**,需遍历所有值
productsByName.containsValue(eBike);
⚠️ 两者性能差异巨大,containsValue
应谨慎使用,避免在大 Map 中频繁调用。
2.8. 遍历 HashMap
遍历 HashMap
有三种常见方式:
- 遍历 keySet:
for(String key : productsByName.keySet()) {
Product product = productsByName.get(key);
}
- 遍历 entrySet(推荐):
for(Map.Entry<String, Product> entry : productsByName.entrySet()) {
Product product = entry.getValue();
String key = entry.getKey();
// do something with the key and value
}
- 遍历 values:
List<Product> products = new ArrayList<>(productsByName.values());
✅ 推荐使用 entrySet
,避免在循环中多次调用 get()
。
3. 键的设计:equals 与 hashCode
HashMap
允许任何类作为键,但必须正确重写 equals()
和 hashCode()
方法,否则会出现“存了取不到”的诡异问题。
假设我们想用 Product
作为键,价格作为值:
HashMap<Product, Integer> priceByProduct = new HashMap<>();
priceByProduct.put(eBike, 900);
必须为 Product
实现 equals
和 hashCode
:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Product product = (Product) o;
return Objects.equals(name, product.name) &&
Objects.equals(description, product.description);
}
@Override
public int hashCode() {
return Objects.hash(name, description);
}
⚠️ 只有作为键的类才需要重写这两个方法,作为值的类无需实现。原因见第 6 节底层原理。
4. Java 8 新增方法
Java 8 为 HashMap
引入了函数式编程风格的方法,代码更简洁。
4.1. forEach()
函数式遍历:
productsByName.forEach((key, product) -> {
System.out.println("Key: " + key + " Product:" + product.getDescription());
});
等价于 Java 7 写法:
for(Map.Entry<String, Product> entry : productsByName.entrySet()) {
// ...
}
4.2. getOrDefault()
获取值或返回默认值,避免 null 判断:
Product chocolate = new Product("chocolate", "something sweet");
Product defaultProduct = productsByName.getOrDefault("horse carriage", chocolate);
Product bike = productsByName.getOrDefault("E-Bike", chocolate);
Java 7 写法:
Product bike2 = productsByName.containsKey("E-Bike")
? productsByName.get("E-Bike")
: chocolate;
4.3. putIfAbsent()
仅当键不存在时才插入:
productsByName.putIfAbsent("E-Bike", chocolate);
Java 7 写法:
if(!productsByName.containsKey("E-Bike")) {
productsByName.put("E-Bike", chocolate);
}
4.4. merge()
合并值:若键存在,用函数合并;否则插入新值。
Product eBike2 = new Product("E-Bike", "A bike with a battery");
eBike2.getTags().add("sport");
productsByName.merge("E-Bike", eBike2, Product::addTagsOfOtherProduct);
Java 7 写法:
if(productsByName.containsKey("E-Bike")) {
productsByName.get("E-Bike").addTagsOfOtherProduct(eBike2);
} else {
productsByName.put("E-Bike", eBike2);
}
4.5. compute()
计算新值,逻辑更灵活:
productsByName.compute("E-Bike", (k,v) -> {
if(v != null) {
return v.addTagsOfOtherProduct(eBike2);
} else {
return eBike2;
}
});
Java 7 写法同上。
⚠️ merge()
和 compute()
功能相似,但参数不同:
merge(key, defaultValue, remappingFunction)
compute(key, remappingFunction)
5. 如何避免 HashMap<String, Object> 的类型强转
使用原始类型或 Object
作为泛型参数会导致强制类型转换,既不安全又难看。
5.1. 为什么需要强转
错误示范:使用 HashMap<String, Object>
HashMap<String,Object> objectMap = new HashMap<>();
objectMap.put("E-Bike", new Product("E-Bike", "A bike with a battery"));
// 无法直接赋值,类型不兼容
// HashMap<String,Product> productMap = objectMap; // 编译错误
// 必须手动遍历并强转
HashMap<String,Product> productMap = new HashMap<>();
for (Map.Entry<String, Object> entry : objectMap.entrySet()) {
if(entry.getValue() instanceof Product){
productMap.put(entry.getKey(), (Product) entry.getValue());
}
}
❌ 这种写法繁琐且容易出错(ClassCastException
)。
5.2. 如何避免强转
✅ 正确做法:从一开始就使用正确的泛型类型
HashMap<String, Product> productMap = new HashMap<>();
productMap.put("E-Bike", new Product("E-Bike", "A bike with a battery"));
// 直接使用,无需强转
Product product = productMap.get("E-Bike");
String desc = product.getDescription();
✅ 代码简洁、类型安全、性能更好。
6. HashMap 底层原理
理解底层机制,才能真正用好 HashMap
。
6.1. 哈希码与 equals
HashMap
不是遍历查找,而是通过 哈希算法 直接定位数据位置。
核心流程:
- 根据键的
hashCode()
计算应存入的 桶(bucket) - 在该桶内,通过
equals()
找到精确匹配的键
✅ hashCode()
决定桶位置,equals()
决定桶内匹配。
6.2. 键的不可变性
⚠️ 强烈建议使用不可变对象作为键。若键可变且被修改,会导致“存进去取不出”的问题。
示例:
public class MutableKey {
private String name;
// getter, setter, equals, hashCode...
}
MutableKey key = new MutableKey("initial");
Map<MutableKey, String> items = new HashMap<>();
items.put(key, "success");
key.setName("changed"); // 修改了键
assertNull(items.get(key)); // 返回 null!
原因:hashCode()
基于 name
计算,修改后哈希值改变,HashMap
会去错误的桶中查找。
✅ 踩坑提示:用 String
、Integer
等不可变类作键最安全。
6.3. 哈希冲突
不同键可能产生相同哈希值(哈希冲突),此时多个键值对会存入同一个桶。
处理方式:
- Java 8 之前:桶内用 链表 存储,最坏查找 O(n)
- Java 8 起:当链表长度 ≥ 8 时,转换为 红黑树,查找性能提升至 O(log n)
- 当树节点 ≤ 6 时,退化回链表
✅ 这是 Java 8 对 HashMap
的重要优化。
6.4. 容量与负载因子
- 初始容量:默认 16
- 负载因子(load factor):默认 0.75
- 扩容机制:当元素数量 > 容量 × 负载因子(即 16×0.75=12)时,容量 自动翻倍,并重新哈希所有元素
✅ 扩容是性能消耗操作,若预知数据量,建议初始化时指定容量。
6.5. put 与 get 操作流程总结
操作 | 流程 |
---|---|
put(K,V) | 1. 计算 key 的 hash → 定位桶 2. 若桶有冲突,插入链表或树 3. 检查是否需扩容 |
get(K) | 1. 计算 key 的 hash → 定位桶 2. 遍历桶内结构,用 equals() 找目标值 |
7. 总结
HashMap
是 Java 开发的基石之一,与 ArrayList
并列最常用数据结构。掌握其使用技巧与底层原理,能帮助我们:
- ✅ 写出更高效、健壮的代码
- ✅ 避免因
hashCode/equals
失效导致的“神秘 bug” - ✅ 理解并发场景下为何要用
ConcurrentHashMap
深入理解 HashMap
,是每个 Java 程序员的必修课。