前言

大家开发中用得最多的工具类就是JAVA的汇合框架了,明天咱们来从源码的角度分析Map汇合的实现之一HashMap

HashMap 简介

这里我就间接翻译JDK源码正文了,其实正文讲得很具体了。

基于哈希表的Map接口的实现。 此实现提供所有可选的映射操作,并容许空值和空键。 ( HashMap类与Hashtable大抵等效,不同之处在于它是不同步的,并且容许为null。)此类不保障映射的程序。 特地是,它不能保障程序会随着工夫的推移放弃恒定。
假如哈希函数将元素正确扩散在存储桶中,则此实现为基本操作( get和put )提供恒定工夫(这里指工夫复杂度为o1)的性能。 汇合视图上的迭代所需的工夫与HashMap实例的“容量”(存储桶数)及其大小(键-值映射数)成正比。 因而,如果迭代性能很重要,则不要将初始容量设置得过高(或负载因数过低),这一点十分重要。
HashMap的实例具备两个影响其性能的参数:初始容量和负载因子。 容量是哈希表中存储桶的数量,初始容量只是创立哈希表时的容量。 负载因子是在主动减少其哈希表容量之前容许哈希表取得的满度的度量。 当哈希表中的条目数超过负载因子和以后容量的乘积时,哈希表将被从新哈希(即,外部数据结构将被重建),因而哈希表的存储桶数约为两倍。
通常,默认负载因子(.75)在工夫和空间老本之间提供了一个很好的衡量。 较高的值会缩小空间开销,但会减少查找老本(在HashMap类的大多数操作中都失去体现,包含get和put )。 设置其初始容量时,应思考映射中的预期条目数及其负载因子,以最大水平地缩小从新哈希操作的次数。 如果初始容量大于最大条目数除以负载因子,则将不会产生任何哈希操作。
如果将许多映射存储在HashMap实例中,则创立具备足够大容量的映射将比让其依据须要增长表的主动从新哈希解决更无效地存储映射。 请留神,应用具备雷同hashCode()许多键是升高任何哈希表性能的必定办法。 为了改善影响,当键为Comparable ,此类能够应用键之间的比拟程序来帮忙突破平局。
请留神,此实现未同步。 如果多个线程同时拜访哈希映射,并且至多有一个线程在结构上批改该映射,则必须在内部进行同步。 (构造批改是增加或删除一个或多个映射的任何操作;仅更改与实例曾经蕴含的键相关联的值不是构造批改。)通常通过在天然封装了Map的某个对象上进行同步来实现。 。 如果不存在这样的对象,则应应用Collections.synchronizedMap办法“包装”Map。 最好在创立时实现此操作,以避免意外不同步地拜访Map:

 Map m = Collections.synchronizedMap(new HashMap(...));

该类的所有“汇合视图办法”返回的迭代器都是疾速失败的:如果在创立迭代器后的任何ff工夫以任何形式对Map进行构造批改,则除了通过迭代器本人的remove办法之外,迭代器都会抛出ConcurrentModificationException 。 因而,面对并发批改,迭代器会疾速洁净地失败,而不会在将来的不确定工夫冒着任意,不确定的行为的危险。
请留神,迭代器的疾速失败行为无奈失去保障,因为通常来说,在存在不同步的并发批改的状况下,不可能做出任何严格的保障。 疾速失败的迭代器会尽最大致力抛出ConcurrentModificationException 。 因而,编写依赖于此异样的程序的正确性是谬误的:迭代器的疾速失败行为应仅用于检测谬误。
此类是Java Collections Framework的成员

几个重要的成员变量

  • Node<K,V>[] table 外围数据结构 数组 + 链表
  • int size 汇合的大小
  • int modCount 被批改的次数
  • int threshold 下一个要调整大小的大小值(容量 * 负载因子)
  • float loadFactor 负载因子
  • Set<Map.Entry<K,V>> entrySet KV集

办法

构造方法

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

有两个参数

  • initialCapacity 初始容量
  • loadFactor 负载因子

HashMap就是对散列表这种数据结构的实现,所以须要这个两个参数去定义散列表

tableSizeFor

咱们从下面的构造方法能够看出,HashMap在初始化的时候,会调用这个办法去计算理论初始化的容量并暂存为threshold

    /**     * Returns a power of two size for the given target capacity.     */    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;    }

这个办法返回大于输出参数且最近的2的整数次幂的数。比方10,则返回16。

先来剖析无关n位操作局部:先来假如n的二进制为01xxx...xxx。接着

  • 对n右移1位:001xx...xxx,再位或:011xx...xxx
  • 对n右移2为:00011...xxx,再位或:01111...xxx
  • 此时后面曾经有四个1了,再右移4位且位或可得8个1
  • 同理,有8个1,右移8位必定会让后八位也为1。

综上可得,该算法让最高位的1前面的位全变为1。

最初再让后果n+1,即失去了2的整数次幂的值了。

当初回来看看第一条语句:

int n = cap - 1;

cap - 1再赋值给n的目标是另找到的目标值大于或等于原值。例如二进制1000,十进制数值为8。如果不对它减1而间接操作,将失去答案10000,即16。显然不是后果。减1后二进制为111,再进行操作则会失去原来的数值1000,即8。

这种办法的效率十分高,可见Java8对容器优化了很多

hash

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

这里是取hash的高16位与hash值进行异或运算,因为在进行槽位计算的时候容易失落高位的特色,所以采取低位与高位进行运算取得一个后果,保留了高位与低位的特色。如果采纳|将会使位偏差1,如果采纳&将会偏差0,而采纳^则没有显著的偏差性。

槽位计算源码

tab[i = (n - 1) & hash]

PUT

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

这个办法将元素退出散列表,具体实现是上面这个putVal办法

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

明确几个局部变量的意义

  • Node<K,V>[] tab 散列表
  • Node<K,V> p 新增的节点
  • int n 散列表数组的长度
  • int i 计算出的槽位

咱们追随源码的流程进行剖析

首先判断散列表是否有被创立进去,如果没有创立,则进行散列表的初始化。这是一种懒加载策略。初始化的过程咱们上面再剖析。

而后就是计算散列地位,判断该地位上是否有元素。若没有则间接把元素放入。如果该节点上有元素了,则产生了hash碰撞,须要另外的解决。

上面剖析一下槽点计算的源码

i = (n - 1) & hash

将容量-1与hash值进行与运算。因为散列表在初始化的时候,容量是2的次幂,所以在减一之后,二进制位都变为了1,再与hash值进行与运算其实就是取余数,这个就相当于hash % n 。然而效率确比取模运算高出很多。

本文由博客群发一文多发等经营工具平台 OpenWrite 公布