深入理解JDK1.8中HashMap,LinkedHashMap源码及设计思路

之前有分析过1.8之前的HashMap源码,今天再来看看1.8中HashMap,LinkedHashMap的比较有意思的代码及设计思路。新版HashMap相较于老版的数组+链表,多了红黑树这样的结构。

需要注意下,并不是说红黑树查找效率就一定比链表的效率高,而是与数组长度和链表长度有关系的。代码里的体现是链表的长度不小于8且数组的长度不小于64的时候才会将链表转化为红黑树。我们就进入正题。

列下着重分析的点:

  1. HashMap,LinkedHashMap的关系
  2. 加载因子的作用和默认的加载因子为什么是0.75f
  3. 为什么哈希表的容量(数组长度)是2的整数次幂
  4. put操作的实现源码及设计思路
  5. get操作的实现源码及设计思路
  6. 怎么进行扩容
  7. HashMap是怎么解决hash冲突的

(一)HashMap,LinkedHashMap的关系

1.LinkedHashMap是继承自HashMap,用源码说话

HashMap的类头是这样的

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
   
   

LinkedHashMap的类头是这样的

public class LinkedHashMap<K,V>
    extends HashMap<K,V>
    implements Map<K,V>

2.HashMap插入顺序与迭代顺序是不一致的,LinkedHashMap相比于HashMap,多维护一个双向链表结构,用于解决顺序的问题。这个点常用于LRU缓存的实现。

(二)加载因子的作用和默认的加载因子为什么是0.75f

我们知道,加载因子主要是在时间成本和空间成本上寻求一种最优解。

如果加载因子过高,例如为1,虽然减少了空间开销,提高了空间利用率,但是Hash碰撞的几率会变高,相应的链表长度会增加,造成的结果激素增加了查询时间成本;

如果加载因子过低,例如0.5,虽然可以减少查询时间成本,但是空间利用率很低,会增加扩容操作的次数,扩容操作也是很费时间和cpu资源的;

那么为什么选择0.75f呢?主要是泊松分布,0.75f的话碰撞几率最小。关于泊松分布,可以自行查阅百度百科。这里主要想表达的就是这个0.75f是有出处的。

(三)为什么哈希表的容量(数组长度)是2的整数次幂

length为2的整数次幂,为偶数,这样length-1为奇数,奇数的最后一位是1,这样便保证了h&(length-1)的最后一位可能为0,也可能为1(这取决于h的值),即进行&运算后的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性。

而假设length为奇数的话,很明显length-1为偶数,它的最后一位是0,这样h&(length-1)的最后一位肯定为0,即只能为偶数,这样任何hash值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间,因此,length取2的整数次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。

(四)put操作的实现源码及设计思路

HashMap中put

设计思路:通过hash值找到数组下标,然后分三条线:

  1. 第一条是对数组操作,数组当前下标没有值,就直接赋值;
  2. 第二条线是对链表操作,这里又分两条线:第一条,当前链表不存在一样的key,找到末尾的元素,对末尾元素的next赋值;第二条,存在一样的key,进行值value替换。
  3. 第三条是对树结构操作,这里也是分两条线:第一条,当前树中不存在一样的key,找到对应位置,进行赋值;第二条,存在一样的key,则返回当前。

公开的方法是这样的

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

