Java——HashMap——2、HashMap的工作原理

1.1.1 *HashMap的工作原理*

HashMap是键值对key-value形式双列集合。它的底层存储原理是哈希表。为了简明描述哈希表(数组+链表),我画了一个图

img

1)E*代表一个Node节点,每个Node节点就是我们理解的一个key-value的mapping映射。

2)每个Node除了保存了key和value的映射外呢,还保存了它下一Node的引用。例如图中,Eb保存了Ebb的引用,而Ebb保存了Ebbb的引用。

3)HashMap的put(key,value)方法介绍

每一个链表,如Eb-->Ebb-->Ebbb,这三个节点的key是不相等的。那么你可能会问,为什么它们三个会被放在一个链表中呢?

是这样的。当你调用put(key,value)方法时,会根据key计算出一个hash值,然后再通过这个hash值和map当前的长度进行位运算,计算出一个index数值,然后将这个数值作为图中数组tab脚标,而获取这个脚标对应的Node节点。

如果这个节点不存在,则直接创建一个新的Node节点,插入到数组中的你计算出的那个脚标的位置。

如果存在,则判断key和你put进来的key是否相等(注意相等的判定:hash值相等且equal相等),

如果相等,那么直接更新其值value,也就是我们常见的覆盖旧value操作,如果旧value为null,则直接将null值设置为你传进来的value值,

如果不相等(此处不介绍LinkedHashMap)则去以遍历的方式寻找这个节点的next节点,

如果和这个链表的每个节点的next节点都不相等,则在链表的最后一个Node节点后创建新节点。如果其中判定有一个相等,那么进行覆盖值并返回旧值操作。

众所周知,HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。

HashMap数组每一个元素的初始值都是Null。

对于HashMap,我们最常使用的是两个方法:Get 和 Put。

1.1.1.1 *Put存储数据的原理*

hashmap是以键值对的方式来存数据的,当我们执行put(key, value)函数的时候,以key作为键,value作为值来存,并且如果key相同的话,*则新的value会覆盖掉旧的value*

调用Put方法的时候发生了什么呢?比如调用 hashMap.put("apple", 0) ,插入一个Key为“apple"的元素。这时候我们需要利用一个哈希函数来确定Entry的插入位置(index):

index = Hash(“apple”)

但是,因为HashMap的长度是有限的,当插入的Entry越来越多时,再完美的Hash函数也难免会出现index冲突的情况。

这时候该怎么办呢?我们可以利用链表来解决。

HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可

需要注意的是,新来的Entry节点插入链表时,使用的是“头插法”。之所以把新插入的节点放在头节点,是因为HashMap的发明者认为,后插入的Entry被查找的可能性更大。

1.1.1.2 *Get取值的原理*

\1. 当我们调用get(key)的时候,会调用key的hashcode方法获得hashcode.

\2. 根据hashcode获取相应的bucket。

\3. 由于一个bucket对应的链表中可能存有多个Entry,这个时候会调用key的equals方法来找到对应的Entry

\4. 最后把值返回

使用Get方法根据Key来查找Value的时候,发生了什么呢?

首先会把输入的Key做一次Hash映射,得到对应的index:

index = Hash(“apple”)

由于刚才所说的Hash冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的Key是“apple”:

1.1.1.3 *哈希冲突*

就是键(key)经过hash函数得到的结果作为地址去存放当前的键值对(key-value)(这个是hashmap的存值方式),但是却发现该地址已经有人先来了。

一句话说就是:如果两个不同对象的hashCode相同,这种现象称为hash冲突。

常用的散列冲突解决方法有两类:开放寻址法(open addressing)和链表法(chaining)

对于开放寻址冲突解决方法,比较经典的有:线性探测方法(Linear Probing)、二次探测(Quadratic probing)和 双重散列(Double hashing)等方法。

1、开放寻址法

i、线性探测方法

当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

ii、二次探测方法

元素 7777777 经过 Hash 算法之后,被散列到位置下标为 7 的位置,但是这个位置已经有数据了,所以就产生了冲突。

按照二次探测方法的操作,有冲突就依次加减1的1次方,2的2次方,3的2次方,4的2次方,5的2次方……

先7+ 1^2,8 这个位置有值,冲突;变为7- 1^2,6 这个位置有值,还是有冲突;于是7+ 2^2, 11 这个位置有值,还是有冲突,于是7- 2^2, 3 这个位置是空闲的,插入。

iii、双重散列方法

元素 7777777 经过 Hash 算法之后,被散列到位置下标为 7 的位置,但是这个位置已经有数据了,所以就产生了冲突。

