【Java基础】HashMap底层实现原理 附源码分析及面试题,带你深入Java集合

Java8的HashMap实现和之前的版本有所不同,本文中将作出详尽的说明。另外对并发环境下的ConcurrentHashMap也做了分析。

1. HashMap的数据结构

在讨论HashMap的存储数据结构之前,先假想两种数据结构:数组和链表。
虽然都可以实现对数据的存储,但这两者基本上是两个极端。

1.1 数组和链表

数组存储区间是连续的,占用内存严重,故空间复杂度很大。但数组的查找时间复杂度小,能直接锁定存储地址中的元素,所以时间复杂度为O(1);数组的特点是:寻址容易,插入和删除困难;
链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。

1.2 哈希

那么存不存在具备综合两者的特性,即寻址容易,插入删除也容易的数据结构呢?答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash table)既满足了查找的迅速(时间复杂度为O(1)),同时不占用太多的内存空间,使用十分方便。
哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法—— 拉链法,我们可以理解为“链表的数组” ,如图:
在这里插入图片描述
从下图可以出看出,哈希是由数组和链表组成的。,一个长度为16的数组中,每个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。一般情况是通过hash(key)%length获得,也就是元素的key的哈希值对数组长度取模得到。比如下图哈希表中,12%16=12,28%16=12,60%16=12,所以12、28及60都存储在数组下标为12的位置。
在这里插入图片描述
事实上,在JDK1.8之前,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里(如上图所示)。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值8时,将链表转换为红黑树(如下图所示),这样大大减少了查找时间。
在这里插入图片描述
首先HashMap里面实现一个接口Entry,其重要的属性有 key , value,由此我们就能很明显的看出来Entry就是HashMap键值对实现的一个基础bean,我们上面说到HashMap的基础就是一个线性数组,这个数组就是Entry[],Map里面的内容都保存在Entry[]里面。
并且,HashMap中维护了一个不参与序列化(由关键字transient保证)的位桶数组,在第一次使用的时候会初始化,附上源码:

transient Node<K,V>[] table;

同时数组元素Node<K,V>实现了Entry接口:

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

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

JDK1.8对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;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }

        /**
         * Returns root of tree containing this node.
         */
        final TreeNode<K,V> root() {
            for (TreeNode<K,V> r = this, p;;) {
                if ((p = r.parent) == null)
                    return r;
                r = p;
            }
        }
        /* 源码篇幅偏长,此处省略,建议感兴趣的同学直接去阅读jdk */
   }

2. HashMap的存取机制

2.1 HashMap的put

先附上put(key,value)源码:

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

    /**
     * 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) {
        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;
    }

接下来以流程图的形式简单说下HashMap的put(key,value)的过程:
在这里插入图片描述
Tips:

可能有同学会存在疑问:如果两个key通过hash%Entry[].length得到的index相同,会不会有覆盖的危险?
  这里HashMap里面用到链式数据结构的一个概念。上面我们提到过Entry类里面有一个next属性,作用是指向下一个Entry。打个比方, 第一个键值对A进来,通过计算其key的hash得到的index=0,记做:Entry[0] = A。一会后又进来一个键值对B,通过计算其index也等于0,现在怎么办?HashMap会这样做:B.next = A,Entry[0] = B,如果又进来C,index也等于0,那么C.next = B,Entry[0] = C;这样我们发现index=0的地方其实存取了A,B,C三个键值对,他们通过next这个属性链接在一起。所以疑问不用担心。也就是说数组中存储的是最后插入的元素。

2.1 HashMap的get

接下来再附上get(key)源码:

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

get(key)方法时获取key的hash值,计算hash&(n-1)并获取在位桶数组中的位置first=tab[hash&(n-1)],Java会始终先检查第一个节点的key是否与参数key相等,不等就判断第一个节点first的下一节点的类型,如果是红黑树的话,就按照红黑树的遍历find()找到对应的Entry,否则就遍历后面的链表找到相同的key值返回对应的Value值即可。
Tips:

注意HashMap计算索引的时候用到了按位取与:(n - 1) & hash,作用相当于取余%(因为&运算返回的值一定小于等于两个数的最小值)。这就意味着数组下标相同,并不表示hashCode也相等。

3. HashMap的扩容机制

加载因子loadFactor(默认0.75):为什么需要使用加载因子,为什么需要扩容呢?因为如果填充比很大,说明利用的空间很多,如果一直不进行扩容的话,链表就会越来越长,这样查找的效率很低,因为链表的长度很大(当然最新版本使用了红黑树后会改进很多),扩容之后,将原来链表数组的每一个链表分成奇偶两个子链表分别挂在新链表数组的散列位置,这样就减少了每个链表的长度,增加查找效率。
构造hash表时,如果不指明初始大小,默认大小为16(即Node数组大小16),如果Node[]数组中的元素达到(加载因子*Node.length)重新调整HashMap大小 变为原来2倍大小,要注意,HashMap扩容是个很耗时的过程。
附上JDK1.8的源码:

/**
     * 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() {
        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;
    }

4. HashMap的遍历方式

直接附代码:

/**
 * @author Carson
 * @date 2020/1/16 14:15
 */
