面试必问究极重点之HashMap的底层原理

1.底层数据结构

        JDK版本不同的数据结构

  • 1.7 数组 + 链表

  • 1.8 数组 + (链表 | 红黑树)

2.添加数据put

  1. 在添加一个值的时候,首先会计算他的hash码,然后进行二次hash,在对当前长度取模得到在底层数组中的索引位置
  2. 当取模完成后,会遇到不同元素索引位置相同的情况。我们把这种情况叫做hash冲突,此时会将后一个元素通过链表的形式挂在下边
  3. 当存储元素数量超过数组容量的四分之三时,会进行扩容,扩容后,也可以减少链表长度。
  4. 但是如果同一条链上的元素原始hash本就相同,此时通过扩容就不能有减少链表的长度了

3.树化与退化

树化意义

  • 红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略

  • hash 表的查找,更新的时间复杂度是 $O(1)$,而红黑树的查找,更新的时间复杂度是 $O(log_2⁡n )$,TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表

  • hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小

树化规则

  • 当链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化

退化规则

  • 情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表

  • 情况2:remove 树节点时,若 root、root.left、root.right、root.left.left 有一个为 null ,也会退化为链表(在移除之前检查

4.索引计算

索引计算方法

  • 首先,计算对象的 hashCode()

  • 再进行调用 HashMap 的 hash() 方法进行二次哈希

    • 二次 hash() 是为了综合高位数据,让哈希分布更为均匀

  • 最后 & (capacity – 1) 得到索引

数组容量为何是 2 的 n 次幂

  1. 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模

  2. 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap

注意

  • 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash

  • 容量是 2 的 n 次幂 这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable

5.put与扩容

put 流程

  1. HashMap 是懒惰创建数组的,首次使用才创建数组

  2. 计算索引(桶下标)

  3. 如果桶下标还没人占用,创建 Node 占位返回

  4. 如果桶下标已经有人占用

    1. 已经是 TreeNode 走红黑树的添加或更新逻辑

    2. 是普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑

  5. 返回前检查容量是否超过阈值,一旦超过进行扩容

1.7 与 1.8 的区别

  1. 链表插入节点时,1.7 是头插法,1.8 是尾插法

  2. 1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容

  3. 1.8 在扩容计算 Node 索引时,会优化

扩容(加载)因子为何默认是 0.75f

        当扩容的个数 > 数组长度*负载因子的值

  1. 在空间占用与查询时间之间取得较好的权衡

  2. 大于这个值,空间节省了,但链表就会比较长影响性能

  3. 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多

6.多线程下HashMap会有什么问题

  • 扩容死链(1.7
    • 出现这个问题的主要原因是,在多线程情况下,扩容时,需要把元素从新放入新数组中,那么在同一位置上的元素会顺序放入新数组中,1.7采用的是头插法从而导致了扩容死链问题。
  • 数据错乱(1.7 1.8

        多个线程同时操作HashMap会出现,数据丢失的情况,是因为在添加元素时,可能在同一位置需要添加多个元素,但是会出现覆盖情况。

7.key 的设计

        key 的设计要求

  1. HashMap 的 key 可以为 null,但 Map 的其他实现则不然

  2. 作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变)如果key是可变的,那么你在HashMap去查询时,它的HashCode就不一样了,也就找不到数据了。

  3. key 的 hashCode 应该有良好的散列性

        

        String 对象的 hashCode() 设计

  • 目标是达到较为均匀的散列效果,每个字符串的 hashCode 足够独特

  • 字符串中的每个字符都可以表现为一个数字,称为 S_i,其中 i 的范围是 0 ~ n - 1

  • 散列公式为: S_0∗31^{(n-1)}+ S_1∗31^{(n-2)}+ … S_i ∗ 31^{(n-1-i)}+ …S_{(n-1)}∗31^0

  • 31 代入公式有较好的散列特性,并且 31 * h 可以被优化为

    • 即 32 ∗h -h 

    • 即 2^5 ∗h -h

    • 即 h≪5 -h

8.源码分析

8.1常量

    //默认初始容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    
    //最大容纳数
    //最大容量,如果使用参数的任一构造函数隐式指定了较高的值,则使用该容量。必须是 2 的幂<= 1<<30。
    static final int MAXIMUM_CAPACITY = 1 << 30;

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

    //使用树而不是列表的箱计数阈值。将元素添加到至少具有此多个节点的图格时,图格将转换为树。该值必须大于 2,并且应至少为 8,以与树木移除中关于在收缩时转换回普通条柱的假设相吻合。
    //阈值 作用于 树化
    static final int TREEIFY_THRESHOLD = 8;

    //取消树化阈值
    static final int UNTREEIFY_THRESHOLD = 6;


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

在成员变量中可以发现

        HashMap定义了默认的初始容量负载因子树化阈值退出树化阈值最小树化数组的容量以及最大容量

8.2成员变量


    //该表在首次使用时初始化,并根据需要调整大小。分配时,长度始终是 2 的幂。(我们还允许在某些操作中使用长度为零,以允许当前不需要的引导机制。
    transient Node<K,V>[] table;

 

    //保存缓存的 entrySet()。请注意,AbstractMap 字段用于 keySet() 和 values()。
    transient Set<Map.Entry<K,V>> entrySet;


    //此映射中包含的键值映射数。
    transient int size;


    //此 HashMap 在结构上被修改的次数 结构修改是指更改 HashMap 中的映射数量或以其他方式修改其内部结构(例如,重新哈希)的修改。此字段用于使 HashMap 的 Collection-views 上的迭代器快速失败。(请参阅 ConcurrentModificationException)。
    transient int modCount;

    //要调整大小的下一个大小值(容量 * 负载系数)。
    int threshold;
    
    //哈希表的负载因子
    final float loadFactor;

8.3构造函数

/** 
构造一个具有指定初始容量和负载因子的空 HashMap。
参数: 
initialCapacity – 初始容量 
loadFactor – 负载系数
 抛出: IllegalArgumentException – 如果初始容量为负或负载系数为非正
*/

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);
    }
