写点什么

🔎【Java 源码探索】深入浅出的分析 HashMap(JDK7)

发布于: 2021 年 06 月 02 日
🔎【Java 源码探索】深入浅出的分析HashMap(JDK7)

每日一句

有望得到的要努力,无望得到的不介意,则无论输赢姿态都会好看。

概念回顾

  • HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前 entry 的 next 指向 null),那么对于查找,添加等操作很快,仅需一次寻址即可;

  • 如果定位到的数组包含链表,对于添加操作,其时间复杂度依然为 O(1),因为最新的 Entry 会插入链表头部,急需要简单改变引用链即可,而对于查找操作来讲,此时就需要遍历链表,然后通过 key 对象的 equals 方法逐一比对查找。

  • 所以,性能考虑,HashMap 中的链表出现越少,性能才会越好。

不同 JVM 版本 HashMap 的展现形式

本章内容主要为介绍 JDK7 的版本文章学习。

JDK7

HashMap 的数据结构为:数组 + 链表

JDK8

可以查看相关对应的另外一篇https://xie.infoq.cn/article/19975ef8ca03291839af31874


HashMap 的数据结构为:数组 + 链表 + 红黑树

基本简介

哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如 memcached)的核心其实就是在内存中维护一张大的哈希表,而 HashMap 的实现原理就是基于此。

数据结构的分析

针对于多种数据结构进行分析为什么会选择 Hash 表的方式进行存储和查询。

数组

采用一段连续的存储单元来存储数据

查询操作场景
  • 对于指定下标的查找,时间复杂度为 O(1)

  • 通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为 O(n)

  • 对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为 O(logn)

插入删除场景
  • 对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为 O(n)


对应到集合类是 ArrayList

线性链表

查询操作场景
  • 查找操作需要遍历链表逐一进行比对,复杂度为 O(n)。

插入删除场景
  • 链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为 O(1)。


对应的集合类是 LinkedList

二叉树

  • 对一棵相对平衡的有序二叉树。

  • 对其进行插入,查找,删除等操作,平均复杂度均为 O(logn)。


对应的集合类有 TreeSet 和 TreeMap。

哈希表

相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为 O(1)。


对应的集合类就是 HashMap。


哈希表的主干就是数组。我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。


即:存储位置 = hash(关键字)

其中,这个函数 hash 一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。这会涉及到哈希冲突。


  • 哈希函数的设计至关重要,好的哈希函数会尽可能地保证计算简单和散列地址分布均匀

Hash 冲突机制

当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。


  • 数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?

  • 哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址)、再散列函数法、链地址法


而 HashMap 即是采用了链地址法,也就是数组+链表的方式。

HashMap 的源码实现

存储结构

Hash 桶(bucket)
  • 当实例化一个 HashMap 时,系统会创建一个长度为 Capacity 的 Entry 数组,这个长度被称为容量(Capacity),在这个数组中可以存放元素的位置我们称之为“桶”(bucket),每个 bucket 都有自己的索引,系统可以根据索引快速的查找 bucket 中的元素

  • 每个 bucket 中存储一个元素,即一个 Entry 对象,但每一个 Entry 对象可以带一个引用变量,用于指向下一个元素,因此,在一个桶中,就有可能生成一个 Entry 链

Entry

HashMap 的基本组成单元,每一个 Entry 包含一个 key-value 键值对。 Entry 是 HashMap 中的一个静态内部类。代码如下


static class Entry<K,V> implements Map.Entry<K,V> {        final K key;        V value;     //存储指向下一个Entry的引用,单链表结构        Entry<K,V> next;    //对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算        int hash;        /**         * Creates new entry.         */        Entry(int h, K k, V v, Entry<K,V> n) {            value = v;            next = n;            key = k;            hash = h;        }
复制代码


经过以上分析,HashMap 的存储结构图如下:




  • 一个长度为 16 的数组中,每个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。

  • 一般情况是通过 hash(key)%len 获得,也就是元素的 key 的哈希值对数组长度取模得到。

  • 比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以 12、28、108 以及 140 都存储在数组下标为 12 的位置

  • 在存储一对值时(Key ->Value 对),实际上是存储在一个 Entry 的对象 e 中,程序通过 key 计算出 Entry 对象的存储位置。

