文章目录
- 一、底层数据结构
- 二、HashMap的实现原理
- 三、LinkedHashMap
- 四、相关经典面试
- 1. 介绍HashMap
- table数组什么时候获得初始化
- 初始化hashMap后,第一次放入元素,table的长度是多少?
- new HashMap(19),创建的map中table数组长度多大?
- 2. 你知道HashMap的工作原理吗?你知道HashMap的get()方法的工作原理吗?
- 3. 两个hashcode相同的时候会发生说明?
- 4. 如果两个键的hashcode相同,如何获取值对象?
- 5. 如果HashMap的大小超过了负载因子(load factor)定义的容量?怎么办?
- 6. 重新调整HashMap大小的话会出现什么问题?
- 7. HashMap在并发执行put操作,会引起死循环,为什么?
- 8. 为什么String, Interger这样的wrapper类适合作为键?
- 9. 使用CocurrentHashMap代替Hashtable?
- 10. hashing的概念
- 11. 扩展:为什么equals()方法要重写?
hashmap作为面试的热门,也是哈希表这种数据结构的工业级应用和范例。非常值得我们花大力气搞懂。如果有所收获或者疑惑,希望大家能够积极反馈,共同学习进步!
本文借鉴了王争、杨晓峰的专栏以及
https://blog.csdn.net/daerzei/article/details/79855507 https://blog.csdn.net/hefenglian/article/details/79763634
https://blog.csdn.net/qq_38182963/article/details/78940047
一、底层数据结构
在JDK1.8后,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(8),时,将链表转换为红黑树,这样大大减少了查找时间。
二、HashMap的实现原理
HashMap把table里面的每个元素形象化为桶bucket
1. 基本组成单元:Node
为什么说底层是数组+链表?
查看源码
1.7部分的初始化的源码
// 初始为空table,由entry组成的数组
static final Entry<?,?>[] EMPTY_TABLE = {};
// resize的时候必须为2的次方
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE
而entry是什么呢?
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next; // 证明entry是单链表
int hash;
1.8解释初始化部分的源码
/*第一次初始化的时候使用,扩容的时候使用,扩容大小总是2的N次方
以Node<K,V>为元素的数组,也就是HashMap的纵向的长链数组,起长度必须为2的n次方*/
transient Node<k,v>[] table;//存储(位桶)的数组</k,v>
node是什么呢?
Node的具体代码
//Node是单向链表,它实现了Map.Entry接口
static class Node<k,v> implements Map.Entry<k,v> {
final int hash;
final K key;
V value;
Node<k,v> next;//next节点的指针,证明单向链表
2. 初始化
public HashMap(int initialCapacity, float loadFactor) {
// ...
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
该hashmap采用懒加载,最开始只是设置些初始值。在首次使用时被初始化。
3. HashMap的put方法
put方法只有一个putVal的调用,直接看putVal
put方法主要实现以下步骤:
第一步:如果数组(table)为空,则调用resize函数扩容创建一个数组
第二步:计算元素所要存储的数组下标,如果此下标没有元素则直接插入
第三步:否则说明要添加的位置已经有元素了,也就是发生了hash冲突,这个时候分以下几种情况
第一种情况:key值相同,直接覆盖
第二种情况:判断链表是否为红黑树
第三种情况:链表是正常的链表(直接插到最后面就可以了)
做完以上三步后判断是否需要扩容啊什么的
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab[]为数组,p是每个桶
Node<K,V>[] tab; Node<K,V> p; int n, i;
//第一步:table为空,则调用resize()函数创建一个
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//第二步:计算元素所要储存的位置index,如果此位置没有元素则直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//否则说明要添加的位置上面已经有元素了,也就是发生了碰撞,这个时候就要具体情况具体讨论了
else {
Node<K,V> e; K k;
//第一种情况:key值相同,直接覆盖
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//第二种情况:判断链表是否为红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//第三种情况:链表是正常的链表
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//链表大于8转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
//如果节点key存在,则覆盖原来位置上的key,同时将原来位置的元素沿着链表身后移一位。
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
...
}
这里面需要注意的是:HashMap的hash函数是如何写的。
将hash值的计算和index的计算摘出来,非常精彩!
hash值的计算,并不是key本身的hashcode,而是另一种hash函数!
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
在插入或查找时,计算key被映射到桶的位置。
int index = hash(key) & (capacity - 1)
为什么必须是右移16位
首先hashcode本身是个32位整型值(int是32位)。获取对象的hashcode之后,先进行移位运算,再和自己做异或运算,非常巧妙,将高16位移到低16位,这样计算得到的整型值将“具有”高位和低位的性质。
因为需要考虑这样的情况:有些数据计算出的hash值差异主要在高位,而hashmap里的hash寻址(也就是计算放置到数组的索引位置)是忽略容量(初始16)以上的高位的,这种处理可以有效避免类似情况的哈希碰撞。
举个例子:我们假设有一种情况,对象A的hashCode 为 1000010001110001000001111000000,对象 B 的 hashCode 为 0111011100111000101000010100000。
如果容量是16,16-1=15,二进制1111,对 与运算这两个数, 你会发现高位都未参与运算,结果都是0。这样的散列结果太让人失望了。很明显不是一个好的hash算法。
但是如果我们将 hashCode 值右移 16 位,也就是取 int 类型的一半,刚好将该二进制数对半切开。并且使用位异或运算(如果两个数对应的位置相反,则结果为1,反之为0),这样的话,就能避免我们上面的情况的发生。
为什么要容量减1
最后,用hash表当前的容量减1,再和刚计算出来的整型值做位与运算,为什么要容量减1呢?
因为A%B = A & (B-1)
,该式子在B是2的指数时成立,转换为取模运算,结果只取决于hash值。这也是为什么容量建议2的幂次方,这样保证&
中的二进制位全是1,最大限度利用hash值,更好的散列,让hash值均匀的分布在桶中!
4. 数组的索引位置
通过对元素的key生成哈希值的函数:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
意思就是取Key的哈希值,然后对HashCode()的高16位异或低16位,
为什么要高16位异或低16位呢?
这样做是为了让最终的哈希值更加离散分布得更加均匀,更加详细的可以去网上查,讲的的话会很长
然后利用这个值对数组的长度取余就是Key在数组中的下标啦
你可能会问如果数组扩容了,它的下标不就变了吗?
对啊,确实变了,需要重新计算它的下标了,然后把它插入到新的更大的数组里
这就是为什么Node类中要存储hash值
这就是为什么HashMap是没有顺序的
这就是为什么说扩容是非常消耗性能的
5. 容量和装载因子
Capacity很容易理解,load factor的话涉及到了哈希表的数据结构的底层,可以理解为数组中空闲槽位的比例,计算公式是:哈希表的装载因子=填入表中的元素个数/哈希表的长度
装载因子越大,空闲位置越少,哈希冲突越多,哈希表的性能下降。
JDK给出的默认装载因子为0.75,是在时间和空间成本上的折中,过低的话浪费内存空间。
容量和装载因子决定数组中可用的桶的数量,空桶太多浪费空间,使用太满影响性能,极端情况下,只有一个桶,退化为了链表。
根据上面公式,如果我们知道要存取的键值对数量,可用预设合适的容量大小,需要满足装载因子*容量>元素数量
预设的容量要满足大于“预估元素数量/装载因子”,同时也是2的幂数
5. 扩容机制
插入的元素太多,数组装不下了就只能扩容了,HashMap会在原来的基础上把数组的容量增加一倍
当然Java里的数组是无法自动扩容的,方法就是创建一个新的更大的数组代替已有的容量小的数组
然后Node类的hash对数组的长度重新取余,以确定数组的下标。于是乎HashMap里元素的顺序又重排了。
扩容:一是扩大table的长度,而是修改node的位置。容量n扩大一倍,新table中,node的下标要么还是原来的t,要么是t+n。
HashMap有两个成员变量:
DEFAULT_INITIAL_CAPACITY: HashMap默认的初始化数组的大小,默认为16
DEFAULT_LOAD_FACTOR: 加载因子,默认为0.75,,当HashMap的大小达到数组的0.75的时候就会扩容
final Node<K,V>[] resize() {
//创建一个oldTab数组用于保存之前的数组
Node<K,V>[] oldTab = table;
//获取原来数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//原来数组扩容的临界值
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
//如果原来的数组长度大于最大值(2^30)
if (oldCap >= MAXIMUM_CAPACITY) {
//扩容临界值提高到正无穷
threshold = Integer.MAX_VALUE;
//返回原来的数组,也就是系统已经管不了了
return oldTab;
}
//新数组(newCap)长度乘2 < 最大值(2^30) && (原来的数组长度) >= 初始长度(2^4)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//这个else if中实际上就是咋判断新数组(此时刚创建还为空)和老数组的长度合法性,
//同时交待了扩容是以2^1为单位扩容的。
newThr = oldThr << 1;
}// newThr(新数组的扩容临界值)一样,在原有临界值的基础上扩2^1
else if (oldThr > 0) // initial capacity was placed in threshold
//新数组的初始容量设置 为老数组扩容的临界值
newCap = oldThr;
// 否则 oldThr == 0,零初始阈值表示使用默认值
else {
//新数组初始容量设置为默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果newThr ==0,说明为上面 else if(oldThr > 0)的情况(其他两种情况都对newThr的值做了改变),
//此时newCap = oldThr;
if (newThr == 0) {
//ft为临时变量,用于判断阈值的合法性,
float ft = (float)newCap * loadFactor;
//计算新的阈值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//改变threshold值为新的阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//改变table全局变量为扩容后的newTable
table = newTab;
if (oldTab != null) {
//遍历老数组,将老数组(或者原来的桶)迁移到新的数组(新的桶)中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//新建一个Node<K,V>类对象,用它来遍历整个数组。
if ((e = oldTab[j]) != null) {
oldTab[j] = null;//老的table不用了,赋值为null,垃圾回收
//如果e的下一个节点是null说明没有链表或树的结构,重新计算下标,赋值到新的table
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果e已经是一个红黑树的元素
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 链表重排,注意,原table的某些key会被计算到同一个下标,但是新的table中不一定
// 因此,链表可能会拆散,变成0-2个链表
// 所以,定义两个node对,一个是loHead,loTail;一个是hiHead,hiTail
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// e.hash & oldCap==0的Node会被分配到同一个位置,确切的说,和原table下标一样
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//其余节点会被分配到另一个的同一位置,确切说是原table下标+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 在对应的位置上赋值
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
扩容涉及到一个小问题,如何知道要将原数组的某个元素放到新数组的哪个索引位置上?
也就是说如何确定元素e在新数组的位置。之前put的时候,用的是hash(key) & (capacity - 1)
确定,为什么不继续用该方法,却转而判断(e.hash & oldCap) == 0
,判断原来的元素在新数组上是否移位,假设capacity是16,只需要看倒数第五位,如果为0,下标不变,如果是1,下标加上容量oldCap。
6. 线程安全性
HashMap是线程不安全的,在多线程的情况下,尽量不要使用HashMap(虽然它的性能很好),而使用线程安全的ConcurrentHashMap
三、LinkedHashMap
LinkedHashMap前面比HashMap多了个"Linked",是不是说,它是一个通过链表法解决散列冲突的散列表呢?
其实,LinkedHashMap并没有这么简单,其中的“Linked”也并不仅仅代表它是通过链表法解决散列冲突的。
先看下面的一段代码,你觉得这段代码会以什么样的顺序打印3,1,5,2这几个key呢?原因又是什么呢?
HashMap<Integer,Integer> m = new LinkedHashMap<>();
m.put(3,11);
m.put(1,12);
m.put(5,23);
m.put(2,22);
for (Map.Entry<Integer, Integer> entry : m.entrySet()) {
System.out.println(entry.getKey());// 3,1,5,2
}
上面的代码会按照数据插入的顺序依次来打印,也就是说,打印的顺序就是3,1,5,2。你有没有觉得奇怪?散列表中数据是经过散列函数打乱之后无规律存储的,这里是如何实现按照数据的插入顺序来遍历打印的呢?
LinkedHashMap也是通过散列表和链表组合在一起实现的。实际上,它不仅支持按照插入顺序遍历数据,还支持按照访问顺序来遍历数据。你可以看下面这段代码:
// 10是初始容量,0.75是装载因子,true表示按照访问时间排序
HashMap<Integer,Integer> m = new LinkedHashMap<>(10,0.75f,true);
m.put(3,11);
m.put(1,12);
m.put(5,23);
m.put(2,22);
m.put(3,26);
m.get(5);
for (Map.Entry<Integer, Integer> entry : m.entrySet()) {
System.out.println(entry.getKey());// 1,2,3,5
}
为什么这段代码会按照这样顺序来打印?
每次调用put()函数,往LinkedHashMap中添加数据的时候,都会将数据添加到链表的尾部,再次将键值为3的数据放入到LinkedHashMap的时候,会先查找这个键值是否已经有了,然后,再将已经存在的(3,11)删除,并且将新的(3,26)放到链表的尾部。访问到key为5的数据的时候,我们将被访问到的数据移动到链表的尾部。
所以,最后打印出来的数据是1,2,3,5。从上面的分析,你有没有发现,按照访问时间排序的LinkedHashMap本身就是一个支持LRU缓存淘汰策略的缓存系统。
总结一下,实际上,LinkedHashMap是通过双向链表和散列表这两种数据结构组合实现的。LinkedHashMap中的“Linked”实际上是指的是双向链表,并非指用链表法解决散列冲突。
四、相关经典面试
1. 介绍HashMap
按照特性来说明一下:储存的是键值对,线程不安全,非Synchronied,储存的比较快,能够接受null。
按照工作原理来叙述一下:Map的put(key,value)来储存元素,通过get(key)来得到value值,通过hash算法来计算hascode值,用hashCode标识Entry在bucket中存储的位置,储存结构就算哈希表。
table数组什么时候获得初始化
第一次插入元素的时候
初始化hashMap后,第一次放入元素,table的长度是多少?
16
new HashMap(19),创建的map中table数组长度多大?
初始化时实际上为null,第一次插入元素时32.
2. 你知道HashMap的工作原理吗?你知道HashMap的get()方法的工作原理吗?
HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。
当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。
这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。
这一点有助于理解获取对象的逻辑。如果你没有意识到这一点,或者错误的认为仅仅只在bucket中存储值的话,你将不会回答如何从HashMap中获取对象的逻辑。这个答案相当的正确,也显示出面试者确实知道hashing以及HashMap的工作原理。
3. 两个hashcode相同的时候会发生说明?
hashcode相同,bucket的位置会相同,也就是说会发生碰撞,哈希表中的结构其实有链表(LinkedList),这种冲突通过将元素储存到LinkedList中,解决碰撞。储存顺序是放在表头。
4. 如果两个键的hashcode相同,如何获取值对象?
如果两个键的hashcode相同,即找到bucket位置之后,我们通过key.equals()找到链表LinkedList中正确的节点,最终找到要找的值对象。
一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。
5. 如果HashMap的大小超过了负载因子(load factor)定义的容量?怎么办?
HashMap里面默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。
6. 重新调整HashMap大小的话会出现什么问题?
多线程情况下会出现竞争问题,因为你在调节的时候,LinkedList储存是按照顺序储存,调节的时候回将原来最先储存的元素(也就是最下面的)遍历,多线程就好试图重新调整,这个时候就会出现死循环。
当多线程的情况下,可能产生条件竞争(race condition)。
当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。
7. HashMap在并发执行put操作,会引起死循环,为什么?
hashmap本身就不是线程安全的。多线程会导致hashmap的node链表形成环形链表,一旦形成环形链表,node 的next节点永远不为空,就会产生死循环获取node。从而导致CPU利用率接近100%。
8. 为什么String, Interger这样的wrapper类适合作为键?
因为他们一般不是不可变的,源码上面final,使用不可变类,而且重写了equals和hashcode方法,避免了键值对改写。提高HashMap性能。
String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。
9. 使用CocurrentHashMap代替Hashtable?
可以,但是Hashtable提供的线程更加安全。
Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。
10. hashing的概念
散列法(Hashing)或哈希法是一种将字符组成的字符串转换为固定长度(一般是更短长度)的数值或索引值的方法,称为散列法,也叫哈希法。由于通过更短的哈希值比用原始值进行数据库搜索更快,这种方法一般用来在数据库中建立索引并进行搜索,同时还用在各种解密算法中。
11. 扩展:为什么equals()方法要重写?
判断两个对象在逻辑上是否相等,如根据类的成员变量来判断两个类的实例是否相等,而继承Object中的equals方法只能判断两个引用变量是否是同一个对象。这样我们往往需要重写equals()方法。
我们向一个没有重复对象的集合中添加元素时,集合中存放的往往是对象,我们需要先判断集合中是否存在已知对象,这样就必须重写equals方法。
怎样重写equals()方法?
重写equals方法的注意点:
1、自反性:对于任何非空引用x,x.equals(x)应该返回true。
2、对称性:对于任何引用x和y,如果x.equals(y)返回true,那么y.equals(x)也应该返回true。
3、传递性:对于任何引用x、y和z,如果x.equals(y)返回true,y.equals(z)返回true,那么x.equals(z)也应该返回true。
4、一致性:如果x和y引用的对象没有发生变化,那么反复调用x.equals(y)应该返回同样的结果。
5、非空性:对于任意非空引用x,x.equals(null)应该返回false。