Java HashMap 详解

一、HashMap 简介

HashMap 是 Java 集合框架中最常用的 Map 实现类之一,基于哈希表实现,用于存储键值对(Key-Value)。它提供了快速的查找、插入和删除操作,是许多 Java 应用程序的核心数据结构之一。

二、HashMap 核心特性

基本特性:

  • 基于哈希表:数组 + 链表 + 红黑树(JDK 1.8+),通过哈希函数将键映射到存储位置。
  • 键唯一性:不允许重复的键。
  • 无序性:不保证元素的顺序。
  • 允许 null:允许一个 null 键和多个 null 值。
  • 非线程安全:多线程环境下需要外部同步。
  • 快速访问:理想情况下 O(1) 时间复杂度。

性能参数:

  • 初始容量:默认 16
  • 负载因子:默认 0.75
  • 扩容阈值:容量 × 负载因子
  • 树化阈值:链表长度达到 8 时转为红黑树
  • 链化阈值:树节点减少到 6 时转回链表

三、HashMap 内部实现原理

3.1 数据结构演进

JDK 1.7 及之前:数组 + 链表

// 简化版内部结构
transient Entry<K,V>[] table;

static class Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
}

JDK 1.8 及之后:数组 + 链表 + 红黑树
在这里插入图片描述

// 简化版内部结构
transient Node<K,V>[] table;

// 链表节点
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;  // 键的哈希值(经过扰动计算后的)
    final K key;    // 键,final 修饰保证不可变
    V value;		// 值,可以修改
    Node<K,V> next; // 指向下一个节点的引用,形成链表
}

// 树节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;    	// 红黑树父节点
    TreeNode<K,V> left;			// 左子节点
    TreeNode<K,V> right;		// 右子节点
    TreeNode<K,V> prev;			// 前驱节点(用于双向链表)
    boolean red;				// 节点颜色(true=红色,false=黑色)
}

从JDK 1.8 及之后其底层数据结构主要包括以下几个部分:

  1. 哈希表
    • 使用一个数组(Node<K,V>[] table)作为哈希表,数组的每个元素称为桶(Bucket)。
    • 每个桶存储一个链表或红黑树,用于解决哈希冲突。
  2. Node 节点
    • 链表节点:当哈希表中某个桶(bucket)中的元素数量较少时(在JDK 1.8中,当链表长度小于8时),HashMap会使用链表来存储该桶中的元素。
    • 树节点:当同一个桶中的元素数量达到一定阈值(在JDK 1.8中,当链表长度大于等于8且桶数组容量大于等于64时),链表会转换为红黑树,以提高查询效率。

3.2 Node 和 TreeNode 的核心作用

  1. Node(链表节点):
    • 处理少量哈希冲突
    • 内存占用小,插入快
    • 查找性能随长度线性下降
  2. TreeNode(红黑树节点):
    • 处理大量哈希冲突
    • 保证最坏情况下 O(log n) 的性能
    • 内存占用大,但查询稳定

3.3 哈希冲突

哈希冲突(Hash Collision)是指两个或更多不同的键(Key)经过哈希函数计算后,得到了相同的哈希值,从而映射到 HashMap 数组的同一个索引位置。

  1. 哈希冲突产生的原因
    • 不同键产生相同哈希值。
    • 不同哈希值映射到相同索引。
  2. 解决哈希冲突的方法
    • 链地址法:HashMap 使用链地址法来解决冲突。每个数组元素是一个链表(或树)的头节点。当发生冲突时,新的键值对会被添加到链表中。在 JDK 1.7 及之前,新节点是插入到链表的头部(头插法)。在 JDK 1.8 及之后,改为插入到链表的尾部(尾插法)。
    • 红黑树:在 JDK 1.8 中,当链表的长度超过 TREEIFY_THRESHOLD(默认为8)并且数组的长度达到 MIN_TREEIFY_CAPACITY(默认为64)时,链表会转换为红黑树。这样可以将最坏情况下的查找时间从 O(n) 降低到 O(log n)。当红黑树的节点数减少到 UNTREEIFY_THRESHOLD(默认为6)时,红黑树会退化为链表。
  3. 哈希冲突的影响
    • 哈希冲突会影响 HashMap 的性能。如果冲突较多,会导致链表过长或树化,从而增加查找时间。理想情况下,哈希函数应该将键均匀地分布到数组中,使得每个索引位置上的元素尽可能少。
  4. 减少哈希冲突的方法
    • 良好的哈希函数设计:键的 hashCode() 方法应该尽可能分布均匀。
    • 使用不可变对象作为键:避免键状态变化导致的哈希值改变。
    • 合适的初始容量和负载因子:根据预估的键值对数量设置初始容量和负载因子,减少扩容次数,从而减少冲突。

