HashMap特性
- 允许空键和空值(但空键只有一个,且放在第一位)
- 元素是无序的,而且顺序会不定时改变
- key 用 Set 存放,所以想做到 key 不允许重复,key 对应的类需要重写 hashCode 和 equals 方法。
- 底层实现是数组+链表,JDK 8 后又加了红黑树。
- 实现了 Map 全部的方法
类的继承关系
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
可以看到HashMap继承自父类(AbstractMap),实现了Map、Cloneable、Serializable接口。
- Map接口定义了一组通用的操作;
- Cloneable接口则表示可以进行拷贝,在HashMap中,实现的是浅层次拷贝,即对拷贝对象的改变会影响被拷贝的对象;
- Serializable接口表示HashMap实现了序列化,即可以将HashMap对象保存至本地,之后可以恢复状态。
类的属性
//jdk8
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16,即初始数组长度为16,为什么写1<<4而不是16?可能是为了提醒容量必须是2的n次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; -->16
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30; -->2^30
// 默认的填充因子/加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于这个值且容量>64时会转成红黑树 ---->
static final int TREEIFY_THRESHOLD = 8;
// 当桶(bucket)上的结点数小于这个值时树转链表---->
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table;
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容 存储3/4即扩容
int threshold;
// 填充因子
final float loadFactor;
}
底层原理
-
HashMap在jdk8之前是数组+链表结构;在jdk8后,当链表长度>8且整个hashMap容量>64时,长度过长的链表结构转换为红黑树,即变为数组+红黑树或者数组+(短)链表+红黑树
-
HashMap的扩容:当HashMap的元素个数>=总容量*扩容因子(0.75)时,总容量会扩容,变成之前的2倍
-
什么是哈希碰撞/哈希冲突
答:对应不同的关键字可能获得相同的hash地址,即 key1≠key2,但是f(key1)=f(key2)。则称为哈希碰撞/哈希冲突。对于这种情况,HashMap中采用链地址法处理冲突,即每个哈希地址(数组下标)对应的一个线性表,将地址相同的记录按序写入链表,而当链表过长时为提高效率在符合一定条件下把链表替换为红黑树.
-
HashMap的工作原理
答:HashMap 底层是hash数组和单向链表实现,数组中的每个元素都是链表,由 Node 内部类(实现 Map.Entry接口)实现,HashMap 通过 put & get 方法存储和获取。
-
存储对象时,将 K/V 键值传给 put() 方法:
- 调用 hash(K) 方法计算 K 的 hash 值,然后结合数组长度,计算的数组下标;
- 调整数组大小(当容器中的元素个数大于 capacity * loadfactor 时,容器会进行扩容resize 为 2n);
-
- 如果 K 的 hash 值在 HashMap 中不存在,则执行插入,若存在,则发生碰撞;
- 如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 true,则更新键值对;
- 如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 false,则插入链表的尾部(尾插法)或者红黑树中(树的添加方式)。(JDK 1.7 之前使用头插法、JDK 1.8 使用尾插法)(注意:当碰撞导致链表大于 TREEIFY_THRESHOLD = 8 且容量>64时,就把链表转换成红黑树)
-
获取对象时,将 K 传给 get() 方法:
- 调用 hash(K) 方法(计算 K 的 hash 值)从而获取该键值所在链表的数组下标;
- 顺序遍历链表,equals()方法查找相同 Node 链表中 K 值对应的 V 值。
-
常用方法
- 添加数据 put(key,value)
//jdk8
public V put(K key, V value) {
// 对 key 进行哈希操作并调用putVal()
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是否为空或者length等于0,如果是则调用resize方法进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 通过hash值计算索引位置,将该索引位置的头节点赋值给p,如果p为空则直接在该索引位置新增一个节点即可
// (n-1) & hash 【注1】
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果 table[i] 等于 null,则直接插入
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e;
K k;
// 注意p在之前已经被赋值:p = tab[i = (n - 1) & hash]
// 判断p节点的key和hash值是否跟传入的相等,如果相等, 则p节点即为要查找的目标节点,将p节点赋值给e节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法查找目标节点
else if (p instanceof TreeNode)
// 红黑树直接插入键值对
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 走到这代表p节点为普通链表节点,则调用普通的链表方法进行查找,使用binCount统计链表的节点数
for (int binCount = 0; ; ++binCount) {
// 如果p的next节点为空时,则代表找不到目标节点,则新增一个节点并插入链表尾部
if ((e = p.next) == null) {
//把添加的键值对添加为链表下一个元素
p.next = newNode(hash, key, value, null);
// 如果链表长度大于等于8,调用treeifyBin方法将链表节点转为红黑树节点
//treeifyBin方法中会判断数组长度是否<64,小于会扩容
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果e节点存在hash值和key值都与传入的相同,则e节点即为目标节点,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果e节点不为空,则代表目标节点存在,使用传入的value覆盖该节点的value,并返回oldValue
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;
}
- 解析:
- 【注1】(n-1) & hash
- 作用:得到数组下标
- 按位取并,作用上相当于取模%(mod)
- 例:一个长度为16的数组中,每个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。一般情况是通过hash(key)%len获得,也就是元素的key的哈希值对数组长度取模得到。比如,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存储在数组下标为12的位置。同时也意味着数组下标相同,并不表示hashCode相同。
- 这里为什么使用&而不是mod或其他?
答:1)因为容量被固定为2的n次幂,故这样取模时,不需要做除法,只需要做位运算。位运算比除法的效率要高很多。2)同时在扩容容量变为2倍时,一样使用&运算结果判断数组下标位置,大大提高效率
- 【注1】(n-1) & hash
- 获取数据 get(key)
//jdk8
public V get(Object key) {
Node<K,V> e;
// 对 key 进行哈希操作然后获取节点值
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;
// 对table进行校验:table不为空 && table长度大于0 &&
// table索引位置(使用table.length - 1和hash值进行位与运算)的节点不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 判断第一个元素是否是要查询的元素,如果是则first即为目标节点,直接返回first节点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 如果first不是目标节点,并且first的next节点不为空则继续遍历
if ((e = first.next) != null) {
// 如果第一节点是树结构,则使用 getTreeNode 直接获取相应的数据
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do { // 非树结构,循环节点判断
// hash 相等并且 key 相同,则返回此节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 找不到符合的返回空
return null;
}
- 容量扩容 resize()
final Node<K,V>[] resize() {
// 扩容前的数组
Node<K,V>[] oldTab = table;
// 扩容前的数组的大小和阈值
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
// 预定义新数组的大小和阈值
int newCap, newThr = 0;
// 老表(数组)的容量不为0,即老表不为空
if (oldCap > 0) {
// 判断老表的容量是否超过最大容量值:如果超过则将阈值设置为Integer.MAX_VALUE,并直接返回老表,
// 此时oldCap * 2比Integer.MAX_VALUE大,因此无法进行重新分布,只是单纯的将阈值扩容到最大
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 将newCap赋值为oldCap的2倍,如果newCap<最大容量并且oldCap>=16, 则将新阈值设置为原来的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//如果老表的容量为0, 老表的阈值大于0, 是因为初始容量被放入阈值,则将新表的容量设置为老表的阈值
else if (oldThr > 0)
newCap = oldThr;
else {
// 老表的容量为0, 老表的阈值为0,这种情况是没有传初始容量的new方法创建的空表,将阈值和容量设置为默认值
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);
}
// 将当前阈值设置为刚计算出来的新的阈值,定义新表,容量为刚计算出来的新容量,将table设置为新定义的表。
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//开始扩容,将新的容量赋值给 table
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) { // 将索引值为j的老表头节点赋值给e
oldTab[j] = null; // 将老表的节点设置为空, 以便垃圾收集器回收空间
// 如果e.next为空, 则代表老表的该位置只有1个节点,计算新表的索引位置, 直接将该节点放在该位置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果是红黑树节点,则进行红黑树的重hash分布(跟链表的hash分布基本相同)
else if (e instanceof TreeNode)
// 红黑树相关的操作
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// 链表复制,JDK1.8扩容优化部分
// 如果是普通的链表节点,则进行普通的重hash分布
// loHead是用来保存新链表上的头元素的,loTail是用来保存尾元素的
Node<K,V> loHead = null, loTail = null; // 存储索引位置为:“原索引位置”的节点
Node<K,V> hiHead = null, hiTail = null; // 存储索引位置为:“原索引位置+oldCap”的节点
Node<K,V> next;
do {
next = e.next;
// 如果e的hash值与老表的容量进行与运算为0,则扩容后的索引位置跟老表的索引位置一样
//【注2】e.hash & oldCap
if ((e.hash & oldCap) == 0) {
if (loTail == null) // 如果loTail为空, 代表该节点为第一个节点
loHead = e; // 则将loHead赋值为第一个节点
else
loTail.next = e; // 否则将节点添加在loTail后面
loTail = e; // 并将loTail赋值为新增的节点
}
// 如果e的hash值与老表的容量进行与运算为非0,则扩容后的索引位置为:老表的索引位置+oldCap
else {
if (hiTail == null) // 如果hiTail为空, 代表该节点为第一个节点
hiHead = e; // 则将hiHead赋值为第一个节点
else
hiTail.next = e; // 否则将节点添加在hiTail后面
hiTail = e; // 并将hiTail赋值为新增的节点
}
} while ((e = next) != null);
// 如果loTail不为空(说明老表的数据有分布到新表上“原索引位置”的节点),则将最后一个节点
// 的next设为空,并将新表上索引位置为“原索引位置”的节点设置为对应的头节点
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 如果hiTail不为空(说明老表的数据有分布到新表上“原索引+oldCap位置”的节点),则将最后
// 一个节点的next设为空,并将新表上索引位置为“原索引+oldCap”的节点设置为对应的头节点
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 12.返回新表
return newTab;
}
- 解析
- 【注2】e.hash & oldCap
从注【1】我们知道使用哈希值和数组长度-1做与(位)运算得到数组下标。那么这里的用(e.hash & oldCap)是什么作用呢?。因为数组长度扩容(*2)后,新的下标位置有两种可能:原位置;原位置+原数组长度。而判断e.hash & oldCap与(&)运算结果就可以得到其属于哪种情况。
例:原数组长度16(0001 0000),扩容后变32(0010 0000)。假设有两个哈希值10(0000 1010)和26(0101 1010),当两个分别对原数组长度-1=15(0000 1111)做&运算时,结果都为0000 1010。当对原数组长度16做&运算时,结果分别为0000 0000 和 0001 0000,即为0和不为0。所以当扩容后,相当于10是原位置情况,26则变为原位置+原数组长度的情况
附:&运算:两个位都为1时,结果才为1
- 【注2】e.hash & oldCap
常见问题
-
什么是加载因子?加载因子为什么是 0.75?
加载因子也叫扩容因子或负载因子,用来判断什么时候进行扩容的,假如加载因子是 0.5,HashMap 的初始化容量是 16,那么当 HashMap 中有 16*0.5=8 个元素时,HashMap 就会进行扩容。
那加载因子为什么是 0.75 而不是 0.5 或者 1.0 呢?
这其实是出于容量和性能之间平衡的结果:
当加载因子设置比较大的时候,扩容的门槛就被提高了,扩容发生的频率比较低,占用的空间会比较小,但此时发生 Hash 冲突的几率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素的操作时间就会增加,运行效率也会因此降低;
而当加载因子值比较小的时候,扩容的门槛会比较低,因此会占用更多的空间,此时元素的存储就比较稀疏,发生哈希冲突的可能性就比较小,因此操作性能会比较高。
为了提升扩容效率,HashMap的容量(capacity)有一个固定的要求,那就是一定是2的幂。所以,如果负载因子是3/4的话,那么和capacity的乘积结果就可以是一个整数。
所以综合了以上情况就取了一个 0.5 到 1.0 的平均数 0.75 作为加载因子。 -
如果new HashMap(19),bucket数组多大?
HashMap的bucket 数组大小一定是2的幂,如果new的时候指定了容量且不是2的幂,实际容量会是最接近(大于)指定容量的2的幂,比如 new HashMap<>(19),比19大且最接近的2的幂是32,实际容量就是32。
参考博文: