HashMap深入理解

  • Post author:
  • Post category:其他


涉及

hashing(散列法或哈希法)的概念

散列法(Hashing)是一种将字符组成的字符串转换为固定长度(一般是更短长度)的数值或索引值的方法,称为散列法,也叫哈希法。由于通过更短的哈希值比用原始值进行数据库搜索更快,这种方法一般用来在数据库中建立索引并进行搜索,同时还用在各种解密算法中。

红黑树

每个节点非红即黑

根节点总是黑色的

如果节点是红色的,则它的子节点必须是黑色的(反之不一定)

每个叶子节点都是黑色的空节点(NIL节点)

从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)

一,HashMap概念和底层结构

HashMap基于Map接口实现,元素以键值对的方式存储,并且允许使用null 建和null值,因为key不允许重复,因此只能有一个键为null,另外HashMap不能保证放入元素的顺序,它是无序的,和放入的顺序并不能相同。HashMap是线程不安全的。

相对于HashTable 来说 不允许使用null 建和null值,HsahTable是同步的,同步导致的性能开销所以HashMap快一些,通常情况下HashMap进行get和put操作可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选,平时使用时若无特殊需求建议使用HashMap,若要在多线程中使用HashMap,需要我们额外的进行同步处理。

对HashMap的同步处理可以使用Collections类提供的synchronizedMap静态方法,或者直接使用JDK 5.0之后提供的java.util.concurrent包里的ConcurrentHashMap类。

HashMap 由数组 + ( 链表 or 红黑树 )结合组成的复合结构 ,数组被分为一个个桶(bucket),每个桶存储有一个或多个Entry对象(其重要的属性有 hash,key,value,next。),通过哈希值决定了Entry对象(键值对)在这个数组的寻址;哈希值相同的Entry对象(键值对),则以链表形式存储。如果链表大小超过树形转换的阈值(TREEIFY_THRESHOLD= 8),链表就会被改造为树形结构。

HashMap的本质可以认为是一个数组,数组的每个索引被称为桶,每个桶里放着一个单链表,一个节点连着一个节点。很明显通过下标来检索数组元素时间复杂度为O(1),而且遍历链表的时间复杂度是常数级别,所以整体的查询复杂度仍为O(1)

简单提下数组和链表的区别

数组

优点:物理地址连续+按下标随机访问效率高O(1)

缺点:占用内存严重,插入,删除效率低,

链表

优点:存储地址不连续,可灵活的扩展自己的长度,插入,删除效率高

缺点:访问效率低O(n)

而哈希表(Hash类数据结构)正是结合了两者的优点,而衍生出来的的一种高效的数据存储结构,本质上是采用空间换时间的方式,提高了读写的效率。

二,HashMap的基本存储原理以及存储内容的组成。

基本原理:先声明一个数组来存储元素(默认长度16),每个元素都是存储的都是一个链表的头结点,这些元素的存储规则是通过hash(key.hashCode())%len获得,也就是元素的key的哈希值对数组长度取模得到,数组存储的元素是一个Entry类,其中重要属性有key,value,next

Next指针,可以连接下一个Entyr实体,以此来解决Hash冲突的问题,针对哈希表直接定址可能存在冲突。

例如:第一个键值对A进来。通过Hash计算得到的index = 0,。记作:Entry[0] = A。

第二个键值对B进来,Hash得到的Index也是0。记作:B.next = A,Entry[0] = B。

第三个键值对C进来,index也等于0。记作:C.next = B,Entry[0] = C。

这样我们发现index=0的地方事实上存取了A,B,C三个键值对,它们通过next这个属性链接在一起。

也就是说数组中存储的是最后插入的元素。

对于不同的元素,可能计算出了相同的函数值,这样就产生了“冲突”,这就需要解决冲突,“直接定址”与“解决冲突”是哈希表的两大特点。

三,HashMap的工作原理以及存取方法过程

HashMap是基于散列法(又称哈希法)的原理,使用put和get 储存和获取对象,当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket(桶)位置来储存Entry对象。HashMap是在bucket中储存键对象和值对象,作为Map.Entry。并不是仅仅只在bucket中存储值。

put键值过程

1.判断键值数组table[i] 是否为空,否则执行resize()进行扩充。

2.根据key计算hash得到插入数组的索引i,如果table[i]为空,新建结点添加,插入成功后,判断实际存在的键值对数量是否超过了最大容量,超过进行扩容。

3.如果table[i]不为空,判断table[i] 首个元素是否和key一样(hashCode以及equals),如果相同直接覆盖value

4.如果不相同,判断table[i]是否为红黑树,如果是直接在树中插入键值

5.如果不是红黑树,遍历table[i] 判断链表长度是否大于8,大于8的话把链表转为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中发现key已经存在覆盖value即可。

6.插入成功后,判断实际存在的键值对数量是否超过了最大容量,超过进行扩容。

get值方法的过程是:

根据hash函数得到key的hash值,调用内部方法 getNode() 得到桶号,然后比较桶的内部元素是否与key相等,都不相等则没有找到。

如果key所在的桶头结点是红黑树,就调用红黑树的getTreeNode()方法,否则就遍历链表结点。

如果对比节点的哈希值和要查找的哈希值相等,就会判断 key 是否相等,相等就直接返回;不相等就从子树中递归查找。

四,问题

拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?

之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。

解决 hash 冲突的常见方法

a. 链地址法:将哈希表的每个单元作为链表的头结点,所有哈希地址为 i 的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。

b. 开放定址法:即发生冲突时,去寻找下一个空的哈希地址。只要哈希表足够大,总能找到空的哈希地址。

c. 再哈希法:即发生冲突时,由其他的函数再计算一次哈希值。

d. 建立公共溢出区:将哈希表分为基本表和溢出表,发生冲突时,将冲突的元素放入溢出表。

HashMap 就是使用链地址法来解决冲突的(jdk8中采用平衡树来替代链表存储冲突的元素,但hash() 方法原理相同)。当两个对象的hashcode相同时,它们的bucket位置相同,碰撞就会发生。此时,可以将 put 进来的 K- V 对象插入到链表的尾部。对于储存在同一个bucket位置的链表对象,可通过键对象的equals()方法用来找到键值对。

为什么Map桶中个数超过8才转为红黑树

我们首先要明白为什么要转换,这个问题比较简单,因为Map中桶的元素初始化是链表保存的,其查找性能是O(n),而树结构能将查找性能提升到O(log(n))。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。

TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成TreeNodes,而是否足够多就是由TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin。并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin。这样就解析了为什么不是一开始就将其转换为TreeNodes,而是需要一定节点数才转为TreeNodes,说白了就是trade-off,空间和时间的权衡:

当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。所以,之所以选择8,不是拍拍屁股决定的,而是根据概率统计决定的。由此可见,发展30年的Java每一项改动和优化都是非常严谨和科学的。



版权声明:本文为qq_36967136原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。