一、疑问
为什么要学HashMap?HashMap 的底层原理?HashSet为何不能有重复的元素(HashSet底层就是HashMap)?如果你也有上述疑问,相信此文能给你一点帮助。
二、为什么要学HashMap
HashMap说到底也是一个存储数据的东西。对数据操作无非就是增删改查,对于增删需求大的业务我们可以利用链表存储数据,对于查改需求大的业务我们可以利用数组。这两种数据结构已经可以互补了,为什么还要一个HashMap呢。HashMap存储的是k-v集合,根据key就可以获得相应的value,HashMap与前两者不同。使用起来很方便,也很常用,各语言都有自己实现k-v的形式,比如java用的HashMap,redis用string存储k-v。也就是从结果导向来看,不学HashMap就out啦。
三、HashMap的底层原理
3.1 存储结构
首先我们看看HashMap是如何存储数据的。在jdk8之前的HashMap利用数组+链表存储,jdk8开始利用数组+链表+红黑树存储。如图
其中每一个框都是一个Node结点。
3.2 存储逻辑
put一个k-v时,HashMap会先利用k的hashcode计算这个k-v应该放在数组的哪一个位置。如果该位置有了元素,就比较新加入的元素和已存在的是否相同(如何判断相同,稍微复杂,后面看源码),相同则更新value值,不同则挂到后面形成链表。如图
其实HashMap一般情况下是Node数组中的元素大于链表中的元素,因为每次添加都是通过key的哈希值确认数据存放的位置。每次哈希算法的结果一般不同。而链表的存在就是解决哈希碰撞。即如果两个不同对象计算的哈希相同了,那么就把新添加的对象挂在后面,也就是拉链法。而红黑树的加入是jdk8为了改进HashMap查询效率,在链表长度>=8且数组长度>=64时,对该链进行树化。红黑树比单纯的链表查询效率要更快不少。
3.3 debug验证
3.3.1先模拟正常情况,向map中添加3个猫,和对应的年龄
public class HashMapTest {
public static void main(String[] args) {
Cat cat01 = new Cat("咪咪");
Cat cat02 = new Cat("喵喵");
Cat cat03 = new Cat("呱呱");
Map<Cat,Integer> map = new HashMap<>();
map.put(cat01,1);
map.put(cat02,2);
map.put(cat03,3);
System.out.println(map);
}
}
class Cat{
private String name;
public Cat(String name) {
this.name = name;
}
}
复制代码
这里补充一个概念:桶,其实就是在数组中的位置,可以理解为索引。但是HashMap中叫桶bucket,更加形象,因为这个位置后面可以挂链表,就感觉这几个元素都在一个桶里。
3.3.2 模拟存在Hash冲突
class Cat{
private String name;
public Cat(String name) {
this.name = name;
}
//我们只要重写一下Cat的hashCode方法返回一个固定的值,这时HashMap每次调用key的hashCode返回的就都是同一个值了。达到哈希冲突的效果
@Override
public int hashCode() {
return 100;
}
}
复制代码
调试查看结果:发现三只猫的hash值都是100,而且都在4号桶中。
到这我们验证了:当存在hash冲突时,确实会把元素挂在已存在元素的后面。而且因为上面例子中的三只猫都不同,所以三只猫都成功添加了。
3.3.3 key的比较
上面三只猫分别是咪咪,喵喵和呱呱。那么如果我把 呱呱 也改成 咪咪。能否添加成功呢?验证:
发现三只猫还是进来了。眼尖的小伙伴可能已经发现了,
cat01
和cat03
本来就不是同一个对象,指向的内存空间不一样,当判断是否相同时,默认调用的是Object的equals,它默认是比较两个对象的内存地址。所以HashMap会认为cat01
,cat03
是不同的猫。明白这一点,我们只要重写equals方法就可以实现,当两只猫的名字相同时,就认为两只猫是同一只猫了。
class Cat{
private String name;
public Cat(String name) {
this.name = name;
}
@Override
public int hashCode() {
return 100;
}
//重写equals方法
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Cat cat = (Cat) o;
return Objects.equals(name, cat.name);
}
}
复制代码
验证:
重写equals方法后,我们发现只添加了两只猫;而且原本cat01的value是1,现在变成cat03的value了。因为HashMap认为这是同一只猫,所以对cat01的value值进行了更新。至此我们验证了HashMap的存储逻辑,接下来我们看看HashMap是怎么实现的.
3.4 源码分析
3.4.1 第一次添加的流程
这里可以发现
put()
底层调用的是putVal()
,可知putVal()
才是真正添加元素的方法;这里还调用了hash()
方法,求key的hash值,并传给putVal()
接下来看
putVal()
方法,由于是第一次添加,满足前两个if,而else中的语句不会执行。先分析第一个if,table
是HashMap中的成员属性,第一次添加它是null。putVal()
中定义了一个临时数组tab
也指向了table,第一次为空,length也=0。故满足第一个if,那么进入resize()扩容(这个方法后面再讲,这里知道它是扩容就行)。然后进入第二个if判断,p=tab[i = (n-1)&hash]
拆开看就是i=(n-1)&hash
,p = tab[i]
,i=(n-1)&hash
就是利用hash值,查到新增的元素应该放到哪个桶中(为什么要这么找桶,后面再讲,这里知道p指向的是table数组中桶的位置就行),第二if就是判断p指向的这个桶是不是空桶,是则把新增的元素放到这个空桶里。由于我们是第一次添加,所有的桶都是空的没有数据。到此第一个元素添加成功。
3.4.2 第二次添加
put()
方法就不看了,它的作用就是给putVal()
传key对应的hash值。
第二次由于我们模拟了hash碰撞,因此会进入到else中。进入else后首先要判断新增的元素和桶中的元素是不是相等。然后判断这个桶里的元素是不是树化了。都不是的话就进入循环遍历桶中的每一个元素,如果都不同的话,那么就把这个新增的元素插入到最后面。
3.4.3 第三次添加
这次添加,由于我们重写equals方法,cat03和cat01的名字相同,HashMap就会认为这是同一只猫。故不会把cat03做为新元素添加进来。
然后进入个判断,如果是相同的key,那么更新value值,并返回旧值。