HashMap整理,面试看这篇就够了

本文详细介绍了HashMap的内部机制,包括容量、装载因子、树化阈值等核心概念,并探讨了扩容和树化的过程。计算hash值时使用了扰动函数减少冲突,解决冲突的方法有开放定址法、链地址法等。HashMap在容量超过阈值时会进行扩容,当单个桶的元素过多时会转换为红黑树。在扩容时,已有的元素会根据新的hash值重新分布,减少了数据移动。此外,文章还提到了不同类型的哈希实现方法。

HashMap整理,面试看这篇就够了!

整理自:https://blog.youkuaiyun.com/zjxxyz123/article/details/81111627
和:https://blog.youkuaiyun.com/moakun/article/details/80231067
用自己的思路缕了一遍,复习自用~

基本概念

  • 一般将数组中的每一个元素称作桶(segment),桶中连的链表或者红黑树中的每一个元素成为bin

  • capacity(方法内变量): 源码中没有将它作为属性,但是为了方便,引进了这个概念,是指HashMap中桶的数量。默认值为16。扩容是按照原容量的2倍进行扩。如果在构造函数中指定了Map的大小,那么进行put操作时,初始化后的容量为离传入值最近的2的整数幂,是通过tableSizeFor() 函数达到该目的。总之,容量都是2的幂。主要是可以使用按位与替代取模来提升hash的效率。

  • loadFactor: 译为装载因子。装载因子用来衡量HashMap满的程度。loadFactor的默认值为0.75f。计算HashMap的实时装载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。

  • threshold: threshold表示当HashMap的size大于threshold时会执行resize操作

  • DEFAULT_INITIAL_CAPACITY : 默认初始化容量 16。容量必须为2的次方。默认的hashmap大小为16.

  • MAXIMUM_CAPACITY :最大的容量大小2^30

  • DEFAULT_LOAD_FACTOR: 默认resize的因子。0.75,即实际数量超过总数DEFAULT_LOAD_FACTOR的数量即会发生resize动作。为什么是0.75,网上有些答案说是,因为capcity是2的次方,那么与之相乘会得到整数。还有一种说法更为可靠,负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。

  • TREEIFY_THRESHOLD: 树化阈值 8。当单个segment的容量超过阈值时,将链表转化为红黑树。

  • UNTREEIFY_THRESHOLD :链表化阈值 6。当resize后或者删除操作后单个segment的容量低于阈值时,将红黑树转化为链表。

  • MIN_TREEIFY_CAPACITY :最小树化容量 64。当桶中的bin被树化时最小的hash表容量,低于该容量时不会树化。

HashMap扩容及其树化的具体过程

  • 如果在创建 HashMap 实例时没有给定capacity、loadFactor则默认值分别是16和0.75。
  • 当好多bin被映射到同一个桶时,如果这个桶中bin的数量小于等于TREEIFY_THRESHOLD当然不会转化成树形结构存储;如果这个桶中bin的数量大于了 TREEIFY_THRESHOLD ,但是capacity小于MIN_TREEIFY_CAPACITY 则依然使用链表结构进行存储,此时会对HashMap进行扩容;如果capacity大于了MIN_TREEIFY_CAPACITY ,才有资格进行树化(当bin的个数大于8时)。

hash值的计算

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

