HashMap向来是面试中的热点话题,深入理解了HashMap的底层实现后,才能更好的掌握它,也有利于在项目中更加灵活的使用。
本文基于JDK8进行解析
一、HashMap解析
1. 结构
HashMap结构由数组加**链表(或红黑树)**构成。主干是Entry数组,Entry是HashMap的基本单位,每一个Entry包含key-value键值对。每个Entry可以看成是一个链表(或红黑树),但是比较特殊的是,当链表中的节点个数超过8时,链表会转为红黑树进行元素存储。
####2. 原理
HashMap中的每个数通过哈希函数计算得出的hash值作为数组下标,存储着一对key-value键值对。当对一个元素进行查找时,若该下标处Entry为空,则表示元素不存在,否则通过遍历链表方式进行查找。
哈希冲突:哈希冲突也叫哈希碰撞,当对两个不同数进行hash函数计算后可能会得到相同的hash值,即存入数组下标相同,此时就发生了碰撞,hashMap使用链地址法存储具有相同hash值的元素。
除此之外,解决hash冲突的办法还有开放地址法(发生hash冲突,寻找下一块未被占用的存储地址)、再散列函数法(对求得的hash值再进行一遍hash运算)。
注意:HashMap是线程不安全,当多个线程进行put操作时,可能会造成put死循环。
3. 源码分析
HashMap位于java.util.HashMap
部分成员变量
//默认数组大小16,即2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//数组最大容量,2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//当链表转换为红黑树时,若数组大小小于64,即为32、16或更小时,需要对数组进行扩容
static final int MIN_TREEIFY_CAPACITY = 64;
//负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//用于判断数组是否需要进行扩容,即当数组元素 >= 数组容量*负载因子时,需要对数组进行扩容
int threshold;
HashMap默认构造器大小为16,并且要求数组长度必须为2的次幂。HashMap中有负载因子,默认值为0.75.当HashMap中元素个数超过数组长度*0.75时,会对数组进行扩容,扩容后会重新计算每个元素的哈希值,即涉及到数组元素的迁移。
Node节点
在JDK7中使用Entry表示数组中的每个元素,JDK8中使用Node,这两者并没有什么区别,为了更好的理解数组中的每个元素,下面将以Node进行描述。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}
每一个节点处都存有key的hash值、key、value和next链四个属性,其中next链用来指向相同hash值不同key值的下一个节点。
put操作
public V put(K key, V value) {
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, i;
//table为空则创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通过hash值找到对应的数组下标,如果该下标处元素为空,则直接插入值
//通过(n - 1) & hash运算,可以保证计算出的下标在数组大小范围内
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) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//存在该key则跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//e != null表示存在与插入值相同的key,直接进行覆盖
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//当数组大小超过容量阈值时,进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
put流程:
- 判断数组是否为空,为空则初始化数组
- 计算该key的hash值作为key存入数组的下标
- 判断该下标处有无元素,没有则直接插入
- 若有元素存在,则首先判断该元素是否为待插入的元素,是则直接覆盖,否则判断该元素是否为红黑树的节点,是则进行红黑树的插入,否则遍历链表进行插入,如果遍历过程中找到key相同的元素则替换,否则在链表尾部插入该节点
在代码93行可以发现,在put操作完成之后需要对数组大小进行判断,若超过数组容量阈值,则需要对数组进行扩容。
treeifyBin操作
JDK8之前对于数组元素以链表的形式存储,对于数组的索引通过hash值可以直接定位,时间复杂度为O(1),因此对于HashMap查询的时间复杂度取决于遍历链表所花费的时间。当链表长度过长时会对索引带来不必要的麻烦,因此在JDK8开始,采用链表或者红黑树的方式进行相同hash值不同key值元素的存储。
treeifyBin(Node,int)
方法则就是当链表长度过长时,将链表转为红黑树进行存储。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//判断数组长度是否大于64,小于则扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
//将Node节点转换成TreeNode节点
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
get操作
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//首先判断定位到的数组下标的第一个节点是否是要找的节点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//如果第一个节点是红黑树的节点,则进行红黑树节点查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//否则去链表中查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
get流程:
- 计算key值的hash值
- 通过hash值找到数组下标,若该下标元素为空则返回null,否则判断该元素是否是待查找的值,是就直接返回
- 若不是判断该元素节点是否为红黑树节点,是则进行红黑树的查找,否则进行链表的查找
resize操作
在构造hash表时如果不指定数组的大小,则数组默认初始化大小为16,当数组元素超过最大容量的75%时就需要对数组进行扩容,而且扩容是件非常耗时的操作。
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) {
//如果旧数组容量大于等于最大容量,则修改threshold的值,并返回旧数组
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//否则将数组大小扩大为原来的两倍(二进制左移一位),并且将threshold也扩大为原来的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
//新数组长度乘以负载因子作为新数组的threshold
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//创建新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//遍历旧数组,进行数据迁移
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//如果该数组位置处只有一个元素就直接迁移这个元素就行
if (e.next == null)
//重新计算节点在新数组中的下标位置,并放入其中
newTab[e.hash & (newCap - 1)] = e;
//如果为红黑树节点就进行红黑树的迁移方式,具体的不展开说明了
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//创建两条链,lo链跟hi链,也就是将原先本应该在一条链表上的节点如今分成两条链存放
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)计算出值为偶数的放在lo链上
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//奇数的放在hi链上
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//将lo链放在新数组的原位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//将hi链放在新数组的原位置+oldCap的下标处
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
总结
HashMap底层通过数组加链表或红黑树(JDK8开始)进行数据存储,当链表中存储过长时会导致查询效率降低,因此当链表长度超过8个时将采用红黑树的方式进行存储。
当数组元素超过数组容量*负载因子大小时,将对数组进行扩容,扩容也可以使得其中的链表长度降低,从而提高查询速度。
对于key值的使用推荐使用不可变类,比如Integer
、String
等等。因为对于key值的定位是通过计算它的hash值找到在数组中的下标,再通过key的equals
方法找到在链表中的位置。因此要求两个key值相等的对象,它们的hash值必须相等,而相同的hash值并不要求一定equals。
二、HashMap面试题
1.HashMap的工作原理,其中get()方法的工作原理?
2.我们能否让HashMap同步?
3.关于HashMap中的哈希冲突(哈希碰撞)以及冲突解决办法?
4.如果HashMap的大小超过负载因子定义的容量会怎么办?
5.你了解重新调整HashMap大小存在什么问题吗?
6.为什么String, Interger这样的wrapper类适合作为键?
7.我们可以使用自定义的对象作为键吗?
8.我们可以使用CocurrentHashMap来代替Hashtable吗?
9.HashMap扩容问题?
10.为什么HashMap是线程不安全的?如何体现出不安全的?
11.能否让HashMap实现线程安全,如何做?
12.HashMap中hash函数是怎么实现的?
13.HashMap什么时候需要重写hashcode和equals方法?
长期收录HashMap面试题,答案也会在下篇文章中给出。
更多了解,还请关注我的个人博客:www.zhyocean.cn