HashMap作为我们日常使用最频繁的容器之一,相信你一定不陌生了。今天我们就从HashMap的底层实现讲起,深度了解下它的设计与优化。
常用的数据结构
我在05讲分享List集合类的时候,讲过ArrayList是基于数组的数据结构实现的,LinkedList是基于链表的数据结构实现的,而我今天要讲的HashMap是基于哈希表的数据结构实现的。
我们不妨 一起来温习下常用的数据结构,这样也有助于你更好地理解后面地内容。
数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1),但在数 组中间以及头部插入数据时,需要复制移动后面的元素。
链表:一种在物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
链表由一系列结点(链表中每一个元素)组成,结点可以在运行时动态生成。每个结点都包含“存储数据单元的数据域”和“存储下一个结点地址的指针域”这两个部分。
由于链表不用必须按顺序存储,所以链表在插入的时候可以达到O(1)的复杂度,但查找一个结点或者访问特定编号的结点需要O(n)的时间。
哈希表:根据关键码值(Key value)直接进行访问的数据结构。通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做哈希函数,存放记录的数组就叫做哈希表。
树:由n(n≥1)个有限结点组成的一个具有层次关系的集合,就像是一棵倒挂的树。
什么是哈希表
从根本上来说,一个哈希表包含一个数组,通过特殊的关键码(也就是key)来访问数组中的元素。
哈希表的主要思想是:
-
存放Value的时候,通过一个哈希函数,通过 关键码(key)进行哈希运算得到哈希值,然后得到 映射的位置, 去寻找存放值的地方 ,
-
读取Value的时候,也是通过同一个哈希函数,通过 关键码(key)进行哈希运算得到哈希值,然后得到 映射的位置,从那个位置去读取。
最直接的例子就是字典,例如下面的字典图,如果我们要找 “啊” 这个字,只要根据拼音 “a” 去查找拼音索引,查找 “a” 在字典中的位置 “啊”,这个过程就是哈希函数的作用,用公式来表达就是:f(key),而这样的函数所建立的表就是哈希表。
哈希表的优势:加快了查找的速度。
比起数组和链表查找元素时需要遍历整个集合的情况来说,哈希表明显方便和效率的多。
常见的哈希算法
哈希表的组成取决于哈希算法,也就是哈希函数的构成,下面列举几种常见的哈希算法。
1) 直接定址法
- 取关键字或关键字的某个线性函数值为散列地址。
- 即 f(key) = key 或 f(key) = a*key + b,其中a和b为常数。
2) 除留余数法
- 取关键字被某个不大于散列表长度 m 的数 p 求余,得到的作为散列地址。
- 即 f(key) = key % p, p < m。这是最为常见的一种哈希算法。
3) 数字分析法
- 当关键字的位数大于地址的位数,对关键字的各位分布进行分析,选出分布均匀的任意几位作为散列地址。
- 仅适用于所有关键字都已知的情况下,根据实际应用确定要选取的部分,尽量避免发生冲突。
4) 平方取中法
- 先计算出关键字值的平方,然后取平方值中间几位作为散列地址。
- 随机分布的关键字,得到的散列地址也是随机分布的。
5) 随机数法
- 选择一个随机函数,把关键字的随机函数值作为它的哈希值。
- 通常当关键字的长度不等时用这种方法。
什么是哈希冲突(hash碰撞)
哈希表因为其本身的结构使得查找对应的值变得方便快捷,但也带来了一些问题,
以上面的字典图为例,key中的一个拼音对应一个字,那如果字典中有两个字的拼音相同呢?
例如,我们要查找 “按” 这个字,根据字母拼音就会跳到 “安” 的位置,这就是典型的哈希冲突问题。
哈希冲突问题,用公式表达就是:
key1 ≠ key2 , f(key1) = f(key2)
一般来说,哈希冲突是无法避免的,
如果要完全避免的话,那么就只能一个字典对应一个值的地址,也就是一个字就有一个索引 (安 和 按就是两个索引),
这样一来,空间就会增大,甚至内存溢出。
需要想尽办法,减少 哈希冲突(hash碰撞)为啥呢?Hash碰撞的概率就越小,map的存取效率就会越高
哈希冲突的解决办法
常见的哈希冲突解决办法有两种:
- 开放地址法
- 链地址法。
一、开放地址法
开发地址法的做法是,当冲突发生时,使用某种探测算法在散列表中寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到。
按照探测序列的方法,一般将开放地址法区分为线性探查法、二次探查法、双重散列法等。
这里为了更好的展示三种方法的效果,我们用以一个模为8的哈希表为例,采用除留余数法,
往表中插入三个关键字分别为26,35,36的记录,分别除8取模后,在表中的位置如下:
这个时候插入42,那么正常应该在地址为2的位置里,但因为关键字30已经占据了位置,
所以就需要解决这个地址冲突的情况,接下来就介绍三种探测方法的原理,并展示效果图。
1) 线性探查法:
fi=(f(key)+i) % m ,0 ≤ i ≤ m-1
探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+1],…,直到 T[m-1],此后又循环到 T[0],T[1],…,直到探查到有空余的地址或者到 T[d-1]为止。
插入42时,探查到地址2的位置已经被占据,接着下一个地址3,地址4,直到空位置的地址5,所以39应放入地址为5的位置。
缺点:需要不断处理冲突,无论是存入还是査找效率都会大大降低。
2) 二次探查法
fi=(f(key)+di) % m,0 ≤ i ≤ m-1
探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+di],di 为增量序列12,-12,22,-22,……,q2,-q2 且q≤1/2 (m-1) ,直到探查到 有空余地址或者到 T[d-1]为止。
缺点:无法探查到整个散列空间。
所以插入42时,探查到地址2被占据,就会探查T[2+1^2]也就是地址3的位置,被占据后接着探查到地址7,然后插入。
3) 双哈希函数探测法
fi=(f(key)+i*g(key)) % m (i=1,2,……,m-1)
其中,f(key) 和 g(key) 是两个不同的哈希函数,m为哈希表的长度
步骤:
双哈希函数探测法,先用第一个函数 f(key) 对关键码计算哈希地址,一旦产生地址冲突,再用第二个函数 g(key) 确定移动的步长因子,最后通过步长因子序列由探测函数寻找空的哈希地址。
比如,f(key)=a 时产生地址冲突,就计算g(key)=b,则探测的地址序列为 f1=(a+b) mod m,f2=(a+2b) mod m,……,fm-1=(a+(m-1)b) % m,假设 b 为 3,那么关键字42应放在 “5” 的位置。
开发地址法的问题:
开发地址法,通过持续的探测,最终找到空的位置。
上面的例子中,开发地址方虽然解决了问题,但是26和42,占据了一个数组同一个元素,42只能向下,此时再来一个取余为2 的值呢,只能向下继续寻找,同理,每一个来的值都只能向下寻找。
为了解决这个问题,引入了链地址法。
二、链地址法:
在哈希表每一个单元中设置链表,某个数据项对的关键字还是像通常一样映射到哈希表的单元中,而数据项本身插入到单元的链表中。
链地址法简单理解如下:
来一个相同的数据,就将它插入到单元对应的链表中,在来一个相同的,继续给链表中插入。
链地址法解决哈希冲突的例子如下:
(1)采用除留余数法构造哈希函数,而 冲突解决的方法为 链地址法。
(2)具体的关键字列表为(19,14,23,01,68,20,84,27,55,11,10,79),则哈希函数为H(key)=key MOD 13。则采用除留余数法和链地址法后得到的预想结果应该为:
(3)哈希造表完成后,进行查找时,首先是根据哈希函数找到关键字的位置链,然后在该链中进行搜索,如果存在和关键字值相同的值,则查找成功,否则若到链表尾部仍未找到,则该关键字不存在。
哈希表性能
哈希表的特性决定了其高效的性能,大多数情况下查找元素的时间复杂度可以达到O(1), 时间主要花在计算hash值上,
然而也有一些极端的情况,最坏的就是hash值全都映射在同一个地址上,这样哈希表就会退化成链表,例如下面的图片:
当hash表变成图2的情况时,查找元素的时间复杂度会变为O(n),效率瞬间低下,
所以,设计一个好的哈希表尤其重要,如HashMap在jdk1.8后引入的红黑树结构就很好的解决了这种情况。
HashMap的类结构
类继承关系
Java为数据结构中的映射定义了一个接口java.util.Map,此接口主要有四个常用的实现类,分别是HashMap、Hashtable、LinkedHashMap和TreeMap,
下面针对各个实现类的特点做一些说明:
(1) HashMap:
它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。
HashMap 最多只允许一条记录的键为null,允许多条记录的值为null。
HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。
如果需要满足线程安全,可以用:
-
Collections的synchronizedMap方法使HashMap具有线程安全的能力,
-
或者使用ConcurrentHashMap。
(2) Hashtable:
Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的。
这个是老古董,Hashtable不建议在代码中使用,
不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。
为何不建议用呢?
任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap。后者使用了 分段保护机制,也就是 分而治之的思想。
(3) LinkedHashMap:
LinkedHashMap是HashMap的一个子类,其优点在于: 保存了记录的插入顺序,
在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
(4) TreeMap:
TreeMap实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,
当用Iterator遍历TreeMap时,得到的记录是排过序的。
如果使用排序的映射,建议使用TreeMap。
在使用TreeMap时,key必须实现Comparable接口, 或者在构造TreeMap传入自定义的Comparator,
否则会在运行时抛出java.lang.ClassCastException类型的异常。
注意:
对于上述四种Map类型的类,要求映射中的key是不可变的。
在创建内部的Entry后, key的哈希值不会被改变。
为啥呢?
如果对象的哈希值发生变化,Map对象很可能就定位不到映射的位置了。
static class Node<K,V> implements Map.Entry<K,V> { final int hash; //key的哈希值不会被改变 final K key; // 映射中的key是不可变的 V value; Node<K,V> next;
HashMap存储结构
通过上面的比较,我们知道了HashMap是Java的Map家族中一个普通成员,鉴于它可以满足大多数场景的使用条件,所以是使用频度最高的一个。
下文我们主要结合源码,从存储结构、常用方法分析、扩容以及安全性等方面深入讲解HashMap的工作原理。
HashMap的重要属性:table 桶数组
从HashMap的源码中,我们可以发现,HashMap有一个非常重要的属性 —— table,
这是由一个Node类型的元素构成的数组:
transient Node<K,V>[] table;
table 也叫 哈希数组, 哈希槽位 数组 ,table 桶数组 , 散列表, 数组中的一个 元素,常常被称之为 一个 槽位 slot
Node类作为HashMap中的一个内部类,每个 Node 包含了一个 key-value 键值对。
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } .......... }
Node 类作为 HashMap 中的一个内部类,除了 key、value 两个属性外,还定义了一个next 指针。
next 指针的作用:链地址法解决哈希冲突。
当有哈希冲突时,HashMap 会用之前数组当中相同哈希值对应存储的 Node 对象,通过指针指向新增的相同哈希值的 Node 对象的引用。
JDK1.8的table结构图
从结构实现来讲,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如下如所示。
问题:
HashMap的有什么特点呢?
HashMap的有什么特点
(1)HashMap采用了链地址法解决冲突
HashMap就是使用哈希表来存储的。
Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。
上图中的每个黑色圆点就是一个Node对象。
Java中HashMap采用了链地址法。链地址法,简单来说,就是 数组加链表 的结合。
在每个数组元素上都一个链表结构, 当数据被Hash后,首先得到数组下标,然后 , 把数据放在对应下标元素的链表上。
例如程序执行下面代码:
map.put("keyA","value1"); map.put("keyB","value2");
对于 第一句, 系统将调用"keyA"的hashCode()方法得到其hashCode ,然后再通过Hash算法来定位该键值对的存储位置,然后将 构造 entry 后加入到 存储位置 指向 的 链表中
对于 第一句, 系统将调用"keyB"的hashCode()方法得到其hashCode ,然后再通过Hash算法来定位该键值对的存储位置,然后将 构造 entry 后加入到 存储位置 指向 的链表中
有时两个key会定位到相同的位置,表示发生了Hash碰撞。
Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。
(2)HashMap有较好的Hash算法和扩容机制
哈希桶数组的大小, 在空间成本和时间成本之间权衡,