深度解析HashMap工作原理

一、引言

在 Java 编程中,HashMap 是一个极为常用且重要的数据结构,它属于 Java 集合框架的一部分,用于存储键值对。HashMap 以其高效的查找、插入和删除操作而闻名,广泛应用于各种 Java 程序中。本文将全方位、深入地剖析 HashMap 的原理,包含底层数据结构、核心属性、构造方法、常用操作的实现细节,还会结合代码示例帮助读者更好地理解。

二、HashMap 概述

2.1 定义与用途

HashMapjava.util 包下的一个类,实现了 Map 接口。它允许存储 null 键和 null 值,并且不保证元素的顺序。HashMap 的主要用途是存储键值对,通过键可以快速查找对应的值,适用于需要快速查找和存储数据的场景,例如缓存、数据映射等。

2.2 继承关系与实现接口

HashMap 的继承关系如下:

plaintext 复制代码
java.lang.Object
    └─ java.util.AbstractMap<K,V>
        └─ java.util.HashMap<K,V>

它实现了 Map<K,V>Cloneablejava.io.Serializable 接口,具备克隆和序列化的能力。

java 复制代码
import java.util.HashMap;
import java.util.Map;

public class HashMapOverview {
    public static void main(String[] args) {
        // 创建一个 HashMap 对象
        HashMap<String, Integer> hashMap = new HashMap<>();
        // 可以将其赋值给 Map 接口类型的变量
        Map<String, Integer> map = hashMap;
    }
}

三、底层数据结构:哈希表(数组 + 链表 + 红黑树)

3.1 哈希表的基本概念

哈希表是一种根据键(Key)直接访问内存存储位置的数据结构。它通过哈希函数将键映射到一个固定大小的数组中的某个位置,这个位置称为桶(Bucket)。当多个键映射到同一个桶时,就会发生哈希冲突。

3.2 Java 8 之前的实现(数组 + 链表)

在 Java 8 之前,HashMap 的底层数据结构是数组 + 链表。数组中的每个元素是一个链表的头节点,当发生哈希冲突时,新的键值对会以链表的形式添加到对应桶的链表中。查找元素时,先通过哈希函数找到对应的桶,然后遍历链表找到目标键值对。

3.3 Java 8 及之后的实现(数组 + 链表 + 红黑树)

Java 8 对 HashMap 进行了优化,当链表长度超过 8 且数组长度大于 64 时,链表会转换为红黑树。红黑树是一种自平衡的二叉搜索树,它的查找、插入和删除操作的时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( l o g n ) O(log n) </math>O(logn),可以提高在链表较长时的查找效率。当树中的节点数小于 6 时,红黑树会转换回链表。

3.4 节点结构

HashMap 中的节点有三种类型:NodeTreeNodeLinkedHashMap.Entry(用于 LinkedHashMap 扩展)。Node 是普通的链表节点,TreeNode 是红黑树节点。

java 复制代码
// 普通链表节点
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;
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
    // 其他方法省略
}

四、核心属性

HashMap 有几个重要的核心属性,这些属性控制着 HashMap 的行为和性能:

java 复制代码
// 存储数据的数组
transient Node<K,V>[] table;
// 键值对的数量
transient int size;
// 扩容阈值,当 size 达到 threshold 时,会进行扩容
int threshold;
// 负载因子,默认为 0.75
final float loadFactor;
// 修改次数,用于快速失败机制
transient int modCount;
  • table:存储键值对的数组,数组的每个元素是一个链表或红黑树的头节点。
  • size:表示 HashMap 中键值对的数量。
  • threshold:扩容阈值,计算公式为 threshold = capacity * loadFactor,当 size 超过 threshold 时,会进行扩容操作。
  • loadFactor:负载因子,默认值为 0.75,它决定了哈希表在多满时进行扩容。
  • modCount:记录 HashMap 的修改次数,用于快速失败机制,当在迭代过程中检测到 modCount 发生变化时,会抛出 ConcurrentModificationException

五、构造方法

5.1 无参构造方法

java 复制代码
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

无参构造方法将负载因子设置为默认值 0.75,初始容量会在第一次插入元素时根据默认值 16 进行初始化。

5.2 指定初始容量的构造方法

java 复制代码
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

该构造方法允许指定 HashMap 的初始容量,负载因子使用默认值 0.75。

5.3 指定初始容量和负载因子的构造方法

java 复制代码
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

此构造方法允许同时指定初始容量和负载因子。tableSizeFor 方法会将初始容量调整为大于等于该值的最小的 2 的幂次方。

