JDK8--HashMap源码阅读

    HashMap作为Java最常用的集合类之一,内部采用了数组 + 链表 + 树的形式来存储Key-Value。内部结构如下图所示:


    链表在大于一定长度时会转换为树形式存储,这个长度默认是8.

1、HashMap的初始化

    HashMap提供了多个构造函数进行初始化,各个构造函数如下:
/**
 * initialCapacity 容量,会作为table数组的长度,默认16
 * loadFactor 负载因子,默认0.75
 * threshold 极限值 = 容量 * 负载因子
 * 当HashMap中的key-value对数大于极限值时就会进行扩容操作(扩充数组长度)
 */
 public HashMap(int initialCapacity, float loadFactor);
 public HashMap(int initialCapacity);
 public HashMap();
 public HashMap(Map<? extends K, ? extends V> m);


   不管怎么指定HashMap的容量,最终HashMap的容量都会为大于等于该值的2的n次方幂,例如,指定初始容量17,最终经过计算,HashMap的容量会为32. 这是由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;
}

   这个方法会通过指定的容量得到一个极限值,然后作为第一次扩容时的数组的长度。由于HashMap扩容是有代价的,所以最好在初始化时指定容量。

2、HashMap的put操作

    HashMap的put方法里面包含了HashMap最重要的行为和逻辑。

2.1、确定Key-Value的存放位置

    put方法的第一步是确定Key-Value对存放在数组的哪一个位置,这由Key的hash值决定。首先通过hash方法计算出Key的hash值(这个值不是hashCode()的返回值):
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

    然后通过该方法的返回值与(当前数组的长度 - 1)进行且运算得出位置,这是JDK8中的做法,put部分方法如下:
HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
//第一次调用put方法会进行扩容,因为当前table并没有进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
   n = (tab = resize()).length;
//这个hash便是hash(key)的返回值,这儿是当前下标并不存在元素
//那么元素作为链表的头结点保存在数组中
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

2.2、解决hash冲突    

    如果没有经过2.1的逻辑,那么表示发生了hash冲突,即有两个key经过计算后落在了同一个下标位置,这样HashMap会为这两个Key生成一个链表,以下是HashMap的Node内部类部分代码:
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;
        }
    }

   可以看到这个Node是支持形成链表的,next指向下一个元素。下面是put方法解决hash冲突代码:
Node<K,V> e; K k;
//if 情况:put的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) {
        //到了链表的尾部,将节点放到链表的尾部
        if ((e = p.next) == null) {
            p.next = newNode(hash, key, value, null);
            //如果hash冲突大于8次,则转换为树
            if (binCount >= TREEIFY_THRESHOLD - 1)
                treeifyBin(tab, hash);
            break;
        }
        //put修改值而不是添加新元素
        if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
            break;
        p = e;
    }
}
// 修改值
if (e != null) {
    V oldValue = e.value;
    if (!onlyIfAbsent || oldValue == null)
        e.value = value;
    //空方法,可以忽略
    afterNodeAccess(e);
    //返回旧值
    return oldValue;
}

    可以看到hash冲突时分为两种情况,插入新元素和修改已有的值。同时是基于Key的hash值和equals方法来判断到底是修改还是插入。
    所以为什么建议hashCode方法与equals方法要同时覆盖,假设有一个User类,只覆盖了equals方法,equals方法对username属性进行比较,然后用User作为HashMap的Key。由于只覆盖了equals方法,为了取得Key对应的value,那必须保存这个User对象,否则通过下次new出来的User对象计算出的下标有很大可能与之前添加时下标不符合。图示如下:


    在这种情况下,如果想得到Key对应的Value,就必须保存这个Key,否则根本无法得到。所以如果要将类用于集合或者Map的Key,必须同时覆盖hashCode方法以及equals方法,不然集合类无法正常使用。

2.3、是否扩容等操作

    经过上面2.1、2.2步骤,如果put方法还没有返回的话,表示当前put方法新添加了元素,会有下面的判断。
//表示HashMap的结构被修改了
//新增、移除都是修改结构,但覆盖旧值不算
++modCount;
//如果当前key-value的对数大于极限值,则进行扩容
//极限值为容量 * 负载因子,默认情况下,如果容量为16,则极限值为12
if (++size > threshold)
    resize();
    afterNodeInsertion(evict);
return null;
   到这儿,一个put操作就完成了。

3、get操作

    HashMap的get方法,containsKey等方法查询Value的过程与put类似,第一步确定Key所在的下标,然后进行比较,知道链表的末尾,没有则返回null。第一步是非常快的,相当于直接使用索引访问元素,第二步则是沿着链表挨个对比,虽然链表查找元素很慢,但由于HashMap对hash冲突超过8次的元素会转为树存储,所以链表很短,也不大影响性能。

4、HashMap扩容

    HashMap在put完成后可能会有扩容操作,这个扩容操作由resize方法完成,该方法分为两步,a)、计算扩容后的数组长度,极限值 b)将元素从旧数组复制到新数组中。

4.1、计算长度

    计算长度,极限值代码如下:
//在初次put时也会触发扩容操作
    HashMap.Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
//这个if属于put后的扩容情况
if (oldCap > 0) {
    //如果旧容量已经达到最大,则不会进行扩容,会直接返回
    if (oldCap >= MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return oldTab;
    }
    //否则新容量为旧容量 * 2; oldCap << 1等价于 oldCap * 2
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
            oldCap >= DEFAULT_INITIAL_CAPACITY)
        //自然,这种情况下新极限值 = 旧极限值 * 2
        newThr = oldThr << 1; // double threshold
}
//指定了容量, 
else if (oldThr > 0)
    newCap = oldThr;
