1.前言
为什么要读源码?
- 知其然知其所以然:阅读源码有助于我们了解开源软件的核心原理和执行流程,这样有助于我们快速定位并修复问题。
- 有助于我们自己写出结构更优质的代码:学习源码可以让我们站在巨人的肩膀上,我们可以学习源码中的优秀编码技巧和巧妙的设计思路,以及设计模式的运用,还有一些经典的编码规范和命名规则等。以此来约束和改进自己的编程代码,有助于我们写成更好的代码。
2.HashMap基本属性
// 真正存储元素的数组,他的长度总是2的幂次方
// 不管你自己设不设定长度,都不影响他的长度为2的幂次方
transient Node<K,V>[] table;
// HashMap的默认初始化容量16(桶的个数)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// HashMap设定的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// HashMap的默认加载因子,用于判断什么时候什么时候table需要进行扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 树化阈值,决定了什么时候桶上的元素超过多少个,就会转变成红黑树
// 所谓“桶”就是哈希表中盛放不同key链表的容器,在这里我们可以把每个key的位置看作是一个桶
static final int TREEIFY_THRESHOLD = 8;
// 反树化阈值,当桶上的元素小于这个值的时候,将把树转换为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树时,table中的最小容量(桶的最少个数),只有超过64个桶,才能进行树化
static final int MIN_TREEIFY_CAPACITY = 64;
// HashMap中存放的所有元素个数,要与桶的个数区分开来
transient int size;
// 对HashMap进行结构修改的计数器(增加节点、删除节点、扩容、树化等不包括修改节点的值)
transient int modCount;
// 扩容临界值,当不为null的桶的个数超过了这个值,就会对table进行扩容
// threshold = loadFactor * capacity
int threshold;
// 加载因子,默认为0.75
// 加载因子越大,table数组中存放的元素越密集,反之则不然。
// 0.75是官方给出的一个较好的临界值,一般不会手动设置加载因子
final float loadFactor;
3.HashMap的构造方法
// (1)无参构造方法,指定加载因子为默认加载因子0.75
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
// (2)指定“容量大小”和“加载因子”的构造函数
public HashMap(int initialCapacity, float loadFactor) {
// 校验初始化Capacity值是否合理
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 如果指定初始化的Capacity值超过HashMap规定的最大值则将initialCapacity重置为MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 校验loadFactor是否合理
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
// 初始化容量,保证容量是2的幂次方
this.threshold = tableSizeFor(initialCapacity);
}
// (3)
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// (4)传入Map集合的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
// 将Map中的键值对复制到该HashMap
putMapEntries(m, false);
}
至此我们就了解了所有构造方法,现在我们具体看一下构造方法2中的tableSizeFor(initialCapacity)
方法以及构造方法4中的putMapEntries(m, false)
方法
tableSizeFor()
// 该方法用于初始化容量,保证容量是2的幂次方,且这个数不会超过太多我们自定义的初始化容量
static final int tableSizeFor(int cap) {
// 减一的目的是为了让初始化的值刚好大于cap,而不是多出很多
// 举个例子来说,cap = 10,我们减一操作后的结果n为16,不减一进行操作后的结果为32
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;
}
注意!!!
到这里你可能对this.threshold = tableSizeFor(initialCapacity);
有点懵,说好的threshold
是扩容阈值,怎么就把table的初始化容量值(tableSizeFor(int cap)
的执行结果)赋值给了threshold
?
你先别急,等看到后面resize()
方法,你就明白了。总而言之这里只是暂存一下容量值,真正的扩容阈值并不是此时的值。
putMapEntries()
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
// 获得传入map的大小
int s = m.size();
if (s > 0) {
// 判断table是否已经初始化
if (table == null) { // pre-size
// 未初始化,s为m的实际元素个数
float ft = ((float)s / loadFactor) + 1.0F;
// 保证计算出来的ft不会超过HashMap定义的最大容量
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// 计算得到的t大于阈值,则初始化阈值
if (t > threshold)
threshold = tableSizeFor(t);
}
// 已初始化,并且m元素个数大于阈值,进行扩容处理
else if (s > threshold)
resize();
// 将m中的所有元素添加至HashMap中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
4.hash()方法
该方法的目的是为了在table
长度不太大的情况下,让高16位参与寻址过程中。我们首先要了解get()
方法中寻址的操作为(n - 1) & hash
这么个操作,如果table
长度不太大的话(比如初始化的16),hash值只有低十六位会参与寻址过程,为了避免碰撞,最好让高16位也参与到寻址过程中,所以hash()
让key的高16位先与其hash值进行一次扰动,将扰动结果作为key的hash值进行寻址运算。
// 比如说你的table长度只有16,但是hashCode有32位,高16位无法参与到寻址运算(hash & (n - 1))这个过程中,而经过hash()函数以后,
// 将高16位与原本的hash值相与,获得最终的hash值,这样高16位就参与到了寻址运算过程中。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
5.put()方法
可以结合着我画的思维导图看。
作图不易,转载请声明出处。
// 调用putVal方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* @param hash key的哈希值
* @param key
* @param value 需要存放的值
* @param onlyIfAbsent 如果为true,则不会修改已经存在的值
* @param evict 如果为false,代表table处于创建模式
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 指向table的索引副本
Node<K,V>[] tab;
// 指向当前节点
Node<K,V> p;
// n表示table.length - 1,i表示路由寻址结果,即当前节点需要插入位置的索引
int n, i;
// 1.table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
// 这是一种延迟加载策略
n = (tab = resize()).length;
// 2.接下来将进行节点(链表节点/树节点)的插入
// (n - 1) & hash 确定元素存放在哪个桶中,如果桶为空,新生成结点放入桶中
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素(处理hash冲突)
else {
// 节点e用于指向key相同的节点,也就是我们要找的节点,后面会根据onlyIfAbsent判断是否将旧值覆盖
Node<K,V> e;
K k;
// (1)在HashMap中查找我们需要找到的节点,存在则将e指向该节点,不存在则插入新节点
// 判断当前桶的位置的节点是否是我们要找的节点
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 {
// 接着遍历链表,如果有同key相等的节点,那么就使用e指向它
// 如果没有找到key相等的节点,那么就会在链表的尾部插入节点,并且在此之前需要判断是否达到阈值TREEIFY_THRESHOLD,是否需要将链表转换为红黑树
for (int binCount = 0; ; ++binCount) {
// 到达链表尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法
// 这个方法会根据 HashMap 数组来决定是否转换为红黑树。
// 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 未到达链表的尾部
// 判断链表中结点的key值与插入的元素的key值是否相等,如果key相等的话,就意味着e已经指向key相等且已存在的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
//(2)找到了我们需要找的元素,根据onlyIfAbsent判断是否进行值覆盖
// 表示在桶中找到key值、hash值与插入元素相等的结点,然后根据onlyIfAbsent判断对其进行覆盖操作
if (e != null) { // existing mapping for key
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false,才会进行值覆盖
// 如果onlyIfAbsent为true则不会进行值覆盖,只有value==null才会进行赋值
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值,执行完毕,下面的代码不会再执行
return oldValue;
}
}
// 3.最后处理
// 表示散列表结构被修改的次数,替换Node的val不算
++modCount;
// 走到这里就意味着插入了新的节点而不是替换了某个节点的val,实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
putVal()
方法的注释写的有点多,不过大体结构可以这样来看:
table
未初始化或者长度为0,进行扩容- 接下来将进行存值操作,也就是节点(链表节点/树节点)的插入
- 判断当前桶位是否为空,为空直接插入新节点
tab[i] = newNode(hash, key, value, null)
- 当前桶位不为空
- 判断当前桶的位置的节点是否是我们要找的节点,是的话就将
Node<K,V> e
指向该节点 - 判断当前桶位置存放的节点是否是树节点,是的话将向树中存值
- 走到这里就意味着桶位置存放的节点为链表头节点,然后对其进行遍历,如果有key相等的节点就将
e
指向该节点,没有则插入新节点,插入之后还需要对链表的长度进行判断,如果超过了树化阈值,那么就需要进行树化操作 - 最后判断
e
指向的节点是否为空,不为空,就根据onlyIfAbsent
是否进行值覆盖,且该方法到此返回,不进行第三步
- 判断当前桶的位置的节点是否是我们要找的节点,是的话就将
- 判断当前桶位是否为空,为空直接插入新节点
- 最后处理
- 走到这里意味着本次方法调用用掉了一个桶位或者在链表/红黑树中插入了一个新节点
- 将
modCount
加1 - 判断插入节点后的
table
是否需要进行扩容
6.resize()方法
在此之前可以看看我的画的思维导图:
resize()
方法主要用于扩容,也用于对table
进行初始化操作,扩容的目的是为了解决哈希冲突导致的链化影响查询效率的问题,扩容可以缓解该问题。
final Node<K,V>[] resize() {
// 引用扩容前的哈希表
Node<K,V>[] oldTab = table;
// 表示扩容前的table数组长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 表示扩容之前的扩容阈值(也就是触发本次扩容的阈值)
int oldThr = threshold;
// newCap表示扩容之后table数组的大小,newThr表示下次再触发扩容的条件
int newCap, newThr = 0;
// 1.对newCap和newThr进行赋值
// oldCap > 0
// 如果oldCap大于0,意味着table已经被初始化过了,现在是一场正常扩容
if (oldCap > 0) {
// 如果oldCap已经到达了HashMap规定的最大值,就意味着不能继续扩容了,将扩容阈值设置为Integer.MAX_VALUE(无法继续扩容了)
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 更多的还是这种情况,一般很难达到MAXIMUM_CAPACITY
// newCap等于oldCap左移1位,意味着扩大了两倍,如果newCap合理(小于MAXIMUM_CAPACITY且大于等于DEFAULT_INITIAL_CAPACITY),
// 那么会将oldThr同样左移1位,意味着扩容阈值也跟着翻倍,threshold = capacity * loadFactor
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 从下面的代码我们也可以了解到为什么new HashMap(initCap, loadFactor)中将initCap赋值给了threshold
// oldCap == 0
// 走到这里意味着oldCap不大于0,也就是说oldCap等于0,table还未进行初始化(table == null)
// 什么时候table=null,而oldThr又大于0呢
// 1.new HashMap(initCap, loadFactor)
// 2.new HashMap(initCap)
// 3.new HashMap(map)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// oldCap == 0, oldThr == 0
// 走到这里意味着该HashMap调用了默认构造函数进行了初始化
// new HashMap()
else {
// 初始化cap
newCap = DEFAULT_INITIAL_CAPACITY;
// 初始化Thr
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果new Thr仍然为0,意味着前面的代码还没有对其进行初始化,在这里我们将对其进行初始化
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 2.对table进行扩容
// 创建一个更大的数组,如果扩容前原数组有数据,将原数组的数据迁移到新数组中,没有则直接将扩容后的新数组直接返回。
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// oldTab == null说明这是初始化操作,不等于null则意味着是正常扩容过程
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
// 用于指向当前节点
Node<K,V> e;
// 将e指向当前节点,当前节点是单个节点还是链表头节点还是红黑树根节点,我们都还未知,接下来进行详细处理。
if ((e = oldTab[j]) != null) {
// 此时e已经指向该节点了,将该节点置为null方便jvm进行垃圾回收
oldTab[j] = null;
// 第一种情况,当前桶为只有一个元素(e.next==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
// 低位链表,存放在扩容之后的数组的下标位置,与当前数组的下标位置一致
Node<K,V> loHead = null, loTail = null;
// 高位链表,存放在扩容之后的数组的下标位置为:当前数组的下标 + 扩容之前的数组的长度
Node<K,V> hiHead = null, hiTail = null;
// 指向当前链表节点的下一个元素
Node<K,V> next;
do {
next = e.next;
// 根据hash & oldCap的结果分别存放到高位链表或者低位链表中
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 断开高位链表和低位链表,将其存入newTab
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
我们再来理一下思路:
-
对
newCap
和newThr
进行赋值(对四种构造方法进行不同的赋值操作)oldCap>0
,意味着本地resize()方法的执行是为了扩容oldCap=0
,意味着本次resize()方法的执行是为了初始化- 不管
oldThr
的值为不为0,后续都会初始化newCap
的值,以及newThr
的值,newThr
的值本来是保存着构造方法传入的initCap
的值,在这里将真正转变为扩容阈值(loadFactor * capacity
)
- 不管
-
对table进行扩容,创建一个新的数组newTab,大小为newCap
- 如果
oldTab
等于null,意味着当前resize()
的目的是进行初始化,直接将newTab
返回即可 - 如果
oldTab
不等于null,意味着当前resize()
的目的是为了进行扩容,需要将原table(oldTab
)中的数据迁移到新数组(newTab
)中- 第一种情况,当前桶为只有一个元素(
e.next==null
说明是单个节点),将其迁移到新数组中即可 - 第二种情况,该桶位的节点已经树化
- 第三种情况,当前桶位已经形成链表,即当前节点为链表的头节点
- 第一种情况,当前桶为只有一个元素(
- 如果
到此,resize方法执行完毕。
要注意的是,第三种情况并不是直接将原链表存入newTab中,而是分别创建高低位链表,将链表中的节点通过(e.hash & oldCap) == 0
分别存放到高位链表和低位链表,最后低位链表和高位链表又将分别存入newTab[j]
和newTab[j + oldCap]
中,至此resize()
方法执行完毕,这样的目的是将数据更加分散,而不是大量的数据都存放在一个桶上,这样一来有利于降低get的时间复杂度。
再贴一下图:
7.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) {
// 指向table的索引副本
Node<K,V>[] tab;
// first指向索引处的第一个节点(桶中的头元素),e用来指向临时Node节点
Node<K,V> first, e;
// n = table.length - 1,k为key
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) {
// 在树中get
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 在链表中get
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
经过了resize()
和put()
的摧残,相信对你来说get()
方法还是很好理解的,这里我就不细说了哈哈哈
8.写在最后
为什么树化阈值要设置为8?
TreeNodes(树) 占用空间是普通 Nodes(链表) 的两倍,所以只有当 bin(bucket 桶) 包含足够多的结点时才会转成 TreeNodes,而是否足够多就是由 TREEIFY_THRESH〇LD 的值决定的。当 bin(bucket 桶) 中结点数变少时,又会转成普通的 bin(bucket 桶)。并且我们查看源码的时候发现,链表长度达到 8 就转成红黑树,当长度降到 6 就转成普通 bin(bucket 桶)。
这样就解释了为什么不是一开始就将其转换为 TreeNodes,而是需要一定结点数之后才转为 TreeNodes,更直白的说就是权衡空间和时间。
hashCode 算法下所有 桶 中结点的分布频率会遵循泊松分布,这时一个桶中链表长度超过 8 个元素的槪率非常小,权衡空间和时间复杂度,所以选择 8 这个数宇。
为什么HashMap中的table的长度要设定为2的幂次方?
因为在HashMap
中的table
的length等于2的n次方的时候,才会有hash%length
等于hash&(length-1)
,哈希算法的目的是为了加快哈希计算以及减少哈希冲突,所以此时&操作更合适,因此table的长度总是2的幂次方。