zoukankan      html  css  js  c++  java
  • JDK 8 HashMap源码解析

    HashMap作为日常开发中最常使用的一个数据结构,其在面试过程中也是必问的一个知识点,所以我们今天就来一窥HashMap的源码。

    先来总览一下HashMap的继承关系吧

    HashMap继承自AbstractMap类,实现了Map、Serializable和cloneable接口。

    下面以一个常见场景引入今天的分析。

    public class HashMapTest {
    
    
        public static void main(String[] args) {
            
            Map<Integer, String> map = new HashMap<>();//1
    
            map.put(1, "1");//2
    
            System.out.println(map.get(1));//3
        }
    }
    

    构造方法

    先从构造方法1开始看起吧,HashMap提供了三种构造方法,其中涉及到的变量分别如下所示:

    //默认加载因子 实验所得 时间和空间的折中
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
     //无参构造
    public HashMap() {
            this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
        }
    
    //容量入参的构造方法
    public HashMap(int initialCapacity) {
            //底层调用的是双参数构造方法
            this(initialCapacity, DEFAULT_LOAD_FACTOR);
        }
    
    // HashMap容量上限  2的30次方
    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    public HashMap(int initialCapacity, float loadFactor) {
            //入参校验
            if (initialCapacity < 0)
                throw new IllegalArgumentException("Illegal initial capacity: " +
                                                   initialCapacity);
            // 如果传入的initialCapacity大于MAXIMUM_CAPACITY
            if (initialCapacity > MAXIMUM_CAPACITY)
                //则将initialCapacity设为MAXIMUM_CAPACITY
                initialCapacity = MAXIMUM_CAPACITY;
            // 负载因子参数校验
            if (loadFactor <= 0 || Float.isNaN(loadFactor))
                throw new IllegalArgumentException("Illegal load factor: " +
                                                   loadFactor);
            this.loadFactor = loadFactor;
            //计算阈值  此时数组table还没有初始化 会在put方法中重新进行赋值
            this.threshold = tableSizeFor(initialCapacity);
        }
    

    这里重点讲一下tableSizeFor方法,这个方法将返回大于等于给定的initialCapacity的最小的2的n次幂,什么意思?打个比方,如果initialCapacity为10的话,大于等于10,且是2的n次幂的有很多,16,32,64...等等,但只有16是与10的差值最小的那个,所以tableSizeFor最后返回的就是16。下面看一下这个方法是如何实现的。

    //
    static final int tableSizeFor(int cap) {
           //先减去1
            int n = cap - 1;//1
            // 先无符号右移 再 | 运算
            n |= n >>> 1;//2
            n |= n >>> 2;//3
            n |= n >>> 4;//4
            n |= n >>> 8;//5
            n |= n >>> 16;//6
            return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;//7
        }
    

    就以10为例,运行完第1行后,n=9,然后下面开始进行右移以及位运算,如下所示:

    n=10
    n-1=9
                                          
    			0000 1001                          
    	>>> 1	0000 0100   	                   
    	    |   0000 1101
    -----------------------
                0000 1101
    	>>> 2	0000 0011
            |   0000 1111
    -----------------------
                0000 1111
    	>>> 4   0000 0000
    	    |   0000 1111
    -----------------------
                0000 1111
    	>>> 8   0000 0000
            |   0000 1111
    -----------------------
                0000 1111
    	>>> 16	0000 0000
            |   0000 1111
    

    最后所得的n为15,且满足由于 n>0 && n<MAXIMUM_CAPACITY,因而最后返回的为 n+1即为16。需要注意的是,这里为什么一定要先进行n=cap-1这个操作。假设此时,传入的是2的n次幂,如果不先减去1的话,此时cap为2n形式,最后进行计算的结果一定是2*2n这种形式,但tableSizeFor方法要求的是返回大于等于cap的最小的2n,结果应该就是2n而不是2 * 2n。举个例子,传入的cap为16,已经是24,如果不先减去1的话,返回结果是32,但是违背了tableSizeFor的含义,读者可自行验证一下。

    接下来,再来思考几个问题。

    ①为什么这里要进行1、2、4、8、16这样的运算呢?
    对于局部来说,其实就是为了把高位移到低位(对于4位来说,前两位是高位,后两位是低位)这样之后再进行"|"操作,那么就可以将局部得到全1
    ②为什么这里只是到16就结束了呢?
    因为我们这里针对的数值都是int类型,在Java当中int类型占到4个字节,也就是32位。为什么不进行32位右移呢,这是因为32位右移之后就变成全0了,"|"操作就没有什么意义,也不会影响结果,只是多余的操作

    put方法

    接下来就是常用的put方法,其底层实际调用的是putVal方法。

    public V put(K key, V value) {
            return putVal(hash(key), key, value, false, true);
        }
    

    hash方法用来计算key的hashcode

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

    put方法

    //存放数据的数组
    transient Node<K,V>[] table;
    
    // 入参 hash:通过 hash 算法计算出来的值。
    // 入参 onlyIfAbsent:false 表示即使 key 已经存在了,仍然会用新值覆盖原来的值,默认为 false
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
            //数组tab
            Node<K,V>[] tab; 
        // n 表示数组的长度,i 为数组索引下标,
            int n, i;
        // p 为 i 下标位置的 Node 值
            Node<K,V> p; 
        
            //1.1若数组为空的话
            if ((tab = table) == null || (n = tab.length) == 0)
                //1.2 使用resize方法进行初始化
                n = (tab = resize()).length;
            //如果当前索引位置tab[i]是空的
            // (n-1)&hash 为了使key分散的更均匀
            if ((p = tab[i = (n - 1) & hash]) == null)
                //直接生成新的节点在当前索引位置上
                tab[i] = newNode(hash, key, value, null);
            else {
                // 否则的话 则说明此处产生了hash冲突
                // e 当前节点的临时变量
                Node<K,V> e; 
                // key的临时变量
                K k;
                // 如果 key 的 hash 和值都相等  即相同的key val可能相同,可能不同 此时直接覆盖即可
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    //直接把当前下标位置的 Node 值赋值给临时变量
                    e = p;
                // 如果是红黑树,使用红黑树的方式新增
                else if (p instanceof TreeNode)
                    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                // 如果是个链表,把新节点放到链表的尾端
                else {
                    //自旋
                    for (int binCount = 0; ; ++binCount) {
                        // e = p.next 表示从头开始,遍历链表
                        // p.next == null 表示p后面没有节点,即是链表的尾节点
                        if ((e = p.next) == null) {
                            // 把新节点放到链表的尾部
                            p.next = newNode(hash, key, value, null);
                            // 当链表的长度大于等于 8 时,链表转红黑树
                            //  static final int TREEIFY_THRESHOLD = 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=p.next 用于在遍历过程中 p一直向后移动
                        p = e;
                    }
                }
                 // 此时已经插入成功
                if (e != null) { 
                    //记录一下旧值
                    V oldValue = e.value;
                    //当 onlyIfAbsent 为 false 时,才会覆盖值  此值默认为false
                    if (!onlyIfAbsent || oldValue == null)
                        //进行赋值操作
                        e.value = value;
                    // hashMap中这个方法无用
                    afterNodeAccess(e);
                    //返回旧值
                    return oldValue;
                }
            }
        // 记录 HashMap 的数据结构发生了变化 增删改都可以算作 数据结构变化
            ++modCount;
        // 根据size大小判断是否要开始扩容
            if (++size > threshold)
                // 扩容
                resize();
        // hashMap中这个方法无用
            afterNodeInsertion(evict);
            return null;
        }
    

    总结一下,hashMap的put过程:

    • 保存数据的数组是否为空,若为空则直接初始化;
    • 如果数组下标所在位置为空,则直接进行赋值操作;
    • 如果此时数组下班不为空,即产生了hash冲突,则使用链地址法进行解决;
    • 如果此时链表中存在相同的key,则直接进行覆盖;
    • 如果不同,此时如果是链表的话,则直接插入到链表尾部;
    • 如果是红黑树的话,则直接插入到红黑树中;
    • 插入成功后,根据onlyIfAbsent来判断是否直接覆盖旧值
    • 返回旧值

    流程图如下所示:

    reSize方法

    resize方法一般有两个场景会触发,一个是调用put方法时,若是此时hashMap尚未初始化,则会调用resize方法进行初始化;第二个就是当目前hashmap中元素个数大于阈值threshold时,调用resize方法进行扩容。

    final Node<K,V>[] resize() {
            Node<K,V>[] oldTab = table;
            //判断此时hashmap是否已经初始化了
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            int oldThr = threshold;
            int newCap, newThr = 0;
            // 1.根据oldCap是否大于0来判断是初始化还是扩容
            //旧容量大于0 说明是扩容
            if (oldCap > 0) {
                if (oldCap >= MAXIMUM_CAPACITY) {//MAXIMUM_CAPACITY=2^30
                    //如果此时hashmap中元素个数已经超过最大容量 直接退出
                    threshold = Integer.MAX_VALUE;
                    return oldTab;
                }
                // 不是的话 新容量为旧容量的2倍 且小于最大容量
                else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                         oldCap >= DEFAULT_INITIAL_CAPACITY)
                    //新阈值也是之前旧阈值的两倍
                    newThr = oldThr << 1; // double threshold
            }
          
          //2.若是初始化 在使用了带有capacity构造函数时,threshold就是此时hashmap的容量大小
            else if (oldThr > 0) // initial capacity was placed in threshold
                //新容量就等于旧阈值
                newCap = oldThr;
        //2.若是初始化,但使用了无参构造,则容量和阈值都使用默认的参数
            else {               // zero initial threshold signifies using defaults
                //新容量等于默认容量
                newCap = DEFAULT_INITIAL_CAPACITY;
                //新阈值就等于默认负载因子与默认容量的乘积
                newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
            }
            
            //用户自定义了map的初始化操作
            if (newThr == 0) {
                float ft = (float)newCap * loadFactor;
                newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                          (int)ft : Integer.MAX_VALUE);
            }
            //更新threshold字段等于新阈值
            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;
                    if ((e = oldTab[j]) != null) {
                        //去除旧数组对桶的引用
                        oldTab[j] = null;
                        //不是链表或者树 只是单节点
                        if (e.next == null)
                            //计算在新数组当中的索引位置,然后赋值
                            newTab[e.hash & (newCap - 1)] = e;
                        //如果是树的话 调用split方法进行处理
                        else if (e instanceof TreeNode)
                            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                        else { // preserve order
                            //如果没超过8个 是链表
                            //两条链表 高位和低位 分别用来存储同一个链表上的数据
                            // 后面根据分配分别插入到新数组中不同的位置
                            Node<K,V> loHead = null, loTail = null;
                            Node<K,V> hiHead = null, hiTail = null;
                            Node<K,V> next;
                            do {
                                //下一个节点
                                next = e.next;
                                //当前元素的hash值 & 旧数组容量==0 使用低位链表来进行记录
                                if ((e.hash & oldCap) == 0) {
                                    if (loTail == null)
                                        loHead = e;
                                    else
                                        loTail.next = e;
                                    loTail = e;
                                }
                                //如果 hash值 & 旧数组容量==1 使用高位链表来进行记录
                                else {
                                    if (hiTail == null)
                                        hiHead = e;
                                    else
                                        hiTail.next = e;
                                    hiTail = e;
                                }
                            } while ((e = next) != null);
                            //开始将这两条链表移动到新数组中
                            // lowHead  与旧数组的index保持一致 然后放到新数组中的index位置上
                            if (loTail != null) {
                                loTail.next = null;
                                newTab[j] = loHead;
                            }
                            //highHead 在旧数组index的基础上+旧数组的容量,然后放到新数组的 
                            //index+oldCap位置处
                            if (hiTail != null) {
                                hiTail.next = null;
                                newTab[j + oldCap] = hiHead;
                            }
                        }
                    }
                }
            }
          // 返回新的hashMap
            return newTab;
        }
    

    总结一下resize的流程:

    • 判断当前数组是否已经初始化了,如果没有则进行初始化

      • 如果使用的无参构造:
        • 则数组容量=16;
        • 阈值=容量*阈值=12;
      • 如果使用带有capacity的构造方法:
        • 则数组容量就是此时的阈值大小;
    • 如果已经初始化过了,则是扩容操作

      • 旧数组容量是否已经达到最大容量,2^30:
        • 是,新阈值直接设为Integer.MAX_VALUE,直接退出;
        • 否,新数组容量为旧数组容量的2倍,新阈值也是旧阈值的两倍;
    • 创建新数组,开始遍历旧数组中的元素移动到新数组中:

      • 当前元素是单节点元素,则直接计算在新数组中的index位置,然后移动到新数组中;

      • 当前元素是红黑树类型,则调用 split方法进行处理;

      • 当前元素是一个链表,则开始遍历这个链表,使用两条新链表来存储元素:

        • 链表上单个节点 e.hash & oldCap ==0 则移动到lowHead这个链表上

        • 链表上单个节点 e.hash & oldCap==1 则移动到highHead这个链表上;

        • lowHead这个链表,直接使用旧数组中的索引index,放入到新数组中;

        • highHead这个链表,在新数组中的索引为 index+oldCap;

    与jdk7中 resize方法的区别

    • jdk7中的resize方法只有扩容这一个功能;jdk8中的resize方法兼具初始化(懒加载,执行put的时候才去初始化数组)和扩容两个功能;
    • jdk7中resize时,会重新计算每个元素在新数组中的位置;jdk8中的resize方法,在移动链表时,利用链表上元素的性质, e.hash & oldCap 这个值来判断这个元素是在新数组中保持与旧数组相同的索引 index,还是 index+oldCap;
    • jdk7中,resize方法在移动链表上的元素时,会改变链表元素的相对顺序,如 a—>b 就会变成 b—> a,又因为使用的头插法,所以导致在多线程环境下进行扩容时可能导致链表成环,这样在调用get方法时陷入死循环;jdk8中采用尾插法,同时插入到新链表的时候不会改变链表中元素的相对位置,因此解决了死循环问题;

    jdk8中resize方法中的链表移动示意图:

    为什么使用 e.hash & oldCap

    计算在新数组中的索引位置使用的 e.hash & (newCap-1),由于newCap是oldCap的两倍,这就会导致参与 & 运算时, newCap-1将会比 oldCap-1多一位参加运算。如果这个需要新判断的位置上为0,那么index不变,否则变为需要迁移到(oldIndex + oldCap)这个位置上去。

    get方法

    public V get(Object key) {
            Node<K,V> e;
            //调用getNode方法
            return (e = getNode(hash(key), key)) == null ? null : e.value;
        }
    
    final Node<K,V> getNode(int hash, Object key) {
            Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
            //判断索引位置处是否有值 否则直接return null
            if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {
                // hash值和key值都相等 则表明命中 直接返回
                if (first.hash == hash && // always check first node
                    ((k = first.key) == key || (key != null && key.equals(k))))
                    return first;
                //判断是链表还是红黑树
                if ((e = first.next) != null) {
                    if (first instanceof TreeNode)
                        //是红黑树的话,调用getTreeNode方法查找
                        return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                    do {
                        //链表的话,遍历链表查找匹配
                        if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                            return e;
                    } while ((e = e.next) != null);
                }
            }
            return null;
        }
    

    get方法的流程如下:

    • 根据元素的hash值和hash方法定位到数组中索引所在位置;
    • 判断该索引位置是否存在值:
      • 不存在的话,直接返回null;
      • 比较hash值和key,若是相等,则表明命中,返回该索引位置所在的元素;
      • 若不相等,判断此处是否存在红黑树或者链表结构:
        • 若是红黑树,则调用getTreeNode方法进行查找;
        • 若是链表,则遍历链表进行查找;

    jdk新增的getOrDefault方法

    jdk8中新增一个getOrDefault方法,该方法在查找时,若是查找不到则返回传入的默认值。源码如下:

     public V getOrDefault(Object key, V defaultValue) {
            Node<K,V> e;
            //可以看出与 get方法的不同 若是查找不到 默认返回defaultValue 其余与get方法一致
            return (e = getNode(hash(key), key)) == null ? defaultValue : e.value;
        }
    

    remove方法

    public boolean remove(Object key, Object value) {
            return removeNode(hash(key), key, value, true, true) != null;
        }
    
    final Node<K,V> removeNode(int hash, Object key, Object value,
                                   boolean matchValue, boolean movable) {
            Node<K,V>[] tab; Node<K,V> p; int n, index;
           //先定位
            if ((tab = table) != null && (n = tab.length) > 0 &&
                (p = tab[index = (n - 1) & hash]) != null) {
                Node<K,V> node = null, e; K k; V v;
                //直接找到
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    node = p;
                //判断是链表还是红黑树
                else if ((e = p.next) != null) {
                    if (p instanceof TreeNode)
                        node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                    else {
                        do {
                            if (e.hash == hash &&
                                ((k = e.key) == key ||
                                 (key != null && key.equals(k)))) {
                                node = e;
                                break;
                            }
                            p = e;
                        } while ((e = e.next) != null);
                    }
                }
                
                //node不为null 说明命中了要删除的节点
                // 如果不需要对比value 或者是需要对比value 但value也相等 则开始进行删除
                if (node != null && (!matchValue || (v = node.value) == value ||
                                     (value != null && value.equals(v)))) {
                    //如果是红黑树的话 调用removeTreeNode进行删除
                    if (node instanceof TreeNode)
                        ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                    //如果是首节点的话 直接指向下一个节点 一个的话可以看做是只有一个节点的链表 这样也可以置
                    //为null
                    else if (node == p)
                        tab[index] = node.next;
                    else
                        //否则的话,进行链表的移动
                        p.next = node.next;
                    //更新modCount
                    ++modCount;
                    //更新size
                    --size;
                    afterNodeRemoval(node);
                    //返回删除的节点
                    return node;
                }
            }
            return null;
        }
    

    remove方法流程梳理如下:

    • 利用元素的hash和hash方法进行定位
    • 若是当前索引位置处为null,直接返回null;
    • 比较hash和key,若相等,则表明找到了待删除的节点
    • 若不是,则判断该位置是红黑树还是链表:
      • 若是红黑树,则调用getTreeNode方法进行查找;
      • 若是链表,则遍历链表进行查找;
    • 若是找到了待删除节点,则开始进行删除:
      • 若该索引位置处为红黑树,则调用removeTreeNode方法进行删除;
      • 若是链表,则执行链表相关的操作进行删除;
    • 最后更新 modCount和 size等属性的值;

    常见面试题

    为什么数组长度都是2的倍数?

    • 当数组都是2的倍数时,2^n-1的二进制表示中所有位置都是1,这样与一个全部都是1的二进制数进行 & 操作时,速度会大大提升;
    • 计算元素的索引位置时,一般采用的是 % 操作,但是如果数组长度都是2的倍数的话,hash & (length-1) 等价于 hash % length,但是 & 操作的效率更高,因为 % 在操作系统会进行转换, & 操作不用;
    • 数组长度为2的倍数时,不同key计算出相同的index的概率较小,减少hash碰撞;

    为了减少hash碰撞,hashMap做了哪些操作?

    • hash方法中,hashCode ^ hashCode >>>16,这样所得的hash值可以将hashCode的高位和低位都利用上,降低不同key通过hash方法获得相同hash值的概率,减少hash冲突;
    • 计算索引位置时,hash & (length-1),由于 length始终是2的倍数,length-1后的二进制表示中各位都是1,一个数与各个位都是1的数进行 & 操作,进一步降低hash冲突;

    参考

    https://juejin.im/post/6844904048185851911#heading-8

    https://juejin.im/post/6847902223884779533#heading-10

    https://juejin.im/post/6844904134080987144#heading-33

  • 相关阅读:
    20169205 2016-2017-2 《移动平台应用开发实践》第4周学习总结
    20169205 2016-2017-2《网络攻防》第4周总结
    20169205 2016-2017-2 《移动平台应用开发实践》第3周学习总结
    tcpdump使用
    wireshark使用
    20169205 2016-2017-2《网络攻防》第3周总结
    OpenSSH/PuTTY/SSH使用
    Aircrack使用
    Metasploit使用
    20155325 2017-2018 1 《信息安全系统设计基础》 第十周学习总结
  • 原文地址:https://www.cnblogs.com/reecelin/p/13456137.html
Copyright © 2011-2022 走看看