此时,再将数据进行一次哈希算法处理,经过另外的 Hash 算法之后,被散列到位置下标为 3 的位置,完成操作。

2、链表法

在散列表中,每个位置对应一条链表,所有散列值相同的元素,都放到相同位置对应的链表中。

1.1.1.4 *hash算法*

哈希函数(Hash Function),也称为散列函数或杂凑函数。哈希函数是一个公开函数,可以将任意长度的消息M映射成为一个长度较短且长度固定的值H(M)。称H(M)为哈希值、散列值(Hash Value)、杂凑值或者消息摘要(Message Digest)。它是一种单向密码体制,即一个从明文到密文的不可逆映射,只有加密过程,没有解密过程

散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存存储位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。

散列函数的特点:

1.确定性:如果两个散列值是不相同的(根据同一函数),那么这两个散列值的原始输入也是不相同的。

2.散列碰撞(collision):散列函数的输入和输出不是唯一对应关系的,如果两个散列值相同,两个输入值很可能是相同的,但也可能不同。

3.不可逆性:一个哈希值对应无数个明文,理论上你并不知道哪个是。

4.混淆特性:输入一些数据计算出散列值,然后部分改变输入值,一个具有强混淆特性的散列函数会产生一个完全不同的散列值。

MD4

​ MD4是MIT的Rivest在1990年设计,MD是信息摘要 Message Digest 的缩写。它是基于32位操作数的位操作来实现的。

1.1.1.4.1 *MD5*

MD5是Rivest在1991年对MD4的改进,MD5比MD4来得复杂,因此速度慢一些,但安全性更好。

MD5 是输入不定长度信息,输出固定长度 128-bits 的算法。可以把一串任意长度的明文转化成一串固定长度(128bit)的字符串,这个字符串就是哈希值。

经过程序流程,生成四个32位数据,最后联合起来成为一个 128-bits 散列。

基本方式为,求余、取余、调整长度、与链接变量进行循环运算,得出结果。

可用于错误检查,即通过计算检查下载到的碎片的完整性

1.1.1.4.2 *SHA-1*

SHA-1是由NIST NSA设计的,它对长度小于264位的输入,产生长度位160位的散列值。因此抗穷举性更好。SHA-1模仿了MD4的算法。

SHA-1可以生成一个被称为消息摘要的160位(20字节)散列值,散列值通常的呈现形式为40个十六进制数。

1.1.1.5 *负载因子Load factor*

一般使用加载因子(load factor)来表示空位的多少。加载因子是表示 Hsah 表中元素的填满的程度,

若加载因子越大,则填满的元素越多,这样的好处是:空间利用率高了,但冲突的机会加大了。

反之,加载因子越小,填满的元素越少,好处是冲突的机会减小了,但空间浪费多了。

1.1.2 *为什么在8的时候转红黑树*

通过查看源码可以发现,默认是链表长度达到 8 就转成红黑树,而当长度降到 6 就转换回去,这体现了时间和空间平衡的思想.

在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为 8 的时候,概率仅为 0.00000006。这是一个小于千万分之一的概率,通常我们的 Map 里面是不会存储这么多的数据的,所以通常情况下,并不会发生从链表向红黑树的转换。

事实上,链表长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率。

通常如果 hash 算法正常的话,那么链表的长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担。所以通常情况下,并没有必要转为红黑树,所以就选择了概率非常小,小于千万分之一概率,也就是长度为 8 的概率,把长度 8 作为转化的默认阈值。

1.1.3 jdk8的hashmap为什么引入红黑树而不是平衡二叉树(美团)

红黑树是”近似平衡“的。

红黑树相比avl树,在检索的时候效率其实差不多,都是通过平衡来二分查找。但对于插入删除等操作效率提高很多。红黑树不像avl树一样追求绝对的平衡,他允许局部很少的不完全平衡,这样对于效率影响不大,但省去了很多没有必要的调平衡操作,avl树调平衡有时候代价较大,所以效率不如红黑树,在现在很多地方都是底层都是红黑树的天下啦。

红黑树的高度只比高度平衡的AVL树的高度(log2n)仅仅大了一倍,在性能上却好很多。

HashMap在里面就是链表加上红黑树的一种结构,这样利用了链表对内存的使用率以及红黑树的高效检索,是一种很happy的数据结构。

AVL树是一种高度平衡的二叉树,所以查找的非常高,但是,有利就有弊,AVL树为了维持这种高度的平衡,就要付出更多代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用AVL树的代价就有点高了。