里面又调用了方法  putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict),具体实现如下

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        //声明承接用的临时变量
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //判断数组是否存在,如果不存在说明是第一次put数据,调用resize()方法
        if ((tab = table) == null || (n = tab.length) == 0)
            //resize方法负责tab数组的创建和扩容,这里new出新的tab数组,并把length赋给n
            n = (tab = resize()).length;
        //经过前面的判断,这里用hash&leng-1,计算出i,再去tab相应的下标查找,如果为null,
        //说明数组这个位置没有被占用
        if ((p = tab[i = (n - 1) & hash]) == null)
            //直接new一个元素,放到tab的这个位置
            tab[i] = newNode(hash, key, value, null);
        else {//进入到这里,说明hash值碰撞了,需要采用链表存取数据了
            Node<K,V> e; K k;
            //这里是对数组中下标为i的链表头进行比较,若hash和key都一样,就把p赋给e
            //说明存在相同的key,下面会进行value替换
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //进入到这里,说明链表表头不是同一个元素,要去链表里面操作
            //这个if判断是否为TreeNode,因为TreeNode继承Node,且只有两种结构,
            //所以先判断子类,如果不是就走Node结构的操作,不会遗漏
            else if (p instanceof TreeNode)
                //执行TreeNode的put方法
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //binCount带有计数器的功能,代表链表的长度
                for (int binCount = 0; ; ++binCount) {
                    //这个if做了两件事:把p.next赋给e;判断p.next是否为null
                    if ((e = p.next) == null) {
                        //如果为null,将新put的元素链接到p的next元素
                        p.next = newNode(hash, key, value, null);
                        //这里拿到链表的长度,如果符合条件,就转换成树结构
                        //static final int TREEIFY_THRESHOLD = 8;
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //这里和前面一样,代表的是有同样的Key存在,下面会进行value替换
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //进入到这里,说明有同样的key存在,要进行value替换,return回去的是oldValue
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);//这是个空方法,具体实现在LinkedHashMap
                return oldValue;
            }
        }
        //进入到这里,说明是添加新的元素,需要递增modCount和size 
        ++modCount;
        if (++size > threshold)//判断容量,如果大于临界值,进行扩容操作
            resize();
        afterNodeInsertion(evict);//这是个空方法,具体实现在LinkedHashMap
        return null;//当put新元素的时候,return的值为null
    }

这里提供下空方法的证据,源码如下

// Callbacks to allow LinkedHashMap post-actions
    void afterNodeAccess(Node<K,V> p) { }
    void afterNodeInsertion(boolean evict) { }
    void afterNodeRemoval(Node<K,V> p) { }

下面分析树结构的put方法TreeNode.putTreeVal方法

/**
* Tree version of putVal.
*/
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
                       int h, K k, V v) {
    Class<?> kc = null;//k的类型
    boolean searched = false;//是否遍历过树了
    TreeNode<K,V> root = (parent != null) ? root() : this;//树的根节点
    for (TreeNode<K,V> p = root;;) {
        int dir, ph; K pk;//dir树的左右方向,ph当前节点的hash,pk当前节点的key值
        if ((ph = p.hash) > h)
            dir = -1;
        else if (ph < h)
            dir = 1;
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))
            return p;
        //根据当前节点key值、hash和传进来要插入的key值、hash一般都能得到一个左或右方向
        //或者当前节点key值、hash和传进来要插入的key值、hash相等或equals
        //但是还有一种情况就是hash相等key不equals
        else if ((kc == null &&
                  (kc = comparableClassFor(k)) == null) ||
                 (dir = compareComparables(kc, k, pk)) == 0) {
            //走到这里说明:指定key没有实现comparable接口
            //或者实现了comparable接口并且和当前节点的键对象比较之后相等
            /**searched 标识是否已经对比过当前节点的左右子节点了
			* 如果还没有遍历过,那么就递归遍历对比,看是否能够得到那个键对象equals相等的节点
			* 如果得到了键的equals相等的的节点就返回
			* 如果还是没有键的equals相等的节点,那说明应该创建一个新节点了
			* find(h, k, kc)递归、while遍历ch的所有节点直到找到与k equals的节点否则就返回空
			*/
            if (!searched) {
                TreeNode<K,V> q, ch;
                searched = true;
                if (((ch = p.left) != null &&
                     (q = ch.find(h, k, kc)) != null) ||
                    ((ch = p.right) != null &&
                     (q = ch.find(h, k, kc)) != null))
                    return q;
            }
            // 走到这里就说明,遍历了所有子节点也没有找到和当前键equals相等的节点
            dir = tieBreakOrder(k, pk);//把两个key的object的hashcode()方法生成的hashcode比较决定方向
        }

        TreeNode<K,V> xp = p; // 定义xp指向当前节点
        /*
        * 如果dir小于等于0,那么看当前节点的左节点是否为空,如果为空,就可以把要添加的元素作为当前节点的左节点,如果不为空,还需要下一轮继续比较
        * 如果dir大于等于0,那么看当前节点的右节点是否为空,如果为空,就可以把要添加的元素作为当前节点的右节点,如果不为空,还需要下一轮继续比较
        * 如果以上两条当中有一个子节点不为空,这个if中还做了一件事,那就是把p已经指向了对应的不为空的子节点,开始下一轮的比较
        */
        if ((p = (dir <= 0) ? p.left : p.right) == null) {  
            // 如果恰好要添加的方向上的子节点为空,此时节点p已经指向了这个空的子节点
            Node<K,V> xpn = xp.next; // 获取当前节点的next节点
            TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn); // 创建一个新的树节点
            if (dir <= 0)
                xp.left = x;  // 左孩子指向到这个新的树节点
            else
                xp.right = x; // 右孩子指向到这个新的树节点
            xp.next = x; // 链表中的next节点指向到这个新的树节点
            x.parent = x.prev = xp; // 这个新的树节点的父节点、前节点均设置为 当前的树节点
            if (xpn != null) // 如果原来的next节点不为空
                ((TreeNode<K,V>)xpn).prev = x; // 那么原来的next节点的前节点指向到新的树节点
            moveRootToFront(tab, balanceInsertion(root, x));// 重新平衡,以及新的根节点置顶
            return null; // 返回空,意味着产生了一个新节点
        }
    }
}

下面分析链表转树结构的方法treeifyBin(tab, hash)

/**
 * tab:元素数组,
 * hash:hash值(要增加的键值对的key的hash值)
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
 
    int n, index; Node<K,V> e;
    /*
     * 如果元素数组为空 或者 数组长度小于 树结构化的最小限制
     * MIN_TREEIFY_CAPACITY 默认值64,对于这个值可以理解为:如果元素数组长度小于这个值,没有必要去进行结构转换
     * 当一个数组位置上集中了多个键值对,那是因为这些key的hash值和数组长度取模之后结果相同。(并不是因为这些key的hash值相同)
     * 因为hash值相同的概率不高,所以可以通过扩容的方式,来使得最终这些key的hash值在和新的数组长度取模之后,拆分到多个数组位置上。
     */
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize(); // 扩容,可参见resize方法解析
 
    // 如果元素数组长度已经大于等于了 MIN_TREEIFY_CAPACITY,那么就有必要进行结构转换了
    // 根据hash值和数组长度进行取模运算后,得到链表的首节点
    else if ((e = tab[index = (n - 1) & hash]) != null) { 
        TreeNode<K,V> hd = null, tl = null; // 定义首、尾节点
        do { 
            TreeNode<K,V> p = replacementTreeNode(e, null); // 将该节点转换为 树节点
            if (tl == null) // 如果尾节点为空,说明还没有根节点
                hd = p; // 首节点(根节点)指向 当前节点
            else { // 尾节点不为空,以下两行是一个双向链表结构
                p.prev = tl; // 当前树节点的 前一个节点指向 尾节点
                tl.next = p; // 尾节点的 后一个节点指向 当前节点
            }
            tl = p; // 把当前节点设为尾节点
        } while ((e = e.next) != null); // 继续遍历链表
 
        // 到目前为止 也只是把Node对象转换成了TreeNode对象,把单向链表转换成了双向链表
 
        // 把转换后的双向链表,替换原来位置上的单向链表
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

LinkedHashMap中put

LinkedHashMap延用的是父类HashMap中的put方法,大体思路是一样的,需要注意的地方有三个:

  1. 重写了newNode方法
  2. HashMap中未实现的方法afterNodeAccess
  3. HashMap中未实现的方法afterNodeInsertion

1.下面先看下newNode方法的源码:

Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
        LinkedHashMapEntry<K,V> p =
            new LinkedHashMapEntry<K,V>(hash, key, value, e);
        linkNodeLast(p);
        return p;
}



// link at the end of list
private void linkNodeLast(LinkedHashMapEntry<K,V> p) {
        LinkedHashMapEntry<K,V> last = tail;
        tail = p;
        if (last == null)
            head = p;
        else {
            p.before = last;
            last.after = p;
        }
}

在这个方法中,LinkedHashMap 创建了 Entry ,并通过 linkNodeLast 方法将 Entry 接在双向链表的尾部,实现了双向链表的建立。双向链表建立之后,我们就可以按照插入顺序去遍历 LinkedHashMap。