n = table.length;
index = (n-1& hash;

HashMap是以hash操作作为散列依据。但是又与传统的hash存在着少许的优化。其hash值是key的hashcode与其hashcode右移16位的异或结果。在put方法中,将取出的hash值与当前的hashmap容量-1进行与运算。得到的就是位桶的下标。那么为何需要使用key.hashCode() ^ h>>>16的方式来计算hash值呢。其实从微观的角度来看,这种方法与直接去key的哈希值返回在功能实现上没有差别。但是由于最终获取下标是对二进制数组最后几位的与操作。所以直接取hash值会丢失高位的数据,从而增大冲突引起的可能。由于hash值是32位的二进制数。将高位的16位于低位的16位进行异或操作,即可将高位的信息存储到低位。因此该函数也叫做扰乱函数。目的就是减少冲突出现的可能性。而官方给出的测试报告也验证了这一点。直接使用key的hash算法与扰乱函数的hash算法冲突概率相差10%左右。

其他的hash实现?

  • 直接定址法:直接以关键字k或者k加上某个常数(k+c)作为哈希地址。

  • 数字分析法:提取关键字中取值比较均匀的数字作为哈希地址。

  • 除留余数法:用关键字k除以某个不大于哈希表长度m的数p,将所得余数作为哈希表地址。

  • 分段叠加法:按照哈希表地址位数将关键字分成位数相等的几部分,其中最后一部分可以比较短。然后将这几部分相加,舍弃最高进位后的结果就是该关键字的哈希地址。

  • 平方取中法:如果关键字各个部分分布都不均匀的话,可以先求出它的平方值,然后按照需求取中间的几位作为哈希地址。

  • 伪随机数法:采用一个伪随机数当作哈希函数。

解决hash冲突的方法?

  • 开放定址法
    • 开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
  • 链地址法
    • 将哈希表的每个单元作为链表的头结点,所有哈希地址为i的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。
  • 再哈希法
    • 当哈希地址发生冲突用其他的函数计算另一个哈希函数地址,直到冲突不再产生为止。
  • 建立公共溢出区
    • 将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。

为什么capcity是2的幂?

  • index:因为 算index时用的是(n-1) & hash,这样就能保证n -1是全为1的二进制数,如果不全为1的话,存在某一位为0,那么0,1与0与的结果都是0,这样便有可能将两个hash不同的值最终装入同一个桶中,造成冲突。所以必须是2的幂。

  • resize:HashMap的数组长度一定保持2的次幂,比如16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h&(length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换),个人理解。

扩容操作resize

resize扩容操作主要用在两处:

  • 向一个空的HashMap中执行put操作时,会调用resize()进行初始化,要么默认初始化,capacity为16,要么根据传入的值进行初始化

    //putval
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    
  • put操作后,检查到size已经超过threshold,那么便会执行resize,进行扩容:

    //putval
    if (++size > threshold)
        resize();
    
  • 如果此时capcity已经大于了最大值,那么便把threshold置为int最大值,否则对capcity,threshold进行扩容操作。

    //resize
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        //运行时 put扩容情况(桶数>0)
        if (oldCap >= MAXIMUM_CAPACITY) {
            //如果此时capcity已经大于了最大值,那么便把threshold置为int最大值并返回,即无法扩容
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //这里先把桶数capcity*2,然后对比最大容量(2 30)再看看原来的桶数是不是>=默认容量(如果删减的多就会小)如果都符合则 阈值(threshold)直接*2,否则newThr还是0
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        //原桶数capcity=0,threshold>0的情况,应该是有参构造函数刚构造完还没put的情况
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        //原capcity=0,threshold=0的情况,应该是无参构造函数构造完还没put的情况,使用默认值
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        //如果newThr在之前没有经过无参构造器或没有*2(原capcity*2>最大容量 或 原capcity<默认容量)则newThr赋值为新capcity*负载因子
        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;
    
  • 发生了扩容操作,那么必须Map中的所有的数进行再散列,重新装入。

    这里写图片描述

    在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变(因为任何数与0与都依旧是0),是1的话index变成“原索引+oldCap”。
    例如:n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

    这里写图片描述

    元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

    这里写图片描述

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

put操作

这里写图片描述

①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;

②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;

③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;

④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

/**
 * Implements Map.put and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //初始化时,map中还没有key-value
    if ((tab = table) == null || (n = tab.length) == 0)
        //利用resize生成对应的tab[]数组
        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))))
            //桶内第一个元素的key等于待放入的key,用
            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;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值