public class Main {
    public static void main(String[] args) {
        Map<String, String> hashMap = new HashMap<>();
        hashMap.put("username", "carson");
        hashMap.put("password", "carson2016");

        /* HashMap的遍历方式1.1 */
        Set<Map.Entry<String, String>> entrySet = hashMap.entrySet();
        for (Map.Entry<String, String> set : entrySet) {
            System.out.println(set.getKey() + "=" + set.getValue());
        }
        /* HashMap的遍历方式1.2 */
        Iterator<Map.Entry<String, String>> entryIterator = hashMap.entrySet().iterator();
        while (entryIterator.hasNext()) {
            Map.Entry<String, String> next = entryIterator.next();
            System.out.println("key=" + next.getKey() + " value=" + next.getValue());
        }
        
        /* HashMap的遍历方式2 */
        Iterator<String> iterator = hashMap.keySet().iterator();
        while (iterator.hasNext()) {
            String key = iterator.next();
            System.out.println("key=" + key + " value=" + hashMap.get(key));

        }
    }
}

个人建议使用第一种 EntrySet 进行遍历,因为该方案可以把 key/value 同时取出,第二种还得需要通过 key 取一次 value,效率稍低。

5. HashMap小结

5.1 哈希碰撞的影响

你可能还知道哈希碰撞会对hashMap的性能带来灾难性的影响。在JDK1.8之前,如果多个hashCode()的值落到同一个桶内的时候,这些值是存储到一个链表中的。最坏的情况下,所有的key都映射到同一个桶中,这样hashmap就退化成了一个链表——查找时间从O(1)到O(n)。
随着HashMap的大小的增长,get()方法的开销也越来越大。由于所有的记录都在同一个桶里的超长链表内,平均查询一条记录就需要遍历一半的列表。
JDK1.8HashMap的红黑树是这样解决的:

如果某个桶中的记录过大的话(当前是TREEIFY_THRESHOLD = 8),HashMap会动态的使用一个专门的treemap实现来替换掉它。这样做的结果会更好,是O(logn),而不是糟糕的O(n)。
它是如何工作的?前面产生冲突的那些KEY对应的记录只是简单的追加到一个链表后面,这些记录只能通过遍历来进行查找。但是超过这个阈值后HashMap开始将列表升级成一个红黑树,使用哈希值作为树的分支变量,如果两个哈希值不等,但指向同一个桶的话,较大的那个会插入到右子树里。如果哈希值相等,HashMap希望key值最好是实现了Comparable接口的,这样它可以按照顺序来进行插入。这对HashMap的key来说并不是必须的,不过如果实现了当然最好。否则在出现严重哈希碰撞的时候很难获得性能提升。

5.2 HashMap和Hashtable的区别

HashMap和Hashtable(顺带一提,这里的Hashtable的"t"确实是小写,不符合驼峰式命名法)都实现了Map接口,但是它们在线程安全性,同步(synchronization),以及速度上都有着区别:

  • Hashtable底层有监视器锁synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable,而HashMap不能在多个线程之间共享;

  • HashMap可以接受为null的键值(key)和值(value),而Hashtable则不行;

  • HashMap的迭代器(Iterator)是fail-fast(即时失败)迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器Iterator本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常;

  • 由于Hashtable是底层维护了监视器锁,所以在单线程环境下它比HashMap要慢。

5.3 HashMap插入key-value的时候,当两个对象的hashCode相同会发生什么?如果两个键的hashCode相同,又该如何获取值对象?

  1. 首先要明确,当两个对象的hashCode相同时,这两个对象未必相等(如果equals()方法判断也相等,则插入失败)。hashCode值相等时,说明两个对象在HashMap中bucket桶中的位置相同,所以会发生碰撞,因为HashMap使用链表存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在链表中。当链表的长度超过8的时候,链表会转换成红黑树。
  2. 当我们调用get()方法,HashMap会使用键对象的hashCode找到bucket位置,然后会调用key.equals()方法去找到链表(由于HashMap在链表中存储的是键值对)中正确的节点,最终找到要找的值对象,这里可参看上述get(key)源码中的处理。

5.4 多线程条件下重新调整HashMap大小存在什么问题?

会产生竞态条件(race condition),因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就会形成环形链表,发生死循环了。
回答到这里,也许你会觉得很满意,但是你想过没有,多线程条件下你为什么要选择HashMap?
并发条件下有个安全的HashMap可供选择,即:ConcurrentHashMap
关于ConcurrentHashMap请参看我的另一篇博客。

参考阅读:
HashMap实现原理分析
Java中HashMap底层实现原理(JDK1.8)源码分析
HashMap底层实现原理及面试问题

发布了28 篇原创文章 · 获赞 12 · 访问量 6万+

猜你喜欢

转载自blog.csdn.net/Carson_Chu/article/details/103972763