HashMap核心原理与源码剖析

HashMap核心原理与源码剖析

HashMap是Java中最重要、最常用的数据结构之一。它基于哈希表实现,提供了平均时间复杂度为O(1)的快速存取能力。本文将从设计思想开始,逐步深入到源码实现,带你全面理解HashMap的奥秘。

一、为什么需要HashMap?

在讲解HashMap之前,我们先思考一个问题:如果我们需要存储一些键值对数据,并且希望可以通过键快速查找到对应的值,有哪些方式可以选择?

1.1 常见数据结构对比

数据结构 查找效率 插入效率 删除效率 空间效率 适用场景
数组 O(1)* O(1)* O(1)* 已知索引
链表 O(n) O(1)* O(1)* 频繁插入删除
二叉搜索树 O(log n) O(log n) O(log n) 有序数据
哈希表 O(1)** O(1)** O(1)** 快速查找

注:

  • *表示在已知位置的情况下
  • **表示平均情况下的时间复杂度

1.2 HashMap的优势

通过以上对比可以看出,HashMap在查找、插入、删除操作上都具有O(1)的平均时间复杂度,这是它最大的优势。

java 复制代码
// HashMap的典型使用方式
Map<String, String> map = new HashMap<>();
map.put("name", "张三");      // 插入操作 O(1)
map.put("age", "25");
String name = map.get("name"); // 查找操作 O(1)
map.remove("age");             // 删除操作 O(1)

1.3 实际应用场景

HashMap在实际开发中应用广泛,常见场景包括:

  1. 缓存系统:利用O(1)查找特性实现快速缓存
  2. 索引构建:数据库或搜索引擎中构建索引
  3. 计数统计:词频统计、访问次数统计等
  4. 去重处理:利用Key的唯一性进行数据去重
  5. 对象关联:关联具有映射关系的数据

二、HashMap的核心思想

HashMap的核心思想是哈希表(Hash Table),也叫散列表。它的基本原理是:

  1. 通过一个哈希函数将键(Key)映射到一个整数(哈希值)
  2. 将这个整数作为数组的索引,直接定位到数组中的某个位置
  3. 将值(Value)存储在这个位置上
graph LR A[Key] --> B[哈希函数] B --> C[哈希值] C --> D[数组索引] D --> E[存储Value]

2.1 理想情况下的哈希表

在理想情况下,每个键都能映射到唯一的数组索引,这样就能实现真正的O(1)操作:

graph TD A[数组] --> B[索引0: Key1->Value1] A --> C[索引1: Key2->Value2] A --> D[索引2: 空] A --> E[索引3: Key3->Value3] A --> F[...]

2.2 现实中的挑战:哈希冲突

然而现实中,由于键的数量通常是无限的,而数组的大小是有限的,这就不可避免地会出现多个键映射到同一个索引的情况,这就是所谓的哈希冲突(Hash Collision)。

graph TD A[Key1] --> B[哈希函数] C[Key2] --> B B --> D[相同索引] D --> E[冲突!]

三、HashMap的底层数据结构演化

面对哈希冲突这个问题,HashMap在不同版本中采用了不同的解决方案:

3.1 JDK 1.7及以前:数组+链表

在JDK 1.7及以前的版本中,HashMap采用了数组+链表的结构来解决哈希冲突:

graph TD A[数组] --> B[索引0: 链表头->节点1->节点2->节点3] A --> C[索引1: 链表头->节点4] A --> D[索引2: null] A --> E[索引3: 链表头->节点5->节点6] A --> F[...]

在这种结构中,当发生哈希冲突时,新的键值对会被添加到对应索引位置的链表中。

3.1.1 JDK 1.7的数据结构定义
java 复制代码
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
    
    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }
    // ... 其他方法
}

3.2 JDK 1.8及以后:数组+链表+红黑树

在JDK 1.8中,HashMap进行了重大改进,引入了红黑树来优化链表过长的情况:

graph TD A[数组] --> B[索引0: 链表->节点1->节点2] A --> C[索引1: 红黑树->节点] A --> D[索引2: null] A --> E[索引3: 链表->节点3->节点4->节点5] A --> F[...]

当链表长度超过一定阈值(默认为8)且数组长度达到一定条件时,链表会转换为红黑树,从而将查找时间复杂度从O(n)优化为O(log n)。

3.2.1 JDK 1.8的数据结构定义
java 复制代码
transient Node<K,V>[] table;

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
    // ... 其他方法
}

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    // ... 其他属性和方法
}

四、HashMap的关键参数

HashMap有几个重要的参数,理解它们有助于我们更好地使用HashMap:

4.1 核心常量

java 复制代码
// 默认初始容量为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 最大容量为2^30
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认负载因子为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 链表转红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;

// 红黑树转链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;

// 最小树化容量
static final int MIN_TREEIFY_CAPACITY = 64;

4.2 实例变量

java 复制代码
// 存储数据的数组
transient Node<K,V>[] table;

// 键值对的数量
transient int size;

// 扩容阈值
int threshold;

// 负载因子
final float loadFactor;

五、HashMap的工作原理

5.1 哈希函数设计

HashMap使用了一个特殊的哈希函数来计算键的哈希值:

java 复制代码
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这个函数的设计很有意思:

  1. 对于null键,直接返回0
  2. 对于非null键,先获取其hashCode,然后将hashCode的高16位与低16位进行异或运算

为什么要这样做呢?这是因为数组的长度通常是2的幂次方,这样在计算索引时只有低位参与运算,容易产生冲突。通过将高16位的信息混合到低16位,可以减少哈希冲突。

5.2 索引计算

得到哈希值后,需要计算在数组中的索引位置:

java 复制代码
i = (n - 1) & hash

这里使用了位运算而不是取模运算,因为当n是2的幂次方时,(n-1) & hash等价于hash % n,但位运算的效率更高。

5.3 put操作详解

put操作是HashMap最核心的操作之一,让我们来看看它是如何工作的:

graph TD A[put key,value] --> B{table是否为null?} B -->|是| C[resize初始化table] B -->|否| D[计算hash和index] D --> E{index位置是否为空?} E -->|是| F[直接插入新节点] E -->|否| G{key是否已存在?} G -->|是| H[更新value并返回旧值] G -->|否| I{当前结构是树还是链表?} I -->|树| J[按红黑树方式插入] I -->|链表| K[遍历链表] K --> L{链表长度是否>=8?} L -->|是| M{table长度是否>=64?} M -->|是| N[链表转红黑树] M -->|否| O[resize扩容] L -->|否| P[链表末尾插入] N --> Q[结束] O --> Q J --> Q P --> Q H --> Q F --> Q C --> D

对应的源码实现(简化版):