3.4 扩容机制

HashMap的扩容机制是为了保证其高效性。通过双倍扩容和重新哈希,使得元素分布更加均匀,减少哈希冲突。在Java 8中,通过链表拆分和红黑树的处理,进一步优化了扩容性能。

3.4.1 扩容的核心参数

public class HashMap<K,V> extends AbstractMap<K,V> {
    // 核心参数
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16 - 默认初始容量
    static final int MAXIMUM_CAPACITY = 1 << 30;        // 最大容量
    static final float DEFAULT_LOAD_FACTOR = 0.75f;     // 默认负载因子
    
    // 实例变量
    transient Node<K,V>[] table;    // 哈希桶数组
    int threshold;                  // 扩容阈值 = capacity * loadFactor
    final float loadFactor;         // 负载因子
    transient int modCount;         // 结构修改次数
    transient int size;             // 键值对数量
}

3.4.2 扩容的触发条件

HashMap扩容触发条件主要有以下几条:

  1. 首次添加元素时(table == null)
  2. 元素数量 > 容量 × 负载因子(size > threshold)
  3. 链表长度 ≥ 8,但数组长度 < 64(优先扩容而不是树化)
public class HashMapResizeCondition {
    public void checkResizeConditions() {
        HashMap<String, String> map = new HashMap<>();
        
        // 条件1:首次put时触发初始化扩容
        map.put("key1", "value1");  // 触发第一次resize()
        
        // 条件2:元素数量超过阈值
        for (int i = 2; i <= 13; i++) {
            map.put("key" + i, "value" + i);
        }
        // 默认容量16,负载因子0.75,阈值12
        // 当put第13个元素时:size=13 > threshold=12 → 触发扩容
        
        // 条件3:链表过长但数组较小
        // 当链表长度≥8且数组长度<64时,优先扩容而不是转换为红黑树
    }
}

3.4.3 扩容核心方法 - resize()

