zoukankan      html  css  js  c++  java
  • Java中HashMap底层实现原理(JDK1.8)源码分析

    在JDK1.6,JDK1.7中,HashMap采用位桶+链表实现。每个桶对应不同的hash值,根据key计算得到的hash值,将键值对存放到对应位置。但是,很可能出现不同的key,计算出的hash值相同,这时就会造成哈希冲突。hashmap使用链表处理冲突,同一hash值的键值对存放在同一个桶中,并以链表形式存放。

    但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。

    hashMap的键值都可以为null。且键不能相同,这里说的相同的是:两个键hash值相同,且key==key2||(key!=null&&key.equals(key2))

     从下图源码中可以看出它的底层实现,

     /**
         * 哈希表的结构是桶(数组)+单向链表+红黑树。
         * 由于不同的key计算出的hash值可能相同,会造成hash冲突,引入单链表解决hash冲突。
         * 所以,桶中的各个元素hash值虽然相同,但是key不相同,因为hashmap不允许相同的key。
         * 当链表长度过大时,访问速度下降,引入红黑树解决这一问题
         * 哈希表的容量(桶的个数)是2的倍数
         * 该数组的初始化放在了put中,构造函数中并没有对其进行初始化
         */
        transient Node<K,V>[] table;

    即HashMap的原理图是:

    先来看看hashmap中的几个参数:

       /**
         * 默认的初始化容量(桶的个数),是16,   要为2的幂次
         */
        static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    
        /**
         * 最大的容量键值对的个数,是2的30次方
         */
        static final int MAXIMUM_CAPACITY = 1 << 30;
    
        /**
         * 加载因子。当hash表中桶的数目超过当前容量与加载因子的乘积时,就会扩容
         */
        static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
        /**
         * 一个桶树化的阈值。当桶中元素个数超过这个值时,将链表转化为红黑树。
         * 这个值至少为8.要不然频繁转化效率不高。
         */
        static final int TREEIFY_THRESHOLD = 8;
    
        /**
         * 一个桶的链表还原阈值。当桶中元素个数小于这个值时,红黑树会还原为链表。
         */
        static final int UNTREEIFY_THRESHOLD = 6;
    
        /**
         * 哈希表的最小树形化的容量。这里的容量是指表的容量(table capacity),也就是桶的个数
         * 只有当表的容量(桶的个数)大于这个值时,表中的桶才能树形化(转化成红黑树),
         * 否则,当桶内元素太多时,不是转换成红黑树,而是扩容,因为容量不够大。
         */
        static final int MIN_TREEIFY_CAPACITY = 64;

        /**
        *   The next size value at which to resize (capacity * load factor).从这里可看出,是键值对的个数
        * 门限,进行rehash的门限(capacity*loadFactor)
         */
        int threshold;
    
    
        final float loadFactor;

    这几个参数的含义上面都有标注。HashMap默认的容量(键值对个数)是16,。默认的加载因子是0.75,加载因子loadFactor是影响hashMap进行扩容的指标之一,还有一个是容量,也就是table数组的大小(桶的个数)。threshold是进行扩容的门限值,为capacity*loadFactor

    当一个桶中元素个数大于8时(添加元素时判断),会将链表转成红黑树;当树的节点个数小于6时(删除节点时判断),会转成链表。

    MIN_TREEIFY_CAPACITY变量:最小树形化的值。意思是:桶的的个数(表的容量)没有达到这个值(64)时,即使桶中元素个数大于8时,也不会转成红黑树,而是直接扩容(resize(),该方法后面介绍),扩大桶的个数,桶个数两倍。只有当桶的个数大于等于该值时,才会树形化。

     还有一个变量size ,表示当前存储键值对的数量,存一个则加1,删一个则减一。而capacity容量,也是表示存储键值对的容量,(不是桶的个数)。。这样,size>capacity*0.75则扩容,指得是键值对数量的比较。

    构造函数

     /**
         * 构造函数只是对loadFactor、threshold两个变量完成赋值,并没有初始化;
         * 具体的初始化将在put时进行。
         */
        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;
            //找到大于等于initialCapacity的第一个2的整数次幂的数。如14,返回16。(该方法最后会提到)
            this.threshold = tableSizeFor(initialCapacity); //新的扩容临界值
        }
    
    
        public HashMap(int initialCapacity) {
            this(initialCapacity, DEFAULT_LOAD_FACTOR);
        }
    
    
        public HashMap() {
            this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
        }

    从构造函数可以看出,构造函数并没有对底层的table数组进行初始化,而是和ArrayList一样,将初始化数组的过程推迟到第一次添加元素时进行。第一个构造函数,将传入的容量赋值给了threshold门限,后面会在resize方法中根据该门限进行初始化。

     
     哈希冲突:

    1、两节点 key 值相同(hash值一定相同),导致冲突

    2、两节点 key 值不同,由于 hash 函数的局限性导致hash 值相同,冲突

    3、两节点 key 值不同,hash 值不同,但 hash 值对数组长度取模后相同,冲突

    注意上面第三点,存元素都是hash&(n-1),所以一个桶内的多个key可能hash值是不同的,所以就可以解释每次遍历链表判断key是否相同时还要再判断key的hash值。

    HashMap的存取机制

     

    1,HashMap如何getValue值,看源码

      /**
         * 返回key对应的value
         * 1.计算key的hash值。
         * 2.根据hash值找到对应桶的第一个节点。
         * 3.判断第一个节点是不是(比较hash值和key)。
         * 4.第一个节点不是就分红黑树和链表继续遍历
         */
        public V get(Object key) {
            Node<K,V> e;
            return (e = getNode(hash(key), key)) == null ? null : e.value;
        }
    
        /**
         * 根据hash值和key找到对应的节点
         * 1.根据hash值找到对应的桶的第一个节点。如果第一个节点hash值以及key都对应的相等,则返回第一个。
         * 2.往后遍历,看看是不是树,然后遍历。
         * 这里找桶的算法是(n-1)&hash。n是桶的个数(2的幂次)。
         *
         * 这里取模时,前后两个数位置无所谓,只要有一个是2的幂次(b),另外一个是a,
         * 取模就等价于求余数(a%(b-1),(2的幂次-1)做分母)。
         * 所以,这里是等价于hash%(n-1)。也就是找桶的位置,从0开始
         */
        final Node<K,V> getNode(int hash, Object key) {
            Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
            if ((tab = table) != null && (n = tab.length) > 0 &&
                    (first = tab[(n - 1) & hash]) != null) {
                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)
                        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;
        }
    View Code

    步骤简述:

    1.计算key的hash值。
    2.根据hash值找到对应桶的第一个节点,hash&(n-1)。
    3.判断第一个节点是不是(比较hash值和key)。
    4.第一个节点不是就分红黑树和链表继续遍历

    这里根据hash值找到对应桶的算法是(n-1)&hash。这个算法其实就是取模运算,又因为n是桶的个数,是2的幂次,所以该算法等价于

    hash%(n-1),也就是找到对应的桶的位置(从0开始)。

    2,HashMap如何put(key,value);看源码

    /**
         * 存元素,见下个方法
         */
        public V put(K key, V value) {
            return putVal(hash(key), key, value, false, true);
        }
    
        /**
         * 存元素的步骤:
         * 1.根据key计算hash值;
         * 2.判断是否是第一次加入元素(table是否为空),如果是,则调用resize函数初始化:
         *    如果threshold=0,则初始化为16,;如果threshold不为0(构造函数中传入加载因子,会给threshold赋值,但是没有初始化table)
         * 3.根据hash值找到((n-1)&hash)对应桶的第一个元素;如果第一个元素为空,那么直接插入新节点。
         * 4.如果第一个元素不为空,则判断结构是不是红黑树,如果是红黑树则调用红黑树插入的方法;
         * 5.如果不是红黑树,则依次遍历链表,如果链表有和传入的key相同的key,则用新的value替换原来的value,并返回旧value;
         * 6.如果没有相同的key,则插入到链表的最后。并判断新链表的大小是否超过门限,超过则转换成红黑树。
         */
        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进行初始化,所以第一次put时,会进行判断table是否为空,为空则要进行初始化。
             * 也就是table初始化为初始化容量16.
             * resize()函数就是扩容:table为空时,扩容(也就是初始化)为默认容量16;table不为空时,扩容两倍(满足容量是2的幂次)
             */
            if ((tab = table) == null || (n = tab.length) == 0)
                n = (tab = resize()).length;
            //根据hash值,找到对应桶位置的第一个元素,如果该元素为空,则直接插入。
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
            else {
                Node<K,V> e; K k;
                //如果第一个元素不为空,且该元素的key与传入的key一样,说明已经存在该key,记录下来
                if (p.hash == hash &&
                        ((k = p.key) == key || (key != null && key.equals(k))))
                    e = p;
                else if (p instanceof TreeNode)
                    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                else {
                    for (int binCount = 0; ; ++binCount) {
                        //如果是最后一个了,且key都不相同,就将新节点插入到链表最后
                        if ((e = p.next) == null) {
                            p.next = newNode(hash, key, value, null);
                            //如果新加入节点后,链表大小超过阈值8,就转成红黑树
                            if (binCount >= TREEIFY_THRESHOLD - 1) // 因为从-1开始的。也就是-1到7,也就是大于8个节点时
                                treeifyBin(tab, hash);
                            break;
                        }//如果有相同的key,跳出循环
                        if (e.hash == hash &&
                                ((k = e.key) == key || (key != null && key.equals(k))))
                            break;
                        p = e;
                    }
                }
                /**
                 * 如果有相同的key,将用新的value替换就的value,并返回原来的value
                 */
                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.根据key计算hash值;
    2.判断是否是第一次加入元素(table是否为空),如果是,则调用resize函数初始化(扩容):(见下面resize)
        如果threshold=0,则初始化为16,;如果threshold不为0,初始化为threshold(构造函数中传入加载因子,会给threshold赋值,但是没有初     始化table)
    3.根据hash值找到((n-1)&hash)对应桶的第一个元素;如果第一个元素为空,那么直接插入新节点。
     4.如果第一个元素不为空,则判断结构是不是红黑树,如果是红黑树则调用红黑树插入的方法;
     5.如果不是红黑树,则依次遍历链表,如果链表有和传入的key相同的key,则用新的value替换原来的value,并返回旧value;
    6.如果没有相同的key,则插入到链表的最后。并判断新链表的大小是否超过门限,超过则转换成红黑树。

    7.判断新size是不是大于threshold,是就扩容

    HasMap的扩容机制resize()

    构造hash表时,如果不指明初始大小,默认大小为16(即Node数组大小16),如果Node[]数组中的元素达到threahold重新调整HashMap大小 变为原来2倍大小,扩容很耗时..。扩容是将数组的大小(桶的个数)扩大为两倍。,而判断是否扩容是根据键值对的个数,size>threshold

    /**
         * table为空时,扩容(也就是初始化)为默认容量16;table不为空时,扩容两倍(满足容量是2的幂次)
         * resize函数中新建一个散列表数组,容量为旧表的2倍,接着把旧表的键值对迁移到新表(重新计算hash值,存入新表),
         * 这里分三种情况:
         1. 表项只有一个键值对时,针对新表计算新的桶位置并插入键值对
         2. 表项节点是红黑树节点时(说明这个bin元素较多已经转成红黑树了),split这个bin。
         3. 表项节点包含多个键值对组成的链表时(拉链法),把链表上的键值对按hash值分成两串,一串放到新表的原索引位置,
            另外一串放到新表的  原索引位置+oldCap  处。
    
         */
        final Node<K,V>[] resize() {
            Node<K,V>[] oldTab = table;
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            int oldThr = threshold;
            int newCap, newThr = 0;
            if (oldCap > 0) {
                //如果原来桶的个数已经最大了,就不扩容了,直接返回原来数组
                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
            }//如果原来门限大于0,则新容量为原来门限
            else if (oldThr > 0) // initial capacity was placed in threshold
                newCap = oldThr;
            else {               // zero initial threshold signifies using defaults
                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;
            @SuppressWarnings({"rawtypes","unchecked"})
            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;
                        else if (e instanceof TreeNode)
                            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                        else { // preserve order
                            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;//另一串放到新表的 原索引+原容量  位置。
                            }
                        }
                    }
                }
            }
            return newTab;
        }
    resize

    步骤:确定扩容后的容量大小

    table不为空时,如果原来容量>16,扩容两倍(满足容量是2的幂次);如果16>容量>0,这是由于一开始传入初始容量比较小,前面扩容后容量也还是没有超过16,此时oldThr肯定>0,会调用下一步。

    当table为空时:1,如果threshold>0,这就是构造函数中传进来的初始化容量,初始化为该容量threshold;2,threshold=0,没有传入初始                            化容量,初始化为默认容量16.

    扩容两倍步骤:
    * resize函数中新建一个散列表数组,容量为旧表的2倍,接着把旧表的键值对迁移到新表(重新计算hash值,存入新表),
    * 这里分三种情况:遍历每个桶 j,
    1. 桶中只有一个键值对时,针对新表计算新的桶位置并插入键值对
    2. 桶中节点是红黑树节点时(说明这个bin元素较多已经转成红黑树了),split这个bin。
    3. 桶中节点包含多个键值对组成的链表时(拉链法),把链表上的键值对按hash值分成两串(根据(hash & oldCap) == 0),一串放到新表的原索引位置 j ,另外一串放到新表的 原索引位置  j+原表容量oldCap 处。

    这里(hash&oldCap)来决定链表上键值对放在哪,这里不是hash&(n-1),所以不是求余数。

     

     

     

    tableSizeFor()

    /**
         * 返回大于cap-1的第一个2的幂次。如cap=10,返回2^4=16
         * 如果cap本身就是2的幂次,就返回cap
         */
        static final int tableSizeFor(int cap) {
            int n = cap - 1;//这是防止cap本身就是2的幂次
            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;
        }

    这个方法是在构造函数中使用到。

    由此可以看到,当在实例化HashMap实例时,如果给定了initialCapacity,由于HashMap的capacity都是2的幂,因此这个方法用于找到大于等于initialCapacity的最小的2的幂(initialCapacity如果就是2的幂,则返回的还是这个数)。 
    下面分析这个算法: 
    首先,为什么要对cap做减1操作。int n = cap - 1; 
    这是为了防止,cap已经是2的幂。如果cap已经是2的幂, 又没有执行这个减1操作,则执行完后面的几条无符号右移操作之后,返回的capacity将是这个cap的2倍。如果不懂,要看完后面的几个无符号右移之后再回来看看。 
    下面看看这几个无符号右移操作: 
    如果n这时为0了(经过了cap-1之后),则经过后面的几次无符号右移依然是0,最后返回的capacity是1(最后有个n+1的操作)。 
    这里只讨论n不等于0的情况。 
    第一次右移

    n |= n >>> 1;

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

    n |= n >>> 2;

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

    n |= n >>> 4;

    这次把已经有的高位中的连续的4个1,右移4位,再做或操作,这样n的二进制表示的高位中会有8个连续的1。如00001111 1111xxxxxx 。 
    以此类推 
    注意,容量最大也就是32bit的正数,因此最后n |= n >>> 16; ,最多也就32个1,但是这时已经大于了MAXIMUM_CAPACITY,所以取值到MAXIMUM_CAPACITY 。

    该算法的思想就是使n=cap-1的二进制中,第一个1后面全转为为1。 
    举一个例子说明下吧。 
    这里写图片描述

    这个算法着实牛逼啊!

    注意,得到的这个capacity却被赋值给了threshold。

    this.threshold = tableSizeFor(initialCapacity);

    开始以为这个是个Bug,感觉应该这么写:

    this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;

    这样才符合threshold的意思(当HashMap的size到达threshold这个阈值时会扩容)。 

     但是,请注意,在构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在resize方法中会对threshold重新计算(如下,结合上面resize代码)。

      if (newThr == 0) {//针对的是上面oldThr>0,对threshold重新计算
                float ft = (float)newCap * loadFactor;
                newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                        (int)ft : Integer.MAX_VALUE);
            }

    参考:http://blog.csdn.net/tuke_tuke/article/details/51588156

           https://blog.csdn.net/fan2012huan/article/details/51097331

    另外,这篇文章写得挺不错:https://blog.csdn.net/zxt0601/article/details/77413921

  • 相关阅读:
    iOS学习笔记21-NSUrlSession与NSUrlConnection
    iOS项目日志1-联系人列表
    iOS学习笔记20-网络
    iOS学习笔记21-popover的使用
    vue-cli+webpack简单使用
    Vue2.0+webpack npm run dev报错
    RideGirl被拒原因
    NSUserDefaults 保存颜色
    UIMenuController
    Xcode报错解决方案
  • 原文地址:https://www.cnblogs.com/xiaolovewei/p/7993440.html
Copyright © 2011-2022 走看看