【JavaEE】JDK1.8的HashMap源码解析

1 篇文章 0 订阅
1 篇文章 0 订阅

HashMap作为我们平常使用很频繁的一个存储工具,我们都知道它的存储效率很高。现在我来对他的源码进行一下分析。

HashMap数据结构

在这里插入图片描述

可以直观的看到,HashMap用的是开链法来解决hash冲突。
其中,table是一个数组,类型是Node类型(TreeNode类型最终也是Node类型的子类),约定,每一个数组元素称为,每个桶中装的及桶之后的元素称为bin。比如,上图中,0号桶中只有一个bin,而1号桶中有5个bin。
还有一些比较难区分的名词,例如:size,capacity,loadFactor,threshold。
size指的是:HashMap中存放KV的数量(包括了数组,链表,还有红黑树中的)。
capacity指的是:HashMap中桶的数量,也就是数组的长度,默认是16,之后会进行扩容,容量都是2的幂次方。
loadFactor指的是:加载因子,用来衡量HashMap满的程度,默认值为0.75f。
threshold指的是:阈值,它等于capacity*loadFactor。

HashMap的成员

	//	这是他的序列号,有关序列号,在之前的博文中已经写道,这里不再赘述
	private static final long serialVersionUID = 362498820763181265L;
	//	默认的桶的数量 16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    //	默认的桶的最大数量 1 << 30
    static final int MAXIMUM_CAPACITY = 1 << 30;
    //	默认加载因子 0.75f
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    //	将每个桶中的链转化为红黑数的bin个数的阈值:8
    static final int TREEIFY_THRESHOLD = 8;
    //	将每个桶中的红黑数转化为链的bin个数的阈值:6
    static final int UNTREEIFY_THRESHOLD = 6;
    //	最小的转化为红黑树的数组容量(也就是桶的个数): 64 
    static final int MIN_TREEIFY_CAPACITY = 64;

	//	数组类型是Node类型,
	transient Node<K,V>[] table; 
	transient Set<Map.Entry<K,V>> entrySet;
	//	总的KV的数量
	transient int size;
	//	会随着KV的增多而增大,减小而减小,(这个值多用在迭代器的快速失败)
	transient int modCount;
	//	阈值,它等于loadFactor * capacity;
	int threshold;
	//	加载因子,会和capacity一起影响整个hashmap的结构(两者乘积大于阈值时,会进行扩容)
	final float loadFactor;

HashMap的构造方法

	//	传输数组大小和加载因子
	public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
        	//	如果传入的数组的大小小于0,抛出异常
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
			//	如果传入的数组的大小最大值就是MAXIMUM_CAPACITY
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
            //	加载因子比0小,或者加载因子不是一个number,抛出异常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
    //	只传输一个数组的大小,加载因子是默认的0.75
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    //	无参构造,加载因子是默认的0.75
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    //	传递一个Map类型的m,加载因子还是默认的0.75
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

影响HashMap效率的因素

关于hash函数

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

在这里插入图片描述在putValue()函数中,会有这样一条判断(putValue()函数我们会在后面进行分析)

if ((p = tab[i = (n - 1) & hash]) == null)

可以看出,我们通过将hash值与数组的(长度-1)进行与运算,将这个结果作为table数组的下标,在这个下标对应的桶中进行元素的存储。
为什么这样做呢?
还是上面图片的结果,它的返回值是 0000 0000 0000 1101 0001 0101 0101 0010 假设现在的数组的长度为16,将它与16-1相与,
     0000 0000 0000 1101 0001 0101 0101 0010
&
    0000 0000 0000 0000 0000 0000 0000 1111
结果是 0000 0000  0000 0000   0000 0000   0000 0010
那么取到的结果是2。
为了得到这个下标,我们先进行了hash()函数,又进行了运算,目的就是位了将它进行位扰动,从而增加散列度,减少哈希碰撞。与(长度-1)的结果相与,是因为,如过和长度相与,长度是2的次幂,转换成位运算,只有一个位置是1,那么将它与哈希函数的返回值相与,只有两种结果,一种是0,一种是长度的值。那么它发生哈希碰撞的可能性就大大增加。与长度-1相与,取到的结果是在0 - (长度-1)之间。
那取下标的时候为什么不用    hash结果 % 长度呢?
因为位运算的效率要比取余运算高,取余最后还是要转换成位运算进行计算。