/**
构造一个空的 HashMap,具有指定的初始容量和默认负载系数 (0.75)。
参数: initialCapacity – 初始容量。
抛出:IllegalArgumentException – 如果初始容量为负数。
*/
public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
//使用默认初始容量 (16) 和默认负载系数 (0.75) 构造一个空的 HashMap。
public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // 所有其他字段默认
    }
/**
使用与指定 Map 相同的映射构造新的 HashMap。
HashMap 是使用默认负载系数 (0.75) 创建的,初始容量足以在指定的 Map 中保存映射。
参数: m – 其映射将放置在此映射中的映射 
Throws: NullPointerException – 如果指定的映射为 null
*/

//参数时一个Map集合的话,就直接添加进去
public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }


8.4get

/*
返回指定键映射到的值,如果此映射不包含键的映射,则返回 null。
更正式地说,如果此映射包含从键 k 到值 v 的映射,
使得 (key==null ? k==null : key.equals(k)),则此方法返回 v;
否则,它将返回 null。(最多可以有一个这样的映射。
返回值 null 并不一定表示映射不包含键的映射;
映射也有可能将键显式映射到 null。containsKey 操作可用于区分这两种情况。
参见:put(Object, Object)
**/


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

8.5put

//参数为 key,value 的形式 
public V put(K key, V value) {
        //调用了putVal方法
        return putVal(hash(key), key, value, false, true);
    }


/*
参数: hash – 键键的哈希值 – 键值 – 要放置的值
       onlyIfAbsent – 如果为 true,则不更改现有值 
       evict – 如果为 false,则表处于创建模式。

这个函数实现了Java中的Map的put方法及其相关方法。
它根据给定的键和值,将键值对添加到Map中。
如果键已存在且onlyIfAbsent为true,则不更改现有的值。
如果evict为false,则表处于创建模式。如果evict为true且表已满,则会清理最久未访问的键值对。
最后,该函数会调整Map的大小并返回旧的值。
**/
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;
    }

8.6Node



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;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

  • 31
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值