本文是我在学习 java集合过程中,针对HashMap的一篇总结文章。由于博主是非科班出身程序员,在学习HashMap原理时遇到了很多困难,所以如果你和博主一样,数据结构基础也不扎实甚至是没有基础,这篇文章可能也非常适合你!
以下是本文的结构,可根据需要跳转到相应位置,不必按顺序阅读!
一、预备知识
时间复杂度
时间复杂度用来度量算法的运行时间,记作: T(n) = O(f(n))。它表示随着输入 n 的增大,算法执行需要的时间的增长速度可以用 f(n) 来描述。渐进时间复杂度用大写O来表示,所以也被称为大 O表示法。
常用的时间复杂度比较:
O(1)<O(log n)<O(n)<O(nlog n)<O(n^2)
从左到右,算法执行效率逐渐下降,
了解更多:
基本数据结构
HashMap主干是哈希表,这之前先了解一下其他几种数据结构的性能。
数组
采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为 O(1);通过给定值进行查找(顺序查找),需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为 O(n);对于有序数组,可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为 O(logn);对于指定位置的插入删除操作,涉及到数组元素的移动,其平均复杂度为 O(n);另外修改和查找实现复杂度相同。
链表
不是按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为 O(1),而查找操作需要遍历链表逐一进行比对,复杂度为 O(n)。
数组与链表的根据指定值查询时间复杂度都是O(1),但数组更快
1. 链表需要在遍历时,需要比数组更多的寻址操作2. CPU缓存会把一片连续的内存空间读入,因为数组结构是连续的内存地址,所以数组全部或者部分元素被连续存在CPU缓存里面,而链表则不会
红黑树
是一种自平衡二叉查找树,可以在O(log n)时间内做查找,插入和删除,jdk8之后HashMap桶内链表长度超过树化阀值且总长度超过最小树化容量后会将链表转换为红黑树。
散列表
散列表(Hash table,也叫哈希表)是一种根据 key-value进行访问的数据结构。在散列表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)。哈希表是 HashMap主干,所以在分析 HashMap前要先详细了解一下哈希表。
散列表(Hash table),是根据键(Key)而直接访问在内存存储位置的数据结构。也就是说,它通过计算一个关于键值的函数f(x),将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数f(x)称做散列函数,存放记录的数组称做散列表。
散列函数(Hash function),经散列函数映象到地址集合中任何一个地址的概率是相等的,则称此类散列函数为均匀散列函数(Uniform Hash function),散列函数的设计至关重要,好的散列函数会尽可能地保证 计算简单和散列地址分布均匀。散列函数构造方法包括直接定址法,随机数法,除留余数法等。
冲突(Collision):对不同的Key可能得到同一散列地址,即k1≠ k2,而 f(k1)=f(k2),再好的散列函数也无法避免冲突。产生冲突就要进行处理,通常处理冲突的方法有开发定址法,单独链表法,双散列,再散列等。在java中使用的是单独链表法。
举个例子:
假设有个数组长度为4数组(每一个位置都叫桶bucket),这里有3个人赵四,钱五,孙六要装进去,我们定义一个规则,按姓氏在百家姓中的顺序除以四得到的余数作为索引放入四个位置。
当前存放三个人记录的数组就是散列表,我们定义的规则就是散列函数,根据散列函数进行映射的过程叫做散列过程。
如果又来一个人叫周日,根据我们的规则,周日也要落在1的位置上,此时就产生了冲突。这里我们使用单独链表法处理,把周天放在索引为一的位置和赵四构成链表(新元素是放在链表前端还是后端完全是取决于怎么方便)。
散列表查找是就是先找到桶的位置,再遍历查找需要的数据。如果散列函数设计的不好,所有的元素都落在一个桶里那效率就特别低,和单链表随机访问没什么区别。
基本位运算
运算符 | 计算方式 |
---|---|
与 & | 只有两个数同一位都是1才会返回1 |
或 l | 两个数同一位只要存在一个1就是1 |
异或 ^ | 两个数同一位不能相同才为1 |
左移 << | 所有位置左移,低位补0 |
右移 >> | 正数:所有位置右移,高位补0 负数:写出补码(符号位不变,其余位置取反,然后加1),所有位置右移高位补1,然后再获取原码(符号位不变,其余位置取反,然后加1) |
无符号右移 >>> | 无论正负高位补0 |
了解更多:
二、HashMap实现原理
结构
Node是 HashMap的静态内部,HashMap主干是一个Node数组,Node是HashMap的最基本组成单位。
1 | // HashMap的主干数组 |
Node
1 | static class Node<K,V> implements Map.Entry<K,V> { |
在jdk8之前HashMap是数组加链表的形式实现,但是在1.8之后为提高哈希冲突后链表的查询速度,当桶内链表长度超过树化阀值且总长度超过最小树化容量后会将链表转换为红黑树。
jdk7
jdk8
速度
查询与修改
先用散列函数对键进行散列,没有冲突的情况下查询是下标查询,时间复杂度是 O(1),速度很快。
存在哈希冲突的情况,需要对链表/红黑树进行遍历,equals比对查询。
性能上,考虑是链表/红黑树上的元素越是越好,越均匀越好;此外HashMap主干未必越长越好,会有用不到的桶浪费空间。
增加与删除
由于查询速度快,而桶里用链表/红黑树实现,所以添加和删除效率也很高。HashMap会在size超过阀值后进行调整大下(resize),所以根据具体情况提前给HashMap一个合适的初始长度是个不错的习惯。
三、源码分析
基本常量
1 | // 默认初始长度,即主干数组的长度,如果创建对象时没有给长度,默认是16 |
CAPACITY是 HashMap容量(主干数组长度),size是键值对个数
基本成员变量
1 | // 主干数组 |
构造方法
主要构造方法
HashMap有四种构造方法,这里只说最核心的一个,只说传入初始容量和负载因子这种。
1 | /** |
tableSizeFor(initialCapacity)
方法是用来计算初始容量的,HashMap容量并不是传多少就是多少,而一定是2的次幂。这个方法会返回一个比给定容量大的最小2的次幂的数。
举个例子:如果你给了9,比9大的最小2的次幂是16(2^4);如果你给个27,比27大的最小的2的次幂是32(2^5)。
1 | static final int tableSizeFor(int cap) { |
解释:
n=0
经过几次次无符号右移还是0,最后返回n+1是1
n>0
下面这个图演示前三步移动的过程
剩下的大家脑补,最后算出来就是32位以内最高位那个1后面跟的都是1,然后n≠1的情况下会加个1,就是我们要的结果,这里结果是2^8,原来那个显然是大于2^7的一个数。看完这个过程是不是觉得”妙啊!”,我也觉得这个算法好机智,哈哈。
其他构造方法
1 | public HashMap(int initialCapacity) { |
以上几个构造函数都没有直接的创建一个切实存在的数组,他们都是在为创建数组需要的一些参数做初始化,table的初始化被推迟到了put方法中,所以这几个构造函数中并没有被初始化的属性都会在实际初始化数组的时候用默认值替换。
这个构造函数有put过程,table已经完成初始化
1 | public HashMap(Map<? extends K, ? extends V> m) { |
小结
1.构造函数会创建一个容量(主干数组长度)大于等于initialCapacity的最小的2的幂长度的HashMap2.负载因子可以自定义
3.多数构造方法中并没有初始化table,table初始化的过程是在put方法中完成的
put方法
put
put方法是一个重点方法,这里有 HashMap初始化,数据在 HashMap中是如何储存的,什么情况下链表会转换为红黑树等内容,需要仔细研究。
1 | public V put(K key, V value) { |
putVal
putVal是final修饰的方法,子类 LinkedHashMap也是用的这各方法,evict(看下面的的第5个参数)就是给 LinkedHashMap使用的,HashMap中并没有什么用。
1 | /** |
hash
再来看一下hash()这个方法吧。
1 | static final int hash(Object key) { |
可能小伙伴有疑惑,好好的hashCode()非弄个亦或运算干啥?
(n - 1) & hash
是table的索引,n的长度不够大时,只和hashCode()的低16位有关,这样发生冲突的概率就变高。举个例子:下图就是table.length为16时的计算情况,如果没有亦或运算就只和低4位有关,这样就会加大冲突的概率。
resize
这也是一个很重要的方法,主要包括两部分,第一部分是根据size是否超过阀值判断是否需要进行扩容,第二部分是扩容后将原Node[]中数据复制到扩容后的Node[]中
扩容部分
1 | final Node<K,V>[] resize() { |
复制数据部分
看源码前,先看下面这个图
正常来讲,向新数组复制元素时需要重新计算位置,现在有了这个规律,就可以这样做:
- x=0不改变位置
- x≠0原位置+原数组长度获取新位置
判断x是否为0,e.hash & oldCap
可以完成,返回结果是0,代表x处是0,位置不用改变,否则改变位置
1 | //创建新的数组 |
复制过程,a过去,假设计算后位置不边,进到i,此时i为null,a进去后即是head,又是tail
然后循环,到b,假设计算后还是i,i中已经有a,所以b直接丢到a后面,a仍是head,但tail已经变成了b
以此类推,a,b,c,d都会放在i,j中
至此,put方法已经说完了,重点是putVal,hash和resize三个方法,如果不理解可以看本文结尾的参考文献,因为不同的人思维方式,表达方式都不同,说不定换一种表述方式就能理解了。
remove
remove就是先找到节点位置,然后移除,核心方法是removeNode()
1 | public V remove(Object key) { |
removeNode
1 | /** |
大致过程就是这个样子的~~,勉强看吧没画图天赋!
源码分析到这里就结束,看了这几个方法,只要不是红黑树的部分,看起来就很没那么困难了。
四、日常使用注意事项
在可以明确HashMap长度的情况下,最好给HashMap一个初始容量
看完上面原码后,会发现HashMap使用过程中会出现resize()操作,会涉及到哈希表的重建,这是一个比较消耗资源的操作,如果在明确长度的情况下,能给定合适的容量就可以减少甚至避免扩容操作。
阿里巴巴开发手册给出如下公式:
initialCapacity = (需要存储的元素个数 / 负载因子) + 1。
注意负载因子(即loader factor)默认为0.75, 如果暂时无法确定初始值大小,请设置为16(即默认值)
在guava中其实也使用这个公式,并且guava提供下面这个方法来创建HashMap:
Map<String, String> map = Maps.newHashMapWithExpectedSize(10)
其实这个公式是来自putAll()方法,感兴趣的小伙伴可以去看一下。
重写equals方法是一定要重写hashCode方法
老生长谈了,这里主要针对key是对象的情况,举个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28class Person{
int idCard;
String name;
public Person(int idCard, String name) {
this.idCard = idCard;
this.name = name;
}
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()){
return false;
}
Person person = (Person) o;
//两个对象是否等值,通过idCard来确定
return this.idCard == person.idCard;
}
public void test(){
Map map = new HashMap();
Person p1 = new Person(1234,"小白");
map.put(p1,"哈哈哈哈");
Person p2 = new Person(1234,"小白");
map.get(p2);
}当用person来做key时,显然,如果在hashcode不重写的情况下,使用p2是无法获得需要的内容的,因为两个对象用来找桶的hashcode是不同的,所以无法找到想同的桶啊!桶都找不到去哪里找值哈哈!
五、总结
本文记录了我学习HashMap的全过程,包括预备知识、实现原理、源码分析、注意事项等几个部分,对我这个没数据结构基础的人来说收获真的很大,希望对各位读者也有一定的帮助!存在问题希望大家指正!
本文首发于cdream个人博客
欢迎转载,转载请注明出处!
本文参考: