1. 概述

本文将深入讲解 Java 中 HashMap 的使用方法及其底层实现机制。作为开发中最常用的集合类之一,掌握其核心原理对写出高性能、少踩坑的代码至关重要。

HashMap 有一个“老前辈”叫 Hashtable,两者功能相似但线程安全性、性能表现差异明显。关于 HashMapHashtable 的详细对比,可参考其他专题文章。本文聚焦 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 有三种常见方式:

  1. 遍历 keySet
for(String key : productsByName.keySet()) {
    Product product = productsByName.get(key);
}
  1. 遍历 entrySet(推荐)
for(Map.Entry<String, Product> entry : productsByName.entrySet()) {
    Product product =  entry.getValue();
    String key = entry.getKey();
    // do something with the key and value
}
  1. 遍历 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 实现 equalshashCode

@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 不是遍历查找,而是通过 哈希算法 直接定位数据位置。

核心流程:

  1. 根据键的 hashCode() 计算应存入的 桶(bucket)
  2. 在该桶内,通过 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 会去错误的桶中查找。

✅ 踩坑提示:用 StringInteger 等不可变类作键最安全。

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 程序员的必修课。


原始标题:A Guide to Java HashMap | Baeldung