//这种情况就是初始化是没有指定容量,使用默认的16容量 以及负载因子0.75f
else {
    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;


   容量即长度,决定了一个Key存放于哪个位置,而极限值决定了什么时候扩容。至于为什么扩容? 这是因为随着不断添加元素,如果容量一直不变,那么会不断地发生hash冲突,影响性能

4.2、复制元素

    虽然System.arrayCopy方法能够非常快速地将元素复制到另一个数组中去,但HashMap数组不同于普通数组,它里面元素的位置是有元素的hash值与数组长度一起决定的,所以,不能使用这个方法。前面说过,HashMap一直保持容量是2的n次方幂,这是有原因的,还记得前面计算下标的算式吗? 它为 hash(key) & (length -1).计算如图示:

    数组扩容后长度变为32,结果如图示:


    所以,如果key的hash值与扩容前的数组长度进行与运算,为0,则下标不会改变,否则新的下标为 长度 + 就下标。代码如下:
final HashMap.Node<K,V>[] resize() {
        //新的数组
        HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
        table = newTab;
        //如果是第一次put触发初始化会跳过这个if
        if (oldTab != null) {
            //遍历旧数组
            for (int j = 0; j < oldCap; ++j) {
                HashMap.Node<K,V> e;
                //当前元素不为null的操作
                if ((e = oldTab[j]) != null) {
                    //清空旧数组当前元素 赋给了上面的e
                    oldTab[j] = null;
                    //链表长度为1,即只有头结点的情况
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                        //树的情况
                    else if (e instanceof HashMap.TreeNode)
                        ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else {
                        //处理链表 ****这算是resize方法的重点
                        //lohead表示下标不变的元素头结点
                        HashMap.Node<K,V> loHead = null, loTail = null;
                        //hihead则表示下标会改变的元素头结点
                        HashMap.Node<K,V> hiHead = null, hiTail = null;
                        HashMap.Node<K,V> next;

                        //这个循环会将链表中下标会改变和下标不会改变的元素分离成两个链表
                        do {
                            next = e.next;
                            //这个if表示下标扩容后不会改变
                            //hash值与上旧容量表示下标不会改变
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            //这个else表示下标扩容后会改变
                            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;
    }


   可以看到将一个链表分离为两个链表的代码的确与数组长度密切相关。所以说HashMap遍历是不稳定的,有很大可能结构会改变,这儿还不知道HashMap是怎样遍历的,但可以知道扩容后数组内元素顺序会改变。

5、HashMap遍历

    由于HashMap不能使用for循环以及foreach进行遍历,但可以使用迭代器进行遍历Key 或者 调用entrySet方法得到Entry数组来进行遍历,这儿主要分析迭代器如何遍历。使用HashMap的keySet即可返回一个Set对象,这个Set对象实现类是HashMap内部的KeySet类,迭代器类是KeyIterator类。 虽然可以使用foreach来遍历Set,但那是java的一个语法糖,最终还是使用迭代器进行遍历,所以着重看下KeyIterator类吧。
final class KeyIterator extends HashIterator
            implements Iterator<K> {
        public final K next() { return nextNode().key; }
}
    可以看到这个类只有一个方法,实现在父类的HashIterator类中。 下面是nextNode方法:
final Node<K,V> nextNode() {
        Node<K,V>[] t;
        //next是数组的第一个不为空的元素,可能是链表头结点,也可能是树的根节点。
        Node<K,V> e = next;
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        //这种情况 整个HashMap的数组没有元素存在,如果调用迭代器的next方法将抛出异常
        if (e == null)
            throw new NoSuchElementException();
        //遍历
        if ((next = (current = e).next) == null && (t = table) != null) {
            //主要逻辑在while条件里面
            //会一次遍历table数组,直到遍历完成或者碰到非空元素停止
            //于是会按照下标从0 - length的顺序遍历链表或者树
            do {} while (index < t.length && (next = t[index++]) == null);
        }
        return e;
    }

    所以为什么HashMap不具备稳定性,原因就在于HashMap遍历是按照数组遍历的顺序来挨个遍历链表或者树的,而HashMap在put后进行扩容可能会导致某些链表分离成两个链表放在不同的下标内,在进行遍历时,输出顺序就会不一致。

6、HashMap与Hashtable对比

    a)、由于HashMap并不像String那样是不可变对象,也不是绝对线程安全的对象,它属于线程兼容类型,即在多线程环境下必须加上同步措施来保证能得到预期的结果,而Hashtable虽然号称是线程安全的,的确它的每个方法都加了锁,但在多线程环境下也不能保证各个方法的调用顺序。
    b)、HashMap的Key与Value可以为空,而Hashtable的Key-Value均不能为空。
    c)、由于Hashtable每个方法都加了锁,所以效率比较低下,HashMap则没有这种限制。
    d)、多线程环境下可以使用ConcurrentHashMap来代替HashMap以及Hashtable

7、HashMap的TreeNode

    如果链表长度大于等于8就会使用树来存储,每个树节点使用TreeNode类来表示,代码如下:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K, V> parent;  // red-black tree links
        TreeNode<K, V> left;
        TreeNode<K, V> right;
        TreeNode<K, V> prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node<K, V> next) {
            super(hash, key, val, next);
        }
    }

   由于在添加时,prev属性等同于parent属性,而left等同于左孩子,right等同于右孩子。添加时到底放左边还是右边由hash值决定,当hash值大于根节点的hash时,元素会放在根节点的右边,小于则放左边,然后会一直遍历直到找到key的hash值相同的节点或者 叶子节点时结束遍历,然后进行相应的更新Value或者插入节点操作。同样,树在resize时也有可能会被分成两棵树。

    以上就是关于HashMap源码的理解,当然由于个人原因,可能会有遗漏以及错误地方,如果你发现了,欢迎在下面评论指出。















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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值