【Java第59集】java HashMap底层实现原理详解

HashMap 是 Java 中最常用的数据结构之一,其底层实现原理涉及 数组、链表、红黑树 的组合结构,以及 哈希函数、动态扩容、冲突解决 等核心机制。以下是详细的底层实现原理解析:

一、核心数据结构

在这里插入图片描述

1. 数组(哈希桶数组)

  • 基础结构:HashMap 的底层是一个 数组(Node<K,V>[] table,数组中的每个元素称为一个 桶(bucket)
  • 特点
    • 数组的初始容量默认为 16(必须是 2 的幂次方),可通过构造方法指定。
    • 每个桶存储一个 Node 对象,Node 包含键值对(key, value)、哈希值(hash)和指向下一个节点的指针(next)。

2. 链表

  • 链表:当多个键的哈希值映射到同一个桶时,这些键值对以链表形式存储。

3. 红黑树

  • 红黑树:在 JDK 8 中,当链表长度超过阈值(默认 8)且数组长度 ≥ 64 时,链表会转换为 红黑树(自平衡二叉搜索树),以降低查找时间复杂度(从 O ( n ) O(n) O(n) 降至 O ( log ⁡ n ) O(\log n) O(logn))。
  • 红黑树引入原因
    • 问题:JDK 7 及之前版本中,当同一个hash值下不断的插入数据,使链表在不断的扩长,而链表过长会导致查询效率退化为 O ( n ) O(n) O(n)
    • 解决方案:JDK 8 引入红黑树,将最坏情况下的查找复杂度从 O ( n ) O(n) O(n) 降低到 O ( log ⁡ n ) O(\log n) O(logn)

二、哈希函数与索引计算

1. 哈希值计算

  • 目标:通过键的哈希值确定其在数组中的存储位置。
  • 实现
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    • 高位异或:将键的 hashCode() 的高 16 位与低 16 位进行异或,减少哈希冲突。
    • null 键处理null 键的哈希值固定为 0,始终存储在数组索引 0 的位置。

2. 索引计算

  • 公式index = (n - 1) & hashn 为数组长度,必须是 2 的幂次方)。
  • 原理
    • (n - 1) 的二进制全为 1(如 n=16n-1=15 的二进制为 1111)。
    • & 运算等价于取模运算(hash % n),但位运算效率更高。

三、哈希冲突与冲突解决

1. 哈希冲突

  • 定义:不同键的哈希值经过索引计算后映射到同一个数组位置。
  • 原因
    • 哈希函数本身可能产生冲突。
    • 数组长度有限,导致哈希值分布受限。

2. 哈希冲突解决方法:链地址法(拉链法)

  • 链表:冲突的键值对以链表形式存储在同一个桶中。
  • 红黑树:当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转换为红黑树。
  • 退化条件:当红黑树节点数 ≤ 6 时,退化回链表。

四、动态扩容机制

1. 扩容触发条件

  • 负载因子(Load Factor):默认值为 0.75,表示数组填充度达到 75% 时触发扩容。
  • 阈值(Threshold)threshold = capacity * loadFactor
    • size ≥ threshold 时,执行 resize() 扩容。

2. 扩容过程

  • 新容量newCap = oldCap << 1(容量翻倍)。
  • 数据迁移
    • 重新计算每个键值对的索引位置。
    • 位运算优化:新索引只能是原索引或 原索引 + 原容量
      if ((e.hash & oldCap) == 0)
          // 保持原位置
      else
          // 移动到新位置:原位置 + oldCap
      
  • 链表/红黑树拆分:迁移时,链表或红黑树会被拆分成两部分,分别插入新数组的两个位置。

3. 扩容目的

  • 降低冲突概率:增加数组长度,减少哈希冲突。
  • 提升性能:避免链表过长导致查询效率下降。

五、HashMap主要过程解析

前面我们已经了解了HashMap的底层基础知识,现在我们来详细破解HashMap的主要操作过程。

1. new HashMap() 创建

  • 构造一个具有默认初始容量(16)和默认加载因子(0.75)的空HashMap

    • 在Java 8中,在调用new HashMap()的时候并没有分配数组堆内存,只是做了一些参数校验,初始化了一些常量,而是在put操作的时候才真正构建table数组。
    • tableSizeFor的作用是找到大于cap的最小的2的整数幂,如果没有超过MAXIMUM_CAPACITY,tableSizeFor最后会返回一个2的正整数次幂,因此tableSizeFor的作用就是保证返回一个比入参大的最小的2的正整数次幂。
    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);
    }
     
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    
    • 在Java 7中初始化的代码大体一致,在HashMap第一次put的时候会调用inflateTable计算桶数组的长度,但其算法并没有变:
    // 第一次put时,初始化table
    private void inflateTable(int toSize) {
        // Find an power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);
        threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry(capacity);
        initHashSeedAsNeeded(capacity);
    }
    