关于数组的capacity的大小为什么一定 是2的幂次

 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为9;
转化为二进制就是:1001
那么n = cap - 1:1000
n >>> 1 : 0100
n |= n >>> 1 : 1100
n >>> 2 : 0011
n |= n >>> 2 : 1111
n >>> 4 : 0000
n |= n >>> 4 : 1111
n >>> 8: 0000
n |= n >>> 8 : 1111
n >>> 16: 0000
n |= n >>> 16 : 1111
这时的n的值为:15,return的结果时 n + 1,所以返回值为16;
HashMap通过一些列的为运算,保证了,最后的结果一定是2的次幂,而其值都是比cap大的最小的2的整数次幂。之所以要用2的次幂,这是和上面所讲的hash()函数是相挂钩的。因为hash函数就是为了进行位干扰,干扰的结果再与长度-1进行与运算,如果长度是2的整数次幂,那么,长度减一,除了最高为为0,剩下的都为1,只要其中有一位不是1,那么结果就会有很大的变化,这样一来,散列度全部由hash函数所决定,也符合java分而治之的原则。否则,其中有的位不是1,那一位的值就必然是0,这就造成了位的浪费。

为什么加载因子是0.75
加载因子如果定的太大,比如1,这就意味着数组的每个空位都需要填满,即达到理想状态,不产生链表,但实际是不可能达到这种理想状态,如果一直等数组填满才扩容,虽然达到了最大的数组空间利用率,但会产生大量的哈希碰撞,同时产生更多的链表,显然不符合我们的需求。
但如果设置的过小,比如0.5,这样一来保证了数组空间很充足,减少了哈希碰撞,这种情况下查询效率很高,但消耗了大量空间。
因此,我们就需要在时间和空间上做一个折中,选择最合适的负载因子以保证最优化,取到了0.75

HashMap的扩容和树化过程

先说结论
1.所有的树化和扩容都是针对某个桶里的bin而言的,而不是所有的桶全部进行扩容或者树化。
2.当某个桶的bin的个数(也就是node节点的个数),大于 TREEIFY_THRESHOLD 值,但是,capacity小于 MIN_TREEIFY_CAPACITY ,只对当前链表进行扩容,不进行树化。
3.当某个桶的bin的个数(也就是node节点的个数),大于 TREEIFY_THRESHOLD 值,并且capacity满足了 MIN_TREEIFY_CAPACITY ,对当前链表进行树化。

来看具体的源码分析
put方法

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

put方法调用了putVal()方法:

	final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //  判断当前数组是否已经初始化
        if ((tab = table) == null || (n = tab.length) == 0)
        //  若没有初始化,则去初始化它
            n = (tab = resize()).length;
        //  现在的tab表示Node数组,n表示其长度
        if ((p = tab[i = (n - 1) & hash]) == null)
            //  执行到这里,表明当前的索引到的index下标,还未存放元素
            tab[i] = newNode(hash, key, value, null);
        else {
            //  执行到这里,表明当前index下标已经存放了元素
            Node<K,V> e; K k;
             //  检测要放的元素的key和已经存放在index下标的元素的key是否相等
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                //  这里表明两个key是相等的,那么进行覆盖,替换原来的旧值
                e = p;
            else if (p instanceof TreeNode) //  若两个key不相等,那么检测当前下标所形成的非线性结构是否是红黑树?
                //  执行到这里,表明非线性结构是红黑树
                //  那就把它插入到红黑树里面
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //  执行到这里,表明存储结构是链表
                //  那就先对链表进行遍历,检测是否存过在这个key
                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    //  这里进行判断,是否当前链表的长度已经 >= 8
                            //  执行到这里,表明已经 >= 8,那么将这个链表转化成红黑树进行存储
                            //	当然,我们前面提到,转化成红黑树需要两个条件,这里只满足了其中之一
                            //	第二个条件在treeifyBin()函数里面进行判断
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))  // 这里判断是否存在key相等的节点
                        //    相等,跳出循环
                        break;
                    p = e;
                }
            }
            //  由于e!=null,那么就说明存在key,
            if (e != null) { // existing mapping for key
            //  这里进行旧值的替换
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
            //	开始执行子类覆盖节点之后的方法
                afterNodeAccess(e);
            //	因为是覆盖,所以长度并未增加,可以直接返回
                return oldValue;
            }
        }
        //  这个modCount是作为迭代器的总长度来用,
        ++modCount;
        //  先对hashmap的总容量进行+1,然后比较它和阈值的大小
        if (++size > threshold)
        //  已经比阈值大,那么进行扩容
            resize();
        afterNodeInsertion(evict);
        return null;
    }

