HashMap底层原理及源码解析

目录

底层数据结构

hash冲突

capacity 容量

loadFactor 负载因子

resize 扩容

红黑树

源码解析

关键属性

Node内部类

数组容量计算

计算key的hash值

put方法

resize方法


底层数据结构

jdk1.8中,HashMap 使用了 数组 + (链表 or 红黑树) 的结构。

当链表中的数据达到 8 个,且数组长度达到 64 时,链表会自动转化为红黑树。不过这个情况基本上达不到。

  • 数组的优势是随机读取和修改效率高。缺点是插入和删除效率低
  • 链表的优势是插入和删除效率高,容易扩展 。缺点是随机读取和修改效率低

HashMap 结合了数组和链表的优点,查询、修改、插入、删除的效率都很高!

插入原理

  • 调用  hash() 得到 key 对应的 hash值,然后将 hash值 转换成数组下标。数组中的元素是一个单向链表

  • 不同的 key,最终得到的数组下标可能是相同的,他们会被放到这个下标对应的链表中

查询原理

  • 调用  hash() 得到 key 对应的 hash值,然后将 hash值 转换成数组下标。

  • 根据此下标获取对应的链表,如果链表存在的话,拿 key 和链表中的每个元素的 key 进行equals,如果有一个匹配的就返回该元素,否则返回 null

hash冲突

hash冲突指的是:不同 key 最终计算出来的数组下标相同。

出现这种情况时,这些 key 会以链表形式存在这个坐标 i 中。

数组坐标 i = (数组长度 - 1) & hash(key)

capacity 容量

capacity 指的是 HashMap 中能存放的条目数量。

容量必须是 2 的幂次方,即使我们传入的容量不是 2 的幂次方,也会自动转成 2 的幂次方。

在设置初始容量时应该考虑hash表中可能需要存放的条目数量,以便最大限度地减少resize次数

为什么容量一定要是 2 的幂次方

因为 HashMap 是 “数组+链表” 的结构,我们希望元素的存放的更均匀,最理想的状态是每个Entry中只存放一个元素,这样在查询的时候效率最高。

那怎么才能均匀的存放呢?我们首先想到的是取模运算 (hash % capacity),而 HashMap 中使用了位运算来达到相同的效果。

为了使位运算和取模运算结果一样,那低位必须全是 1,如下面这个例子所示:

---------------------------------
e.hash=   1: 0000 0001
capacity 15: 0000 1111
&                =  0000 0001
---------------------------------
e.hash=  17: 0001 0001
capacity  15: 0000 1111
&                 =  0000 0001
----------------------------------

capacity = 16 时,1 % capacity = 1 & (capacity - 1),17 % capacity = 17 & (capacity - 1)

同理,当 capacity 为其他 2 的幂次方数时,效果也是一样。所以容量(Capacity)的大小就必须为 2 的幂次方。

loadFactor 负载因子

负载因子指的是,当Hash表中的条目数量,达到一定比例后,需要进行自动扩容。这个比例就是负载因子。默认值是 0.75,表示条目数量达到容量的 75% 时,会进行自动扩容。

为什么负载因子默认是 0.75

负载因子是表示Hash表中元素的填满的程度。

  • 负载因子越大,填满的元素越多,空间利用率越高,但冲突的概率加大了,查找成本变高
  • 负载因子越小,填满的元素越少,冲突的机会减小,查找成本小,但空间浪费多了

因此,必须在 “冲突概率” 与 “空间利用率” 之间寻找一种平衡。

而 0.75 这个数值是官方给出的,根据官方的说法,当负载因子为 0.75 时候,Entry单链表的长度几乎不可能达到 8,作用就是让 Entry 单链表的长度尽量小,让HashMap 的查询效率尽可能高,同时也兼顾了空间利用率!

resize 扩容

当哈希表中的条目数超出了(加载因子 * 当前容量)时,哈希表会自动进行扩容,每次扩容后容量会翻倍。后面源码部分会详细说明。

红黑树