2.下面来看下LinkedHashMap中实现的afterNodeAccess方法,这里的变量accessOrder需要注意,是在构造函数中进行赋值的,默认是false,代表按插入顺序进行排序,如果为ture,代表按访问顺序进行排序。

这个方法在put操作中进行,意义并不是很大,因为我们正常put操作,就是在尾端进行追加元素的,主要意义是在get操作时,将要取出的元素排到尾端,这正是LRU缓存基于LinkedHashMap实现的原因之一。

设计思路主要分三种情况:

  1. 当前节点没有前驱,没有后继时,链表的head,tail都指向当前节点;
  2. 当前节点只有前驱,没有后继时,位置并没有移动;
  3. 当前节点有前驱,有后继时,从链表中,把当前节点拿出来,然后对当前节点的前后节点进行链接,然后把当前节点指向链表的末端。
// LinkedHashMap 中重写
void afterNodeAccess(Node<K,V> e) { // move node to last
        LinkedHashMap.Entry<K,V> last;
        if (accessOrder && (last = tail) != e) {
            LinkedHashMap.Entry<K,V> p =
                (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
            p.after = null;
            // 如果 b 为空,表面 p 为头节点
            if (b == null)
                head = a;
            else
                b.after = a;
            if (a != null)
                a.before = b;
            else
                last = b;
            if (last == null)
                head = p;
            else {
                // 将 p 接在链表的最后
                p.before = last;
                last.after = p;
            }
            tail = p;
            ++modCount;
        }
}

3.下面来看下LinkedHashMap中实现的afterNodeInsertion方法,可以看到,removeEldestEntry默认返回false,但是当我们重写这个方法,根据我们自己的策略返回true时,就可以从head节点进行删除操作了。这正是LRU缓存基于LinkedHashMap实现的另一个原因。

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    // 根据条件判断是否移除最近最少访问的节点
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

// 移除最近最少被访问条件之一,通过覆盖此方法可以实现不同策略的缓存
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;
}

(五)get操作的实现源码及设计思路

HashMap中get

设计思路:跟前面put的思路刚好相反。

通过hash值找到数组下标,然后分三条线:

  1. 第一条是对数组操作,取出数组当前下标值,进行key判断,如果相同直接return;
  2. 第二条线是对链表操作,这里又分两条线:第一条,当前链表不存在一样的key,返回null;第二条,存在一样的key,return。
  3. 第三条是对树结构操作,这里也是分两条线:第一条,当前树中不存在一样的key,返回null;第二条,存在一样的key,return。

方法源码如下所示:

public V get(Object key) {
        Node<K,V> e;//这里主要做判空操作,主要逻辑在下面的方法
        return (e = getNode(hash(key), key)) == null ? null : e.value;
}

/**
     * Implements Map.get and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @return the node, or null if none
     */
    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;
    }

LinkedHashMap中get

这里和父类的get基本一样,只有当查找的节点不为null且accessOrder为true时,多了排序的操作。

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

(六)怎么进行扩容

这里HashMap,LinkedHashMap是一样的。

设计思路:先计算 新的hash表容量和新的容量阀值,然后初始化一个新的hash表,将旧的键值对重新映射在新的hash表里。如果在旧的hash表里涉及到红黑树,那么在映射到新的hash表中还涉及到红黑树的拆分。

源码如下:

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;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        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;
    }

(七)HashMap是怎么解决hash冲突的

我们就先来看看HashMap中,是如何计算hash值的。

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

代码是超级简单,hash值其实就是通过hashcode与16异或计算的来的,为什么要使用异或运算呢?因为相较于与,或运算,通过异或运算能够是的计算出来的hash比较均匀,不容易出现冲突。但是偏偏出现了冲突现象,这时候该如何去解决呢?在数据结构中,我们处理hash冲突常使用的方法有:开发定址法、再哈希法、链地址法、建立公共溢出区。而hashMap中处理hash冲突的方法就是链地址法。这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。正好与HashMap底层的数据结构相呼应。

哦了,上面列的7点就全都分析完了。以上是个人观点,如果有同行觉得哪里有问题,请在下方留言,我们一起进步啊!

 

 

猜你喜欢

转载自blog.csdn.net/CallmeZhe/article/details/110262786