HashMap原理分析

HashMap原理分析

小龙 624 2020-03-06

HashMapp底层基于哈希表(hash table)也叫散列表,哈希表是一种非常重要的数据结构,应用场景及其丰富,许多的缓存技术(比如memcached)的核心其实就是在内存中维护一张很大的哈希表。HashMap分为两个阶段,JDK1.8之前、JDK1.8及其以后。

  • JDK1.8以前:HashMap使用的是数组加链表的形式
  • JDK1.8以及以后:HashMap使用的是数组加链表加红黑树的形式

如果想要深入的理解HashMap我们需要先连接哈希表(Hash Table)

什么是哈希表(Hash Table)

首先来大改了解一下其他数据结构在增加、删除和查找等基础操作的执行性能

  • 数组:数组采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度是O(1);通过给定值进查找,需要遍历数组,逐一比对给定关键值和数据元素,时间负复杂度为O(n),对于一般的插入、删除操作,涉及到数组元素的移动,其也需要遍历数组,所以其平均的时间复杂度为O(n)
    HashMap1.png
  • 链表:链表的增加、删除等操作,仅需要节点间的引用即可,时间复杂度为O(1),而查找操作需要遍历整个链表进行逐一比对,时间复杂度为O(n)
  • 二叉树:二叉树数一个树状的形态,对于一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均时间复杂度为O(logn)
  • 哈希表:哈希表相对于上面的几种数据结构,在哈希表中进行增加、删除、查找等操作,性能都十分的高,在不考虑哈希冲突的情况下,仅需要一次定位即可完成,时间复杂度为O(1)。
    |- 数据结构的物理存储结构只有两种:顺序结构链式结构(像栈、队列、树、图等是从逻辑结构去抽象的,映射到内存中),在数字中通过下标查找某个元素,一次定位就可达到,而哈希表就是利用的这种特性,哈希表的主干就是数组,hashCode就是哈希表中数组的下标,在哈希表中每一个数组称为bucket(桶)
    |- 比如我们要新增或查找某个元素,我们通过把当前元素的关键字,通过某个函数映射到数组中的某个位置,通过数组的下标一次定位就可完成操作。存储位置=f(关键字),其中这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。
    hashmap2.png
    再增加、查找的时候先通过哈希函数计算出实际的存储地址、然后从数组中对应地址进行增加或取出即可。

哈希冲突

通过哈希函数虽然可以快速的找到数组对应的下标,但是万事无完美,如果两个不通的元素,通过哈希函数计算出的实际存储地址相同怎么办?也就是说当某一元素在进哈希运算的时候,算出来的地址上已经存在有其他元素,改地址已经被占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。哈希函数的设计至关重要,好的哈希函数会尽可能的保证计算简单散列分布均匀,但是数组只是一块连续且固定长度的内存空间,再好的哈希算法也不能保证计算的结果不发生冲突。其实发生了哈希冲突是有多种方法解决的

  • 开放定址法:发生冲突继续寻找下一快未被占用的存储地址。
  • 再散列函数法:再使用哈希函数去散列一个输入,如果输出的位置继续被占用,那么就再一次散列,直至不发生冲突。
  • 链地址法:如果发生了哈希冲突,那么就在冲突的数组位置上开辟一个链表,将冲突的数据存储的链表之中。

在HashMap之中就是使用了链地址发解决哈希冲突。

HashMap实现原理

HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个Key-value键值对。

//HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂,至于为什么这么做,后面会有详细分析。
    transient Entry[] table;

Entry是HashMap中的一个静态内部类。

 // 内置class输入对象,也就是我们说的桶
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;
        }
    }

HashMap的整体结构
HashMap3.png

简单来说,HashMap是有数组+链表组成的,数组是HashMap的主体,链表则是为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前Entry的next指向null),那么对于查找、添加等操作很快,仅需要一次寻址即可,如果定位的数据包含链表,对于添加操作其时间复杂度为O(n)首先需要遍历链表,存在相同元素及覆盖,否则就新增;对于查询操作也需要遍历链表,然后通过“equals()”方法进行逐一比对查找。所有从性能上考虑,HashMap中的链表出现的越少性能才会越好。
其他重要的字段

//默认初始容量为16,0000 0001 右移4位 0001 0000为16,主干数组的初始容量为16,而且这个数组
//必须是2的倍数(后面说为什么是2的倍数)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
 
//最大容量为int的最大值除2
static final int MAXIMUM_CAPACITY = 1 << 30;
 
//默认加载因子为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
 
//阈值,如果主干数组上的链表的长度大于8,链表转化为红黑树
 static final int TREEIFY_THRESHOLD = 8;
 
//hash表扩容后,如果发现某一个红黑树的长度小于6,则会重新退化为链表
 static final int UNTREEIFY_THRESHOLD = 6;
 
//当hashmap容量大于64时,链表才能转成红黑树
 static final int MIN_TREEIFY_CAPACITY = 64;
 
//临界值=主干数组容量*负载因子
int threshold;

HashMap有四个构造器(构造方法),如果调用构造器没哟传入initialCapacity和loadFactor这两个参数,会使用默认值

  • initialCacpcity = 16
  • loadFactory = 0.75
// 其中一个构造器
// 构造一个带指定初始容量和加载因子的空 HashMap。
public HashMap(int initialCapacity, float loadFactor) {
        // 如果指定初始容量小于0,抛错
        if (initialCapacity < 0) {
            throw new IllegalArgumentException("Illegal initial capacity: " +
                    initialCapacity);
        }
        //此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(2的30次方)
        // 如果初始容量大于系统默认最大容量,则初始容量为系统默认最大容量
        if (initialCapacity > MAXIMUM_CAPACITY) {
            initialCapacity = MAXIMUM_CAPACITY;
        }
        // 如果负载因子loadFactor小于0,或loadFactor是NaN,则抛错
        if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
            throw new IllegalArgumentException("Illegal load factor: " +
                    loadFactor);
        }
        // 设置负载因子
        this.loadFactor = loadFactor;
        // 设置阈值
        this.threshold = tableSizeFor(initialCapacity);
    }

HashMap构造器在设置阈值

/**
     * Returns a power of two size for the given target capacity.
     * tableSizeFor的作用就是,如果传入A,当A大于0,小于定义的最大容量时,
     * 如果A是2次幂则返回A,否则将A转化为一个比A大且差距最小的2次幂。
     * 例如传入7返回8,传入8返回8,传入9返回16
     * 如给定1->1,3->4 ,4->4 ,5->8 ,9->16,17->32,33->64
     */
    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;
    }