5.4 从其他 Map 创建 HashMap 的构造方法

java 复制代码
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

该构造方法接受一个 Map 对象作为参数,将该 Map 中的所有键值对添加到新创建的 HashMap 中。

java 复制代码
import java.util.HashMap;
import java.util.Map;

public class HashMapConstructors {
    public static void main(String[] args) {
        // 无参构造方法
        HashMap<String, Integer> hashMap1 = new HashMap<>();

        // 指定初始容量的构造方法
        HashMap<String, Integer> hashMap2 = new HashMap<>(20);

        // 指定初始容量和负载因子的构造方法
        HashMap<String, Integer> hashMap3 = new HashMap<>(15, 0.8f);

        // 从其他 Map 创建 HashMap 的构造方法
        Map<String, Integer> anotherMap = new HashMap<>();
        anotherMap.put("apple", 1);
        anotherMap.put("banana", 2);
        HashMap<String, Integer> hashMap4 = new HashMap<>(anotherMap);
        System.out.println(hashMap4);
    }
}

六、常用操作原理

6.1 插入元素(put 方法)

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;
    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;
        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) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

put 方法的主要步骤如下:

  1. 计算键的哈希值:通过 hash(key) 方法计算键的哈希值。
  2. 检查数组是否为空:如果数组为空,则调用 resize() 方法进行初始化。
  3. 计算桶的索引:通过 (n - 1) & hash 计算键值对应该存储的桶的索引。
  4. 检查桶是否为空:如果桶为空,则直接创建一个新的节点放入桶中。
  5. 处理哈希冲突:如果桶不为空,检查第一个节点的键是否与要插入的键相同,如果相同则更新值;如果是红黑树节点,则调用红黑树的插入方法;如果是链表节点,则遍历链表,找到相同的键则更新值,否则在链表尾部插入新节点。如果链表长度超过 8,则将链表转换为红黑树。
  6. 检查是否需要扩容:插入新节点后,如果键值对的数量超过阈值,则调用 resize() 方法进行扩容。

