HashMap在工作中比较常用,而且面试中经常被问到,所以我特意把它列出来进行分析。
注意:本文基于JDK8的源码进行分析。以下是常见的面试题:
1. HashMap底层是如何实现的?
2. HashMap数组长度怎么设置?
3. HashMap是怎么存放元素的?
4. HashMap是怎么减少冲突的?
5. HashMap怎么获取元素的?
6. HashMap的扩容机制是怎么样的?
7. HashMap的链表什么时候转化为红黑树,为什么要转化为红黑树?
8. HashMap和HashTable的区别?
9. JDK7和JDK8的HashMap设计有什么区别?JDK8为什么要改进?
10.HashMap为什么线程不安全?
本文试图从源码开始进行分析,然后再逐一解答上述问题。
基础知识
-
java 中的 << , >> , >>>
<< 左移,低位补0,例如:二进制1 << 4 结果为10000,十进制为 2^4=16; >> 右移, 正数高位补0 , 负数高位补1; >>> 无符号右移,不论正负,高位补0。
-
serialVersionUID
源码分析
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
//序列化版本ID号
private static final long serialVersionUID = 362498820763181265L;
//数组默认初始化大小为 1左移4位,即二进制10000,十进制位16
//he default initial capacity - MUST be a power of two.
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//数组的最大容量:2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认的加载因子为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//表示当链表长度为8时,链表可能转为红黑树存储
static final int TREEIFY_THRESHOLD = 8;
//当红黑树节点数为6时,红黑树可能转为链表存储
static final int UNTREEIFY_THRESHOLD = 6;
//桶可能被转化为红黑树的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
DEFAULT_INITIAL_CAPACITY :数组默认初始化大小为16;(为2的次方);
MAXIMUM_CAPACITY :数组的最大容量为:2^30;(为2的次方);
DEFAULT_LOAD_FACTOR :默认的加载因子:0.75, 即数组中元素个数 >= 数组长度 x 加载因子 时,进行扩容;
TREEIFY_THRESHOLD : 表示当链表长度为8时,链表可能转为红黑树存储;
UNTREEIFY_THRESHOLD:当红黑树节点数为6时,红黑树可能转为链表存储;
MIN_TREEIFY_CAPACITY :桶可能被转化为红黑树的最小容量。
内部类Node
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
...
可以看出HashMap中的每个结点由四部分组成:hash值,key,value, next 引用;
- hash扰乱函数
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash函数可以称为 扰乱函数:
- 传入的key值为null时,直接返回0;
- 否则,将 key 的 hashCode 和 key 的 hashCode无符号右移16位的结果进行异或,返回异或后的结果。
该步相当于将hashCode的高位和低位进行异或操作,可以减少冲突。
tableSizeFor()设置数组大小
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
该函数的目的在于保证数组大小为2的次方:当用户传入的数组大小不是2的次方时,该函数可以使数组大小变成2的次方。如用户给构造函数传入数组大小为11,则实际的数组大小为16。
先来假设n的二进制为01xxx…xxx,
对n右移1位:001xx…xxx,再与01xxx…xxx位或得:011xx…xxx
对n右移2为:00011…xxx,再与011xx…xxx位或得:01111…xxx
对n右移4为:000001111…xxx,再与01111xx…xxx位或得:011111111…xxx
当右移8位时,我想应该都知道得到什么结果了吧,
综上可得,该算法让最高位的1后面的位全变为1。
最后再让结果n+1,即得到了2的整数次幂的值了。
现在回来看看第一条语句:
int n = cap - 1;
让cap-1再赋值给n的目的是另找到的目标值大于或等于原值。例如二进制1000,十进制数值为8。如果不对它减1而直接操作,将得到答案10000,即16。显然不是结果。减1后二进制为111,再进行操作则会得到原来的数值1000,即8。
该函数的举例解释来自于https://blog.csdn.net/weixin_41565013/article/details/93070794
transient Node<K,V>[] table;
由上面代码可以看到HashMap底层是一个table数组,数组中的每个元素是链表。
构造函数
//含有数组初始大小和负载因子的构造函数
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
//只含初始大小的构造函数,可以看出它的负载因子是默认的值0.75
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//不传参数时,默认负载因子为0.75
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
当只传入initialCapacity或者不传参数时,装载因子为默认的0.75。
4. get方法查找元素
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
get()调用了getNode方法,该方法如下:
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;
}
可以看到:
- 如果发现 数组为null 或者 定位到的索引位置无元素,返回null;
- 否则,如果数组不为null且数组大小不为0且我们要找的数组的索引位置有元素,然后进行查找,如果key的hash相等,且只要key的值相等,就直接返回该结点;
- 如果发现第一个结点不是我们要找的,则查看第二个结点:首先判断是否为红黑树,是则交给红黑树去查找,否则循环进行查找链表,只要找到传入的key的hash值和该结点的hash值相等且者key值也相等,则返回。
put方法添加元素
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
putVal方法如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; //数组长度为0时,调用resize()扩容
if ((p = tab[i = (n - 1) & hash]) == null)//如果该索引到的位置为null,则直接存储到数组中
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash && //如果桶中第一个结点key的值与传入的key值相等,且hash也等,将e指向该结点;
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)// 如果是树,则根据树查找,将e指向找到的结点
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
//这个方法里还会判断总节点数大于64则会转换为红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && //如果找到数组中某个结点key的值与传入的key值相等,对应的hash也等
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;//p指向e结点
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value; //在此处才修改value的值
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)//判断书否需要进行扩容
resize();
afterNodeInsertion(evict);
return null;//否则返回空
}
当HashMap进行put元素时,
- 如果数组为null,调用resize()进行扩容;
- 如果该桶中没有元素,则直接将结点存入;
- 否则,查看第一个结点的key值和hash值是否和传入的key值和hash值相等,如果相等,则覆盖;
- 然后看若为红黑树,进行红黑树的遍历并找到存放结点的位置进行存放;若为链表,则进行遍历, 当遍历到链表末尾时,直接插入结点,并检查是否需要转化为红黑树:该桶里链表中结点的个数是否大于8并且总的结点个数大于64,则链表转为红黑树。如果遍历过程中发现某一结点的key值和hash值和传入的key值和hash值相等,则进行覆盖。
图片来自于互联网
注意:这里是怎么定位到数组的下标的:
(数组大小 - 1) & hash; 数组大小为2的n次方,(数组大小 - 1)二进制表示全是1和key的hash进行“与”,结果刚好所在数组的范围内。这样设计效率比较高。
resize方法进行扩容
重新创建新数组,将新数组大小变为原来的2倍,然后再将原数组中的值依次存到新数组中。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; //oldTab指向旧数组
int oldCap = (oldTab == null) ? 0 : oldTab.length;//oldCap为旧数组的容量
int oldThr = threshold;//旧的threshold,决定什么时候扩容
int newCap, newThr = 0;//新数组容量,新数组的threshold为0
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {//如果旧数组容量>2^30,此时不扩容,只修改threshold
threshold = Integer.MAX_VALUE;//threshold设置为2^31
return oldTab;//直接返回
}
//oldCap < MAXIMUM_CAPACITY,newCap为oldCap的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 为原来threshold的2倍
}
else if (oldThr > 0)//oldCap = 0 且 oldThr > 0 即数组未初始化时
newCap = oldThr;//初始化容量为threshold
else {//oldCap = 0 且 oldThr = 0
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//创建新数组
table = newTab;//table指向新数组
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {//开始逐个进行复制
Node<K,V> e;
if ((e = oldTab[j]) != null) {
/** 这里注意, table中存放的只是Node的引用,这里将oldTab[j]=null只是清除旧表的引用,
* 但是真正的node节点还在, 只是现在由e指向它
*/
oldTab[j] = null;
if (e.next == null)//如果e的下个节点(即第二个节点)为null,则只需要将e转移到新的哈希桶中
newTab[e.hash & (newCap - 1)] = e;
//如果哈希桶内的节点为红黑树,则交给TreeNode进行转移
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { //如果是链表
//loHead表示低位的头结点,loTail表示低位的尾结点
Node<K,V> loHead = null, loTail = null;
//hiHead表示高位的头结点,hiTail表示高位的尾结点
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;//临时变量
do {
next = e.next;
if ((e.hash & oldCap) == 0) { //如果hash & oldCap为0,则新的位置为原来的位置
if (loTail == null)//如果低位尾结点尾null,说明低位为空
loHead = e;//低位头结点指向e结点
else//如果低位尾结点不为空
loTail.next = e;//则将e插入低位链表尾部
loTail = e;//尾结点指针移动到新的尾结点
}
else {//如果hash & oldCap为1,则新的位置为原来的位置+原来的数组容量
if (hiTail == null)//如果高位的尾结点为null,说明高位为null
hiHead = e; //高位头结点指向e
else//如果高位尾结点不为空
hiTail.next = e;//高位尾结点插入e
hiTail = e;//高位尾结点指向新的尾结点
}
} while ((e = next) != null);
if (loTail != null) {//如果低位尾结点不为null
loTail.next = null;//低位尾结点的next指向null
newTab[j] = loHead;//最后将newTab[j]指向低位头结点
}
if (hiTail != null) {//高位尾结点不为null
hiTail.next = null;//高位尾结点指向null
newTab[j + oldCap] = hiHead;//最后将newTab[j+oldCap]指向高位头结点
}
}
}
}
}
return newTab;
}
这张图中index=2的桶中有四个节点,在未扩容之前,它们的 hash& cap 都等于2。在扩容之后,它们之中2、18还在一起,10、26却换了一个桶。这就是这句代码的含义:选择出扩容后在同一个桶中的节点。
if ((e.hash & oldCap) == 0)
我们这时候的oldCap = 8,2的二进制为:0010,8的二进制为:1000,0010 & 1000 =0000
10的二进制为:1010,1010 & 1000 = 1000,
18的二进制为:10010, 10010 & 1000 = 0000,
26的二进制为:11010,11010 & 1000 = 1000,
从与操作后的结果可以看出来,2和18应该在同一个桶中,10和26应该在同一个桶中。
所以lo和hi这两个链表的作用就是保存原链表拆分成的两个链表。
上图和所配文字引用自https://blog.csdn.net/weixin_41565013/article/details/93190786
解答
1. HashMap底层是如何实现的?
HashMap底层是使用数组 + 链表 + 红黑树实现的,键和值都可以为空。
2. HashMap数组长度怎么设置?
数组长度默认为2的次方,如果我们传入的长度不是2的次方,HashMap会调用相关函数将长度
设置成2的次方。
3. HashMap是怎么存放元素的?
1.看数组是否为null,如果为null,进行初始化;
2. 不为null,根据 (length - 1)& hash 确定元素所在的桶,如果该位置没有元素,创建结点并存入桶table[length - 1)& hash]中;
3. 如果桶不为null,则检查hash值和key的值是否相等,如果相等,用value值覆盖原来的值;
4. 如果不相等,且结点为树结点,创造树结点并插入红黑树中;
5. 如果结点是链表结点,则创建普通结点并加入链表中,判断链表长度是否大于8,
大于的话转为红黑树;
6. 看数组中元素的个数是否超过 数组大小 * 负载因子,如果超过,则扩容。
4. HashMap是怎么取元素的?
根据key的hash值和可以进行取值操作,先根据hash判断桶的位置,然后用equals判断key的值
是否相等,相等就返回该结点,不等的,如果第二个结点是树结点,则在树中进行查找,否则,
在链表中进行查找。
5. HashMap怎么设置数组初始大小?
我们不设置大小时,HashMap默认大小为16,我们自己设的话一般设为2的次方,否则,HashMap
会将我们设的大小改成大于数组大小的最小的2的次方。
6. 为什么数组长度要设置成2的次方?
为了减少冲突,进而可以提高效率。(length - 1)& hash 确定桶位置时,如果length为2的
次方,这样会减少hash冲突,效率也特别高。如果不为2的次方,那么length - 1 表示的二进制
位中就可能有包含0的位,再进行&的时候,该位的结果为0,这样就有可能使不同的hash对应同一个
桶中,冲突较大;
扩容后,省去重新计算 hash 的时间。
7.HashMap的哈希函数怎么设置的?有什么好处?
hash函数中,h = key.hashCode()) ^ (h >>> 16)来让key的hashCode的高位和低位
进行异或,这样高低位都进行运算,减小了hash冲突
好处:高低位都进行运算,减小了hash冲突; 位运算比取余效率高,提高了效率。
8. HashMap什么时候进行扩容?
put元素时,发现数组未初始化,初始化时进行扩容;
数组中元素个数 > 负载因子 * 数组长度;
某个桶中一个链表中的结点个数>8 且 数组大小 <= 64 扩容。
9. JDK8和JDK7的HashMap有什么区别?为什么要进行优化?
数组 + 链表 改为 数组 + 链表 + 红黑树;’
头部插入改为尾部插入;
扩容时,jdk7需要重新定位桶的位置,jdk8则只需要将 hash & oldLength,观察最高位,
为0桶位置不变,为1,桶位置变为table[oldLength + 原来的桶位置];
插入时,jdk7先判断是否扩容再进行插入,jdk8则先插入再看需不需要扩容
如果链表过长查找效率会很低,所以转为红黑树,将时间复杂度由O(n)降为O(logn);
10.HashMap的链表什么时候转化为红黑树,为什么要转化为红黑树?
桶中链表长度大于8时,且数组长度大于64时,桶中的链表转为红黑树,数组长度小于64时,
则进行扩容。
如果链表过长查找效率会很低,所以转为红黑树,将时间复杂度由O(n)降为O(logn)。
11. HashMap和HashTable的区别?
1. 线程安全。 HashMap是线程不安全的,而HashTable是线程安全的 ;
2. 效率。HashMap比HashTable效率高,原因在于HashTable的方法通过synchronized修饰
后,并发的效率会降低;
3. 允不允许null。HashMap运行只有一个key为null,可以有多个null的value。
而 HashTable不允许key,value为null。
12. HashMap线程安全吗?
jdk7的HashMap在多线程下扩容时用头插法会形成环;还会有数据覆盖问题;
链接https://www.jianshu.com/p/3ef6e9d26ef8)
而jdk8的尾插法也会产生数据覆盖问题。
13. 怎么解决HashMap线程不安全的问题?
1. 改用HashTable,和HashMap实现差不多,用synchronized锁住整个数组,锁的粒度很大;
2. Collections.synchronizedMap(new HashMap<String,String>());
3. java.util.current concurrent包下的的ConcurrentHashMap。
14. 为什么String, Interger这样的wrapper类适合作为HashMap的键?
因为这些类都是用final修饰的,为不可变类,我们将键放入map中,如果可变的话,放入时
的HashCode和获取时的HashCode不同,那么取值得时候会取不到。
Map<List<Integer>,Object> map = new HashMap<>();
List<Integer> list = new ArrayList<>();
list.add(1);
map.put(list, new Object());
map.get(list);//1.
list.add(2);
map.get(list);//2.
15. 关于equals()和HashCode()?
HashMap底层用设计的特别巧妙,值得我们去学习借鉴。