对于put方法,我们可以进行一个简单概括,如果在数组中找到该节点,那就进行覆盖,否则,检测当前是否是红黑树结构,如果是,就直接进行存储,否则就是链表,就进行遍历,如果找到,就进行覆盖,如果遍历完还没有找到,那就在链表的末尾进行增加,增加之后进行判断,当前链上的bin是否已经达到阈值,如果达到,转化为红黑树(当然,我们前面提到,转化成红黑树需要两个条件,这里只满足了其中之一,第二个条件在treeifyBin()函数里面进行判断)。最后,如果是覆盖了原来的节点,那么直接返回,如果是在原来的基础上增加了,那么进行modCount++,如果这时已经超过了阈值,那就进行扩容(通过resize()方法)。

resize()方法:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        //  判断旧的数组容量是否大于0(也就是table是否已经初始化过了)
        if (oldCap > 0) {
            //  table已经初始化过了
            //  判断旧的数组的容量是否已经达到或多于默认的最大容量(1 << 30)
            if (oldCap >= MAXIMUM_CAPACITY) {
                //  改变阈值为整型量的最大值:0x7fffffff
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //  执行到这里表明,那就是还未达到默认的最大容量
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //  左移1位,表示扩大到原来的两倍
                newThr = oldThr << 1; // double threshold
        }
        //  判断旧的数组的阈值是否大于0
        else if (oldThr > 0) // initial capacity was placed in threshold
            //  新的容量设置为阈值
            newCap = oldThr;
        //  两者都不大0.那就证明数组从未初始化过,只能使用默认值
        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;
                //	如果当前的数组元素不为null,把值赋值给e
                if ((e = oldTab[j]) != null) {
                	//	把当前的数组元素赋值为null
                    oldTab[j] = null;
                    if (e.next == null)
                    	//	表明数组元素没后后继节点,该桶中只有一个节点
                    	//	将该节点放到新数组中(下标通过hash运算和长度-1相与得到)
                        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;
                            //	这条语句表明,当前的bin在新数组中是否改变了位置
                            //	因为假设在原来链中,e.hash()的结果转化为二进制是 1 0010;
                            //	原来数组的长度是 16;
                            //	那它存放的数组下标就是 0010,也就是2
                            //	在新数组中,因为e.hash()的结果仍是不变的,而且数组长度是扩大为原来的2倍
                            //	那么当前数组的长度也就是32,长度减一转化为二进制就是:01 1111
                            //	两者相与的结果 1 0010 ,和原来相比较,只有最高为进行变动,如果最高为不变,那么数组下标就和原来的一样,正因为最高为变动,所以数组下标才进行了变动
                            //	可见,在新数组中的位置是取决于原来长度的二进制的最高一位,恰好长度全部是2的整数次幂,所以,只需要hash值和长度相与,便可知道在新数组中的位置是否进行了变动。
                            if ((e.hash & oldCap) == 0) {
                            	//	结果为0,表明在新数组中的位置没有变动
                                if (loTail == null)
                                	//	当前的低位的头指向e所指向的空间,也就是链表的头部
                                    loHead = e;
                                else
                                	//	当前低位的尾的next指向e所指向的空间
                                    loTail.next = e;
                                    //	当前的低位的尾指向了e所指向的空间
                                loTail = e;
                                //	上述这几条语句,目的就是为了让头指针指向链表的头部,尾指针一直指向e所指向的空间
                            }
                            else {
                            //	这个else里面的语句的意思与上面if里面的意思一样,只不过是这个是存放在数组中下标高的链
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);//	这个do-while循环,目的就是进行链的遍历,并自行判断应该放在原来的位置还是新的位置。
                        //	然后下面这些语句是将这条链放在新的数组中,
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            //	执行完一次,继续循环执行,直到循环完旧的数组
            }
        }
        //	将新生成的数组进行返回,这也是等于是将旧的数组进行了扩容
        return newTab;
    }