final Node<K,V>[] resize() {
    // 步骤1:计算新容量和新阈值
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    
    // 情况1:已经初始化过的HashMap
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;  // 已达最大容量,不再扩容
        }
        // 新容量 = 旧容量 × 2
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 
                 oldCap >= DEFAULT_INITIAL_CAPACITY) {
            newThr = oldThr << 1; // 新阈值 = 旧阈值 × 2
        }
    }
    // 情况2:使用指定容量构造的HashMap(第一次扩容)
    else if (oldThr > 0) {
        newCap = oldThr;
    }
    // 情况3:使用默认构造的HashMap(第一次扩容)
    else {
        newCap = DEFAULT_INITIAL_CAPACITY;  // 16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
    }
    
    // 计算新的阈值
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    // 步骤2:创建新数组
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    
    // 步骤3:迁移数据(如果旧表不为空)
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;  // 帮助GC
                
                // 情况1:该位置只有一个元素
                if (e.next == null) {
                    newTab[e.hash & (newCap - 1)] = e;
                }
                // 情况2:该位置是红黑树
                else if (e instanceof TreeNode) {
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                }
                // 情况3:该位置是链表
                else {
                    // 链表拆分优化(Java 8 的重要优化)
                    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 {
                            // 移动到新位置:原索引 + oldCap
                            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;
}

3.4.4 扩容的基本步骤

扩容主要包括以下步骤:

  • 创建一个新的数组(桶数组),容量为原数组的两倍。
  • 重新计算每个元素在新数组中的位置,并将它们转移到新数组中。

扩容的详细过程:

  1. 计算新容量和新阈值
    • 新容量 = 旧容量 * 2
    • 新阈值 = 新容量 * 负载因子
  2. 转移元素(重新哈希)
    • 遍历旧数组的每个桶,对于每个桶中的每个元素(可能是单个节点、链表或红黑树),重新计算其在新数组中的索引位置,并将其放置到新数组对应的桶中。

3.4.5 扩容中的优化

  1. 链表拆分优化:在Java 8中,扩容时对链表进行了优化。通过(e.hash & oldCap)的值(0或非0)将链表拆分成两个链表,其中一个链表放在新数组的原来索引位置,另一个放在原来索引+旧容量的位置。这样做的原因是:由于容量是2的幂,扩容后元素的位置变化只取决于新增的最高位是0还是1。
public class ResizeOptimization {
    /**
     * Java 7 与 Java 8 扩容对比:
     * 
     * Java 7: 重新计算每个元素的hash,然后模新容量
     * Java 8: 利用位运算快速判断元素位置
     */
    
    public void explainOptimization() {
        // 假设旧容量 = 16 (二进制: 10000)
        int oldCap = 16;
        
        // 新容量 = 32 (二进制: 100000)
        int newCap = 32;
        
        // 关键技巧:判断 (hash & oldCap) == 0
        // 因为容量总是2的幂,所以扩容时只需要检查hash值在新增的bit位上是0还是1
        
        // 示例:
        int hash1 = 5;   // 二进制: 00101
        int hash2 = 21;  // 二进制: 10101
        
        System.out.println("hash1 & oldCap: " + (hash1 & oldCap)); // 0 → 留在原位置
        System.out.println("hash2 & oldCap: " + (hash2 & oldCap)); // 16 → 移动到新位置
        
        // 原位置计算:
        int index1_old = hash1 & (oldCap - 1);  // 5 & 15 = 5
        int index2_old = hash2 & (oldCap - 1);  // 21 & 15 = 5
        
        // 新位置计算:
        int index1_new = hash1 & (newCap - 1);  // 5 & 31 = 5 (不变)
        int index2_new = hash2 & (newCap - 1);  // 21 & 31 = 21 = 5 + 16
    }
}
  1. 红黑树处理:如果桶中的元素是红黑树,那么会调用红黑树的拆分方法。在拆分过程中,如果拆分后的链表长度小于等于6,则将红黑树转换为链表。
public class TreeResize {
    /**
     * 红黑树在扩容时的处理
     */
    final void split(HashMap<K,V> map, Node<K,V>[] newTab, int index, int bit) {
        TreeNode<K,V> loHead = null, loTail = null;
        TreeNode<K,V> hiHead = null, hiTail = null;
        int lc = 0, hc = 0;
        
        // 遍历树节点,按 (e.hash & bit) 拆分为两个链表
        for (TreeNode<K,V> e = this; e != null; e = e.next) {
            if ((e.hash & bit) == 0) {
                // 连接到lo链表
                if ((e.prev = loTail) == null)
                    loHead = e;
                else
                    loTail.next = e;
                loTail = e;
                ++lc;
            } else {
                // 连接到hi链表
                if ((e.prev = hiTail) == null)
                    hiHead = e;
                else
                    hiTail.next = e;
                hiTail = e;
                ++hc;
            }
        }
        
        // 将链表转换为树或保持为链表
        if (loHead != null) {
            if (lc <= UNTREEIFY_THRESHOLD)  // 6
                newTab[index] = loHead.untreeify(map);
            else {
                newTab[index] = loHead;
                if (hiHead != null) // 确保重新树化
                    loHead.treeify(newTab);
            }
        }
        if (hiHead != null) {
            if (hc <= UNTREEIFY_THRESHOLD)
                newTab[index + bit] = hiHead.untreeify(map);
            else {
                newTab[index + bit] = hiHead;
                if (loHead != null)
                    hiHead.treeify(newTab);
            }
        }
    }
}

3.4.6 扩容策略总结

版本扩容策略优点缺点
Java 7头插法,重新hash所有元素实现简单多线程下可能产生死循环
Java 8尾插法,位运算优化定位避免死循环,性能更好实现相对复杂

四、HashMap常见问题

4.1 线程安全问题

  • 问题:HashMap 不是线程安全的,在多线程环境下同时进行 put 操作可能导致数据不一致、死循环或元素丢失。
    • 数据不一致:因为多个线程同时修改同一个HashMap,可能导致一个线程的修改被另一个线程覆盖,或者导致内部状态错误。
    • 死循环:在JDK1.7及之前,HashMap在扩容时采用头插法转移节点,多线程同时扩容可能导致环形链表的产生,进而导致死循环。
    • 元素丢失:多个线程同时put时,可能会因为覆盖而导致元素丢失。
  • 解决方案:
    • 使用 Collections.synchronizedMap 方法将 HashMap 包装成线程安全的 Map。
    • 使用 ConcurrentHashMap,它提供了更好的并发性能。

4.2 哈希冲突导致的性能退化

  • 问题:如果多个键的哈希值相同,它们会被存储在同一个桶中,如果冲突严重,会导致链表过长(在 Java 8 之前)或红黑树退化(在 Java 8 及以后),从而降低查询性能。
  • 解决方案:
    • 确保键的哈希函数分布均匀。对于自定义对象,重写 hashCode() 方法以产生分布均匀的哈希值。
    • 在 Java 8 中,当链表长度超过 8 且数组长度大于等于 64 时,将链表转换为红黑树,以提高查询效率。

4.3 扩容导致的性能开销

  • 问题:当 HashMap 中的元素数量达到阈值(容量 * 负载因子)时,会进行扩容,即创建一个新的数组并重新哈希所有元素,这个过程比较耗时。
  • 解决方案:
    • 在创建 HashMap 时指定初始容量和负载因子,以减少扩容次数。例如,如果预计要存储 1000 个元素,负载因子为 0.75,则初始容量可以设置为 1000 / 0.75 = 1333,然后取最近的 2 的幂次(2048)。

4.4 内存占用问题

  • 问题:HashMap 可能会占用比实际存储元素所需更多的内存,因为数组的大小总是 2 的幂次,而且负载因子默认是 0.75,意味着有 25% 的空间是浪费的。
  • 解决方案:根据实际需要调整负载因子。如果内存紧张,可以适当增加负载因子(如 0.9),但这会增加哈希冲突的概率;如果追求性能,可以适当降低负载因子(如 0.5)。

4.5 键的不可变性

  • 问题:如果使用可变对象作为键,并且键的哈希值在存入 HashMap 后发生变化,那么将无法再通过该键获取值,因为哈希值变化后,键可能被映射到不同的桶中。
  • 解决方案:使用不可变对象(如 String、Integer)作为键,或者确保键对象在存入 HashMap 后不会改变其哈希值相关的字段。

4.6 遍历时修改异常

  • 问题:在遍历 HashMap 时,如果直接通过 HashMap 的方法修改结构(如添加或删除元素),会抛出 ConcurrentModificationException。
  • 解决方案:
    • 使用迭代器的 remove 方法进行删除。
    • 或者使用 ConcurrentHashMap 来避免这个问题。

4.7 空键和空值

  • 问题:HashMap 允许一个空键和多个空值,但在某些情况下,这可能会导致异常(例如,如果键的 hashCode() 方法在空键时抛出异常)。
  • 解决方案:确保键的 hashCode() 和 equals() 方法能够处理空值,或者避免使用空键。

4.8 顺序不确定性

  • 问题:HashMap 不保证元素的顺序,即使在同一环境中,多次运行顺序也可能不同(因为哈希种子随机化)。如果需要有序,HashMap 不适用。
  • 解决方案:如果需要插入顺序,使用 LinkedHashMap;如果需要自然顺序或自定义顺序,使用 TreeMap。

4.9 Java 8 中的树化阈值

  • 问题:在 Java 8 中,当链表长度超过 8 且数组长度大于等于 64 时,链表会转换为红黑树。但在某些情况下,如果哈希函数分布不均匀,会导致红黑树过深,影响性能。
  • 解决方案:同样需要优化哈希函数,减少冲突。

4.10 HashMap 如何保证 Key 的唯一性

HashMap 通过一套精密的机制来保证 key 的唯一性,主要依赖于 hashCode() 和 equals() 方法的配合使用。HashMap 保证 key 唯一性的核心流程如下:

  1. 计算key的哈希值(通过key的hashCode()方法),然后通过哈希函数(通常是对数组长度取模)得到在数组中的索引位置。
  2. 如果该位置为空,则直接插入。
  3. 如果该位置不为空(即发生了哈希冲突),则遍历该位置上的链表或红黑树,使用key的equals()方法依次比较每个已存在节点的key。
    • 如果找到相等的key(即equals返回true),则用新的value覆盖旧的value。
    • 如果没有找到,则将新的键值对插入到链表或红黑树中。
  • hashCode():决定key在哪个桶中。
  • equals():决定是否与桶中存在的节点是相同的key。

4.11 为什么在重写equals方法时,必须同时重写hashCode方法

在Java的Object类中定义,并且所有Java对象都遵循这个契约:

  1. 如果两个对象根据 equals() 方法是相等的,那么调用这两个对象的 hashCode()必须返回相同的整数结果。
  2. 如果两个对象根据 equals() 方法是不相等的,不要求 hashCode() 返回不同的值。但是,为不相等的对象生成不同的哈希值可以提高哈希表的性能。

当我们将对象放入哈希表(如HashMap)时,哈希表使用对象的hashCode值来确定对象在表中的位置(桶)。当从哈希表中查找对象时,首先通过hashCode定位到桶,然后使用equals方法在桶中查找正确的对象。

如果只重写equals而不重写hashCode,那么两个相等的对象(根据equals方法)可能具有不同的hashCode值,这会导致它们被放入哈希表的不同桶中。这样,当我们尝试使用其中一个对象作为键来查找时,可能无法找到另一个相等的对象,因为哈希表会在不同的桶中查找。

举个例子:
假设我们有一个类Person,我们只重写了equals方法,认为两个Person对象的id相等则他们相等,但没有重写hashCode方法。

那么,两个id相同的Person对象,equals返回true,但是它们的hashCode(继承自Object的默认实现)可能不同。

现在,我们将其中一个Person对象放入HashMap中,然后尝试用另一个相等的Person对象来获取,由于hashCode不同,HashMap会在不同的桶中查找,因此找不到之前放入的对象。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值