为什么哈希查找那么快?
HashMap是基于哈希值的桶和链表
因为哈希的存储类似于数组,在数组中我们只需要知道数组的下标就可以取到数组的值了,查找速度极佳,时间复杂度为O(1),哈希的存储就类似于数组的存储,将值映射到内存的某个单元,然后给他一个哈希值,我们查找的时候只需要查找那个哈希值就可以了,不需要进行任何比较,但是在此之前我们需要指定哈希函数用来将无限大的值映射到内存中,如何选择哈希函数方法并不唯一,一般存储正整数的话就用一个正整数来mod数组的长度,但是这样做的致命缺点就是哈希冲突,我们的哈希值可能会存n个数据,我们解决哈希冲突的方法有两种,一种是rehash一种是链地址法,链地址法就是将多个值放在这一个位置上 用链表的形式进行存储,但是这样一来链表的长度很有可能很长,这样一来我们的哈希表就没有意义了。所以我们有了rehash的方法,将哈希数组变成原来的两倍,然后再将值mod长度后的第一个素数就可以了,这样一来我们的链表就不会很长了,但是问题又来了,我们什么时候进行rehash呢?首先我们有一个装载因子用来定义一个进行rehash的标准,这个装载因子就是元素的个数除以数组的长度,即哈希表满载的程度 给他一个常量,当这个哈希表要满的时候我们就进行rehash,这样一来我们的性能就是最佳的!
关键点就是:哈希函数的选择,处理哈希冲突的方法还有装载因子的调整,这些工作都完善之后我们的哈希绝对是性能极佳的!
初探HashMap源码
ok,我们大体了解过了哈希表这个数据结构之后我们就可以探讨哈希表在Java中的实现,即HashMap
,我们打开源码可以看到,HashMap
存在于java.util
包下,往下滑映入眼帘的是第一段的注释
他说哈希表是实现Map
接口的一个类,他执行的时候可以允许key和value是空的。
好,我们可以看到这个类确实是实现了Map接口的。我们继续往下看。
为什么默认的初始空间必须是2的幂
大家都知道HashMap的初始容量是16,负载因子是0.75,当它的容量达到16*0.75=12时,便开始进行扩容。但是你知道为什么HashMap的初始容量是16吗?为什么不能是17?为什么不能是19?
这个4的意思就是2的4次幂,也就是说我们最后put创建的桶的容量就是16,
我们先来看一下默认的构造函数
构造函数说使用默认的初始空间16和默认的加载因子0.75来创建一个HashMap。
需要知道的是我们在创建HashMap的时候我们的桶并没有被创建出来,只有在put
的时候才会创建一个默认初始空间为16的桶。
Java 8的put方法与Java 7的put方法有所区别,Java 8的put返回的是一个方法,而Java 7是直接进行逻辑判断了,Java 7中如果没有初始化的话,就给你开辟一个16的空间。
我们再来看一下put方法
添加元素的源码:
扩容的源码:
我们可以发现上面两部分源码中用红方框括起来的部分都是与运算,因为与运算是非常高效的运算,HashMap的容量为什么是2的n次幂,和这个(n - 1) & hash的计算方法有着千丝万缕的关系,符号&是按位与的计算,这是位运算,计算机能直接运算,特别高效,按位与&的计算方法是,只有当对应位置的数据都为1时,运算结果也为1,当HashMap的容量是2的n次幂时,(n-1)的2进制也就是1111111。。。111这样形式的,这样与添加元素的hash值进行位运算时,能够充分的散列,使得添加的元素均匀分布在HashMap的每个位置上,减少hash碰撞。
我们进一步理解(n-1)&hash值为什么是n-1呢?又为什么是与上他呢?因为所有的2次幂的前一个的二进制都是全包含1的,如图
63也全是1,只有全是1的时候才会发挥&
运算符的作用,只有运算的时候两个都是1的时候才会得到1,所以我们用2的次幂-1&hash值就是为了让这些数据分散开来避免哈希冲突,这是最有效最简洁的方法。
下面举例进行说明。当HashMap的容量是16时,它的二进制是10000,(n-1)的二进制是01111,与hash值得计算结果如下:
那么为什么要用2的幂呢?是因为容量是2的n次幂,可以使得添加的元素均匀分布在HashMap中的数组上,减少hash碰撞,避免形成链表的结构,使得查询效率降低!
HashMap的Java 8对于Java 7的更新有哪些
- 数组+链表/红黑树
- 扩容时插入顺序的改变
- 函数方法
- foreach
- compute系列
- Map的新API
- merge
- replace
理解部分源码
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
关于这部分源码注释中也有说到
他的大概意思就是当key等于空的时候我们返回0,不等于空的时候我们为什么不直接返回key.hashCode()
呢?而是对h的高16位进行^的运算,首先我们这样做是为了让结果更加的散列,因为length 绝大多数情况小于2的16次方。所以始终是hashcode 的低16位(甚至更低)参与运算。要是高16位也参与运算,会让得到的下标更加散列。
所以这样高16位是用不到的,如何让高16也参与运算呢。所以才有hash(Object key)方法。让他的hashCode()和自己的高16位^ 运算。所以(h >>> 16)得到他的高16位与hashCode()进行^运算。
我们只有在hash方法中将hash的值给设计好之后,我们才能在putVal中进行存储的时候更加随机。
因为我们这里用到的hash就是我们hash函数中返回的值。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
这部分源码是putVal中的意思就是说现在存在这个映射就进行覆盖操作
if (++size > threshold)
resize();
这一块就是判断当这个数组的长度大于阈值的话就进行resize操作,就是扩容。
然后我们再来看一下resize方法里面的扩容操作
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
........
}
第二条语句就是扩容部分,扩充为原来的2倍。
HashMap的一些面试题
1.谈一下HashMap的特性?
- HashMap存储键值对实现快速存取,允许为null。key值不可重复,若key值重复则覆盖。
- 非同步,线程不安全。
- 底层是hash表,不保证有序(比如插入的顺序)
2.谈一下HashMap的底层原理是什么?
基于hashing的原理,jdk8后采用数组+链表+红黑树的数据结构。我们通过put和get存储和获取对象。当我们给put()方法传递键和值时,先对键做一个hashCode()的计算(即hash方法)来得到它在bucket数组中的位置来存储Entry对象。当获取对象时,通过get获取到bucket的位置,再通过键对象的equals()方法找到正确的键值对,然后在返回值对象。
3.谈一下hashMap中put是如何实现的?
- 计算关于key的hashcode值(与Key.hashCode的高16位做异或运算)
- 如果散列表为空时,调用resize()初始化散列表
- 如果没有发生碰撞,直接添加元素到散列表中去
- 如果发生了碰撞(hashCode值相同),进行三种判断
- 若key地址相同或者equals后内容相同,则替换旧值
- 如果是红黑树结构,就调用树的插入方法
- 链表结构,循环遍历直到链表中某个节点为空,尾插法进行插入,插入之后判断链表个数是否到达变成红黑树的阙值8;也可以遍历到有节点与插入元素的哈希值和内容相同,进行覆盖。
- 果桶满了大于阀值,则resize进行扩容
4.谈一下HashMap中hash函数是怎么实现的?还有哪些hash函数的实现方式?
对key的hashCode做hash操作,与高16位做异或运算
还有平方取中法,除留余数法,伪随机数法
5.为什么不直接将key作为哈希值而是与高16位做异或运算?
因为数组位置的确定用的是与运算,仅仅最后四位有效,设计者将key的哈希值与高16为做异或运算使得在做&运算确定数组的插入位置时,此时的低位实际是高位与低位的结合,增加了随机性,减少了哈希碰撞的次数。