深入理解HashMap底层原理(1.7与1.8比较)

HashTable △△△△△

哈希表

在数组中根据下标(索引)查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。

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

好的哈希函数会尽可能地保证 计算简单散列地址分布均匀

HashMap实现原理

HashMap(用数组+链表解决哈希冲突)的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。【 Entry是HashMap中的一个静态内部类】
在这里插入图片描述

HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

重要字段

//实际存储的key-value键值对的个数
transient int size;

//阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity * loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到
int threshold;

//负载因子,代表了table的填充度有多少,默认是0.75
final float loadFactor;

//用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;

Node[] table的初始化长度length(默认值是16),loadFactor为负载因子(默认值是0.75)。

threshold是HashMap所能容纳的最大数据量的Node(键值对)个数
threshold = length * loadFactor
超过threshold就要扩容。

modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。size是实际存储的key-value键值对的个数。

哈希冲突

在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组。

数组长度一定为2的次幂。

如果负载因子取得太大,threshold与capacity太接近,当容量增大时,冲突会增加,造成同一地址链表过大;如果太小,哈希表太稀疏,浪费存储空间。负载因子可以大于1(即threshold大于数组长度,因为是链地址法)

当发生哈希冲突并且size大于阈值(threshold=capacityloadFactory)的时候,需要进行数组扩容(resize),扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Entry数组中的元素全部传输过去(遍历,重新计算索引位置,将老数组数据复制到新数组中去),扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。

如果负载因子取得太大,threshold与capacity太接近,当容量增大时,冲突会增加,造成同一地址链表过大;如果太小,哈希表太稀疏,浪费存储空间。负载因子可以大于1(即threshold大于数组长度,因为是链地址法)

Put时如果key为null,存储位置为table[0]或table[0]的冲突链上(table为HashMap中存的数组),如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value,如果对应数据不存在,则添加到链表的头上(保证插入O(1))

put和get流程

put:首先判断key是否为null,若为null,则直接调用putForNullKey方法。若不为空则先计算key的hash值,然后根据hash值搜索在table数组中的索引位置,如果table数组在该位置处有元素,循环遍历链表,比较是否存在相同的key,若存在则覆盖原来key的value,否则将该元素保存在链头(最先保存的元素放在链尾)。若table在该处没有元素,则直接保存。

hash()函数(扰乱函数),用了很多的异或,移位等运算,对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀。hash函数计算出的值,通过indexFor进一步处理来获取实际的存储位置。

get方法的实现相对简单,key(hashcode-返回int)–>hash–>indexFor–>最终索引位置,找到对应位置table[i],再查看是否有链表,遍历链表,通过key的equals方法比对查找对应的记录。(&length-1也将范围较大的hash值缩小到了length内)

我们知道java.util.HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。这一策略在源码中的实现是通过modCount域,modCount顾名思义就是修改次数,对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。在迭代过程中,判断modCount跟expectedModCount是否相等,如果不相等就表示已经有其他线程修改了Map

由 Object 类定义的 hashCode 方法确实会针对不同的对象返回不同的整数。(这一般是通过将该对象的内部地址转换成一个整数来实现的

put函数大致的思路为:

  1. (哈希表为空时,初始化)对key的hashCode()做hash,计算出在数组的index;
  2. 如果没碰撞直接放到bucket里;
  3. 如果碰撞了,以链表的形式存在buckets后;
  4. 如果碰撞导致链表过长,大于等于TREEIFY_THRESHOLD(数组长度* loadFactor),就把链表转换成红黑树;
  5. 如果节点已经存在就替换old value(保证key的唯一性)
  6. 如果bucket满了(超过load factorcurrent capacity),就要resize扩容了。

get函数大致的思路:

  1. bucket里的第一个节点,直接命中;
  2. 如果有冲突,则通过key.equals(k)去查找对应的entry
    若为树,则在树中通过key.equals(k)查找,O(logn);
    若为链表,则在链表中通过key.equals(k)查找,O(n)。

HashMap查找:

  1. 扰乱函数,获取新的哈希值
  2. 计算数组下表,获取节点
  3. 当前节点与key匹配,直接返回
  4. 否则,当前节点是红黑树的话,查找红黑树
  5. 不是红黑树,就遍历链表查找【O(n)】

红黑树是一种平衡二叉树,插入,查找,删除的最坏时间复杂度都是O(logn),二叉树是O(n),至于不直接用平衡二叉树,是因为平衡二叉树更严格,为了保持平衡,需要旋转次数更多,插入和删除的效率比红黑树低。

hash()方法的原理

key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。
但这个散列值是不能直接拿来用的,太大了,内存放不下,要在indexFor( )函数里对数组的长度取模运算。

indexFor的代码也很简单,就是把散列值和数组长度做一个"与"操作。
h && (length - 1)

HashMap的数组长度要取2的整次幂

为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
因为这样(数组长度-1)正好相当于一个低位掩码。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。
以初始长度16举例,16-1 = 15, 2进制是 0000,0000 0000,0000 0000,1111
和某散列值做与操作如下,结果就是截取了最低的四位值。
但此时问题就是,即使散列值再分散,要是只取最后几位,碰撞也会很多,这个时候hash()扰乱函数就很重要,右位移16位,正好是自己32bit的一半,自己的高半位和低半位做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。

数据结构

最上面图的Entry数组在1.8里变成哈希桶数组(Node[] table),每一个Entry就是一个Node。

     */
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;//用于定位数组索引的位置
        final K key;
        V value;
        Node<K,V> next;//链表的下一个Node
 
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

Node本质就是一个映射

在JDK8中,重大改变就是当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。

确定哈希桶数组索引位置

三步:取key的hashCode值、高位运算、取模运算。

对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash码值总是相同的。

在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

jdk 1.7 扩容
void resize(int newCapacity) {   //传入新的容量
     Entry[] oldTable = table;    //引用扩容前的Entry数组
     int oldCapacity = oldTable.length;         
     if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了
          threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
         return;
     }
 
     Entry[] newTable = new Entry[newCapacity];  //初始化一个新的Entry数组
     transfer(newTable);                         //!!将数据转移到新的Entry数组里
     table = newTable;                           //HashMap的table属性引用新的Entry数组
   threshold = (int)(newCapacity * loadFactor);//修改阈值
}

