Spring技术Java技术硬核技术

Java HashMap源码深度分析

2020-11-08  本文已影响0人  一字马胡

​在java语言中,HashMap是一个非常重要的数据结构,它被广泛用于存储具有key-value映射关系的数据,HashMap提供了高效的数据结构来实现key-value的映射;从HashMap的设计与实现中,我们可以学到很多巧妙的计算机思维,对我们日常工作中进行编码及方案设计存在很高的参考价值,学习和掌握HashMap就成了非常有必要的一件事情了。
学习HashMap,有这么几个关键问题需要搞明白:

搞明白这几个关键问题,HashMap就算是掌握得差不多了,下面,从源码级来分析一下这些关键问题的答案是什么。

一、 HashMap的数据结构


HashMap数据结构

HashMap的数据结构由数组+链表组成,从java 8开始,会新增红黑树这种数据结构,也就是在java8中,链表超过一定长度后就会变为红黑树。

在下文的分析中,如果不是特别说明,都是指的是java 8中的HashMap。

二 、HashMap关键属性


有几个关键的属性需要我们知道:

  /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

默认的table的大小,table的大小只能是2的幂次方,这和HashMap的哈希槽寻址算法存在着很大的关系,当然,HashMap执行resize的时候也会得益于这个table数组的幂次方大小的特性,这些问题稍后再分析;

 /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

table的最大长度;

    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

默认的负载因子,负载因子用于扩容,当HashMap中的元素数量大于:capacity * loadFactor后,HashMap就会执行扩容流程。

 /**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;

当链表的长度超过一定长度之和,链表就会被升级为红黑树,用于解决因为哈希碰撞非常严重的情况下的数据查询效率低下问题,最坏情况下,如果没有引入红黑树的情况下,get操作的时间复杂度将达到O(N),而引入红黑树,最坏情况下get操作的时间复杂度为O(lgN);8是默认的链表树化阈值,当某个hash槽内的链表的长度超过8之后,这条链表将演变为红黑树。

 /**
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     */
    static final int UNTREEIFY_THRESHOLD = 6;

红黑树也有可能会降级为链表,当在resize的时候,发现hash槽内的结构为红黑树,但是红黑树的节点数量小于等于6的时候,红黑树将降级为链表。

    /**
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

链表升级为红黑树还需要一个条件,就是table的长度要大于64,否则是不会执行升级操作的,会转而执行一次resize操作。

    /**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node<K,V>[] table;

table和上文中的结构图中的数组对应。

    /**
     * The next size value at which to resize (capacity * load factor).
     *
     * @serial
     */
    // (The javadoc description is true upon serialization.
    // Additionally, if the table array has not been allocated, this
    // field holds the initial array capacity, or zero signifying
    // DEFAULT_INITIAL_CAPACITY.)
    int threshold;

下次数组扩容阈值,这个值是通过:capacity * loadFactor计算出来的,每次扩容都会计算出下一次扩容的阈值,这个阈值说的是元素数量。

三、 HashMap数据插入流程


下面来跟着源码来学习一下HashMap的put操作流程:

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

首先需要注意的是hash这个方法:

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

如果key是null,则hash的计算结构为0,这里就有一个关键信息,如果一个HashMap中存在key为null的entry,那么这个entry一定在table数组的第一个位置,这是因为hash计算的结果直接影响了数据插入的hash槽位置,这一点稍后再看。
如果key不为null,则会拿key对象的hashCode来进行计算,这里的计算称为“扰动”,为什么要这样操作呢?本质上是为了降低哈希碰撞的概率,这里需要引出HashMap中定位哈希槽的寻址算法。
HashMap的table数组容量只能是2的幂次方,这样的话,2的幂次方的数有一个特性,那就是:hash & (len - 1) = hash % len,这样,在计算entry的哈希槽位置的时候,只需要位运算就可以快速得到结果,不需要执行取模运算,位运算的速度非常快,要比取模运算快很多。
回到上面的问题,为什么要对key对象的hashCode执行扰动呢?因为计算哈希槽位置的时候需要和table数组的长度进行&运算,在绝大部分场景下,table数组的长度不会很大,这就导致hashCode的高位很大概率不能有效参加寻址计算,所以将key的hashCode的高16位于低16位执行了异或运算,这样得到的hash值会均匀很多。
接下来继续看put操作的流程:

/**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict
        // 1           
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        
        // 2
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 3    
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        
        // 4     
        else {
            // 5
            Node<K,V> e; K k;
            
            // 6
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            
            // 7    
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            
            // 8    
            else {
            
                // 9
                for (int binCount = 0; ; ++binCount) {
                
                    // 10
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        
                        // 11
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    
                    // 12 
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    
                    // 13
                    p = e;
                }
            }
            
            // 14
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
​
                return oldValue;
            }
        }
​
        // 15
        if (++size > threshold)
            resize();
​
        return null;
    }

下面根据注释的编号来看一下对应位置的含义:

四、 HashMap扩容机制


扩容对于HashMap是一个很重要的操作,如果没有扩容机制,因为有哈希碰撞的发生,会使得链表或者红黑树的节点数量过多,导致查询效率较低。下面,就从源码的角度来分析一下HashMap的扩容是如何完成的:

/**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     *
     * @return the table
     */
    final Node<K,V>[] resize() {
    
        // 1
        Node<K,V>[] oldTab = table;
        
        // 2
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        
        // 3 
        int oldThr = threshold;
        
        // 4
        int newCap, newThr = 0;
        
        // 5
        if (oldCap > 0) {
        
            // 6
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            
            // 7
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        
        // 8
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        
        // 9
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        
        // 10
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        
        // 11
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        
        // 12
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        
        // 13
        table = newTab;
        
        // 14
        if (oldTab != null) {
        
            // 15
            for (int j = 0; j < oldCap; ++j) {
            
                // 16
                Node<K,V> e;
                
                // 17
                if ((e = oldTab[j]) != null) {
                
                    // 18
                    oldTab[j] = null;
                    
                    // 19
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    
                    // 20
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    
                    // 21
                    else { // preserve order
                        
                        // 22
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            
                            // 23
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            
                            // 24
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        
                        // 25
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        
                        // 26
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

resize操作很复杂,下面根据代码注释来看一下每个位置都在做什么:

并发数据迁移

我们假设扩容前数组长度为2,则扩容后数组的长度为4,原数组的数组下标为1的位置上存在一条链表需要迁移到新数组中去,这条链表长度为3,根据哈希槽位置计算方法:hash & (len -1),原来的len = 2, len - 1 = 1,新的len = 4, len - 1 = 3;用二进制表示为:01 => 11,如果继续扩容,则(len - 1)的变化规则为:01 => 11 => 111 => 1111 => ...,可以看到,没扩容一次,hash值的高位就会多一位来参与哈希计算,多一位的这位hash数二进制表现为:要么是0,要么是1,只有这两种可能,如果为0,则相当于计算出来的哈希槽位置和原来一样,如果为1,则哈希槽位置会+oldCap,比如对于k1和k2,假设k1的hash计算结果为3,二进制表示为:11,则原来的下标为 (11 & 01) = b01 = 1,扩容后下标计算为:(11 & 11) = b11 = 3,3 = 1 + 2 = oldIndex + oldCap;有了这个知识点,那么就可以继续来看22这个位置上的代码了,这里有两组变量,loHead和loTail是一组,hiHead和hiTail是一组,这两组分别表示上面分析的两种情况,也就是loHead和loTail表示那些扩容后依然在原来下标的Node,hiHead和hiTail表示那些扩容后需要移动到oldIdex + oldCap位置上的Node;

五、 HashMap数据查询机制


数据查询比较简单,我们来分析一下HashMap的get操作是如何完成的;

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

上来首先计算了key的hash,然后在调用getNode来实现节点查找,接下来看一下getNode方法的实现;

final Node<K,V> getNode(int hash, Object key) {
        // 1
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        
        // 2
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            
            // 3
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            
            // 4
            if ((e = first.next) != null) {
            
                // 5
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                
                // 6
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        
        // 7
        return null;
    }

六、 HashMap数据更新机制


数据更新操作主要看一下remove操作是如何实现的:

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

还是会先计算key的哈希值,然后调用removeNode方法来执行删除操作;下面来看一下removeNode方法的实现细节:

/**
     * Implements Map.remove and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to match if matchValue, else ignored
     * @param matchValue if true only remove if value is equal
     * @param movable if false do not move other nodes while removing
     * @return the node, or null if none
     */
    final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        
        // 1
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        
        // 2
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            
            // 3
            Node<K,V> node = null, e; K k; V v;
            
            // 4
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            
            // 5
            else if ((e = p.next) != null) {
            
                // 6
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                
                // 7
                else {
                    do {
                    
                        // 8
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            
            // 9
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                
                // 10
                if (node instanceof TreeNode)
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                
                // 11
                else if (node == p)
                    tab[index] = node.next;
                
                // 12
                else
                    p.next = node.next;
                ++modCount;
                
                // 13
                --size;
                return node;
            }
        }
        return null;
    }

七、 HashMap并发安全问题分析


我们都知道,HashMap是线程不安全的,所谓线程不安全,就是使用不当在并发环境下使用了HashMap,那么就会发生不可预料的问题,一个典型的问题就是cpu 100%问题,这个问题在java 7中是因为扩容的时候链表成环造成的,这个成环是因为java 7在迁移节点的时候使用的是头插法,在java 8中使用了尾插法避免了这个问题,但是java 8中依然存在100%的问题,在java 8中是因为红黑树父子节点成环了。
下面,来简单分析一下java 7中链表成环的问题:

 /**
     * Transfers all entries from current table to newTable.
     */
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                // 1
                Entry<K,V> next = e.next;
                
                // 2
                int i = indexFor(e.hash, newCapacity);
                
                // 3
                e.next = newTable[i];
                
                // 4
                newTable[i] = e;
                
                // 5
                e = next;
            }
        }
    }
image

如图所示,在扩容前,位置1上有一条长度为3的链表,扩容后数组的长度为4,这条链表的key=5的节点会被迁移到新数组位置为1的位置上,其余两个节点会按照尾插法迁移到新数组位置为3的位置上。
假设两个线程同时指向扩容,thread1执行到代码位置(1)的时候失去cpu,thread2此时获得cpu并完成了数据迁移,之后,thread1重新获得cpu,开始执行迁移操作,此时e执行key=3的节点,next指向key=7的节点,而此时thread2完成迁移后,key=7的节点的next为key=3的节点,此时已经成环,此时如果有线程执行get等查询操作,那么就可能陷入死循环;如果thread1可以继续执行迁移,执行注释中(3)这行代码后,就将key=3的节点的next指向了key=7的节点,执行(4)后,key=3的节点成了头结点,执行(5)后,e指向了key=7的节点,接着继续下一轮迁移;这样,这个迁移永远也完成不了,只会不断在更新槽位的头结点,死循环了。所以,如果是在并发环境下,我们应该使用线程安全的并发HashMap,

<pre style="margin: 0px; padding: 0px; background-color: rgb(255, 255, 255); color: rgb(0, 0, 0); font-family: "Source Code Pro"; font-size: 12pt;">ConcurrentHashMap是最好的选择,当然还有其他的方案,但是如果可以使用</pre>

ConcurrentHashMap,其他方案都不推荐。


参考资料:

上一篇 下一篇

猜你喜欢

热点阅读