从上面的代码可以看出,在常规构造器中,没有对数组table分配内存空间,而是在执行put操作的时候才真正构建table数组,在调用了put()方法之后,又去调用了putVal()将key的hash值算出来之后传过去

    public V put(K key, V value) {
        //内部调用putVal
        return putVal(hash(key), key, value, false, true);
    }
    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方法,调整table的长度。resize方法兼顾初始化table,和大小不足时进行扩容
        if ((tab = table) == null || (n = tab.length) == 0) {
             /* 这里调用resize,其实就是第一次put时,对数组进行初始化。
               如果是默认构造方法会执行resize中的这几句话:
               newCap = DEFAULT_INITIAL_CAPACITY;  新的容量等于默认值16
               newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
               threshold = newThr;   临界值等于16*0.75
               Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
               table = newTab; 将新的node数组赋值给table,然后return newTab

                如果是自定义的构造方法则会执行resize中的:
                int oldThr = threshold;
                newCap = oldThr;   新的容量等于threshold,这里的threshold都是2的倍数,原因在
                于传入的数都经过tableSizeFor方法,返回了一个新值
                float ft = (float)newCap * loadFactor;
                newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                (int)ft : Integer.MAX_VALUE);
                 threshold = newThr; 新的临界值等于 (int)(新的容量*负载因子)
                Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
                table = newTab; return newTab;
            */
            n = (tab = resize()).length; //将调用resize后构造的数组的长度赋值给n
        }
        // 将数组长度与计算得到的hash值比较
        if ((p = tab[i = (n - 1) & hash]) == null) {
            tab[i] = newNode(hash, key, value, null);//位置为空,将i位置上赋值一个node对象
        } else {//位置不为空
            // 这里的逻辑,如果通过hashkey找到Node的节点p,该节点p已经有值,那么就要在链表的后面添加(p也可能已经是红黑树节点)
            Node<K, V> e;
            K k;
            // 如果这个位置的old节点与new节点的key完全相同,则替换value
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k)))) {
                e = p;
            }
            //key不同,那么有2种情况,一种是当前链表已经树化改造,或者还没有树化改造
            else if (p instanceof TreeNode) { // 如果p已经是树节点的一个实例,既这里已经是树了
                //已经是树化改造后的节点,那么新的节点需要插入到红黑色的指定位置
                e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
            } else {// p与新节点既不完全相同,p也不是treenode的实例,既还是链表
                for (int binCount = 0; ; ++binCount) {//一个死循环
                    if ((e = p.next) == null) { // e=p.next,如果p的next指向为null
                        p.next = newNode(hash, key, value, null);// 指向一个新的节点
                        if (binCount >= TREEIFY_THRESHOLD - 1) {// 如果链表长度大于等于8
                            treeifyBin(tab, hash);//将链表转为红黑树
                        }
                        break;
                    }
                    //如果遍历过程中链表中的元素与新添加的元素完全相同,则跳出循环
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k)))) {
                        break;
                    }
                    p = e;// 将e中的next赋值给p,即将链表中的下一个node赋值给p,
                    //继续循环遍历链表中的元素
                }
            }
            // 找到e节点,然后将value赋值
            if (e != null) { //这个判断中代码作用为:如果添加的元素产生了hash冲突,那么调用
                //put方法时,会将他在链表中他的上一个元素的值返回
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null) {//判断条件成立的话,将oldvalue替换
                    e.value = value;
                }
                // 这个方法在LinkedHashMap中能把访问到的元组放入双向链表末端,表示最近使用,(移除的是头部元素)
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
        //如果长度大于容量,则扩容
        if (++size > threshold)
            resize();
        // 这个方法是空的,在LinkedHashMap中,使用LRU算法,该方法有内容,实际是为了扩展其他
        afterNodeInsertion(evict);
        return null;
    }

resize的源码详解,扩容机制,单元如何散列到新的数组中,链表中的元素如何散列到新的数组中,红黑树的元素如何散列到新的数组中

    final Node<K, V>[] resize() {
        Node<K, V>[] oldTab = table;
        // 获取扩容前table长度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 这个值是2的次方,数组长度
        int oldThr = threshold;
        int newCap, newThr = 0;
        // 大于0,表示已经初始化,现在执行的是扩容操作
        if (oldCap > 0) { //扩容肯定执行这个分支
            if (oldCap >= MAXIMUM_CAPACITY) { //当容量超过最大值时,临界值设置为int最大值
                threshold = Integer.MAX_VALUE;
                return oldTab;
            } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; //扩容容量为2倍,临界值为2倍
        } else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // oldCap为零表示使用默认值,该处为初始化使调用
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {  //不会执行
            float ft = (float) newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ?
                    (int) ft : Integer.MAX_VALUE);
        }
        threshold = newThr; // 将新的临界值赋值赋值给threshold
        @SuppressWarnings({"rawtypes", "unchecked"})
        Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
        table = newTab; // 新的数组赋值给table
        // 扩容后,重新计算元素新的位置
        if (oldTab != null) { // 原数组
            for (int j = 0; j < oldCap; ++j) {//通过原容量遍历原数组
                Node<K, V> e;
                if ((e = oldTab[j]) != null) {// 判断node是否为空,将j位置上的节点保存到e,
                                              // 然后将oldTab[j]置为空,这里为什么要把他置为空呢,置为空有什么好处吗??难道是吧oldTab变为一个空数组,便于垃圾回收?? 这里不是很清楚
                    oldTab[j] = null;
                    if (e.next == null) //判断node上是否有链表
                        newTab[e.hash & (newCap - 1)] = e;//无链表,确定元素存放位置,扩容前的元素地址为 (oldCap - 1) & e.hash ,所以这里的新的地址只有两种可能,
                                                          //一是地址不变,二是变为老位置+oldCap
                    else if (e instanceof TreeNode)  //判断节点是不是树
                        ((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        // JDK8之前是transfer方法,多线程操作可能引起cpu100%
                        Node<K, V> loHead = null, loTail = null;
                        Node<K, V> hiHead = null, hiTail = null;
                        Node<K, V> next;
                        /* 这里如果判断成立,那么该元素的地址在新的数组中就不会改变。因为oldCap的最高位的1,在e.hash对应的位上为0,
                        所以扩容后得到的地址是一样的,位置不会改变 ,在后面的代码的执行中会放到loHead中去,最后赋值给newTab[j];
                        如果判断不成立,那么该元素的地址变为 原下标位置+oldCap,也就是lodCap最高位的1,
                        在e.hash对应的位置上也为1,所以扩容后的地址改变了,
                        在后面的代码中会放到hiHead中,最后赋值给newTab[j + oldCap]
             举个栗子来说一下上面的两种情况:
            设:oldCap=16 二进制为:0001 0000
                oldCap-1=15 二进制为:0000 1111
                e1.hash=10 二进制为:0000 1010
                e2.hash=26 二进制为:0101 1010
            e1在扩容前的位置为:e1.hash & oldCap-1  结果为:0000 1010 
            e2在扩容前的位置为:e2.hash & oldCap-1  结果为:0000 1010 
            结果相同,所以e1和e2在扩容前在同一个链表上,这是扩容之前的状态。
            
    现在扩容后,需要重新计算元素的位置,在扩容前的链表中计算地址的方式为e.hash & oldCap-1
    那么在扩容后应该也这么计算呀,扩容后的容量为oldCap*2=32 0010 0000 newCap=32,新的计算
    方式应该为
    e1.hash & newCap-1 
    即:0000 1010 & 0001 1111 
    结果为0000 1010与扩容前的位置完全一样。
    e2.hash & newCap-1 
    即:0101 1010 & 0001 1111 
    结果为0001 1010,为扩容前位置+oldCap。
    而这里却没有e.hash & newCap-1 而是 e.hash & oldCap,其实这两个是等效的,都是判断倒数第五位
    是0,还是1。如果是0,则位置不变,是1则位置改变为扩容前位置+oldCap。
            再来分析下loTail loHead这两个的执行过程(假设(e.hash & oldCap) == 0成立):
            第一次执行:
            e指向oldTab[j]所指向的node对象,即e指向该位置上链表的第一个元素
            loTail为空,所以loHead指向与e相同的node对象,然后loTail也指向了同一个node对象。
            最后,在判断条件e指向next,就是指向oldTab链表中的第二个元素
            第二次执行:
            lotail不为null,所以lotail.next指向e,这里其实是lotail指向的node对象的next指向e,
            也可以说是,loHead的next指向了e,就是指向了oldTab链表中第二个元素。此时loHead指向        
            的node变成了一个长度为2的链表。然后lotail=e也就是指向了链表中第二个元素的地址。
            第三次执行:
            与第二次执行类似,loHead上的链表长度变为3,又增加了一个node,loTail指向新增的node
               ......
            hiTail与hiHead的执行过程与以上相同,这里就不再做解释了。
            由此可以看出,loHead是用来保存新链表上的头元素的,loTail是用来保存尾元素的,直到遍            
            历完链表。   这是(e.hash & oldCap) == 0成立的时候。
            (e.hash & oldCap) == 0不成立的情况也相同,其实就是把oldCap遍历成两个新的链表,
            通过loHead和hiHead来保存链表的头结点,然后将两个头结点放到newTab[j]与 
            newTab[j+oldCap]上面去      
*/
                        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;
                        }
                    }
                }
            }
        }
        return newTab;
    }

hashmap4png.png


# HashMap