HashMap -1.8 源码解析
简介:
- HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。
HashMap
可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个- JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。 JDK1.8 以后的
HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。 HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且,HashMap
总是使用 2 的整数幂作为哈希表的大小。
底层数据结构:
JDK1.8 之前
JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。
![HashMap_base](https://www.pdai.tech/_images/collection/HashMap_base.png)
JDK1.8 之后
Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。
内部结构分析:
/**
* 表,在第一次使用时初始化,并根据需要调整大小。分配时,长度始终是 2 的幂。
* (我们还在某些操作中容忍长度为零,以允许当前不需要的引导机制。)
*/
//位桶数组
transient java.util.HashMap.Node<K,V>[] table;
//静态内部类
static class Node<K,V> implements Map.Entry<K,V> {
//哈希值
final int hash;
//关键码
final K key;
//关键码值
V value;
//next指针
java.util.HashMap.Node<K,V> next;
//构造函数
Node(int hash, K key, V value, java.util.HashMap.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; }
//计算hashCode()
public final int hashCode() {
//调用Object类的hashcode方法,返回key value的hash值异或
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
//设置新值
public final V setValue(V newValue) {
//保存旧值
V oldValue = value;
//赋值新值
value = newValue;
//返回旧值
return oldValue;
}
//重写equals方法
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;
}
}
实现接口:
Map<K,V>接口
实现键值对的映射
Cloneable标记接口
Cloneable:是一个标记接口,按照约定,实现Cloneable的类应当重写Object.clone()方法,但是此接口不包含clone方法,在不实现Cloneable接口的对象上调用Object的clone方法会抛CloneNotSupportedException异常。
Serializable序列化接口
说明此类可以序列化,网络传输需要序列化,只要实现了Serilizable接口,这个对象就可以被序列化,java的这种序列化模式为开发者提供了很多便利,我们可以不必关系具体序列化的过程,这个类的所有属性和方法都会自动序列化
成员属性:
-
loadFactor 加载因子
loadFactor 加载因子是控制数组存放数据的疏密程度,loadFactor 越趋近于 1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor 越小,也就是趋近于 0,数组中存放的数据(entry)也就越少,也就越稀疏。
loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。
给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。
-
threshold
threshold = capacity * loadFactor,当 Size>=threshold的时候,那么就要考虑对数组的扩增了,也就是说,这个的意思就是 衡量数组是否需要扩增的一个标准。
//默认初始容量16,必须是 2 的幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子0.75,判断容器是否扩容的参数
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//树化阈值,如果链表节点大于8个,会进行链表转树,之后的插入算法就变成了树的插入算法
static final int TREEIFY_THRESHOLD = 8;
// 当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
//最小树容量,执行树形化之前,会先检查数组长度,如果长度小于64,则对数组进行扩容,而不是进行树形化。
static final int MIN_TREEIFY_CAPACITY = 64;
//位桶数组
transient java.util.HashMap.Node<K,V>[] table;
//存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;
//元素个数,此映射中包含的键值映射数
transient int size;
// 记录HashMap 被结构修改的次数
transient int modCount;
//判断扩容的临界值(容量*负载因子)
int threshold;
//负载因子
final float loadFactor;
构造函数:
//无参构造
//使用默认初始容量 (16) 和默认负载因子 (0.75) 构造一个空的 HashMap
public HashMap() {
//默认负载因子赋值0.75
this.loadFactor = DEFAULT_LOAD_FACTOR; // 所有其他字段默认
}
/**
使用指定的初始容量和负载因子构造一个空的 <tt>HashMap<tt>。
@param initialCapacity 初始容量
@param loadFactor 负载因子
@throws IllegalArgumentException 如果初始容量为负或负载因子为非正数
*/
public HashMap(int initialCapacity, float loadFactor) {
//判断容量正确性,小于0抛出异常
if (initialCapacity < 0)
//非法初始容量
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//如果指定的初始容量>最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
//将初始容量设置为最大容量
initialCapacity = MAXIMUM_CAPACITY;
//判断负载因子是否合法,小于0或者为NaN无效数字(未定义或不可表示的值),例如除数为0.0
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//合法则赋值
//负载因子赋值
this.loadFactor = loadFactor;
//设置临界阈值,返回最接近指定容量的2的幂
this.threshold = tableSizeFor(initialCapacity);
}
/**
使用指定的初始容量和默认加载因子 (0.75) 构造一个空的 <tt>HashMap<tt>。
@param initialCapacity 初始容量。如果初始容量为负,则
@throws IllegalArgumentException。
*/
public HashMap(int initialCapacity) {
//使用指定的初始容量和默认的默认的负载因子0.75
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
实现 Map.putAll 和 Map 构造函数
@param m 最初构造此映射时映射
@param evict false,否则为 true(中继到方法 afterNodeInsertion)。
*/
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
//s为m的实际元素个数
int s = m.size();
if (s > 0) {
// 判断table是否已经初始化
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F;
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);
}
}
}
方法函数:
定位位桶数组索引位置
不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。
HashMap 的数据结构是“数组+链表+红黑树”的结合,所以我们当然希望这个 HashMap 里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表/红黑树,大大优化了查询的效率。HashMap 定位数组索引位置,直接决定了 hash 方法的离散性能。
hash()
计算key的hashCode
//计算key的hash值
static final int hash(Object key) {
//保存用Object的hashCode算法计算出的hash值
int h;
//1.先计算得到key的hashcode值
//2.将得到的hashcode值右移16位再与原来的hashcode值异或计算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
数组位置为用hash方法计算返回得到的值再与(数组长度-1)&(与)运算
int index=(hash(Object key)-1)&(tab.length-1);
**总结**
- 拿到 key 的 hashCode 值
- 将 hashCode 的右移16位与原hashcode 异或(^)运算,重新计算 hash 值
- 将计算出来的 hash 值与 (table.length - 1) 进行 & 运算
get(Object key)
返回指定键映射到的值,如果此映射不包含键的映射,则返回null
/**
返回指定键映射到的值,如果此映射不包含键的映射,则返回 {@code null}。
*/
public V get(Object key) {
//创建结点应用 e
java.util.HashMap.Node<K,V> e;
//getNode(hash(key), key) 传入key的hash值和key
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
实现 Map.get 和相关方法
@param hash 键的hash值
@param key 键
@return 节点,如果没有,则为 null
*/
final java.util.HashMap.Node<K,V> getNode(int hash, Object key) {
//位桶数组引用
java.util.HashMap.Node<K,V>[] tab;
//first为位桶数组的头结点,e将存放头结点的下一个结点
java.util.HashMap.Node<K,V> first, e;
//数组长度
int n;
K k;
// (n - 1) & hash 确定元素存放在哪个桶
//如果数组不等于空 && 数组的长度>0 && table索引位置的节点不为空
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
//永远先判断第一个结点
//检查first节点的hash值和key是否和传入参数相等
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
//如果相等则first首节点即为目标节点,直接返回first节点
return first;
//如果first不是目标节点,并且first的next节点不为空则继续遍历
if ((e = first.next) != null) {
//判断first是否红黑树结点,是树则调用对应的getTreeNode()方法
if (first instanceof java.util.HashMap.TreeNode)
return ((java.util.HashMap.TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//执行链表节点的查找,向后遍历链表, 直至找到key相等的结点,返回该节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
//没有返回空
return null;
}
put(K key, V value)
返回此列表中的第一个元素
/**
将指定值与此映射中的指定键相关联。如果映射先前包含键的映射,则旧值将被替换。
@param key 与指定值相关联的键
@param value 与指定键相关联的值
@return 之前与key 相关联的值,或者null 如果有key 没有映射。 (null 返回也可以表明映射先前将null 与key 关联。)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
*
实现 Map.put 和相关方法
@param hash 键的hash值
@param key 键
@param value 要放置的值
@param onlyIfAbsent 如果为真,不改变现有值
@param evict 如果为假,表处于创建模式。
@return 以前的值,如果没有,则为 null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
java.util.HashMap.Node<K,V>[] tab;
//指向位桶数组头结点
java.util.HashMap.Node<K,V> p;
int n, i;
// 1.校验table是否为空或者length等于0,如果是则调用resize方法进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
//n返回扩容初始化后的长度
n = (tab = resize()).length;
// 2.通过hash值找到索引位置,将该索引位置的头节点赋值给p,如果p为空则直接在该索引位置新增一个节点即可
if ((p = tab[i = (n - 1) & hash]) == null)
//为空创建新结点
tab[i] = newNode(hash, key, value, null);
else {
// 该index结点不为空,进行遍历查找
//用来指向要寻找的目标结点
java.util.HashMap.Node<K,V> e;
//存key
K k;
// 3.判断p节点的key和hash值是否跟传入的相等,如果相等, 则p节点即为要查找的目标节点,将p节点赋值给e节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4.判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法查找目标节点
//instanceof判断p是否是TreeNode的实例化
else if (p instanceof java.util.HashMap.TreeNode)
e = ((java.util.HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 5.不是红黑树,则用普通的链表方法进行查找,使用binCount统计链表的节点数
for (int binCount = 0; ; ++binCount) {
// 6.如果p的next节点为空时,则代表找不到目标节点,则新增一个节点并插入链表尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 7.校验节点数是否超过8个,如果超过则调用treeifyBin方法将链表节点转为红黑树节点,
// 减一是因为循环是从p节点的下一个节点开始的
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//链表转红黑树
treeifyBin(tab, hash);
break;
}
// 8.如果e节点存在hash值和key值都与传入的相同,则e节点即为目标节点,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e; // 将p指向下一个节点
}
}
// 9.如果e节点不为空,则代表目标节点存在,使用传入的value覆盖该节点的value,并返回oldValue
if (e != null) { // existing mapping for key
V oldValue = e.value;
//如果onlyIfAbsent为false或者原结点值为空
if (!onlyIfAbsent || oldValue == null)
//结点赋值
e.value = value;
afterNodeAccess(e); // 用于LinkedHashMap
return oldValue;
}
}
++modCount;
// 10.如果插入节点后节点数超过阈值,则调用resize方法进行扩容
if (++size > threshold)
resize();
//evict为true
afterNodeInsertion(evict);
return null;
}
resize()
用于初始化数组或数组扩容,每次扩容后,容量为原来的 2 倍,并进行数据迁移。
/**
返回此列表中的第一个元素
。 @return 此列表中的第一个元素
@throws NoSuchElementException 如果此列表为空抛出空指针异常
*/
public E getFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return f.item;
}
remove(Object key)
传入key,删除对应的结点,成功返回删除的结点,否则返回空
进行扩容,会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。在编写程序中,要尽量避免 resize
/**
如果存在,则从此映射中删除指定键的映射。
@param key key 其映射将从map中删除
@return 与 key 关联的先前值,如果 key 没有映射,则返回 null。 (null 返回也可以表明映射先前将 null 与 key 关联。)
*/
public V remove(Object key) {
java.util.HashMap.Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
/**
实现 Map.remove 和相关方法
@param hash 键的hash值
@param key 键
@param value 匹配的值,如果 matchValue,否则忽略
@param matchValue 如果为真,则仅在值相等时删除
@param 可移动,如果为假,则不移动其他节点同时删除
@return 节点,如果没有,则为 null
*/
final java.util.HashMap.Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
java.util.HashMap.Node<K,V>[] tab; //指向位桶数组
java.util.HashMap.Node<K,V> p; //用来指向数组中的结点
int n, index;
//如果表不为空 and 表容量>0 and key的hash值对应数组的索引位置不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
//node存放要找的目标节点
java.util.HashMap.Node<K,V> node = null, e; K k; V v;
//判断p的key和hash值与key和key的hash值是否相等,
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//相等则p为目标结点,赋值给node
node = p;
//否则p的next赋值给e
else if ((e = p.next) != null) {
//判断p是否是红黑树
if (p instanceof java.util.HashMap.TreeNode)
//是红黑树就调用红黑树的方法查找
node = ((java.util.HashMap.TreeNode<K,V>)p).getTreeNode(hash, key);
//不是红黑树,则遍历链表
else {
do {
// 当节点的hash值和key与传入的相同,则该节点即为目标节点
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
//结点赋值给node
node = e;
//跳出循环
break;
}
// p节点赋值为本次结束的e,记录目标节点的前一个节点
p = e;
} while ((e = e.next) != null);
}
}
//如果node不为空(即根据传入key和hash值查找到目标节点),node结点值和传入值相等,则进行移除操作
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//如果是TreeNode则调用红黑树的移除方法
if (node instanceof java.util.HashMap.TreeNode)
((java.util.HashMap.TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
// “node == p”只会出现在node是头节点的时候,
// 如果node是该索引位置的头节点
else if (node == p)
//将该索引位置的值赋值为node的next节点,相当于删除头结点
tab[index] = node.next;
//如果node不是头节点,则目标节点node为p的next节点
else
// 将node的上一个节点p的next属性设置为node的next节点,相当于node节点移除, 将node的上下节点进行关联(链表的移除)
p.next = node.next;
//改动次数+1
++modCount;
//元素个数-1
--size;
// 供LinkedHashMap使用
afterNodeRemoval(node);
//返回被删除的node结点
return node;
}
}
//找不到 返回空
return null;
}
containsValue(Object value)
判断hashmap中是否包含指定值
//判断hashmap中是否包含指定值
public boolean containsValue(Object value) {
java.util.HashMap.Node<K,V>[] tab; V v;
//判断位桶数组是否为空 并且 元素个数>0
if ((tab = table) != null && size > 0) {
//满足条件则循环遍历数组
for (int i = 0; i < tab.length; ++i) {
//遍历链表
for (java.util.HashMap.Node<K,V> e = tab[i]; e != null; e = e.next) {
//如果结点值等于传入参数value 返回true
if ((v = e.value) == value ||
(value != null && value.equals(v)))
return true;
}
}
}
return false;
}
思考:
扩容后,节点重 hash 为什么只可能分布在 “原索引位置” 与 “原索引 + oldCap 位置” ?
-
扩容代码中,使用 e 节点的 hash 值跟 oldCap 进行位与运算,以此决定将节点分布到 “原索引位置” 或者 “原索引 + oldCap 位置” 上,这是为什么了?
-
假设老表的容量为 16,即 oldCap = 16,则新表容量为 16 * 2 = 32,假设节点 1 的 hash 值为:0000 0000 0000 0000 0000 1111 0000 1010,节点 2 的 hash 值为:0000 0000 0000 0000 0000 1111 0001 1010,则节点 1 和节点 2 在老表的索引位置计算如下图计算1,由于老表的长度限制,节点 1 和节点 2 的索引位置只取决于节点 hash 值的最后 4 位。
-
再看计算2,计算2为新表的索引计算,可以知道如果两个节点在老表的索引位置相同,则新表的索引位置只取决于节点hash值倒数第5位的值,而此位置的值刚好为老表的容量值 16,此时节点在新表的索引位置只有两种情况:“原索引位置” 和 “原索引 + oldCap位置”,在此例中即为 10 和 10 + 16 = 26。
-
由于结果只取决于节点 hash 值的倒数第 5 位,而此位置的值刚好为老表的容量值 16,因此此时新表的索引位置的计算可以替换为计算3,直接使用节点的 hash 值与老表的容量 16 进行位于运算,如果结果为 0 则该节点在新表的索引位置为原索引位置,否则该节点在新表的索引位置为 “原索引 + oldCap 位置”。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fq9G6SaK-1636095672001)(JDK%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90.assets/20180107234931776)]
总结:
- HashMap 的底层是个 Node 数组(Node<K,V>[] table),在数组的具体索引位置,如果存在多个节点,则可能是以链表或红黑树的形式存在。
- 增加、删除、查找键值对时,定位到哈希桶数组的位置是很关键的一步,源码中是通过下面3个操作来完成这一步:1)拿到 key 的 hashCode 值;2)将 hashCode 的高位参与运算,重新计算 hash 值;3)将计算出来的 hash 值与 “table.length - 1” 进行 & 运算。
- HashMap 的默认初始容量(capacity)是 16,capacity 必须为 2 的幂次方;默认负载因子(load factor)是 0.75;实际能存放的节点个数(threshold,即触发扩容的阈值)= capacity * load factor。
- HashMap 在触发扩容后,阈值会变为原来的 2 倍,并且会对所有节点进行重 hash 分布,重 hash 分布后节点的新分布位置只可能有两个:“原索引位置” 或 “原索引+oldCap位置”。例如 capacity 为16,索引位置 5 的节点扩容后,只可能分布在新表 “索引位置5” 和 “索引位置21(5+16)”。
- 导致 HashMap 扩容后,同一个索引位置的节点重 hash 最多分布在两个位置的根本原因是:1)table的长度始终为 2 的 n 次方;2)索引位置的计算方法为 “(table.length - 1) & hash”。HashMap 扩容是一个比较耗时的操作,定义 HashMap 时尽量给个接近的初始容量值。
HashMap 有 threshold 属性和 loadFactor 属性,但是没有 capacity 属性。初始化时,如果传了初始化容量值,该值是存在 threshold 变量,并且 Node 数组是在第一次 put 时才会进行初始化,初始化时会将此时的 threshold 值作为新表的 capacity 值,然后用 capacity 和 loadFactor 计算新表的真正 threshold 值。 - 当同一个索引位置的节点在增加后达到 9 个时,并且此时数组的长度大于等于 64,则会触发链表节点(Node)转红黑树节点(TreeNode),转成红黑树节点后,其实链表的结构还存在,通过 next 属性维持。链表节点转红黑树节点的具体方法为源码中的 treeifyBin 方法。而如果数组长度小于64,则不会触发链表转红黑树,而是会进行扩容。
- 当同一个索引位置的节点在移除后达到 6 个时,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点。红黑树节点转链表节点的具体方法为源码中的 untreeify 方法。
HashMap 在 JDK 1.8 之后不再有死循环的问题,JDK 1.8 之前存在死循环的根本原因是在扩容后同一索引位置的节点顺序会反掉。 - HashMap 是非线程安全的,在并发场景下使用 ConcurrentHashMap 来代替。