文章目录
1. 引言
HashMap在编程中经常用到,很多人都知道它的作用的使用方式,却对其实现原理比较模糊。在面试中,HashMap是一个经常问道的问题,如果不知道HashMap的实现原理进大厂几乎是不太可能的。本文以循序渐进的方式,让你逐步理解HashMap的原理,希望能够帮到你。
2. 如何实现数据快速查找
在开发中,经常会遇到需要根据某个特征快速查找到某个数据的需求,如根据用户名,查找出用户信息;根据年龄找出所有符合条件的用户等。而HashMap可以实现这个需求。如下的代码,在800万的数据量中,快速查找出用户名为叶无缺的用户出来,来看看性能怎么样。
public class HashMap学习 {
private static class People {
int num;
String name;
public People(int num, String name) {
this.num = num;
this.name = name;
}
}
public static void main(String[] args) {
People[] array = new People[8000000];
for (int i = 0; i < 8000000; i++) {
People people = new People(i, "员工" + i);
if (i == 6000000) {
people.name = "叶无缺";
}
array[i] = people;
}
//用数组查找
long start = System.currentTimeMillis();
People result = null;
for(int i=0;i<8000000;i++){
if(array[i].name.equals("叶无缺")){
result = array[i];
break;
}
}
System.out.println("数组查找耗时:"+(System.currentTimeMillis()-start));
HashMap<String, People> hashMap = new HashMap<>();
for (int i = 0; i < 8000000; i++) {
People people = new People(i, "员工" + i);
if (i == 6000000) {
people.name = "叶无缺";
}
hashMap.put(people.name, people);
}
start = System.currentTimeMillis();
result = hashMap.get("叶无缺");
System.out.println("HashMap查找耗时:"+(System.currentTimeMillis()-start));
}
}
运行结果
可以看到,HashMap在800万数据中查找,耗时低于1毫秒,其性能非常好。下面来研究一下它是怎么实现的。
3. HashMap用数组保存数据
假设我们公司有10000名员工,每个员工的结构如下:
public class People{
int num;
String name;
...
}
那么我们就用数组来保存所有员工的实例吧
People[] peoples = new People[10000];
//省略数组的内容赋值
这个时候,一个庞大的数组就建立起来的,这个数组保存于内存中的一段连续的空间
我们需要读写任意位置的员工数据都很快(只需要简单地址偏移计算就可以得到任意位置的地址)。
//通过数组下标快速寻址,读取到员工内容
String name = poeples[10].getName()
这里需要特别注意的是,这里说的地址连续是指数组本身各元素的地址连续,而每个员工本身却不一定是连续的。如
peoples[0] = new People(10000,"叶无缺");
peoples[1] = new People(10001,"叶辰");
在这里叶无缺和叶尘两个员工对象在内存的地址是不确定的,是由new对象的时候由JVM分配的。在数组peoples中的第一个元素和第二个元素分别保存的是这两个员工的引用。如果用C++的话就可以很方便的查看数据的地址和数组的内存占用大小。
用数组来保存10000名员工的实例对象后,我们可以很方便的遍历每个员工,也可以很快速的读取和修改任意一个员工的数据。
//遍历
for(int i=0;i<peoples.length;i++){
System.out.println("编号:%d-%s", peoples[i].getNum(), peoples[i].getName());
}
//读取
People pn = peoples[10];
//修改
peoples[10] = new People(10, "叶无缺2");
对于公司员工,还应该满足一下需求:
- 员工编号应该是唯一的
- 可以根据员工编号快速查找到具体员工
用上面单纯数组的方式显然无法满足这两个条件。对于第一个条件,我们可以在每次添加、修改操作时进行遍历数组以满足员工编号唯一性;对于第二个条件,只能通过遍历数组找出某个编号的员工。都需要对数组进行遍历,这样的效率很低,当数组很大时其性能更是恐怖。
再看查询字典的例子,我们查找三个字叶无缺,查找方式是根据字母Y—W---Q的顺序在不同的区间去查找,不需要从第一页开始遍历。这是一种分类存储的思想,根据被查找的内容特征,到特定的位置查找。
那么我们的员工数组是否也应该以这种方式来保存员工数据呢?以员工编号为分类,每个分类对应与数组的特定位置,当需要根据编号查找员工时,就只需要到特定位置去查找。
这里我们以上图的方式把员工编号转换为数组下标,这个时候数组添加/修改内容应该是这样的
int num = 10086
peoples[num-10000] = new Peoples(num, "叶无缺");
当我们要根据编号查找任意员工时
int num = 10010;
People result = peoples[num-10000];
看到这里会不会感觉挺有意思的,这样的实现方式就完美解决了上面提到的两个问题。避免了遍历数组带来的性能开销。来看一下下面代码100万的员工中查找任意编号员工出来的测试吧:
public static void main(String[] args) {
People[] peoples = new People[1000000];
for(int i=10000;i<1010000;i++){
peoples[i-10000] = new People(i, "员工"+i);
}
long start = System.currentTimeMillis();
People result = peoples[880000-10000];
System.out.println("查找员工["+result.name+"]耗时:"+(System.currentTimeMillis()-start));
}
运行结果
这里员工编号相当于做了索引,查找几乎是不耗时的,时间复杂度为O(1),在常用的数据库中,如mysql、oracle等为什么能够在几百万上千万的数据中快速查找某个数据出来,其实就是做了索引。这里是以数字来做索引的,大家都知道这不就是相当于读取数组特定下标的某个元素吗。如果能够以任意数据类型都做索引的话,那样才够牛逼。这当然能够做到,我们知道HashMap的key就相当于是索引,而key是一个泛型,也就可以是任意类型。
我们来对比一下这个员工数组和HashMap。
员工数组 | HashMap |
---|---|
数据内容的特征应是唯一的 | key是唯一的 |
根据数组内容的特征,计算出其应该保存在数组中的位置 | 根据key计算出内容保存到数组中的位置 |
发现我们这里实现的员工数组和HashMap具有惊人的相似性。其实HashMap的实现就用到了数组。说到这里,细心的小伙伴可能会发现员工数组具有特定性,如
- 这里用数字(员工编号)转化为数组下标,数字可通过计算得出数字(数组下标)。假若要根据员工姓名、爱好、部门、职位等快速查找呢?
- 员工数是固定的,假若公司开始时只有10个员工,需要创建10000长度的数组?后期公司发展到几十万、上百万人呢?
- 员工编号是有规则的,可以通过计算实现一个编号对应一个数组位置,假若员工编号无规则,通过计算可能有多个员工编号对应的数组下标相同
这样应该怎么实现?下面我们来探索一下这几个问题,同时揭开HashMap的实现原理。
4. HashMap用数组+链表保存数据
从上面我们通过仿照现实中查字典的方案来实现了数组内容的快速查找,最后提出了几个问题,解决这几个问题后这个数组就会成为一个通用的数组,可以存放各种类型的数据、可以根据不同类型的数据来快速查找。把这样的一个员工数组起一个新的名字,叫做HashMap。
4.1 HashMap中链表的引入
再看上面定义People类,这个类有多个属性:编号、姓名、性别、生日、爱好、职位、部门等。我们需要以任意类型的属性都能快速查找到其数组中的位置。也就是说要实现
在这个转换过程需要注意两点:
- 任意类型数据的变化是无限的(千变万化,如字符串)
- 数组下标是有限(内存有限,数组长度有限)
所以这是一个多对一的关系,那么我现在有两个员工,通过计算得出对应的数组下标相同怎么办?
可以这么实现,数组中的元素当作一个链表,如上图当peoples[100]的位置放了叶无缺这个元素时,需要把叶辰也放到这个位置,就把叶辰放到叶无缺的后面。
这时候就形成上图这样的一种结构,横向是一个数组,纵向是10000个链表。当要查找/修改员工时,先通过算法计算出该员工在数组中的下标,然后如果这个位置只有一个员工,这个员工就是要查找的对象,如果有多个(员工.next不为空),则遍历链表,通过equals判断是否要查找的对象。为了使得横向数组和纵向链表在编程时更方面,我们People类封装一下。
private static class Node<K,V>{
//键的哈希值
final int hash;
//键
final K key;
//值
V value;
//下一个元素
Node<K,V> next;
}
KV是两个泛型,K用来计算数组下标(如People.name),V是实际保存的值(People),这样就可以根据K值快速找到V值。而在纵向上又可以根据next组成一个链表。
看到这里可能会有点懵,讲了一大堆理论,代码呢?别急,后面会结合HashMap源码讲解数组-链表的转换实现过程。
5. 数组下标的计算
5.1 原理解析
在上面说到把任意数据类型的数据转换为数组下标,那么是怎么转换的呢?首先,我们看这个转换算法需要满足的条件
- 结果为整型(数组下标是整数)
- 结果是在特定范围内的整型(数组长度有限)
- 数据变化了,数组下标应该要跟着变化
如上中,叶无缺和叶辰得出的数组下标都是100,那么就需要以链表方式保存,而链表的查找时间复杂度为O(n),遍历链表的速度回到原来遍历数组的情况,甚至低于数组的遍历,因为需要进行next节点的判断。因为数组长度有限,不能做到数据的每个变化都能对应一个数组位置,但是我们需要做到尽量让变化的数据比较平均的分配到数组的各个位置,这样就减少了链表的数量,从而提高查找速度。
如上图中的两个数组+链表的组合方式,很显然第一种组合中,由于把员工特征转换为数组下标的算法不够好,导致多个员工得出的数组下标相同,当需要查找员工78时,首先计算得出这个员工位于数组的第三个位置,然后遍历链表,数据在数组中的分配不均导致查找速度慢,而在第二种组合中,查找员工78时相对快一点。
看到这里我们又会发现一个问题,若最开始设置数组长度为10000,当有100万员工时,岂不是有很多数组位置链表都较长了,速度又会变慢了。别方,这个问题后面会解决。我们先来解决如何让数组中的元素相对平均分配的问题。
我们来看Java中,Object类刚好就提供了一个哈希函数,返回值刚好就是int类型。而Object类是所有类型的基类,也就是说所有类都具有这个方法。
public class Object{
public native int hashCode();
}
我们只需要重写这个方法,确保内容变化,返回值也跟着变化,并且各不相同就可以。而在Java中,常用的数据类型Integer、Float、Double、String等已经做好了这个重写操作,有兴趣可以去查看一下源码。如Integer类:
public class Integer{
//哈希值就是数值本身
@Override
public int hashCode() {
return Integer.hashCode(value);
}
public static int hashCode(int value) {
return value;
}
}
hashCode函数返回Int类型,取值范围为:
我们的数组不能这个长,可以把这个值对数组长度进行取模操作
这样得到的结果就刚好在数组下标的范围。但是由于取模操作,导致hashCode的高位数据相当于有对数组下标没有影响。如当数组长度100时:
这样又会导致分配不均的问题,这种情况我们把它叫做哈希碰撞。我们应该尽量避免哈希碰撞的出现,来看HashMap源码中是怎么做的(以下代码来自Oracle JDK8,如果是其他版本的JDK源码可能有所不同):
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这段代码是把key的哈希值高16位和低16进行异或运算,这样得出的结果是高16位和低16位混合值。再用这个值去计算数组下标,这样数据分配就相对均匀了。
5.2 源码实现
我们来看HashMap插入数据时的代码实现。
//存放数据的数组
transient Node<K,V>[] table;
//插入一个数据,传入键和值两个参数
public V put(K key, V value) {
//把key的哈希值进行高低位异或运算混合
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
//数组长度
int n;
//插入的数组下标
int i;
//如果存放数据的数组为空则调用resize函数创建数组
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//数组下标 = (数组长度-1)& 哈希值
//如果数组下标的这个位置内容为空,要插入的数据就放到这里
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//如果数组下标的这个位置已经有内容了,插入的数放到这个链表的末尾
//省略插入数到链表和链表转换红黑树的过程
}
//修改次数加1
++modCount;
//数据量达到一定值后,重新调整数组大小
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
从上面的代码中,看到计算数组下标的方式是这样的:
这个hash值是key的hashCode经过高低位混合后的结果,让hashCode值的高低位都参与数组下标的计算。而n-1怎么回事呢?似乎没有什么合理的解释。先把上面这个公式在脑海中过几遍,下面会讲到为什么是(n-1)* hash,这是HashMap原理中的重点,也是难点。
6. 数组扩容
HashMap的数组扩容函数在中高级工程师的面试中经常会问到,比较重要,请做好板凳认真听讲了。上面讲到的i =(n-1)* hash这个计算数组下标的方式,需要结合数组扩容函数resise()才能理解,先说原理再看代码,这里比较难理解。
- HashMap用于保存数据的数组table的大小必定为2的n次方
- 默认数组长度为16
DEFAULT_INITIAL_CAPACITY = 1 << 4 - 当数组table的长度达到阈值threshold时执行扩容
- 阈值 = 当前数组长度*加载因子(loadFactor)
threshold = table.length * loadFactor - 默认加载因子为0.75
DEFAULT_LOAD_FACTOR = 0.75f;
上面这些规则的建立,很方便根据哈希值计算出数组下标,并且使得下标值相对分散。来看下图模拟table数组两次扩容的情况
再来看数组长度 = 2的n次方的作用:
从上面两张图,可以知道结果:
- 结果值的范围必然属于0到n-1
刚好就是数组的下标范围
这就是数组长度设置为2的n次方的作用。说完了原理,再来看HashMap对这些原理的实现:
//数组扩容函数
final Node<K,V>[] resize() {
//当前数据作为旧数组
Node<K,V>[] oldTab = table;
//旧数组的大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//旧数组的扩容阈值
int oldThr = threshold;
//新数组的容量,新数组的扩容阈值
int newCap, newThr = 0;
if (oldCap > 0) {
//旧数组的大小大于最大容量,直接返回旧数组
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//新数组的容量 = 旧数组的容量*2
//新阈值 = 旧阈值*2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//数组扩容后,所有元素重排放,这里比较耗性能,应尽量避免数组扩容
//省略部分代码
return newTab;
从上面的代码可以知道,数组扩容后
- 数组容量增加一倍
- 扩容阈值增加一倍
由于数组初始容量是16,所以数组长度必为2的n次方。另外,HashMap的可以在构造函数中,传入数组的初始容量,和扩容加载因子,在程序中可以根据需求自定义,从而避免多次数组扩容(数组扩容会执行内容的重排放,影响性能)。
public HashMap(int initialCapacity, float loadFactor) {
...
}
看了这么多,思考一下如果这里初始容量设置为20会怎么样?其实,如果传入initialCapacity=20,则初始容量会是32,为了满足上面说到限定条件,这里会做自动做出调整。这里就不贴代码了,思考一下怎么实现吧!
7. HashMap用数组+链表+红黑树保存数据
7.1 数组+链表无法解决的问题
一个HashMap查询速度最快的情况是数组中的每个位置只有一个元素,也就是说数组中每个位置只有一个节点(Node),每个Node的next都是null。这样的情况下,不需要遍历链表。
现实中,数组中的某些位置经常会有多个元素
这取决于插入的数据的差异性和hashCode方法的优劣。在比较极端的情况下,会出现某些位置的元素比较集中,就算用了上面的数组扩容和hashCode高低位混合运算的方案依旧不能保证不会出现这种情况,只能更大程度地避免了出现这种情况。
既然这是一个无法避免的问题,还有什么解决办法,可以让出现数据集中的情况还能快速查找。这当然有解决办法,毕竟人类就是喜欢研究探索的物种。
7.2 红黑树
为了解决纵向链表查询速度慢的问题,HashMap中当链表长度大于8时,会把链表转换为红黑树。红黑树的查询速度比链表快很多。红黑树是一种二叉搜索树,具有二叉搜索树的全部特征。如下图中典型的二叉搜索树,要查找出59,只需要4步(60-56-58-59)。
- 速度比直接数组下标索引要慢,但是比数组遍历要快。
这里不对红黑树的原理深入,涉及内容较多,如二叉搜索树、均衡二叉树、2/3树、红黑树等。