源码分析系列之HashMap源码分析(基于JDK 1.8)
HashMap底层是由 数组+(链表)=(红黑树) 组成,每个存储在HashMap中的键值对都存放在一个Node节点之中,其中包含了Key-Value之外,还包括hash值(key.hashCode()) ^ (h >>> 16)) 以及执行下一个节点的指针next。
HashMap的底层实现图示,如下图所示:
2.HashMap源码分析
2.1 重要常量
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
private static final long serialVersionUID = 362498820763181265L;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;
2.2 重要方法
1)获取hash值 hash
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash方法用传入的key的hashCode和hashCode无符号右移16位的结果,做异或运算后作为hash值返回。
注:之所以获取hashCode后,还需要和右移16位的hashCode做异或运算,原因是:在根据hash值获取键值对在bucket数组中的下标时,采用的算法是:index=h & (length-1),当数组的length较小时,只有低位能够参与到“与”运算中,但是将hashCode右移16位再与本身做异或获取到的hash,可以使高低位均能够参与到后面的与运算中。
下面图说明:
2)根据键值对数量获取HashMap容量方法 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;
}
tabSizeFor方法,主要根据传入的键值对容量,来返回大于容量的最小的二次幂数值。
3)插入方法 putVal
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; //存储Node节点的数组tab
Node<K,V> p; //单个Node节点p
int n, i; //HashMap的容量n
//初始化数组桶table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果数组桶中不包含要插入的元素,将新键值对作为新Node存入数组,
if ((p = tab[i = (n - 1) & hash]) == null) //此处p初始化,p和需要存储的键值对下标相同且P是链表的第一个元素
tab[i] = newNode(hash, key, value, null);
//桶中包含要插入的元素
else {
Node<K,V> e; K k;
//如果key和链表第一个元素p的key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//则将e指向该键值对
e = p;
//若p是TreeNode类型,则使用红黑树的方法插入到树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//else:键值对的引用不在链表的第一个节点,此时需要遍历链表
else {
for (int binCount = 0; ; ++binCount) {
//将e指向p的下一个元素,直到其next为null时,将键值对作为新Node放到p的尾部或树中。
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;
//将e指向p,便于下次遍历e = p.next
p = e;
}
//上述for循环执行完毕后,e要么指向了存储的新节点,要么是原来已有的元素,具有和新节点一样key值
}
//当e非空时,说明e是原来HashMap中的元素,具有和新节点一样的key值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null) //onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值
e.value = value;
//空实现,LinkedHashMap用
afterNodeAccess(e);
return oldValue;
}
}
//HashMap结构更改,modCount+1
++modCount;
//判断是否需要扩容
if (++size > threshold)
resize();
//空实现,LinkedHashMap用
afterNodeInsertion(evict);
return null;
}
4)扩容方法 resize
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length; //原HashMap的容量
int oldThr = threshold; //原HashMap的扩容临界值
int newCap, newThr = 0;
//case1:odlCap>0,说明桶数组已经初始化过
if (oldCap > 0) {
//原HashMap的越界检查
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//容量扩大一倍后的越界检查
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//case2:oldCap=0 && oldThr >0,桶数组尚未初始化,当调用带初始化容量的构造函数时会发生该情况
else if (oldThr > 0) // initial capacity was placed in threshold
//在前面HashMap的初始化中,将Initial capcity暂存在threshold中
newCap = oldThr; //未初始化,则用Initial capcity作为新的容量
//若oldThr = threshold = 0,则说明未传入初始化容量参数
//case3:oldCap=0 && oldThr = 0,当调用无参构造函数时会发生该情况,此时使用默认容量初始化
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY; //默认容量
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //默认扩容临界值
}
// 当newThr 为 0 时,阈值按照计算公式进行计算
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
/*
* 在上面的操作中,主要是获取了Resize之后的新的Capcity和新的扩容临界值threshold
*/
@SuppressWarnings({"rawtypes","unchecked"})
//上面获取到的新的Capcity,来创建一个新的桶数组 newTab,并指向table
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//若oldTab非空,则需要将原来桶数组的元素取出来放到新的桶数组中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null; //将原桶数组的元素占用的空间释放,便于GC
if (e.next == null)
//若桶中元素的next为空,获取index后直接将其放入新桶数组中
newTab[e.hash & (newCap - 1)] = e;
//若桶中元素的next是树节点
else if (e instanceof TreeNode)
//采用树的方式插入
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//若桶中元素的next是链表节点
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
/*遍历原链表,按照原来的顺序进行分组
*/
/*原始链表中的元素,在resize之后,其下标有两种可能,一种是在原来下标处,另一种是原来下标+oldCap处
*举例说明: 若原来的容量 -1后 只有n位,低位有n个1,去下标公式为:i = (oldCap - 1) & hash,若hash值只有低n为有值,则与运算后获得的值和
*扩容前是一样的,若hash不止第n位有值,那采用与运算后,结果比原来刚好大oldCap。下面有图片示例)
*/
do {
next = e.next
//若e.e.hash & oldCap 结果为0,则下标在新桶数组中不用改变,此时,将元素存放在loHead为首的链表中
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//若e.e.hash & oldCap 结果不为0,则下标在新桶数组等于原下标+oldCap,此时,将元素存放在hiHead为首的链表中
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) { //loHead为首的链表中,下标不改变
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) { //hiHead为首的链表中,下标=原下标+oldCap
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
上述代码分析较长,总结如下:
5)移除元素 remove
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//通过hash值获取下标,下标对应的节点p不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//若节点p的key和待移除的节点key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//则p就是待移除节点
node = p; //将p指向待移除节点
//p的key和待移除的节点key不相等,遍历p作为头的链表或者树
else if ((e = p.next) != null) {
//若p是树节点
if (p instanceof TreeNode)
//采用树节点方式获得要移除的节点
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {//p是链表的头节点
do {
//采用循环,当p.next不为空,比对它和传入的key,直到找到相等的key
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
//找到后,将节点指向node
node = e; //将e指向待移除节点,此时相当于p.next就是待移除的节点node,可自行验证循环便知
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//若node非空,传入的matchValue参数为flase或 node的value等于传入value
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//若node是树节点
if (node instanceof TreeNode)
//采用树节点的方式移除
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//若待移除节点是链表头,将其指向待移除元素的next,移除对node的引用
else if (node == p)
tab[index] = node.next;
else//待移除元素是链表中的元素,此时其等于p.next
//将p.next指向node.next,移除了对node的引用
p.next = node.next;
//增加结构修改计数器
++modCount;
//size-1
--size;
//空实现,LinkedHashMap用
afterNodeRemoval(node);
//返回移除的节点node
return node;
}
}
return null;
}
6)查找元素方法 get
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//根据hash值,获取对应下标的第一个元素first
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//如果first的key和待查询的key相等,返回first
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//若first不是待查询的元素
if ((e = first.next) != null) {
//若first是树节点,采用树节点的方式获取
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//first是链表节点头,使用循环获取
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
查询元素的入口方法是:public V get(Object key),返回值是node的value,核心方法是getNode(int hash, Object key)。
2.3 构造方法
1)无参构造函数
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
使用所有默认参数来构造一个HashMap,我们常用的就是这种。
2)给出容量的构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
此处调用了下面这个构造函数,将给出的容量传入和默认负载因子。
3)给出容量和负载因子的构造函数
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); //此处将初始化的容量暂存到threshold中
}
4)用一个map来初始化的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
此处将map中所有元素放入HashMap进行初始化。
3.常见问题解答
3.1 HashMap的容量为什么必须是2的n次幂?
答:当容量是2的n次幂时,不同的key获取在桶数组中的下标相同的概率减小,发生Hash碰撞几率减少,元素分布更加均匀,见下图:
结论
1)由上述实例可以看出,当HashMap容量为2的n次幂的时候,length-1,可以使得在计算index的"&"运算过程中,hash值的对应位都能参与到计算;若HashMap容量不等于2的n次幂,leng-1后必然有一些位是等于0的,那么在计算index的"&"运算过程中,对应位的数字无论是0或者1,都未能参与到运算中。导致Hash冲突概率增大。
2)初次之外,若HashMap容量不为2的n次幂,无论Hash值如何变化,始终有一些下标值无法取得,因为"&"运算过程中,必然有一些位置结果始终为0,如实例所示,其最低位始终为0,因此下标 1(二进制0000 0001)、3(二进制0000 0011)、5(二进制0000 0101)、7(二进制0000 0111)等下标、永远都获取不了。造成了容量的浪费
3.2 HashMap的时间复杂度?
答:O(1)或者O(log(n))或O(n),分析如下:
3.3 负载因子LoadFactor为何默认值为0.75。
当负载因子过大时,Hash冲突概率增加、读写的时间复杂度也大大增加,当负载因子过小时,Hash冲突概率较小,时间复杂度较低,但占用内存空间较大。
至于为什么默认值是0.75,这是一个基于时间和空间复杂度的综合考虑的结果,可以参考这篇文章:HashMap的loadFactor为什么是0.75?[https://www.jianshu.com/p/64f6de3ffcc1]
3.4 作为HasHMap的key有什么条件?
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
3.5 HashMap key允许为空吗?,最多几个?
答:允许但只允许一个key值为空。当key值为空时,其hash值为0,因此在桶数组中位置是0,即第一个元素。
那么为什么不能有两个key值为null呢,原因是两个key为null,是一样的,后面put进去的(null,value2)会覆盖先put进去的(null,value1)。验证如下:
3.6 HashMap value允许为空吗?最多几个?
答:允许,可以有多个value为null,查看源码可知,在putVal中没有对value进行限制,验证如下:
写在最后
1)本文中设计到数操作的都没有详细介绍,因为红黑树本身概念较为抽象复杂,打算下一篇文章中再来详细分析一下,还有其他一些类似于“map.clear()、map.ContainsKey()”等等逻辑较为简单的方法也未作赘述。
2)不得不感叹一些设计Java集合类的大牛是真的牛,看似一个简单的HashMap中、对于位运算、链表。红黑树的应用可谓是炉火纯青,看起来都不能一目了然,设计时那更是天马行空,匠心独运。
作者:LearnAndGet
https://www.cnblogs.com/LearnAndGet/p/9971526.html
公众号“大咖笔记”所发表内容注明来源的,版权归原出处所有(无法查证版权的或者未注明出处的均来自网络,系转载,转载的目的在于传递更多信息,版权属于原作者。如有侵权,请联系,笔者会第一时间删除处理!
3000+ 道 BAT 大厂面试题在线刷,最新、最全 Java 面试题!
Java 中 volatile 关键字的最全总结,抓紧差缺补漏吧!
一次 List 集合去重失败,引发对 Java 8 中 distinct() 的思考!
为什么 MySQL 不推荐使用 uuid 或者雪花 id 作为主键?
最近有很多人问,有没有读者交流群!想知道如何加入?方式很简单,兴趣相投的朋友,只需要点击下方卡片,回复“加群”,即可无套路入交流群!
文章有帮助的话,在看,转发吧。
谢谢支持哟 (*^__^*)