文章目录
1. HashMap源码概述
HashMap源码核心属性:
// table 的默认大小16,总是为 2 的n次幂
int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// table 的最大容量 1 * 2^30
int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子
float DEFAULT_LOAD_FACTOR = 0.75f;
// 树化的阀值 8
int TREEIFY_THRESHOLD = 8;
// 由树退化成链表的阀值 6
int UNTREEIFY_THRESHOLD = 6;
// 可树化的最小table长度 64;因为扩容的代价小于树化的代价,因此在前期元素少的情况下优先考虑扩容,而不是树化
int MIN_TREEIFY_CAPACITY = 64;
// 构成HashMap的数组结构
Node<K,V>[] table;
// HashMap的实时键值对数量
int size;
// 记录哈希Map的被修改(add, remove)次数
int modCount;
// table长度达到 threshold 就进行扩容, threshold = 容量*负载系数
int threshold;
// 当前的负载因子
float loadFactor;
2. 简单题
2.1 为什么初始容量必须保证为2的n次幂?
因为使用hash
值定位到元素所在的数组位置时用的是按位与运算 index = hash &(n-1)
,只有n为2的n次幂的时候按位与运算才等效于取模运算%;至于为什么要这样做其实是出于对性能的追求,因为单纯的二进制操作的速度是要大于取模操作的。
例如:
当capacity = 16;(16-1) 换算成二进制为 10000-00001 = 1111;观察可知 2的n次幂的二进制比特位都为1,此时1111与任何二进制数进行&运算结果都在 0000 ~ 1111 之间,等效于取模运算%,而且性能更高。
2.2 如何确定元素在数组的位置?
获取hashcode值
:k.hash()进入扰动,返回hash值
:hahs = hashCode ^ hashCode >>> 16 高低16位异或(让所有的bit位都能参与到异或运算中,从而让hash值变得更加分散均匀,并且异或也能让0,1出现的概率都相等,尽可能让hahs值随机均匀,降低hash冲突的几率)通过运算获取元素在数组中的位置
hash & (n-1)
(疑问:取模运算不是%吗,为什么使用&?)- 当数组的容量保证n为2的n次幂时
hash & (n-1)
等效于hash % n
- &按位与是二进制操作,比%十进制的取余操作更加高效,提高性能。
- 当数组的容量保证n为2的n次幂时
2.3 你一般用什么类型来做HashMap的Key? 为什么?
- 一般使用 String 或者 Integer 这些不变类来做为 HashMap 的 key ,因为:
- 字符串是不可变的,所以在它创建的时候hahs值就被缓存了,不需要重新计算。
- 字符串是Java中最常用的对象,做过很多优化,处理速度要快过其它的键对象。
- 要想HashMap的增删改查正常工作就必须正确重写作为key对象的hashCode,和equals方法,String和Integer都已经帮我们重写了hashCode()以及equals()方法。
2.4 使用自定义对象作为HashMap的Key没有正确重写HashCode方法和Equals方法会发生什么问题?
问题:
-
①能够正常put进元素但 查找,修改,删除无法正常工作
- 当没有对对象的hashCode进行重写时,默认使用的是Object的的hashCode方法,该方法是根据对象实例的内存地址来生成的,因此
即使两个属性完全一致的对象因为他们的内存地址不一致,导致生成的hashCode值也是不一样的
。 除非使用同一个属性未被修改过的实例
,否则无法正确进行:修改,查询,删除,因为即使两个对象属性完全相同hash值也不同,永远找不到。
- 当没有对对象的hashCode进行重写时,默认使用的是Object的的hashCode方法,该方法是根据对象实例的内存地址来生成的,因此
-
②性能低效,每次修改了属性hash值都会发生改变,要重新计算hash值。
3. 源码和原理分析
3.1 get方法的执行过程?
- get() 核心逻辑:
- 首先排除掉HahsTable未初始化和table中对应位置没找到有node的情况,返回null
- 该位置有值存在,进行
三种情况判断
- 比较该位置第一个值的key,相等即返回第一个节点first
- 第一个节点有next,说明该位置是一棵树或一条链表
- 该节点是
TreeNode
,调用getTreeNode
在红黑树中查找元素 - 是链表,遍历链表比较key是否相等,即返回该Node元素
- 该节点是
/**
* 本质还是调用的是getNode() 方法
*/
public V get(Object key) {
HashMap.Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final HashMap.Node<K,V> getNode(int hash, Object key) {
// tab:引用table
// first:table中的某位置的第一个Node元素
// e:临时Node变量
// n:table数组长度
HashMap.Node<K,V>[] tab; HashMap.Node<K,V> first, e; int n; K k;
// 排除掉hashMap为空,或者table中该(n - 1) & hash位置找不到元素的情况,直接返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 匹配数组中该位置的第一个元素,如果该元素的key相等即找到,立即return first(幸运情况)
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 该位置是一棵树或者一条链表
if ((e = first.next) != null) {
// 是红黑树,调用 getTreeNode 方法查找红黑树中的元素并返回
if (first instanceof HashMap.TreeNode)
return ((HashMap.TreeNode<K,V>)first).getTreeNode(hash, key);
// 是链表,遍历链表,逐个比较node.key ,找到立即return node
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
3.2 put 方法的执行过程?
- put() 核心逻辑:
- 调用putVal()
- if- 判断
table
容量是否为空,为空调用resize()
初始化 - if- 判断对应位置没有值,没有直接将元素加入
table
中 - else -对应位置已有值,进入以下三种情况判断
- if- 情况一:该元素和待加入元素
hahs
和key
都相等,直接将value进行覆盖后返回oldValue
- else if- 情况二:该位置是一颗红黑树,调用
putTreeValue
方法将元素加入到红黑树中(或覆盖红黑树中的相同key
元素) - else- 情况三:该位置是一条链表,遍历链表,若找到key相等的就覆盖
value
,没找到就在链表尾部插入,最后判断链表元素是否达到 8 个,达到就调用treeifyBin()
进行树化 - (元素是覆盖旧的)覆盖value,return oldValue值
- if- 情况一:该元素和待加入元素
- (元素是新加入的)判断数组长度是否大于阈值(capacity * loadFactor),大于就调用resize进行扩容,最后return null
- if- 判断
- 调用putVal()
/**
* put方法内部通过调用putVal方法传入:key的hash值,key,value
* 三个核心参数实现将元素加入到 HashMap 中
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* @param hash key对应的哈希值
* @param key key值
* @param value value值
* @param onlyIfAbsent 如果元素已存在就不进行覆盖,false覆盖(不重要)
* @param evict 给 afterNodeInsertion(evict) 空实现函数使用的参数(不重要)
* 还有几个关键方法要解决:
* resize() 扩容方法
* putTreeVal() 将元素加入到红黑树中方法
* treeifyBin() 树化方法
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
/**
* tab 对当前table数组的引用
* p 计算出在table中的元素
* n 数组长度
* i 数组索引
*/
HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
/**
* 如果数组为空,调用resize方法进行初始化
*/
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/**
* 将hash值和数组长度n-1进行与运算,计算出元素所在数组中的位置。
* 如果该位置没有元素就直接加入到数组中
*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
/**
* 数组中对应位置已经有值的情况
*/
else {
/**
* e 临时桶变量
* k 临时key变量
*/
HashMap.Node<K,V> e; K k;
/**
* table中已存在的元素与要加入元素的hash值相等并且他们的key也相等,直接覆盖掉数组中的元素
* (将要加入hashMap中的元素赋值给临时变量e后续会进行替换操作并返回旧的value)
* 注意是在这里初始化p的 key和hash
*/
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
/**
* 该位置是一颗红黑树,调用 putTreeValue 方法将元素加入到红黑树中
*(或覆盖红黑树中的相同key元素)
*/
else if (p instanceof HashMap.TreeNode)
e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
/**
* 遍历链表,若找到key相等的元素就覆盖value,没找到就在链表尾部插入;
* 最后判断链表元素是否达到8个,达到就调用treeifyBin() 进行树化
*/
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
/**
* hash值相同,key相同,进行覆盖
*/
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
/**
* 如果临时变量e不为空,代表是进行了覆盖(覆盖table中的,覆盖链表中的,覆盖红黑树中的)
* 从而覆盖value,return oldValue值
* 覆盖hashMap size未发生改变,因此无需检查是否需要扩容
*/
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
/**
* 到这里代表元素是新加入操作,因此要判断数组长度是否大于阈值(capacity * loadFactor),
* 大于就调用resize进行扩容
* 最后return null
*/
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
3.3 resize 扩容过程?
为什么发生hash冲突的元素经过扩容后位置分布只有两种可能性?
要么在原来的位置要么在依赖的位置+oldCapacity
例如:从16 扩容到32;hahs & (16-1)-> hahs & (32-1);hahs & 1111 -> hash & 11111 观察发现总是最高位多出一位1
因此当hahs值最高位为0时位置不变,当hash值最高位为1是位置变为oldIndex + oldCapacity。
验证程序:
// 验证扩容是元素的偏移
public static void main(String[] args) {
Map<String,String> map = new HashMap<>();
int hash1 = 15; // 01111
int hash2 = 31; // 11111
int n =16; // 尝试从16 -> 32 库容查看输出位置
System.out.println(hash1 & (n-1)); // 索引不变
System.out.println(hash2 & (n-1)); // 索引 = oldIndex + dolCapacity 发生偏移
}
完整源码分析:
- resize核心扩容逻辑:
- 第一步:确定新的数组table容量和新的阈值threshold
- 初始化table和threshold,
直接返回初始化好的table
- 正常扩容
- 达到了table最大容量,直接返回
- 初始化table和threshold,
- 第二步将元素重新映射到新数组中
- 单个的元素,随机映射到新数组高位或低位(
e.hash & (newCap - 1)
) - 是红黑树:调用
split
方法将红黑树元素重新映射到新数组中(split的核心逻辑也待理解) - 是链表:创建高位链表和低位链表,将元素对应添加到高位链表和低位链表,最后将高位链表和低位链表重新加入到新的table中,返回信息的table
- 单个的元素,随机映射到新数组高位或低位(
- 第一步:确定新的数组table容量和新的阈值threshold
final HashMap.Node<K,V>[] resize() {
// oldTab:引用扩容前的table
HashMap.Node<K,V>[] oldTab = table;
// oldCap:扩容前table的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// oldThr:扩容前table的扩容阈值
int oldThr = threshold;
// newCap,newThr:定义新的table容量和库容阈值
int newCap, newThr = 0;
//↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 这一段都是为了确定newCap和newThr值的
// table已经被初始化过,进行正常扩容
if (oldCap > 0) {
// 如果table已经达到最大限制,直接设置扩容阈值为Int最大值,并返回原来的table不再进行扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// table容量在16~MAX之间,设置newCap长度变为oldCap的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 使用的是有参构造方法,间接设置了threshold,扩容阈值的值
// new HashMap(int initialCapacity, float loadFactor)
// new HashMap(int initialCapacity)
// new HashMap(Map<? extends K, ? extends V> m) 传入的map是有数据的
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// capacity=0,threshold=0;使用的是无参构造方法new HashMap(),赋予默认值
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY; // 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
}
// 当出现newThr为零的情况,还得将newThr计算出来
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑这一段都是为了确定newCap和newThr值的
//↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 创建新数组,准备将旧的table中的数据重新映射到新table中去
@SuppressWarnings({"rawtypes","unchecked"})
HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
// 直接让table指向新构建的数组
table = newTab;
// 如果旧得table为null,说明只执行初始化,直接将初始化好的数组返回,否则将旧数组中的数据重新映射到table中
if (oldTab != null) {
// 遍历oldTable,将数据重新移动到newTable中
for (int j = 0; j < oldCap; ++j) {
// e:存放旧的
HashMap.Node<K,V> e;
// oldTable中有元素
if ((e = oldTab[j]) != null) {
// 赋值为null,便于垃圾回收掉oldTab
oldTab[j] = null;
// 只有一个元素,直接移动,在newTable的新位置为: 旧的位置+oldTable.length 或者是旧的位置
// 因为只有两种结果 16 -> 32; hahs&1111 -> hahs&11111结果只看hahs的第六位0不变1偏移16
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 该位置是一颗红黑树
else if (e instanceof HashMap.TreeNode)
// 调用split方法将红黑树元素映射到新table中
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 该位置是一条链表
else { // preserve order
// 创建高位链表和低位链表,将数组拆成两条
HashMap.Node<K,V> loHead = null, loTail = null;
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
do {
next = e.next;
// 最高位为0,加入到低位链表中(注意这里是e.hash & oldCap 参考的是oldCapacity的二进制最高位)
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 最高位为1,加入到高位链表中
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 将低位链表挂载到数组低位
if (loTail != null) {
loTail.next = null; // 防止成环
newTab[j] = loHead;
}
// 将高位链表挂载到数组高位
if (hiTail != null) {
hiTail.next = null; // 防止成环
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
3.4 reomve()方法分析
final HashMap.Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
// tab: 指向table
// p:临时Node变量,存储table指定位置的元素
// n:table长度
// index:在table中的索引
HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, index;
// 首先排除HashTable为空和index位置无元素情况
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
// node:要查找的元素
// e:table中第一个元素的next
HashMap.Node<K,V> node = null, e; K k; V v;
// 比较hash和key, table中的第一个元素就是要删除的元素,赋值给node进行保存
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
// 该位置是红黑树或者链表
else if ((e = p.next) != null) {
// 是红黑树,通过getTreeNode()方法查找到元素赋值给node保存
if (p instanceof HashMap.TreeNode)
node = ((HashMap.TreeNode<K,V>)p).getTreeNode(hash, key);
// 是链表,逐个匹配hahs和key,找到就将元素赋值给node保存
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 若node变量不为空,说明找到了元素,准备删掉,matchValue变量代表当value也相等才删除
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// node是树节点,调用removThreeNode进行删除
if (node instanceof HashMap.TreeNode)
((HashMap.TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
// node 是单个节点,直接覆盖table元素
else if (node == p)
tab[index] = node.next;
// node是链表元素,直接删除
else
p.next = node.next;
// 记录修改次数,调整hashTable大小
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
4. 相关补充 💪
异或和按位与运算:
-
异或运算^的特点,0,1出现的概率都相等(
相同为0不同为1
)- 1^1 = 0
- 1^0 = 1
- 0^1 = 1
- 0^0 = 0
-
按位与&运算的特点,二进制位置相与(
同1才为1
)- 1&1 = 1
- 1&0 = 0
- 0&1 = 0
- 0&0 = 0
关于哈希算法:
定义:将任意长度输入数据转换成固定长度输出
- 特点:
- 相同的输入一定得到相同的输出;
- 不同的输入大概率得到不同的输出。
- 哈希冲突:
- 两个不同的输入得到了相同的输出
- 好的哈希函数:
- 哈希值均匀分散,发生碰撞的概率低
- 常见哈希算法与输出长度:
- MD5 128 bits
- SHA-256 256 bits
- SHA-512 512 bits
- 哈希算法的用途:
- 验证数据完整性
- 验证数据是否被篡改
查找链表和红黑树的时间复杂度:
- 链表:O(n)
- 红黑树:O(logn)
2021年10月8日16:11:08 会持续更新的…