HashMap作为我们经常使用的集合,我们除了熟练的使用它,更应该掌握其具体的实现原理(JDK1.8)。关于HashMap是个啥,我这里就不讲述了。

总览

WX20200404-221223@2x.png

从上图中我们可以看出HashMap的父类以及一些属性。下面我抽取其中几个关键的属性进行说明:

transient Node<K,V>[]

存储K-V数据的结构体,可以看出这是一个数组(bucket),关于HasMap,我们会根据Key值计算一个索引即该K-V存储在数组位置中,当随着数据增多,会有不同的key会被存储在bucket相同的位置,在HasMap中解决冲突主要有两种方式:

  • 链表
  • 红黑树

该字段被标记为transient,表明不可被序列化,关于hashmap的序列化和反序列化我们后面会讲到,这里不过多提及。

链表

看一下HashMap中链表的数据结构。

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
}

从上述的定义,基于链表的实现主要有以下几个字段:

  • hash:bucket位置,也就是key的hash值
  • key:Key
  • value:Value值
  • next:链表中的下一个,如果该K-V为链表中的最后一个,那么该值为null
红黑树

看一下HashMap中红黑树的数据结构,关于树的相关内容,我会单独开一篇文章写,这里就不过多讲述了。

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;
}

从上述的定义,基于红黑树的实现主要有以下几个字段:

  • parent:父节点
  • left:左节点
  • right:右节点
  • prev:上一个节点
  • read:是否是红黑树的标记
Set<Map.Entry<K,V>> entrySet

缓存了所有的K-V节点

size

k-v的数量

threshold

当k-v的数量达到threshold,默认值是DEFAULT_INITIAL_CAPACITY(16),当经历过一次扩容以后,该值的计算规则是capacity * load factor(当前容量*负载因子)

loadFactor

负载因子,默认值是0.75,该默认值平衡性能和存储空间,在实际使用中不建议修改。增大该值,会降低空间开销但是会增大查询成本(受影响的操作主要有get和put方法)。

DEFAULT_INITIAL_CAPACITY

HashMap默认的初始化容量,默认值16,初始化的容量可以在HashMap被初始化时进行指定,但是必须是2的幂。

MAXIMUM_CAPACITY

默认的最大容量(2的30次方),HashMap的最大容量也可以在初始化时进行指定,但指定的值必须在2的幂并且小于等于2的30次方

DEFAULT_LOAD_FACTOR

默认的负载因子0.75

TREEIFY_THRESHOLD

由于JDK1.8HashMap引入了红黑树,当同一个bucket中的链表长度过长时数据结构会被替换成红黑树,这个长度的阀值就是由TREEIFY_THRESHOLD控制的,默认值为8

UNTREEIFY_THRESHOLD

当HashMap的key被移除时,会动态计算同一个bucket中的数量,当数量低于某个值时,那么数据结构会由红黑树再转化会列表。

MIN_TREEIFY_CAPACITY

上面两个属性用来控制同一个bucket中节点数量过多时会进行树状化,这个属性是用来控制当bucket的容量超过该值时强制进行树状化。

构造方法

public HashMap() {}
public HashMap(int initialCapacity) {}
public HashMap(int initialCapacity, float loadFactor) {}
public HashMap(Map<? extends K, ? extends V> m) {} 

HashMap的构造方法主要有上面三种,我们主要看第三种:

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;
    this.threshold = tableSizeFor(initialCapacity);
}

首先会check参数的正确性(初始化容量、负载因子),check完参数以后会设置负载因子,以及下一次扩容时HashMap中k-v的数量。下面看一下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;
}

解释一下上面一些特殊运算符的含义

  • |=:|=相当于 a= a | b
  • |:位运算,按位或,只有左右都为0才位0,否则为1
  • >>>:无符号右移,左边补0,右移N位,相当于除以2的N次方

在计算扩容的size时是HashMap在JDK1.8的一次性能优化,上述代码虽然很复杂,但最终功能是获得hash桶(bucket)的数量,假设指定的cap不是2的幂,那该方法获得的是比cap大的最小的2的幂。

首先分析一下 >>> 的作用并且为什么只右移到16位,首先我们返回的值是int,位数为32位。下面假设我们的n为01XX..XXX

  1. n |= n >>> 1:首先右移一位以后001X..XXX,然后再或之后赋值给n,那么n的情况就是011X..XXX,那么n现在前面肯定有2个1
  2. n |= n >>> 2:首先右移两位以后0000..XXX,然后再或之后赋值给n,那么n的情况就是01111..XXX,那么n的前面肯定有4个1

下面依次类推,右移4位以后,n前面有8个1,右移8位以后,n前面有16个1,当右移16位以后,n前面就有32个1,因此对于32位的整形数字数字来说,右移16位就够了,
最后再将结果+1,就变成2的幂了。

那么为什么先要将cap进行-1呢?原因是防止cap本身就是2的幂,如果cap本身就是2的幂不减1得到的数量将会有问题。

下节预告

后面我们会讲述HashMap的关键方法,比如get、put以及扩容等。

put方法

public V put(K key, V value) {
    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;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        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);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        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;
}
  1. 首先查看当前的bucket集合是否为空,如果为空则进行扩容,具体的扩容逻辑后面详细说
  2. 计算key存储在bucket的位置,并判断当前bucket位置上有没有数据节点,如果没有则新建一个
  3. 如果bucket位置上存在数据节点,如果数据节点的hash值等于新数据的hash,并且数据节点的key和新数据的key相等,最终只是用新节点的Value替换原来节点Value(onlyIfAbsent为false的前提下)
  4. 如果不满足3的条件,并且Node是个TreeNode的话,那么将采用红黑树的插入方式增加节点
  5. 如果不满足条件3并且节点不是一个TreeNode,则需要循环遍历链表中的节点,如果链表中的节点有和新的数据相等的节点,则直接退出,并且用新节点的Value替换原来节点的Value(onlyIfAbsent为false的前提下)
  6. 当遍历到链表节点的最后一个节点时,将该节点的下一个节点赋值为新的数据节点,并且判断是否已经满足树状化的条件,如果是则需要bucket中的链表进行树状化,除了满足TREEIFY_THRESHOLD需要还有一个条件才能进行树状化,那就是整个bucket的数量需要满足64
  7. 当新增完节点以后,会判断数量是否已经超过threshold,如果超过了就需要进行扩容

get方法

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

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;
}
  1. 根据key值计算出来的hash找到bucket的位置,先比较第一个节点,如果key相等直接返回该节点
  2. 如果key不相等,然后判断节点如果是TreeNode,则需要遍历树找到对应的节点
  3. 如果节点不是TreeNode,则遍历列表找到key相等的节点

remove方法

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}
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;
    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;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                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;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}
  1. 首先还是计算bucket中的位置,如果bucket中的第一个节点相等,那么就需要吧bucket中的元素替换为原来的节点的下一个节点
  2. 如果bucket中的第一个节点不相等,那么就需要根据Node类型来遍历树或者链表查找相等的节点
  3. 找到相等的节点以后,如果是树状化的节点,就将树中的节点移除,如果是链表,就需要将删除的节点的next值更改为要删除的的节点的next的值
  4. 最后修改size,也就是整个hashmap中节点的数量

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;
    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;
}
  1. 首先要做的事计算cap和threshold的值,新的容量计算需要根据老的threshold,如果扩容是第一次,那么第一次扩容的的容量就是在初始化map时指定的容量值(initalCapacity),如果不是第一次扩容,扩容后的容量就是扩容前的threshold值
  2. threshold的值的计算规则是,如果容量已经超过了16,那么新的threshold直接在原来的基础上double,否则就是用新的容量*负载因子
  3. 将计算好的threshold进行赋值
  4. 扩容时首先准备好一个新容量的Node集合。
  5. 然后遍历bucket,取出bucket的节点,如果取出的bucket中的节点只有一个节点那么直接重新hash放入新的bucket中
  6. 如果不是,并且节点是一个树状节点那么就利用树节点的扩容逻辑并且将节点移到合适的位置
  7. 如果是链表这边JDK8也是做了优化,减少了重新hash的性能消耗,而是采用了一个简单的逻辑,当节点的hash值和原来的容量做与运算时如果结果为0,表示该节点还是在原来的位置,如果不是,那么久是在原来的位置再加+原来的容量的位置。

下面讲一下为什么用(e.hash & oldCap)==0就可以判断元素的位置,首先按照原来的计算位置的方法都是按照hash值和length-1进行与运算取值的,而length又都是2的N次幂,那么length-1说明低位全是1,新容量只不过是将老的容量进行左移一位,如果(e.hash & oldCap) == 0说明与新老length-1的计算结果是一样的,所以就在原位置,否则就是老位置+原数组长度。

欢迎关注公众号
qrcode_for_gh_1706f00849c9_258.jpg


shysh
82 声望17 粉丝