Java 集合——HashMap

Java 集合——HashMap

介绍

  先前介绍了 List 集合的三种子类实现原理,今天我们来讲讲另一种数据结构——Map 集合,Map 是一种用来存储 key-value 的数据结构,每一个 key 对应了一个 value,并且同一个集合里面不允许存在相同的 key。
  在 Java 中,常见的 Map 实现是 HashMap、HashTable、LinkedHashMap、TreeMap 和 ConcurentHashMap,下面从源码的层面上,给大家讲解 HashMap 的实现原理。

HashMap 定义

  从 HashMap 名字中我们可以看出这是一个用 hash 表这种数据结构实现 Map 集合,涉及到 hash 表这种数据结构就需要考虑两个问题:
  1. 使用什么算法来计算 key 的 hash 值
  2. 出现冲突时,使用什么机制去解决冲突
  这两个是 hash 表要考虑也是最核心的两个问题,只要搞懂了这两个问题,其实你就能够理解 HashMap 的实现原理了。

首先先来看看 HashMap 类的定义:

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

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    static final int MAXIMUM_CAPACITY = 1 << 30;
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    static final int TREEIFY_THRESHOLD = 8;
    static final int UNTREEIFY_THRESHOLD = 6;
    static final int MIN_TREEIFY_CAPACITY = 64;

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

        Node(int hash, K key, V value, Node next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }

    transient Node[] table;
    transient int size;
    transient int modCount;
}

  上面列举了 HashMap 定义的一些静态常量,这些静态常量的具体用途在下面讲解具体操作的时候会再介绍,首先需先理解 Node 的定义和其他变量的用途。
  Node 是集合中结点的抽象,它内部封装了该结点的 key-value,同时还保存了 hash 值,key 和 hash 都是 final 域,这代表加入集合中的 key 是不可变的,要求其 hashcode 值不会发生改变,否则将可能会出现内存泄露问题。另外该结点定义中还有一个 next 域,用于指向下一个结点,这是用于解决冲突所定义的结构(其实从这个定义就可以猜想到 HashMap 是采用拉链法来解决冲突的)。
  table 是 HashMap 的用于存储结点元素的数据结构,其实就是 hash 表,通过 size 来记录当前 hash 表存放了多少个元素。其实从这里定义可知,HashMap 的底层数据结构是数组 + 链表,所有操作都是通过操作这两个数据结构来进行的。

HashMap 的基本操作

  map 是一种集合的数据结构,当然就需要实现集合的基本操作,那么现在就从最最基本的添加操作来讲解,通过结合源码和例子来让大家更加清晰地了解它的设计思想

put 操作

  put 操作用于在集合添加一个 key-value 的键值对,在 HashMap 中采用数组+链表的形式来存储数据,每一个数组元素都是一个链表,该链表存储相同 hash 值的 key 结点(拉链法)。
它的主要工作流程:
1. 先通过 hash 算法计算 key 的 hash 值,再通过 hash & (n-1) 计算该 key 应该存放在数组哪一个位置
2. 若当前 index 位置上不存在元素,则代表未发生冲突,直接创建新结点存放,并作为该位置链表的头结点,结束;
3. 若当前 index 位置上存在元素,则代表发生冲突,则采用拉链法解决冲突,遍历链表查看是否存在相同的 key 元素(比较方法采用 equals 方法,null 元素特别处理),存在,则替换旧值;若不存在,则插入到链表的末尾
4. 更新当前的元素个数,若超过负载因子,则进行扩容操作

JDK1.8 的源码实现:

    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[] tab; 
        Node p; 
        int n, i;

        /*
         如果当前 table 为null,则当前 hash 表未初始化,则进行初
         始化操作
         */     
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

        /*
         如果当前 hash 后的数组位置不存在结点,即代表还未没有发生冲突,
         则创建新结点保存
         */
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {

        /*
         当前位置已经有元素,则代表出现冲突,采用拉链法解决冲突,遍历
         链表查看是否存在相同的 key ,若存在,则将新值替换旧值,返回
         旧值;若不存在,则在链表的末尾插入元素。
         注:比较 key 是否相同,是采用 equals 方法来进行的,除 key
         为 null 的情况,HashMap 是可以存放 null 元素的
         */
            Node 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)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) 
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }

            // 上面的过程查询相同的 key 的结点,若找到,则执行值替换
            if (e != null) {
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;

        // 插入元素成功后,容量增加,若当前超过负载因子,则进行扩容操作
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

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

  从上述的过程和源码,相信大家对 put 操作有一个大致的了解,那么现在再具体地来说一下一些内部的细节是如何实现,增加了哪些优化操作,以及在 jdk 1.8 为什么要这样实现。

hash 算法实现

  从源码可以看出,hash 值是通过公式 hashcode ^ (hashcode >>> 16) 计算所得,在 JDK 1.8 前都是直接采用 hashcode,并未经过计算,该种方式保证了高 16 位全为 1,具体的优化目的其实我也不太清楚,可能可增大命中高位位置的概率。

计算 index 位置

  要计算 index 位置,先通过 hash 算法计算 key 的 hash 值,再通过公式 hash & (n-1) 来算的,这也是 1.8 后引入的优化操作。在之前,都是通过 hash % n 来计算 index,但在 1.8 时,HashMap 的容量都要求是 2 的整数倍,并且根据公式 hash & (n-1) = hash % n,当 n = 2^i 时,& 运算比 % 运算的效率要高,因此采用 & 运算代替 % 运算计算 index 。

红黑树的引入

  从源码部分其实可以看到增加了一些 TreeNode 的操作,这部分也是 JDK 1.8 新引入的红黑树机制。考虑当未引入红黑树时,出现冲突时采用拉链法解决冲突,那么当查找元素时则需要遍历链表找到匹配的 key,最优时查找次数为 1,最差时查找次数为 n,那么总得平均查找次数为 n/2,因此当出现较多碰撞时,HashMap 的查询性能则会下降,时间复杂度可能会去到 O(n)。
  因为为解决决该种情况时的性能下降,JDK 1.8 引入了红黑树,红黑树也是一种较平衡的二叉树,它的插入、查找和删除的时间复杂度都为 O(logn),具体的红黑树结构可以自行去 google 了解。因此,在 JDK 1.8 中,当添加元素时,链表元素个数大于 8 个时,则会触发将链表转换成红黑树,从而提高查询性能。

扩容操作

  前面也提到,当当前负载大于负载因子,则会触发扩容操作。
  那么问题就来了:为什么需要扩容?负载因子是什么?
  首先负载因子 = 当前元素个数 / 总容量,即当前元素所占的比例。我们知道 HashMap 底层采用的数组这种结构,在有限的存储空间存放元素,当存放 n + 1 个元素时,那么至少有两个元素会存放在同一个位置,那就是说当存放的元素个数越多,发生冲突的概率就会越高,那么就会影响查询性能。因此为了降低的冲突的概率,当负载超过 75% 时,则将数组扩容,增大容量。

具体的扩容工作流程:
1. 先将当前容量增大一倍(因为都是 2 的倍数,采用 << 1 操作),创建一个新容量的新数组
2. 遍历旧数组每一个元素,对链表上的元素或红黑树上的元素进行重定位,存放到新数组中(这里面会涉及到红黑树的退化以及链表的拆分)

重定位操作是通过公式 hash & n 来进行,由于重定位的结果只有两种,要么放在原 index 位置,要么放在 index + n 位置,那么现在假设当前 n 为 2,在 1 位置上 存放了 key 为 1 和 key 为 3 的元素,即如下图:
Java 集合——HashMap_第1张图片
经过扩容后,则转化为:
Java 集合——HashMap_第2张图片

从图上可以看出 3 去到新数组的 3 位置,而 1 仍在 1 位置。那么现在来看 1 的二进制表示为 0001,3 的二进制表示为 0011 ,扩容后容量为 4(0100), 即计算位置的公式为 hash & 0011,那么只要当 hash 的最高位为 1,则新位置为 index + n,否则为 index。(与旧容量的对齐的最高位,当前为第二位)

扩容源码实现:

    final Node[] resize() {
        Node[] 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) 
            newCap = oldThr;
        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;

        // 创建新数组,容量为原来的两倍
        @SuppressWarnings({"rawtypes","unchecked"})
            Node[] newTab = (Node[])new Node[newCap];
        table = newTab;

        // 遍历旧数组元素,将元素重定位
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node 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)e).split(this, newTab, j, oldCap);

                    // 这里进行链表的分裂,将链表根据最高位是否为1,来分配位置
                    else {
                        Node loHead = null, loTail = null;
                        Node hiHead = null, hiTail = null;
                        Node 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;
    }

  通过 put 操作的分析其实已经能够了解 HashMap 的具体实现原理,删除和获取操作实际上也是对数组+链表+红黑树这个数据结构进行操作而已,只要理解了添加操作,对于其他操作应该也不难理解。另外的话,HashMap 的迭代器也是属于 fast-fail 类型,在遍历时,若其他线程修改了 map(导致 modCOunt 被修改),会抛出 ConcurentModifyException 。

总结

  在 Java 中,Map 的实现子类有HashMap、HashTable、LinkedHashMap、TreeMap 和 ConcurentHashMap,HashMap 在该文章已经讲解过,LinkedHashMap 则是在 HashMap 的基础上,增加了一个链表机制来维护元素添加的顺序;TreeMap 则是可排序的 Map 集合,内部是采用红黑树来实现的。
  而 HashTable 是一个比较古老的容器,它与 HashMap 的实现机制类似,都是采用 数组+链表的形式来实现的,不过 HashMap 增加了红黑树的实现。两者还有一个重要的区别是 HashMap 是线程不安全的,而 HashTable 是线程安全的,因为后者每一个方法都使用了 synchronize 关键字来定义。另外的话,HashTable 不支持 key 为 null 的元素,且扩容容量为原来的 1.5 倍。虽然 HashTable 是线程安全的,但同样不适用于并发环境编程,因为 synchronize 会导致性能下降,限制了并发量。因此,若要用于多线程开发,可使用 ConcurentHashMap ,它采用了分段锁和 volatile 变量来提高并发性能,关于 ConcurrentHasnMap 的实现原理可看我的这篇文章Java 集合——ConcurrentHashMap

你可能感兴趣的:(Java)