其中,通过对每个bin的hash值与旧的数组的长度进行再次相与的结果与0作比较,这样一来,会改变某些bin的位置,会使得散列更加的均匀。画个图来进行说明:
在这里插入图片描述上图可见,原来下标为1的数组,在新数组中只存放了两个,显然,散列度更高。

get()方法:

public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

get方法会调用getNode()方法,
来看getNode()方法:

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //	先判断table数组是否存在,table数组的长度是否大于零,
        //	根据当前的hash值与(长度-1)相与得到当前的数组元素,检测其值是否为null,
        //	若都是否,那直接return null
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //	再检测当前(也就是数组元素,也是头节点)节点是否是要找的key,
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                //	如果是,直接返回当前节点
                return first;
                //	检测下一个节点是不是null,如果是,直接返回null,未找到
            if ((e = first.next) != null) {
            	//	判断当前存储结构是否是红黑树结构
                if (first instanceof TreeNode)
                	//	是,直接交由红黑树的get函数处理,并将结果返回
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                	//	否则,执行循环,循环遍历链表,直到遍历到末尾或者找到key为止
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        //	如果找到,将该节点返回
                        return e;
                } while ((e = e.next) != null);
            }
        }
        //	否则,未找到,返回null
        return null;
    }

再来看remove()方法,

public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }

同样,remove方法调用了removeNode()方法,我们来分析removeNode()方法:

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;
        //	这个判断和getNode()方法类似,先检测数组的性质是否满足
        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;
            //	满足,开始判断该数组下标下的第一个bin是否满足
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                //	第一个满足,直接将node指向它
                node = p;
           	//	否则,判断下一个是否为null
            else if ((e = p.next) != null) {
				//	若不是null,先检测是否为红黑树,
                if (p instanceof TreeNode)
                	//	若是,交由红黑树处理,把查找的结果返回给node(这里,和getNode方法的处理一样)
                    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,此时node已经指向了要删除的目标
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                     //	执行完循环后,p始终是e的上一个节点,如果找到了,那么node就等于e,
                     //	若找不到,node还是null
                }
            }
            //	检测当前node的值,若为null,直接返回null
            //	
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode)
                	//	node是个红黑树节点类型,交由红黑树删除节点的函数处理
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)
                //	node == p 是指数组里面存放的就是要删除的节点,
                //	那样直接改变数组里面存放的节点就好
                    tab[index] = node.next;
                else
                	//	执行到这里会有两种情况,一种是找到了,切p一定是node的上一个节点
                	//	另一种是没有找到,node为null,p为末节点
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);
                //	若找到,node不为null,如找不到,node就为null
                return node;
            }
        }
        return null;
    }

其实,get和remove方法,两个方法很多相似的地方,尤其是查询的时候。
对于HashMap的这四个方法,他们的存取原则,整体上都是先从数组入手,然后在判断是否是红黑树,是的话,按红黑树处理,不是的话,按链表处理。这样的处理方法,很有逻辑。这次看源码的收获还是很大的,看到了HashMap里面对于位运算,存储结构等的巧妙应用,很受启发。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值