• 面试再也不怕问到HashMap(一)


    1.源码解析

    1.1 构造方法

    HashMap有四个构造方法:

    1. 无参构造方法HashMap()

      构造一个空的HashMap,初始容量为16,负载因子为0.75

    	
    	/**
         * Constructs an empty <tt>HashMap</tt> with the default initial capacity
         * (16) and the default load factor (0.75).
         */
        public HashMap() {
            this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
        }
    
    1. HashMap(int initialCapacity)
    	/**
         * Constructs an empty <tt>HashMap</tt> with the specified initial
         * capacity and the default load factor (0.75).
         */
        public HashMap(int initialCapacity) {
            this(initialCapacity, DEFAULT_LOAD_FACTOR);
        }
    
    1. HashMap(int initialCapacity, float loadFactor)
     	/**
         * Constructs an empty <tt>HashMap</tt> with the specified initial
         * capacity and load factor.
         */
        public HashMap(int initialCapacity, float loadFactor) {
        	//如果初始容量小于0,抛出异常
            if (initialCapacity < 0)
                throw new IllegalArgumentException("Illegal initial capacity: " +
                                                   initialCapacity);
             //如果初始容量超过1 << 30(即2的30次方)                       
            if (initialCapacity > MAXIMUM_CAPACITY)
                initialCapacity = MAXIMUM_CAPACITY;
             //如果负载因子小于等于0,或者不是个数字,抛出异常
            if (loadFactor <= 0 || Float.isNaN(loadFactor))
                throw new IllegalArgumentException("Illegal load factor: " +
                                                   loadFactor);
            this.loadFactor = loadFactor;
            //关键方法
            this.threshold = tableSizeFor(initialCapacity);
        }
    

    注意这里设置了threshold。 这个threshold = capacity * load factor ,当HashMap的size到了threshold时,就要进行resize,也就是扩容。

    	/**
         * The next size value at which to resize (capacity * load factor).
         *
         * @serial
         */
        // (The javadoc description is true upon serialization.
        // Additionally, if the table array has not been allocated, this
        // field holds the initial array capacity, or zero signifying
        // DEFAULT_INITIAL_CAPACITY.)
        int threshold;
    

    tableSizeFor()的功能是返回下一扩容后的容量值,即一个比给定整数大且最接近的2的幂次方整数,如给定10,返回2的4次方16。 HashMap要求容量必须是2的幂。

    	/**
         * Returns a power of two size for the given target capacity.
         */
        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;
        }
    

    在说明这个方法前,先说一下右移的含义,运算规则:
    >> :按二进制形式把所有的数字向右移动对应位数,低位移出(舍弃),高位的空位补符号位,即正数补零,负数补1。符号位不变。
    >>>:按二进制形式把所有的数字向右移动对应位数,低位移出(舍弃),高位的空位补零。对于正数来说和带符号右移相同,对于负数来说不同。
    -1在32位二进制中表示为:
    11111111 11111111 11111111 11111111
    -1>>1:按位右移,符号位不变,仍旧得到
    11111111 11111111 11111111 11111111
    因此值仍为-1
    而-1>>>1的结果为 01111111 11111111 11111111 11111111

           接着说tableSizeFor()。首先,int n = cap -1是为了防止cap已经是2的幂时,执行完后面的几条无符号右移操作之后,返回的capacity是这个cap的2倍。

           如果n=0(经过了cap-1之后),则经过后面的几次无符号右移依然是0,最后返回的capacity是1(最后有个n+1的操作)。
           以16位为例,假设开始时 n 为 0000 1xxx xxxx xxxx (x代表不关心0还是1)

    • 第一次右移 n |= n >>> 1;

           由于n不等于0,则n的二进制表示中总会有一bit为1,这时考虑最高位的1。通过无符号右移1位,则将最高位的1右移了1位,再做或操作,使得n的二进制表示中与最高位的1紧邻的右边一位也为1,如0000 11xx xxxx xxxx 。

    • 第二次右移 n |= n >>> 2;

           注意,这个n已经经过了n |= n >>> 1; 操作。此时n为0000 11xx xxxx xxxx ,则n无符号右移两位,会将最高位两个连续的1右移两位,然后再与原来的n做或操作,这样n的二进制表示的高位中会有4个连续的1。如0000 1111 xxxx xxxx 。

    • 第三次右移 n |= n >>> 4;

           这次把已经有的高位中的连续的4个1,右移4位,再做或操作,这样n的二进制表示的高位中会有8个连续的1。如0000 1111 1111 xxxx 。

           这时基本清晰了,容量最大也就是32位的正数,所以最后一次 n |= n >>> 16; 可以保证最高位后面的全部置为1。当然如果是32个1的话,此时超出了MAXIMUM_CAPACITY ,所以取值到MAXIMUM_CAPACITY 。
    下面举个实际的例子:
    在这里插入图片描述
           注意,得到的这个capacity直接被赋值给了threshold。 开始认为应该这么写:this.threshold = tableSizeFor(initialCapacity) * this.loadFactor; 因为这样子才符合threshold的定义:threshold = capacity * load factor 。但是请注意,在构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算 。

    1. HashMap(Map<? extends K, ? extends V> m)
       /**
         * Constructs a new <tt>HashMap</tt> with the same mappings as the
         * specified <tt>Map</tt>.  The <tt>HashMap</tt> is created with
         * default load factor (0.75) and an initial capacity sufficient to
         * hold the mappings in the specified <tt>Map</tt>.
         *
         * @param   m the map whose mappings are to be placed in this map
         * @throws  NullPointerException if the specified map is null
         */
        public HashMap(Map<? extends K, ? extends V> m) {
            this.loadFactor = DEFAULT_LOAD_FACTOR;
            putMapEntries(m, false);
        }
    

    直接看putMapEntries()方法,

     	/**
         * 将m的所有元素存入本HashMap实例中,putAll()调用的其实就是这个方法
         */
        final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
            //得到 m中元素的个数
            int s = m.size();
            //当 m 中有元素时才进行拷贝
            if (s > 0) {
                //如果table未初始化,则先初始化一些变量。(table初始化是在put时)
                if (table == null) { // pre-size
                    // 根据待插入的map 的 size 计算要创建的 HashMap 的容量。
                    float ft = ((float)s / loadFactor) + 1.0F;
                    int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                             (int)ft : MAXIMUM_CAPACITY);
                    // 把要创建的 HashMap 的容量存在 threshold 中
                    if (t > threshold)
                        threshold = tableSizeFor(t);
                }
                // 如果table初始化过,因为别的函数也会调用它,所以有可能HashMap已经被初始化过了。
                // 若 size 大于 threshold,则先进行resize()扩容
                else if (s > threshold)
                    resize();
                //然后就开始遍历待插入的 map ,将每一个 <Key ,Value> 插入到本HashMap实例。
                for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                    K key = e.getKey();
                    V value = e.getValue();
                    // put(K,V)也是调用 putVal 函数进行元素的插入
                    putVal(hash(key), key, value, false, evict);
                }
            }
        }
    

    其中重要的有resize(),hash(key),putVal( )方法,下面重点讲解。

    源码分析

    1. hashmap的数据结构

           其实就是数组加链表,java8引入红黑树来提高查询效率。
           HashMap使用链表法处理哈希值冲突的情况(相同hash值),当链表长度大于TREEIFY_THRESHOLD(默认为8)时,将链表转换为红黑树,当然小于UNTREEIFY_THRESHOLD(默认为6)时,又会转回链表以达到性能均衡。 我们看一张HashMap的数据结构(数组+链表+红黑树 )就更能理解table(每个table其实就是Node<K,V>)了:

    在这里插入图片描述
    几个重要的成员变量:

    /**
      * 数组的默认初始长度,java规定hashMap的数组长度必须是2的次方
      * 扩展长度时也是当前长度 << 1。
      */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    
    // 数组的最大长度
    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    // 默认负载因子,当元素个数超过这个比例则会执行数组扩充操作。
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    // 树形化阈值,当链表节点个大于等于TREEIFY_THRESHOLD - 1时,
    // 会将该链表换成红黑树。
    static final int TREEIFY_THRESHOLD = 8;
    
    // 解除树形化阈值,当链表节点小于等于这个值时,会将红黑树转换成普通的链表。
    static final int UNTREEIFY_THRESHOLD = 6;
    
    // 最小树形化的容量,即:当内部数组长度小于64时,不会将链表转化成红黑树,而是优先扩充数组。
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    // 这个就是hashMap的内部数组了,而Node则是链表节点对象。
    transient Node<K,V>[] table;
    
    // 下面三个容器类成员,作用相同,实际类型为HashMap的内部类KeySet、Values、EntrySet。
    // 他们的作用并不是缓存所有的key或者所有的value,内部并没有持有任何元素。
    // 而是通过他们内部定义的方法,从三个角度(视图)操作HashMap,更加方便的迭代。
    // 关注点分别是键,值,映射。
    transient Set<K>        keySet;  // AbstractMap的成员
    transient Collection<V> values; // AbstractMap的成员
    transient Set<Map.Entry<K,V>> entrySet;
    
    // 元素个数,注意和内部数组长度区分开来。
    transient int size;
    
    // 是容器结构的修改次数,fail-fast机制。
    transient int modCount;
    
    // 阈值,超过这个值时扩充数组。 threshold = capacity * load factor,具体看上面的静态常量。
    int threshold;
    
    // 装在因子,具体看上面的静态常量。
    final float loadFactor;
    
    

    1. hash()

    	/**
         * key 的 hash值的计算是通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16)
         * 主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候
         * 也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销
         */
        static final int hash(Object key) {
            int h;
            return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        }
    

    注意这里的异或运算:(h = key.hashCode()) ^ (h >>> 16)
    为什么要这么干呢? 这个与HashMap中table下标的计算有关

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

    因为table的长度都是2的幂,因此当长度较小时index仅与hash值的低n位有关,hash值的高位都被&操作置为0了,见下图最后一列。

    假设table.length=2^4=16,要插入的key的hashcode为1111 1111 1111 1111 1111 0000 1110 1010。
    在这里插入图片描述
           图中可以看出,(h = key.hashCode()) ^ (h >>> 16)操作将hashcode高位和低位的值进行混合做异或运算,混合后,低位的信息中加入了高位的信息,这样高位的信息被变相的保留了下来。这样做的好处是最后一步进行(n-1) & hash时,掺杂的元素多了,那么生成的hash值的随机性会增大。

    2. resize()

    扩容(resize):其实就是重新计算容量,之后重新定义一个新的容器,将原来容器中的元素放入其中。

    什么时候扩容: 在put操作时,即向容器中添加元素时,判断当前容器中元素的个数是否达到阈值(当前数组长度乘以加载因子的值)的时候,就要自动扩容了。

    //return the table
     final Node<K,V>[] resize() {
            // 保存当前table
            Node<K,V>[] oldTab = table;
            // 保存当前table的容量和阈值
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            int oldThr = threshold;
            // 初始化新的table容量和阈值 
            int newCap, newThr = 0;
            //resize()函数在size > threshold时被调用。oldCap大于 0 代表原来的 table 表非空
            if (oldCap > 0) {
                // 若旧table容量已超过最大容量,更新阈值为最大整型值,这样以后就不会自动扩容了。并直接return 
                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
            }
            /*
            2. resize()函数在table为空被调用。oldCap 小于等于 0 且 oldThr 大于0,代表用户创建了一个HashMap,
            但是使用的构造函数为HashMap(int initialCapacity, float loadFactor) 或 HashMap(int 
            initialCapacity)或 HashMap(Map<? extends K, ? extends V> m),导致 oldTab 为 null,oldCap 为0,
             oldThr 为用户指定的 HashMap的初始容量。
          */
            else if (oldThr > 0) // initial capacity was placed in threshold
                //当table没初始化时,threshold持有初始容量。还记得threshold = tableSizeFor(t)么;
                newCap = oldThr;
            /*
            3. resize()函数在table为空被调用。oldCap 小于等于 0 且 oldThr 等于0,用户调用 HashMap()构造函
            4. 创建的 HashMap,所有值均采用默认值,oldTab(Table)表为空,oldCap为0,oldThr等于0,
            */
            else {               // zero initial threshold signifies using defaults
                newCap = DEFAULT_INITIAL_CAPACITY;
                newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
            }
            // 新阈值为0
            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"})
            // 初始化table
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
            table = newTab;
            if (oldTab != null) {
                // 把 oldTab 中的节点 reHash 到 newTab 中去
                for (int j = 0; j < oldCap; ++j) {
                    Node<K,V> e;
                    if ((e = oldTab[j]) != null) {
                        oldTab[j] = null;
                        // 若节点是单个节点,直接在 newTab 中进行重定位
                        if (e.next == null)
                            newTab[e.hash & (newCap - 1)] = e;
                        // 若节点是 TreeNode 节点,要进行 红黑树的 rehash 操作
                        else if (e instanceof TreeNode)
                            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                        // 若是链表,进行链表的 rehash 操作
                        else { // preserve order
                            Node<K,V> loHead = null, loTail = null;
                            Node<K,V> hiHead = null, hiTail = null;
                            Node<K,V> next;
                            // 将同一桶中的元素根据(e.hash & oldCap)是否为0进行分割(代码后有图解,可以回过
                            //再来看),分成两个不同的链表,完成rehash
                            do {
                                next = e.next;
                                // 根据算法 e.hash & oldCap 判断节点位置rehash 后是否发生改变
                                //最高位==0,这是索引不变的链表。
                                if ((e.hash & oldCap) == 0) { 
                                    if (loTail == null)
                                        loHead = e;
                                    else
                                        loTail.next = e;
                                    loTail = e;
                                }
                                //最高位==1 (这是索引发生改变的链表)
                                else {  
                                    if (hiTail == null)
                                        hiHead = e;
                                    else
                                        hiTail.next = e;
                                    hiTail = e;
                                }
                            } while ((e = next) != null);
                            if (loTail != null) {  // 原bucket位置的尾指针不为空(即还有node)  
                                loTail.next = null; // 链表最后得有个null
                                newTab[j] = loHead; // 链表头指针放在新桶的相同下标(j)处
                            }
                            if (hiTail != null) {
                                hiTail.next = null;
                                // rehash 后节点新的位置一定为原来基础上加上oldCap,
                                newTab[j + oldCap] = hiHead;
                            }
                        }
                    }
                }
            }
            return newTab;
        }
    }
    

    我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。原因分析如下:

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

    在这里插入图片描述
           因此,我们在扩充HashMap的时候,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,省去了重新计算hash值的时间。

    下面是从16扩充为32的resize示意图 :
    在这里插入图片描述

    hashMap的容量是2的次幂还有一个好处是会使得数组索引index更加均匀:
    在这里插入图片描述
           我们看到,上面的&运算,高位是不会对结果产生影响的(hash函数采用各种位运算可能也是为了使得低位更加散列),我们只关注低位bit,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。这也是数组长度设计为必须为2的次幂的原因。
    在这里插入图片描述
           如果不是2的次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了。

    3. putVal( )

     	//实现put和相关方法。
        final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
            Node<K,V>[] tab; Node<K,V> p; int n, i;
            //如果table为空或者长度为0,则resize()
            if ((tab = table) == null || (n = tab.length) == 0)
                n = (tab = resize()).length;
            //确定插入table的位置,算法是(n - 1) & hash,在n为2的幂时,相当于取摸操作。
            ////找到key值对应的槽并且是第一个,直接加入
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
            //在table的i位置发生碰撞,有两种情况,1、key值是一样的,替换value值,
            //2、key值不一样的有两种处理方式:2.1、存储在i位置的链表;2.2、存储在红黑树中
            else {
                Node<K,V> e; K k;
                //第一个node的hash值即为要加入元素的hash
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    e = p;
                //2.2
                else if (p instanceof TreeNode)
                    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                //2.1
                else {
                    //不是TreeNode,即为链表,遍历链表
                    for (int binCount = 0; ; ++binCount) {
                    ///链表的尾端也没有找到key值相同的节点,则生成一个新的Node,
                    //并且判断链表的节点个数是不是到达转换成红黑树的上界达到,则转换成红黑树。
                        if ((e = p.next) == null) {
                             // 创建链表节点并插入尾部
                            p.next = newNode(hash, key, value, null);
                            ////超过了链表的设置长度8就转换成红黑树
                            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;
                    }
                }
                //如果e不为空就替换旧的oldValue值
                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;
        }
    

    总结来将put操作就是以下几个步骤:

    1. 检查数组是否为空,然后执行resize()扩充;

    2. 通过hash值计算数组索引,获取到该索引位的首节点

    3. 如果首节点为null,直接添加节点到该索引位。

    4. 如果首节点不为null,那么有3种情况:

      ① key和首节点的key相同,覆盖value;否则执行②或③
      ② 如果首节点是红黑树节点(TreeNode),将键值对添加到红黑树。
      ③ 如果首节点是链表,将键值对添加到链表。添加之后会判断链表长度是否到达TREEIFY_THRESHOLD - 1这个阈值,“尝试”将链表转换成红黑树

    5. 最后判断当前元素个数是否大于threshold阈值,判断是否扩充数组

    hash 冲突发生的几种情况:
    1.两节点key 值相同(hash值一定相同),导致冲突;
    2.两节点key 值不同,由于 hash 函数的局限性导致hash 值相同,冲突;
    3.两节点key 值不同,hash 值不同,但 hash 值对数组长度取模后相同,冲突;

  • 相关阅读:
    PHP 开发 APP 接口 学习笔记与总结
    Java实现 LeetCode 43 字符串相乘
    Java实现 LeetCode 43 字符串相乘
    Java实现 LeetCode 43 字符串相乘
    Java实现 LeetCode 42 接雨水
    Java实现 LeetCode 42 接雨水
    Java实现 LeetCode 42 接雨水
    Java实现 LeetCode 41 缺失的第一个正数
    Java实现 LeetCode 41 缺失的第一个正数
    Java实现 LeetCode 41 缺失的第一个正数
  • 原文地址:https://www.cnblogs.com/seasail/p/12179346.html
Copyright © 2020-2023  润新知