使用红黑树的条件和原因

jdk1.8中,HashMap的实现中用到了红黑树。当hash表的单一链表长度超过 8 个,且数组长度达到 64 时,链表结构就会转为红黑树结构。

使用红黑树是为了避免在极端情况下,链表会变得很长。

  • 红黑树的时间复杂度为 O(logn)
  • 链表的时间复杂度为 O(n)

红黑树近似平衡二叉查找树,其主要的优点就是“平衡“,左右子树高度几乎一致,通过这种方式来保障查找的时间复杂度为 log(n)。  

为什么节点数达到8个才转为红黑树

树节点的比普通节点更大,在链表较短时红黑树并未能明显体现性能优势,反而会浪费空间,所以在链表较短时采用链表而不是红黑树。

为什么需要数组长度到64才会转化红黑树

当数组长度较短时,如16,链表长度达到 8 已经是占用了最大限度的 50%,意味着负载已经快要达到上限,此时如果转化成红黑树,之后的扩容又会再一次把红黑树拆分平均到新的数组中,这样非但没有带来性能的好处,反而会降低性能。所以在数组长度低于 64 时,优先进行扩容。

源码解析

关键属性

// 默认容量2的4次方:16。必须是2的幂次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

// 最大容量2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;

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

// 当单向链表中元素达到8个时,转红黑树
static final int TREEIFY_THRESHOLD = 8;

// 当红黑树中元素达到6个时,转链表
static final int UNTREEIFY_THRESHOLD = 6;

// 哈希桶,一个数组,里面存放链表。 长度是2的N次方,或者初始化时为0.
transient Node<K,V>[] table;

// 哈希表内元素数量的阈值,超过阈值时,会发生扩容resize()
// threshold = 哈希桶.length * loadFactor;
int threshold;

// 加载因子,用于计算哈希表元素数量的阈值
final float loadFactor;

// hashmap中存放的元素个数
transient int size;

Node内部类

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash; // key的hash值
    final K key;
    V value;
    Node<K,V> next; // 单向链表中下一个节点
    ...
}

数组容量计算

// 获得一个大于等于cap,且确保一定是2的幂次方的数
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中, >>> 是右移高位补 0,我们将原数与移位后得到的数进行或运算,就可以确保在最高位的后面那一位它的值也是1。下一次 n >>> 2 的时候就能够确保最高位后面跟上三个1。在所有的移位、或的操作后,我们得到的就是最高位后面全是1的一个int类型数据。此时 n+1 必定是一个2幂次的数。以 17 为例:

int n = 17 - 1 = 16,再对 16 进行右移,最终得到的是 31。31 再加 1 得到新的数组长度是 32。

--------------------
0001 0000   16
0001 1000
0001 1110
0001 1111    31
--------------------

如果传入的 cap 本身就是 2 的幂次方,比如 16。那 n = 15,二进制就是 00001111,最终右移完成后还是 00001111,值没有改变。得到的长度还是 16。

计算key的hash值

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

put方法