  • Key->Value 的对应关系是通过 key—-Entry—-value 这个过程实现的,所以就有我们表面上知道的 key 存在哪里,value 就存在哪里

重要属性

先看 HashMap 中的几个重要属性:



//默认初始化化容量,即16static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量,即2的30次方static final int MAXIMUM_CAPACITY = 1 << 30;
//默认装载因子static final float DEFAULT_LOAD_FACTOR = 0.75f;
//HashMap内部的存储结构是一个数组,此处数组为空,即没有初始化之前的状态static final Entry<?,?>[] EMPTY_TABLE = {};
//空的存储实体transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//实际存储的key-value键值对的个数transient int size;
//阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考thresholdint threshold;
//负载因子,代表了table的填充度有多少,默认是0.75final float loadFactor;
//用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationExceptiontransient int modCount;
//默认的threshold值static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
//计算Hash值时的key种子值transient int hashSeed = 0;
复制代码

构造方法

HashMap 有 4 个构造器,其他构造器如果用户没有传入 initialCapacity 和 loadFactor 这两个参数,会使用默认值。initialCapacity 默认为 16,loadFactory 默认为 0.75。



//通过初始容量和状态因子构造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; threshold = initialCapacity; // init方法在HashMap中没有实际实现,不过在其子类如 // linkedHashMap中就会有对应实现 init(); }
//通过扩容因子构造HashMap,容量去默认值,即16 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
//装载因子取0.75,容量取16,构造HashMap public HashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); }
//通过其他Map来初始化HashMap, // 容量通过其他Map的size来计算,装载因子取0.75 public HashMap(Map<? extends K, ? extends V> m) { this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR); inflateTable(threshold);//初始化HashMap底层的数组结构 putAllForCreate(m);//添加m中的元素 }
复制代码


从上面这段代码我们可以看出,在常规构造器中,并没有马上为数组 table 分配内存空间(有一个入参为指定 Map 的构造器例外),事实上是在执行第一次 put 操作的时候才真正构建 table 数组。

put 操作

  • 如果两个 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 这个属性链接在一起。

  • 也就是说数组中存储的是最后插入的元素。到这里为止,HashMap 的大致实现,我们应该已经清楚了。


public V put(K key, V value) {        //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(=16)        if (table == EMPTY_TABLE) {            inflateTable(threshold);//分配数组空间        }       //如果key为null,存储位置为table[0]或table[0]的冲突链上        if (key == null)            return putForNullKey(value);        int hash = hash(key);  //对key的hashcode进一步计算,确保散列均匀        int i = indexFor(hash, table.length);//获取在table中的实际位置        for (Entry<K,V> e = table[i]; e != null; e = e.next) {        //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value            Object k;            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {                V oldValue = e.value;                e.value = value;                e.recordAccess(this);//调用value的回调函数,其实这个函数也为空实现                return oldValue;            }        }        modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败        addEntry(hash, key, value, i);//新增一个entry        return null;    }
复制代码

putForNullKey 方法

  • key 为 null 的时候,只会放在 hashMap 的 0 位置(即 key 的 hashCode 为 0,对数组长度取余后的下标也是 0),不会有链表

  • 在 HashMap 源码中对 put 方法对 null 做了处理,key 为 null 的判断后进入 putForNullKey(V value)这个方法,里面 for 循环是在 talbe[0]链表中查找 key 为 null 的元素,如果找到,则将 value 重新赋值给这个元素的 value,并返回原来的 value。如果没找到则将这个元素添加到 talbe[0]链表的表头


/** * Offloaded version of put for null keys */private V putForNullKey(V value) {    // for循环处理key为空的情况    for (Entry<K,V> e = table[0]; e != null; e = e.next) {        if (e.key == null) {            V oldValue = e.value;            e.value = value;            e.recordAccess(this);            return oldValue;        }    }    modCount++;    addEntry(0, null, value, 0);    return null;}
复制代码


inflateTable 的源码如下:


private void inflateTable(int toSize) {//capacity一定是2的次幂        int capacity = roundUpToPowerOf2(toSize);//此处为threshold赋值,取capacity*loadFactor和  MAXIMUM_CAPACITY+1的最小值,capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1        threshold = (int) Math.min(capacity * loadFactor,                   MAXIMUM_CAPACITY + 1);    //分配空间        table = new Entry[capacity];    //选择合适的Hash因子        initHashSeedAsNeeded(capacity);}
复制代码


inflateTable 这个方法用于为主干数组 table 在内存中分配存储空间,通过 roundUpToPowerOf2(toSize)可以确保 capacity 为大于或等于 toSize 的最接近 toSize 的二次幂,比如 toSize=13,则 capacity=16;to_size=16,capacity=16;to_size=17,capacity=32。


二次幂其实现如下:


private static int roundUpToPowerOf2(int number) {        // assert number >= 0 : "number must be non-negative";        return number >= MAXIMUM_CAPACITY                ? MAXIMUM_CAPACITY                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;}
复制代码


roundUpToPowerOf2 中的这段处理使得数组长度一定为 2 的次幂,Integer.highestOneBit 是用来获取最左边的 bit(其他 bit 位为 0)所代表的数值。


在对数组进行空间分配后,会根据 hash 函数计算散列值,其实现如下:


//用了很多的异或,移位等运算,对 key 的 hashcode 进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀


final int hash(Object k) {        int h = hashSeed;    //这里针对String优化了Hash函数,是否使用新的Hash函数和Hash    // 因子有关          if (0 != h && k instanceof String) {            return sun.misc.Hashing.stringHash32((String) k);        }        h ^= k.hashCode();        h ^= (h >>> 20) ^ (h >>> 12);        return h ^ (h >>> 7) ^ (h >>> 4);    }
复制代码


  • 从上面的操作看以看出,影响 HashMap 元素的存储位置的只有 key 的值,与 value 值无关

  • 通过 hash 函数得到散列值后,再通过 indexFor 进一步处理来获取实际的存储位置


其实现如下:


    //返回数组下标    static int indexFor(int h, int length) {        return h & (length-1);    }
复制代码


  • h &(length-1)保证获取的 index 一定在数组范围内,举个例子,默认容量 16,length-1=15,h=18,转换成二进制计算为


最终计算出的 index=2。有些版本的对于此处的计算会使用取模运算,也能保证 index 一定在数组范围内,不过位运算对计算机来说,性能更高一些(HashMap 中有大量位运算)。


  • 通过以上分析,我们看到,要得到一个元素的存储位置,需要如下几步:

  • 获取该元素的 key 值

  • 通过 hash 方法得到 key 的散列值,其中需要用到 key 的 hashcode 值。

  • 通过 indexFor 计算得到存储的下标位置。

  • 最后,得到存储的下标位置后,我们就可以将元素放入 HashMap 中,具体通过 addEntry 实现:


 void addEntry(int hash, K key, V value, int bucketIndex) {    //当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容,新容量为旧容量的2倍        if ((size >= threshold) && (null != table[bucketIndex])) {            resize(2 * table.length);            hash = (null != key) ? hash(key) : 0;      //扩容后重新计算插入的位置下标            bucketIndex = indexFor(hash, table.length);        }        //把元素放入HashMap的桶的对应位置        createEntry(hash, key, value, bucketIndex);    }  //创建元素    void createEntry(int hash, K key, V value, int bucketIndex) {      //获取待插入位置元素        Entry<K,V> e = table[bucketIndex];    //这里执行链接操作,使得新插入的元素指向原有元素。        table[bucketIndex] = new Entry<>(hash, key, value, e);    //这保证了新插入的元素总是在链表的头    //元素个数+1      size++; }
复制代码


  • 通过以上代码能够得知,当发生哈希冲突并且 size 大于阈值的时候并且对应的 key 对应的 table 桶的首地址元素不为 null 的情况下:需要进行数组扩容

  • 扩容时,需要新建一个长度为之前数组 2 倍的新的数组,然后将当前的 Entry 数组中的元素全部传输过去,扩容后的新数组长度为之前的 2 倍,所以扩容相对来说是个耗资源的操作。

扩容操作

扩容操作通过 resize 操作实现:



//按新的容量扩容Hash表void resize(int newCapacity) { Entry[] oldTable = table;//老的数据 int oldCapacity = oldTable.length;//获取老的容量值 //老的容量值已经到了最大容量值 if (oldCapacity == MAXIMUM_CAPACITY) { //修改扩容阀值 threshold = Integer.MAX_VALUE; return; } //新的结构 Entry[] newTable = new Entry[newCapacity]; //将老的表中的数据拷贝到新的结构中 transfer(newTable, initHashSeedAsNeeded(newCapacity)); //修改HashMap的底层数组 table = newTable; //修改阀值 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);}
复制代码


如果数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index 也可能会发生变化,需要重新计算 index,我们先来看看 transfer 这个方法


//将老的表中的数据拷贝到新的结构中    void transfer(Entry[] newTable, boolean rehash) {          int newCapacity = newTable.length;//容量          for (Entry<K,V> e : table) { //遍历所有桶            while(null != e) {  //遍历桶中所有元素(是一个链表)                Entry<K,V> next = e.next;        //如果是重新Hash,则需要重新计算hash值.                if (rehash) {                    e.hash = null == e.key ? 0 : hash(e.key);                }        //定位Hash桶                int i = indexFor(e.hash, newCapacity);        //元素连接到桶中,这里相当于单链表的插入,总是插入在最前面                e.next = newTable[i];        //newTable[i]的值总是最新插入的值                newTable[i] = e;                e = next;//继续下一个元素              }          }      }  
复制代码


这个方法将老数组中的数据逐个链表地遍历,重新计算后放入新的扩容后的数组中,我们的数组索引位置的计算是通过对 key 值的 hashcode 进行 hash 扰乱运算后,再通过和 length-1 进行位运算得到最终数组索引位置


  • 注意:HashMap 数组元素长度的设计

  • 通过源码可以发现,hashMap 的数组长度一定保持 2 的次幂,这样做有什么好处呢?


//根据Hash值和Hash表的大小选择合适的Hash桶      static int indexFor(int h, int length) {          return h & (length-1);      }  
复制代码


  • 如果 length 为 2 的次幂,其二进制表示就是 100….0000;则 length-1 转化为二进制必定是 0111….11 的形式,在于 h 的二进制与操作效率会非常的快,而且空间不浪费;如果 length 不是 2 的次幂,比如 length 为 15,则 length-1 为 14,对应的二进制为 1110,再于 h 与操作

  • 最后一位都为 0,所以 0001,0011,0101,1001,1011,0111,1101 这几个位置永远都不会存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费

get 操作

    //获取key值为key的元素值      public V get(Object key) {          if (key == null)//如果Key值为空,则获取对应的值,这里也可以看到,HashMap允许null的key,其内部针对null的key有特殊的逻辑              return getForNullKey();          Entry<K,V> entry = getEntry(key);//获取实体  
return null == entry ? null : entry.getValue();//判断是否为空,不为空,则获取对应的值 }
//获取key为null的实体 private V getForNullKey() { if (size == 0) {//如果元素个数为0,则直接返回null return null; } //key为null的元素存储在table的第0个位置 for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null)//判断是否为null return e.value;//返回其值 } return null; }
复制代码


get 方法通过 key 值返回对应 value,如果 key 为 null,直接去 table[0]处检索。我们再看一下 getEntry 这个方法:


//获取键值为key的元素      final Entry<K,V> getEntry(Object key) {          if (size == 0) {//元素个数为0              return null;//直接返回null          }  
int hash = (key == null) ? 0 : hash(key);//获取key的Hash值 for (Entry<K,V> e = table[indexFor(hash, table.length)];//根据key和表的长度,定位到Hash桶 e != null; e = e.next) {//进行遍历 Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))//判断Hash值和对应的key,合适则返回值 return e; } return null; }
复制代码


  • get 方法的实现相对简单,key(hashcode)–>hash–>indexFor–>最终索引位置,找到对应位置 table[i],再查看是否有链表,遍历链表,通过 key 的 equals 方法比对查找对应的记录。

  • 在定位到数组位置之后然后遍历链表的时候,e.hash == hash 这个判断没必要,仅通过 equals 判断就可以。

HashMap 中的 hashcode 怎么生成

调用对象 key 的 hashCode 方法,再对这个 hashcode 方法进行一些右移以及异或运算(使的 hashCode 的高位和低位都参与到运算中);通过右移和异或运算可以使 hashMap 的散列化更强,提高 hashMap 的 get 方法的效率

为什么使用 HashCode

HashCode 的存在主要是为了查找的快捷性,HashCode 是用来在散列存储结构中确定对象的存储地址的 ( 用 hashcode 来代表对象在 hash 表中的位置 ) , hashCode 存在的重要的原因之一就是在 HashMap(HashSet 其实就是 HashMap)中使用(其实 Object 类的 hashCode 方法注释已经说明了)。

equals 方法和 hashcode 的关系

  • 若重写了 equals(Object obj)方法,则有必要重写 hashCode()方法

  • 若两个对象 equals(Object obj)返回 true,则 hashCode()有必要也返回相同的 int 数

  • 若两个对象 equals(Object obj)返回 false,则 hashCode()不一定返回不同的 int 数

  • 若两个对象 hashCode()返回相同 int 数,则 equals(Object obj)不一定返回 true

  • 若两个对象 hashCode()返回不同 int 数,则 equals(Object obj)一定返回 false


同一对象在执行期间若已经存储在集合中,则不能修改影响 hashCode 值的相关信息,否则会导致内存泄露问题

总结

HashMap 之所以速度快,因为他使用的是散列表,根据 key 的 hashcode 值生成数组下标(通过内存地址直接查找,不需要判断,但是需要多出很多内存,相当于以空间换时间)

发布于: 2021 年 06 月 02 日阅读数: 49
用户头像

我们始于迷惘,终于更高水平的迷惘。 2020.03.25 加入

🏆 【酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“】 🏅 【Java技术领域,MySQL技术领域,APM全链路追踪技术及微服务、分布式方向的技术体系等】 🤝未来我们希望可以共同进步🤝

评论

发布
暂无评论
🔎【Java 源码探索】深入浅出的分析HashMap(JDK7)