欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

JDK8的HashMap源码解析

程序员文章站 2022-05-25 11:49:26
...

hashMap底层数据结构

- JDK1.8 之前

底层 数组+链表,大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,极端情况HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n)

- JDK1.8

底层 数组+链表+红黑树 ,hash冲突后处理办法由原来的链表结构,引入了 红黑树 概念

- 重要对象

java.util.HashMap.Node 链表Node节点
java.util.HashMap.TreeNode TreeNode 红黑树

- 重要成员变量

ddd


    /**
     * The number of key-value mappings contained in this map.
     */
    transient int size;
    
    /**
     * 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;    
    
     /**
     * The load factor for the hash table.
     *
     * @serial
     */
    final float loadFactor;   
    
     /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16   

    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

需要注意的一点是,HashMap的哈希桶table的大小必须为2的n次方,初始大小为16,下文中将会说明为什么一定要是2的n次方。size字段的意思是当前记录数量,loadFactor是负载因子,默认为0.75,而threshold是作为扩容的阈值而存在的,它是由负载银子决定的。下面的方法是返回与给定数值最接近的2的n次方的值

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

- get操作

找到记录,首先要确定记录在table中的index,然后才能去table的index上的链表或者红黑树里面去寻找记录。下面的方法hash展示了HashMap是如何计算记录的hashCode值的方法


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

上面的hash方法仅仅是第一步,它只是计算出了hashCode值,但是还可以确定table中的index,接下来的一步需要做的就是根据hashCode来定位index,也就是需要对hashCode取模(hashCode % length),length是table的长度,但是我们知道,取模运算是较为复杂的计算,是非常耗时的计算,那有没有方法不通过取模计算而达到取模的效果呢,答案是肯定的,上文中提到,table的长度必然是2的n次方,这点很重要,HashMap通过设定table的长度为2的n次方,在取模的时候就可以通过下面的算法来进行


int index = hashCode & (length -1)

在length总是2的n次方的前提下,上面的算法等效于hashCode%length,但是现在通过使用&代替了%,而&的效率要远比%高,为了说明上面的算法是成立的,下面进行试验


hashCode = 8
length = 4

index = (8 % 4) = 0
index = 8 & (4-1) = (1000&0011) = 0 

获得当前table的一个快照,然后根据需要查找的记录的key的hashCode来定位到table中的index,如果该位置为null,说明没有没有记录落到该位置上,也就不存在我们查找的记录,直接返回null。如果该位置不为null,说明至少有一个记录落到该位置上来,那么就判断该位置的第一个记录是否使我们查找的记录,如果是则直接返回,否则,根据该index上是一条链表还是一棵红黑树来分别查找我们需要的记录,找到则返回记录,否则返回null

- put 操作

主要流程
JDK8的HashMap源码解析
①.判断键值对数组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,如果超过,进行扩容。

/**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
     *         (A <tt>null</tt> return can also indicate that the map
     *         previously associated <tt>null</tt> with <tt>key</tt>.)
     */
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

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

- resize 扩容操作

  1. 向一个空的HashMap中执行put操作时,会调用resize()进行初始化,要么默认初始化,capacity为16,要么根据传入的值进行初始化
  2. put操作后,检查到size已经超过threshold,那么便会执行resize,进行扩容,如果此时capcity已经大于了最大值,那么便把threshold置为int最大值,否则,对capcity,threshold进行扩容操作。

具体扩容图如下:将一个原先capcity为16的扩容成32的
JDK8的HashMap源码解析
在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变(因为任何数与0与都依旧是0),是1的话index变成“原索引+oldCap”
上面展示了resize方法的细节,可以看到扩容的实现时较为复杂的,但是我们知道所谓扩容,就是新申请一个较大容量的数组table,然后将原来的table中的内容都重新计算哈希落到新的数组table中来,然后将老的table释放掉。这里面有两个关键点,一个是新哈希数组的申请以及老哈希数组的释放,另外一个是重新计算记录的哈希值以将其插入到新的table中去。首先第一个问题是,扩容会扩大到多少,通过观察上面的代码可以确定,每次扩容都会扩大table的容量为原来的两倍,当然有一个最大值,如果HashMap的容量已经达到最大值了,那么就不会再进行扩容操作了。第二个问题是HashMap是如何在扩容之后将记录从老的table迁移到新的table中来的。上文中已经提到,table的长度确保是2的n次方,那么有意思的是,每次扩容容量变为原来的两倍,那么一个记录在新table中的位置要么就和原来一样,要么就需要迁移到(oldCap + index)的位置上

- 指定大小初始化

public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    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);
    }
    第一个常用,第二个建议是不用,不去动0.75的这个容量比例,当然不绝对
    这里tableSizeFor是一个很神奇的算法,我非常佩服的一个算法
        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;
        }
        这个方法是在找大于等于cap且最小2的幂
        比如cap=1   结果 2 0次方 1
        cap=2  2
        cap=3 4
        cap=9  16
        分析下等于9
        cap - 1  第一步结果8
        00000000000000000000000000001000    8
        00000000000000000000000000000100    右移100000000000000000000000000001100    或运算 结果
        00000000000000000000000000000011    右移200000000000000000000000000001111    或运算 结果
                                            
        00000000000000000000000000001111    右移 4 8 16没用全是0结果还是这个15
        最终 +1   16
        
        分析下等于大点 12345678
        00000000101111000110000101001110  12345678
        00000000101111000110000101001101  -1结果   12345677
        00000000010111100011000010100110  右移100000000111111100111000111101111  或运算 结果
        00000000001111111001110001111011  右移200000000111111111111110111111111  差不多了在移0就没了都是1了,+1不是肯定是2的倍数了
        
        再说开始-1原因这是为了防止,cap已经是2的幂。
        如果cap已经是2的幂, 又没有执行这个减1操作,则执行完后面的几条无符号右移操作之后,返回的capacity将是这个cap的2倍。如果不懂,要看完后面的几个无符号右移之后再回来看看

#### - jdk 7 与 jdk 8 中关于HashMap的对比
1. 8时红黑树+链表+数组的形式,当桶内元素大于8时,便会树化
hash值的计算方式不同
2. 1.7 table在创建hashmap时分配空间,而1.8在put的时候分配,如果table为空,则为table分配空间。
3. 在发生冲突,插入链中时,7是头插法,8是尾插法。
4. 在resize操作中,7需要重新进行index的计算,而8不需要,通过判断相应的位是0还是1,要么依旧是原index,要么是oldCap + 原index


参考:https://www.jianshu.com/p/aa017a3ddc40
相关标签: java相关