2. put() 插入元素

在这里插入图片描述

在Java 8中put这个方法的思路分为以下几步:

  1. 判断table是否为空:如果table为空,HashMap会进行初始化操作,将容量扩充为默认大小16。

  2. 计算hash值和索引位置:通过key的hashCode值经过扰动函数处理后,再通过(n - 1) & hash计算出该元素存放的数组下标index。

  3. 检查是否有哈希冲突:检查table[index]处是否已经有节点。

    • 如果没有节点,直接构造一个新的Node节点放入table[index]处;
    • 如果已经有节点,说明发生了哈希冲突,进入下一步判断。
  4. 哈希冲突处理:在处理哈希冲突时,则使用 equals() 比较链表上是否存在 key 相同的节点。

    • 若现有节点的key与新节点的key相同,就会用新的value覆盖原有值。

    • 如果不相同,检查现有节点类型,如果是链表节点,则将新节点添加到链表尾端;如果链表长度超过阈值8且数组长度大于64,则调用 treeifyBin() 将链表节点转为红黑树节点,将新节点挂到红黑树上。

  5. 判断是否需要扩容:当插入完成后,判断HashMap总容量是否超过阈值threshold,即当 size ≥ threshold 时,则调用 resize() 方法进行扩容,扩容后数组的长度变成原来的2倍。

参考Java 8具体代码以及注释如下:

public V put(K key, V value) {
    // 调用上文我们已经分析过的hash方法
    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)
        // 第一次put时,会调用resize进行桶数组初始化
        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;
}

相比之下Java 7中的put方法:

public V put(K key, V value) {
    // 如果 key 为 null,调用 putForNullKey 方法进行处理  
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key.hashCode());
    int i = indexFor(hash, table.length);
    for (Entry<K, V> e = table[i]; e != null; e = e.next) {
        Object k;  
        if (e.hash == hash && ((k = e.key) == key
                || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(hash, key, value, i);
    return null;
}
 
void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K, V> e = table[bucketIndex];     // ①  
    table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
    if (size++ >= threshold)
        resize(2 * table.length);    // ②  
}

小知识:HashMap允许putkey为null的键值对,但是这样的键值对都放到了桶数组的第0个桶中。

3. resize() 自动扩容

resize()是整个HashMap中最复杂的一个模块,如果在put数据之后超过了threshold的值,则需要扩容,扩容意味着桶数组大小变化,我们在前文中分析过,HashMap寻址是通过index =(table.length - 1) & key.hash();来计算的,现在table.length发生了变化,势必会导致部分key的位置也发生了变化,HashMap是如何设计的呢?

这里就涉及到桶数组长度为2的正整数幂的第二个优势了:当桶数组长度为2的正整数幂时,如果桶发生扩容(长度翻倍),则桶中的元素大概只有一半需要切换到新的桶中,另一半留在原先的桶中就可以,并且这个概率可以看做是均等的。

在这里插入图片描述
通过这个分析可以看到如果在即将扩容的那个位上key.hash()的二进制值为0,则扩容后在桶中的地址不变,否则,扩容后的最高位变为了1,新的地址也可以快速计算出来newIndex = oldCap + oldIndex;

下面是Java 8中的代码实现:

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) {
        // 如果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; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        // 如果oldCap为0, 但是oldThr不为0,则代表的是table还未进行过初始化
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        // 如果到这里newThr还未计算,比如初始化时,则根据容量计算出新的阈值
        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;
                    // hiHead和hiTail代表元素在新的桶中和旧的桶中的位置不一致
                    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;
                        // loHead和loTail代表元素在新的桶中和旧的桶中的位置一致
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        // 新的桶中的位置 = 旧的桶中的位置 + oldCap, 详细分析见前文
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

Java 7中的resize方法相对简单许多:

  1. 基本的校验之后new一个新的桶数组,大小为指定入参
  2. 桶内的元素根据新的桶数组长度确定新的位置,放置到新的桶数组中
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
 
    Entry[] newTable = new Entry[newCapacity];
    boolean oldAltHashing = useAltHashing;
    useAltHashing |= sun.misc.VM.isBooted() &&
            (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    boolean rehash = oldAltHashing ^ useAltHashing;
    transfer(newTable, rehash);
    table = newTable;
    threshold = (int) Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
 
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K, V> e : table) {
        //链表跟table[i]断裂遍历,头部往后遍历插入到newTable中
        while (null != e) {
            Entry<K, V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

4. get() 获取元素

get方法理解起来就比较简单了,主要步骤如下:

  1. 先调用K的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。
  2. 通过上一步哈希算法转换成数组的下标之后,在通过数组下标快速定位到某个位置上。如果这个位置上什么都没有,则返回null。如果这个位置上有单向链表,那么它就会拿着K和单向链表上的每一个节点的K进行equals,如果所有equals方法都返回false,则get方法返回null。如果其中一个节点的K和参数K进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value。

六、链表成环问题

1. JDK 1.7 的头插法问题

  • 问题描述
    • 在 JDK 1.7 中,HashMap 的扩容(resize)使用 头插法 将链表节点迁移至新数组。在多线程环境下,可能导致链表成环,从而在 get 操作时陷入死循环。
  • 示例场景
    1. 多线程同时执行 put 操作,触发扩容。
    2. transfer 函数中,头插法导致链表顺序反转。
    3. 多个线程操作同一链表时,可能形成循环链表。
  • 代码片段(JDK 1.7 的 transfer 函数)
    void transfer(Entry[] newTable) {
        Entry[] src = table;
        for (int j = 0; j < src.length; ++j) {
            Entry<K,V> e = src[j];
            while (null != e) {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newTable.length);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }
    
  • 后果
    • 链表成环后,get 操作会无限循环,导致程序崩溃或性能严重下降。

2. JDK 1.8 的优化

  • 解决方法
    1. 尾插法
      • 在扩容时使用尾插法(e.next = next),避免链表顺序反转。
      • 示例代码片段(JDK 1.8 的 transfer 函数):
        do {
            next = e.next;
            int newIdx = (e.hash & (newCap - 1));
            if ((e.hash & oldCap) == 0) {
                ...
                e.next = newTableIndex;
                newTable[newIdx] = e;
            } else {
                ...
                e.next = newTable[newIdx + oldCap];
                newTable[newIdx + oldCap] = e;
            }
        } while ((e = next) != null);
        
    2. 链表转红黑树
      • 当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转换为红黑树,降低查询复杂度(从 O ( n ) O(n) O(n) O ( log ⁡ n ) O(\log n) O(logn))。
  • 效果
    • 避免了链表成环问题,提高了并发环境下的安全性。

七、线程安全问题

  • 非线程安全:HashMap 在多线程环境下扩容时可能导致数据丢失或死循环(如链表成环)。
  • 替代方案
    • ConcurrentHashMap:线程安全的哈希表实现(分段锁或 CAS + synchronized)。
    • Collections.synchronizedMap:通过包装 HashMap 实现同步。

八、性能分析

操作时间复杂度说明
插入/查找/删除 O ( 1 ) O(1) O(1)理想情况下,哈希分布均匀,无冲突。
最坏情况 O ( n ) O(n) O(n)所有键映射到同一桶(链表),查找需遍历链表。
红黑树优化后 O ( log ⁡ n ) O(\log n) O(logn)链表长度 ≥ 8 时转换为红黑树,降低最坏情况下的复杂度。

九、关键属性与常量

属性说明
table存储键值对的哈希桶数组。
size当前 HashMap 中键值对的数量。
threshold扩容阈值(capacity * loadFactor)。
loadFactor负载因子,默认值为 0.75。
TREEIFY_THRESHOLD链表转红黑树的阈值,默认值为 8。
UNTREEIFY_THRESHOLD红黑树转链表的阈值,默认值为 6。
MIN_TREEIFY_CAPACITY最小树化容量,数组长度必须 ≥ 64 时才允许树化。

十、实际应用示例

1. 插入键值对

HashMap<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
  • 流程
    1. 计算 "apple" 的哈希值。
    2. 根据 (n - 1) & hash 计算索引。
    3. 若桶为空,直接插入;否则,处理冲突(链表或红黑树)。

2. 扩容示例

  • 初始容量:16,负载因子 0.75 → 阈值 12。
  • 当插入第 13 个键值对时,触发扩容,数组长度变为 32。

十一、总结

HashMap 的底层实现通过 数组 + 链表 + 红黑树 的组合,结合 哈希函数优化、动态扩容、冲突解决机制,实现了高效的键值对存储与查找。其核心设计目标是平衡时间复杂度与空间占用,适用于大多数单线程场景。在多线程环境中,需使用 ConcurrentHashMap 或外部同步机制。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值