红黑树只是做到了近似平衡,并不严格的平衡,所以在维护的成本上,要比AVL树要低。

所以,红黑树的插入、删除、查找各种操作性能都比较稳定。

java8不是用红黑树来管理hashmap,而是在hash值相同的情况下(且重复数量大于8),用红黑树来管理数据。 红黑树相当于排序数据,可以自动的使用二分法进行定位,性能较高。一般情况下,hash值做的比较好的话基本上用不到红黑树。

红黑树牺牲了一些查找性能 但其本身并不是完全平衡的二叉树。因此插入删除操作效率略高于AVL树。

AVL树用于自平衡的计算牺牲了插入删除性能,但是因为最多只有一层的高度差,查询效率会高一些。

1.1.4 *对一个对象算出key,需要进行什么操作*

需要重写重写hashCode方法和equals方法

1.1.5 *HashMap什么时候重写hashcode和equals方法,为什么需要重写*

什么时候重写?

如果要在HashMap的“键”部分存放自定义的对象,一定要在这个对象里的equals()和hashCode()方法来覆盖Object里的同名方法。

为什么重写?

当我们不重写hashcode()和equals()的情况下,两个对象即便属性相同,他们的内存地址不同,hashcode是不同的。HashTable,HashMap,HashSet等利用哈希值来存取的时候,不同的哈希值对应不同的bucket,如果不重写,那么同一个类实例化后,得到的对象的哈希值是不一样的,接着会调用equals方法,由于两个对象分配在堆上不同位置,因此默认也不会相等,这样就算你想让这两个对象等同也不可能,HashTable/HashMap中将会有两条你本来不想重复的数据。

HashSet内部是通过HashMap实现。只有使用排序的时候才使用TreeMap。而HashSet内部不能存放重复元素,会自动剔除重复数据,但以下示例:

HashSet set = new HashSet

set.put(new Student(1,"aa") );

set.put(new Student(1,"aa") );

set.put(new Student(2,"aa") );

结果set内的元素为3个,没有去处重复的new Student(1,"aa") ?为什么呢?

这里由于两个new Student(1,"aa")是不一样的Student对象。而默认的Student类的hashcode是根据对象的引用算的。所以直接认为是两个不一样的对象,直接put进去了。所以需要重写hashcode方法,如果hashcode不一样则直接认为是不同对象

发现还是不对,还是put进去了呢?

这里重写的hashcode是一样的,所以还是put进去了。所以还需要重新equals方法。其实是有这样一个规定,如果hahscode一样时,则还需要继续调用equals方式看看对象是否相等

HashMap是底层实现是数组加链表。

A.当put元素时:

1.首先根据put元素的key获取hashcode,然后根据hashcode算出数组的下标位置,如果下标位置没有元素,直接放入元素即可。

2.如果该下标位置有元素(即根据put元素的key算出的hashcode一样即重复了),则需要已有元素和put元素的key对象比较equals方法,如果equals不一样,则说明可以放入进map中。这里由于hashcode一样,所以得出的数组下标位置相同。所以会在该数组位置创建一个链表,后put进入的元素到放链表头,原来的元素向后移动。

B.当get元素时:

根据元素的key获取hashcode,然后根据hashcode获取数组下标位置,如果只有一个元素则直接取出。如果该位置一个链表,则需要调用equals方法遍历链表中的所有元素,与当前的元素比较,得到真正想要的对象。

可以看出:如果根据hashcdoe算出的数组位置尽量的均匀分布,则可以避免遍历链表的情况,以提高性能。

所以,要求重写hashmap时,也要重写equals方法。以保证他们是相同的比较逻辑

1.1.6 *HashMap默认的初始长度是多少?为什么这么规定*

HashMap的默认长度是16,并且每次自动扩展或手动初始化时,都必须是2的幂

之所以选择16,是为了服务于从key映射到index的hash算法

从Key映射到HashMap数组的对应位置,会用到一个Hash函数:

index = Hash(“apple”)

如何实现一个尽量均匀分布的Hash函数呢?我们通过利用Key的HashCode值和HashMap长度来做位运算

如何进行位运算呢?有如下的公式(Length是HashMap的长度):

index = HashCode(Key) & (Length - 1)

下面我们以值为“book”的Key来演示整个过程:

1.计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。

2.假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。

3.把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。

可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。

如果长度不是16或2的幂,计算出来的相同的index概率会大大增加,不符合hash算法均匀的原则

posted on 2021-09-18 10:57  夜萤火虫和你  阅读(241)  评论(0编辑  收藏  举报

导航