6.2 获取元素(get 方法)

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;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((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;
}

get 方法的主要步骤如下:

  1. 计算键的哈希值:通过 hash(key) 方法计算键的哈希值。
  2. 检查数组和桶是否为空:如果数组为空或对应的桶为空,则返回 null
  3. 检查第一个节点:如果第一个节点的键与要查找的键相同,则返回该节点的值。
  4. 处理链表或红黑树:如果第一个节点不是目标节点,判断是链表还是红黑树。如果是红黑树,则调用红黑树的查找方法;如果是链表,则遍历链表查找目标节点。

6.3 删除元素(remove 方法)

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

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

remove 方法的主要步骤如下:

  1. 计算键的哈希值:通过 hash(key) 方法计算键的哈希值。
  2. 检查数组和桶是否为空:如果数组为空或对应的桶为空,则返回 null
  3. 查找目标节点:如果第一个节点是目标节点,则直接记录;如果不是,则判断是链表还是红黑树,分别进行查找。
  4. 删除节点:如果找到目标节点,根据节点类型(链表节点或红黑树节点)进行删除操作。如果是红黑树节点,调用红黑树的删除方法;如果是链表节点,将其从链表中移除。
  5. 更新修改次数和大小:删除节点后,更新 modCountsize

6.4 扩容操作(resize 方法)

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; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    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 { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        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;
}

resize 方法的主要步骤如下:

  1. 计算新的容量和阈值:如果旧容量大于 0,则将容量和阈值翻倍;如果旧容量为 0 且旧阈值大于 0,则将旧阈值作为新容量;如果旧容量和旧阈值都为 0,则使用默认的初始容量和阈值。
  2. 创建新的数组:根据新的容量创建一个新的数组。
  3. 迁移元素:遍历旧数组,将每个桶中的元素迁移到新数组中。如果是单个节点,直接计算新的桶索引并放入新数组;如果是红黑树节点,调用红黑树的拆分方法;如果是链表节点,将链表拆分为两个链表,分别放入新数组的不同位置。
java 复制代码
import java.util.HashMap;
import java.util.Map;

public class HashMapOperations {
    public static void main(String[] args) {
        HashMap<String, Integer> hashMap = new HashMap<>();

        // 插入元素
        hashMap.put("apple", 1);
        hashMap.put("banana", 2);
        hashMap.put("cherry", 3);

        // 获取元素
        Integer value = hashMap.get("banana");
        System.out.println("Value of banana: " + value);

        // 删除元素
        hashMap.remove("cherry");
        System.out.println("After removing cherry: " + hashMap);

        // 扩容操作会在元素数量达到阈值时自动触发
    }
}

七、哈希函数与哈希冲突处理

7.1 哈希函数

HashMap 中的哈希函数用于计算键的哈希值,其实现如下:

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

该哈希函数将键的 hashCode 值的高 16 位与低 16 位进行异或运算,目的是让哈希值的高位也参与到桶索引的计算中,减少哈希冲突的概率。

7.2 哈希冲突处理

HashMap 使用链地址法来处理哈希冲突,即当多个键映射到同一个桶时,将这些键值对以链表或红黑树的形式存储在该桶中。当链表长度超过 8 且数组长度大于 64 时,链表会转换为红黑树,以提高查找效率。

八、性能分析

8.1 时间复杂度

  • 插入操作:平均情况下,插入操作的时间复杂度为 O(1)。因为哈希表可以通过哈希函数快速定位到桶的位置,在没有哈希冲突的情况下,插入操作可以在常数时间内完成。但在极端情况下,当所有元素都映射到同一个桶时,插入操作的时间复杂度会退化为 O(n)。
  • 查找操作:平均情况下,查找操作的时间复杂度为 O(1)。同样,哈希表可以通过哈希函数快速定位到桶的位置,然后在桶中查找元素。
  • 删除操作:平均情况下,删除操作的时间复杂度为 O(1)。通过哈希函数定位到桶的位置,然后在桶中删除元素。

8.2 空间复杂度

HashMap 的空间复杂度为 O(n),主要用于存储数组、链表和红黑树节点。

九、注意事项

9.1 键的哈希码和相等性

HashMap 判断键是否相等是基于键的 hashCode()equals() 方法。因此,存储在 HashMap 中的键必须正确重写这两个方法,否则可能会导致键值对的存储和查找出现问题。

9.2 线程安全问题

HashMap 不是线程安全的。如果在多线程环境下需要使用线程安全的 Map,可以考虑使用 ConcurrentHashMap 或使用 Collections.synchronizedMap() 方法将 HashMap 包装成线程安全的 Map

java 复制代码
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

public class HashMapThreadSafety {
    public static void main(String[] args) {
        HashMap<String, Integer> hashMap = new HashMap<>();
        Map<String, Integer> synchronizedMap = Collections.synchronizedMap(hashMap);
    }
}

9.3 初始容量和负载因子的选择

初始容量和负载因子会影响 HashMap 的性能。如果初始容量设置过小,会导致频繁的扩容操作,影响性能;如果负载因子设置过大,会增加哈希冲突的概率,降低查找效率。一般情况下,使用默认的初始容量和负载因子即可,但在某些场景下,可以根据实际情况进行调整。

十、总结

HashMap 是 Java 中一个非常重要且强大的数据结构,它通过哈希表(数组 + 链表 + 红黑树)实现了高效的键值对存储和查找。在插入、查找和删除操作上,平均时间复杂度为 O(1)。通过合理设计哈希函数和处理哈希冲突,HashMap 能够在大多数情况下提供良好的性能。但在使用时,需要注意键的哈希码和相等性的重写、线程安全问题以及初始容量和负载因子的选择。深入理解 HashMap 的原理和性能特点,有助于我们在实际开发中更好地利用它来解决各种问题。

相关推荐
嘵奇11 分钟前
Java单例模式:实现全局唯一对象的艺术
java·开发语言·单例模式
_一条咸鱼_33 分钟前
深入解析 Vue API 模块原理:从基础到源码的全方位探究(八)
前端·javascript·面试
eternal__day36 分钟前
第二期:[特殊字符] 深入理解MyBatis[特殊字符]MyBatis基础CRUD操作详解[特殊字符]
java·spring·java-ee·maven·mybatis
匹马夕阳1 小时前
(二十五)安卓开发一个完整的登录页面-支持密码登录和手机验证码登录
android·智能手机
Tiger_shl1 小时前
【Python语言基础】19、垃圾回收
java·python
无问8171 小时前
Lombok库
java·lombok
吃饭了呀呀呀1 小时前
🐳 深度解析:Android 下拉选择控件优化方案——NiceSpinner 实践指南
android·java
keep one's resolveY1 小时前
pgsql:关联查询union(并集)、except(差集)、intersect(交集)
java
图南随笔2 小时前
Spring Boot(二十一):RedisTemplate的String和Hash类型操作
java·spring boot·redis·后端·缓存
吃饭了呀呀呀2 小时前
🐳 《Android》 安卓开发教程 - 三级地区联动
android·java·后端