java 复制代码
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    
    // 如果table为null或者长度为0,则进行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    
    // 如果对应位置为空,直接插入新节点
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        
        // 如果第一个节点就是要找的key,记录下来
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果是红黑树节点
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 如果是链表节点
        else {
            for (int binCount = 0; ; ++binCount) {
                // 遍历到链表末尾,插入新节点
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 如果链表长度达到阈值,转换为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果找到了相同的key,记录下来
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 如果找到了相同的key,更新value
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    
    ++modCount;
    // 如果size超过阈值,进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

5.4 get操作详解

get操作相对简单一些:

graph TD A[get key] --> B{table是否为null或empty?} B -->|是| C[返回null] B -->|否| D[计算hash和index] D --> E{index位置是否为空?} E -->|是| F[返回null] E -->|否| G{第一个节点key是否匹配?} G -->|是| H[返回该节点value] G -->|否| I{节点是树还是链表?} I -->|树| J[按红黑树方式查找] I -->|链表| K[遍历链表查找] K --> L{找到匹配的key?} L -->|是| M[返回value] L -->|否| N[返回null] J --> O[返回结果] M --> O N --> O H --> O F --> O C --> O

对应的源码实现(简化版):

java 复制代码
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    
    // table不为空且对应位置不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        
        // 检查第一个节点
        if (first.hash == hash &&
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        
        // 遍历链表或红黑树
        if ((e = first.next) != null) {
            // 如果是红黑树节点
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            
            // 如果是链表节点
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

5.5 resize扩容机制

当HashMap中的元素数量超过阈值时,就需要进行扩容。扩容的过程包括:

  1. 创建一个容量为原来两倍的新数组
  2. 将原数组中的元素重新计算位置并放入新数组
graph TD A[原数组 容量:8] --> B[索引0: 节点A] A --> C[索引1: 节点B->节点C] A --> D[索引2: 节点D] A --> E[...] B --> F[扩容后数组 容量:16] C --> F D --> F F --> G[索引0: 节点A] F --> H[索引1: 节点B] F --> I[索引2: 节点D] F --> J[索引8: 节点C] F --> K[...]

JDK 1.8对扩容过程进行了优化,不需要重新计算每个元素的哈希值:

java 复制代码
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    
    if (oldCap > 0) {
        // 如果已经达到最大容量
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 扩容:容量和阈值都翻倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;
    }
    // ... 初始化等情况的处理
    
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    
    // 重新安排原数组中的元素
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // 如果只有一个节点,直接放置到新位置
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果是红黑树节点
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 如果是链表节点
                else {
                    // 将链表拆分为两个链表
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 关键优化:根据(e.hash & oldCap)是否为0决定节点位置
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        } else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    
                    // 放置低位链表
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 放置高位链表
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

这个优化的关键在于:对于节点在新数组中的位置,只需要判断(e.hash & oldCap)是否为0:

  • 如果为0,节点在新数组中的索引与原数组相同
  • 如果不为0,节点在新数组中的索引为原索引 + oldCap

六、HashMap的使用建议

6.1 合理设置初始容量

如果能够预估HashMap中将要存放的元素数量,建议设置合理的初始容量:

java 复制代码
// 不好的做法:频繁扩容
Map<String, String> map1 = new HashMap<>();
for (int i = 0; i < 10000; i++) {
    map1.put("key" + i, "value" + i);
}

// 好的做法:预设容量
int expectedSize = 10000;
int capacity = (int) Math.ceil(expectedSize / 0.75);
Map<String, String> map2 = new HashMap<>(capacity);
for (int i = 0; i < 10000; i++) {
    map2.put("key" + i, "value" + i);
}

6.2 选择合适的key类型

作为HashMap的key,应该满足以下条件:

  1. 不可变性:key对象应该是不可变的,避免哈希值发生变化
  2. 正确实现equals和hashCode方法:保证逻辑一致性
  3. 良好的hashCode实现:减少哈希冲突
java 复制代码
// 好的key实现示例
public final class PersonKey {
    private final String name;
    private final int age;
    
    public PersonKey(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        PersonKey personKey = (PersonKey) o;
        return age == personKey.age && Objects.equals(name, personKey.name);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

6.3 注意线程安全问题

HashMap不是线程安全的,在并发环境中使用可能会出现问题:

java 复制代码
// 线程安全的替代方案
// 1. 使用Collections.synchronizedMap
Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());

// 2. 使用Hashtable(性能较差)
Map<String, String> hashtable = new Hashtable<>();

// 3. 使用ConcurrentHashMap(推荐)
Map<String, String> concurrentMap = new ConcurrentHashMap<>();

6.4 常见问题和陷阱

  1. 循环遍历中修改HashMap
java 复制代码
// 错误示例 - 会抛出ConcurrentModificationException
Map<String, String> map = new HashMap<>();
map.put("a", "1");
map.put("b", "2");
for (Map.Entry<String, String> entry : map.entrySet()) {
    if ("a".equals(entry.getKey())) {
        map.remove(entry.getKey()); // 这里会出问题
    }
}

// 正确示例
Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<String, String> entry = iterator.next();
    if ("a".equals(entry.getKey())) {
        iterator.remove(); // 使用迭代器的remove方法
    }
}
  1. Key的hashCode和equals不一致
java 复制代码
// 错误示例 - Key类没有正确实现hashCode和equals
class BadKey {
    private String value;
    
    public BadKey(String value) {
        this.value = value;
    }
    
    // 没有重写hashCode和equals方法
}

// 正确示例 - Key类正确实现hashCode和equals
class GoodKey {
    private final String value;
    
    public GoodKey(String value) {
        this.value = value;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        GoodKey goodKey = (GoodKey) o;
        return Objects.equals(value, goodKey.value);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(value);
    }
}

七、总结

HashMap作为一个经典的数据结构,其设计体现了计算机科学中的很多重要思想:

  1. 空间换时间:通过数组实现O(1)的访问速度
  2. 适度冗余:默认负载因子0.75平衡了时间和空间
  3. 动态适应:链表与红黑树的动态转换应对不同场景
  4. 优化细节:扰动函数、位运算优化等细节提升性能
  5. 预防性设计:在性能下降前提前扩容

通过深入理解HashMap的设计思想和实现原理,不仅能帮助我们更好地使用它,还能学习到如何设计高效的数据结构和算法。

相关推荐
苍何6 分钟前
30分钟用 Agent 搓出一家跨境网店,疯了
后端
ssshooter20 分钟前
Tauri 2 iOS 开发避坑指南:文件保存、Dialog 和 Documents 目录的那些坑
前端·后端·ios
追逐时光者34 分钟前
一个基于 .NET Core + Vue3 构建的开源全栈平台 Admin 系统
后端·.net
程序员飞哥40 分钟前
90后大龄程序员失业4个月终于上岸了
后端·面试·程序员
zs宝来了1 小时前
Playwright 自动发布 CSDN 的完整实践
java
吴声子夜歌2 小时前
TypeScript——基础类型(三)
java·linux·typescript
GetcharZp3 小时前
Git 命令行太痛苦?这款 75k Star 的神级工具,让你告别“合并冲突”恐惧症!
后端
Victor3563 小时前
MongoDB(69)如何进行增量备份?
后端
Victor3563 小时前
MongoDB(70)如何使用副本集进行备份?
后端