/**
 * Implements Map.put and related methods
 *
 * @param hash key对应的hash值
 * @param key  主键
 * @param value 需要插入的值
 * @param onlyIfAbsent 如果为true,则插入时,发现已经有这个key了,则不更新值
 * @param evict if false, the table is in creation mode.
 * @return 如果有覆盖情况,则返回原来的值。否则返回null
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; // tab存放当前哈希桶
    Node<K,V> p; // key对应的数组坐标中存放的节点,节点通过next属性组成一个链表
    int n, i; // n为哈希桶的长度,i为数组坐标
    // 如果当前哈希桶是空的,则进行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        // 扩容哈希桶,并且将扩容后的哈希桶长度赋值给n
        n = (tab = resize()).length;
    // 如果当前数组坐标中的节点是空的,表示没有发生hash冲突,则新建节点
    // 计算数组坐标:i = (n - 1) & hash
    // 为什么要-1?因为n是2的幂次方,-1后转化成2进制,低位全都是1,这样进行&运算可以方便地得到坐标
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 如果节点不为空,也就是说发生了hash冲突
    else {
        // e用来存储需要被覆盖的节点
        Node<K,V> e; 
        // k用于在判断节点是否需要被覆盖时,临时存储节点的key值
        K k;
        // 如果hash值和key都相等,说明key已经存在了,则覆盖value
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            // 将当前节点引用赋值给e
            e = p;
        // 红黑树    
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 不是覆盖操作,则插入一个[普通链表节点]
        else {
            // 遍历链表
            for (int binCount = 0; ; ++binCount) {
                // 将p.next赋值给e,如果e为空,则在链表最后新增一个节点,并且跳出循环
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 如果链表元素达到阈值,则尝试转红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                // e如果不为空,且e是要被覆盖的节点,则跳出循环
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                // 上面两种情况都不满足,则继续循环 p = p.next
                p = e;
            }
        }
        // 如果e不等于null,说明有要被覆盖的节点
        if (e != null) {
            V oldValue = e.value;
            // onlyIfAbsent默认是false的,表示有冲突时,值会覆盖
            if (!onlyIfAbsent || oldValue == null)
                // 覆盖节点值
                e.value = value;
            // 空函数,HashMap中没有实现
            afterNodeAccess(e);
            // 返回oldValue
            return oldValue;
        }
    }
    ++modCount;
    
    // 如果存放的元素个数超过阈值,执行resize
    if (++size > threshold)
        resize();
    // 空函数,HashMap中没有实现
    afterNodeInsertion(evict);
    return null;
}

resize方法

/**
 * 初始化或者将size翻倍
 * 
 * @return 新的数组
 */
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 {
        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"})
    // 创建一个新的节点数组,也就是新的桶,长度为newCap
    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;
            // 如果当前数组坐标中有元素,则赋值给e
            if ((e = oldTab[j]) != null) {
                // 旧数组中元素清空
                oldTab[j] = null; 
                // 如果e.next没有节点了,表示没有哈希冲突,直接计算新数组中的坐标,并把e放进去
                if (e.next == null)
                    // 计算数组坐标:e.hash & (newCap - 1)
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果发生了哈希冲突,且节点数达到8个,操作红黑树
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 如果发生了哈希冲突,且节点数小于8个
                else {
                    // 低位链表头结点和尾节点
                    Node<K,V> loHead = null, loTail = null;
                    // 高位链表头结点和尾节点
                    Node<K,V> hiHead = null, hiTail = null;
                    // 临时节点,存放e的下一个节点
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // [e.hash & oldCap]:计算元素的在数组中的位置是否需要改变
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // (e.hash & oldCap) != 0,表示存在在高位
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    }
                    // e = e.next
                    while ((e = next) != null);
                    
                    // 最终过滤链表中的元素
                    // 假设某数组坐标中原链表里有4个节点,它们的hash值为 1、5、17、20
                    // 过滤完之后就应该是 loHead(低位)中有 1和5,hiHead(高位)中有 17和20
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

e.hash & oldCap

此公式在resize过程中比较重要,用于计算元素的在数组中的位置是否需要改变

既然扩容后容量翻倍了,那之前分配好的元素,有些也需要挪地方。有些元素保留在原位置(低位链表),有些元素保存到扩充出来的数组位置中(高位链表)

high位 = low位 + oldCap

假设 e.hash 分别为 1 和 17,oldCap=16, 此时根据公式 [(oldCap - 1) & hash] 可以算出:未扩容之前,得到的数组坐标都是1。而扩容之后,newCap=32,此时再计算时,显然 hash=17 时,计算出来的坐标会不同,所以 17 这种就需要改变位置  

--------------------------------
e.hash= 1:   0000 0001
oldCap=16: 0001 0000
&                =  0000 0000  = 0   表示坐标不需要改变
--------------------------------
e.hash=17:  0001 0001
oldCap=16: 0001 0000
&                =  0001 0000  != 0  表示坐标需要改变
--------------------------------

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值