这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。

newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话)

扩容resize()
如果size大于threshold(capacityloadFactory)就进行扩容,原容量乘以2,再进行rehash的过程。如果capacity已经达到最大(2^30),则threshold变为Integer.MAX_VALUE(没有新建节点,只是新的指针)

jdk 1.8

Jdk1.8 中没有 indexFor 函数,直接使用 table[index = (n – 1) & hash](与运算交换左右,结果不变)
这里存在一个问题,即使负载因子和 Hash 算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。于是,在 JDK1.8 版本中,
对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(TREEIFY_THRESHOLD 默认超过 8、大于等于)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高 HashMap
的性能(O(logn))。当长度小于(UNTREEIFY_THRESHOLD 默认为 6、小于等于),就会退化成链表。
下面我们讲解下 JDK1.8 做了哪些优化。经过观测可以发现,我们使用的是 2 次幂的扩展(指长度扩为原来 2 倍),所以,元素的位置要么是在原位置,要么是在原位置再移动 2 次幂的位置。看下图可以明白这句话的意思,n 为 table 的长度,图(a)表示扩容前的 key1和 key2 两种 key 确定索引位置的示例,图(b)表示扩容后 key1 和 key2 两种 key 确定索引位置的示例,其中 hash1 是 key1 对应的哈希与高位运算结果。

元素在重新计算 hash 之后,因为 n 变为 2 倍,那么 n-1 的 mask 范围在高位多 1bit(红色),因此新的 index 就会发生这样的变化:
在这里插入图片描述

因此,我们在扩充 HashMap 的时候,不需要像 JDK1.7 的实现那样重新计算 hash,只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引
变成“原索引+oldCap”。(每个节点 e 的 hash 早就计算好,并保存在 final hash 中)。通过 if ((e.hash & oldCap) == 0)判定前面那个 bit 是不是 1,如果是 1 则加上 oldCap static class Node<K,V> implements Map.Entry<K,V> {},jdk1.8 中用 node 替代了 entry

hashcode方法

本地方法。当集合要添加新的对象时,先调用这个对象的hashCode方法,得到对应的hashcode值,实际上在HashMap的具体实现中会用一个table保存已经存进去的对象的hashcode值,如果table中没有该hashcode值,它就可以直接存进去,不用再进行任何比较了;如果存在该hashcode值, 就调用它的equals方法与新元素进行比较,相同的话就不存了,不相同就散列其它的地址,所以这里存在一个冲突解决的问题,这样一来实际调用equals方法的次数就大大降低了。

也就是说Java中的hashCode方法就是根据一定的规则将与对象相关的信息(比如对象的存储地址,对象的字段等)映射成一个数值,这个数值称作为散列值。

重写equals()时一定也要重写hashcode():
虽然通过重写equals方法使得逻辑上姓名和年龄相同的两个对象被判定为相等的对象(跟String类类似),但是要知道默认情况下,hashCode方法是将对象的存储地址进行映射。那么上述代码的输出结果为“null”就不足为奇了。原因很简单,p1指向的对象和System.out.println(hashMap.get(new People(“Jack”, 12)));这句中的new People(“Jack”, 12)生成的是两个对象,它们的存储地址肯定不同。

如果不重写equals()方法,HashMap没有判断两个对象相等的标准。如果不重写hashcode(),将对用object默认的hashcode方法(根据对象地址生成hashcode),如果new了两个对象,它们的属性均相同,但由于是两个对象,所以object生成的hashcode不同,但在hashmap中这两个key应该当做相同的key,但不重写hashcode则无法实现。

问题总结 △△△

  1. HashMap的工作原理?

通过hash的方法,通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。

  1. get和put的原理?equals()和hashCode()的都有什么作用?

通过对key的hashCode()进行hashing,并计算下标( n-1 & hash),从而获得buckets的位置。如果产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点

  1. hash()的实现?为什么要这样实现?

在Java 1.8的实现中,是先拿到 key 的hashcode,是一个32位的int类型的数值,然后让hashcode的高16位和低16位进行异或操作:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。

  1. 当两个对象的hashcode相同会发生什么?

    因为hashcode相同,所以它们的bucket位置相同,‘碰撞’会发生。因为HashMap使用链表存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在链表中。

  2. 如果两个键的hashcode相同,你如何获取值对象?

找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。因此,设计HashMap的key类型时,如果使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。

  1. 如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

    默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置

  2. 你了解重新调整HashMap大小存在什么问题吗?

    当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。因此在并发环境下,我们使用CurrentHashMap来替代HashMap

  3. 为什么String, Interger这样的wrapper类适合作为键?

    因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。

  4. jdk1.8的主要优化

  • 改成了数组+链表或红黑树
  • 从头插改成尾插,头插扩容会使链表反转,多线程会产生环。
  • 扩容1.7要对原数组元素重新hash定位在新数组位置,1.8不用算,因为hashmap初始大小都是2的次幂,新的位置不变或索引+新增容量大小(只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”)。
  • 1.7插入时要先判断是否需要扩容,再插。而1.8先插,再判断。
  • 散列函数:1.7 做了四次移位和四次异或,jdk1.8只做一次。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值