一篇文章带你彻底搞懂HashMap的实现,再也不用担心被欺负。
文章目录
Author : lss 路漫漫其修远兮,不止于代码
HashMap
前言
在介绍HashMap之前先了解一个别的东西:红黑树。 发布后再次声明一条由于刚开始写文章。有点本末倒置。将源码放在了最上面,文字解析过程和图文放在了源码后面。这里提前声明下,以后小编多多注意。
什么是红黑树
红黑树其实是一种自平衡二叉查找树。它的左右子树高度可能大于1,严格意义上来讲,红黑树并不是完全平衡的二叉树。那么又引入了另一个问题:什么是二叉查找树 ? 二叉查找树是有什么缺点呢 ? 为什么会衍生出红黑树呢 ?
这里推荐大家一个链接 : https://www.cs.usfca.edu/~galles/visualization/Algorithms.html 可以清楚的看到各种数据结构的 插入,删除,取值等过程。
如图所示:这就是一个二叉查找树的实列。每次数据插入的时候,都是先判断是否比根节点大 (当前数据 > 根节点 ? 从右边插入 : 从左边插入 ) 这是一个三元运算符 。而且插入都是从最下面的叶子节点做比较再选择是否在叶子节点的左边还是右边。这里便会产生一个缺点: 树的高度问题。数据越多,高度越大。这样导致查询最下面的叶子节点耗时过长。于是便出现了它其中的一种红黑树 。
如图所示:便是一个红黑树了。红黑树维护了俩中不同的颜色。
每个节点要么是红色,要么是黑色
根节点必须为黑色
红色节点不可以连续 (红色节点的孩子不能为红色)
对于每个节点,从该节点到 null (树尾端)的任何路径,都含有相同个数的黑色节点。
直观上看红黑树的插入和二叉查找树的插入相似,只是维护了两种不同颜色而且,为什么会说是一个平衡二叉树呢? 于是为了解决这个问题引入了一些平衡操作:变色,旋转。旋转又分为 左旋和右旋
变色
变色可以分为很多种情况。这里只是说其中一个,因为不是本次重点内容。有兴趣的可以去了解。
从图中可以看出 B 的左节点孩子 也是红色。明显违背了红黑树的概念 (红色节点不可以连续) 那么经过调整后(途中的右侧) 。将 B 节点变为黑色。于是解决了不可以连续的问题。但是又会产生一个新的问题。该侧路径的黑色节点多了一个,导致两边黑色节点不一致。
于是我们又将 B 的父节点 A 变色 红色。虽然是解决了两侧黑色节点数量一致的问题,但是又会产生上一个 不可以连续的问题。 于是我们又将 A 的右节点孩子也改变为 黑色。如下图所示 这样这两个问题都得到解决。
了解了如何通过 变色 调整平衡后,那么下来就看看 旋转是如何操作的吧
旋转
上面我们提到过,旋转分为 左旋 和 右旋,那么就分别来展示下吧
左旋
左旋 : 自己的右节点成为自己的夫节点。取而代之的是 右节点的左节点成为了自己的右节点。
右旋
右旋 :自己的左节点成为自己的父节点。取而代之的是 左节点的右节点成为自己的左节点。
如果到这里你还没明白 旋转的话,给大家来一组动态展示图吧
至此,到这里就结束了。说是讲 HashMap 的 结果却说了这么就的 数据结构,但是没办法啊,如果你不了解上面的东西。你可能不太能看懂下面的源码部分。
简介
说了那么多,终于到了正文了。 HashMap 实现了 Map 接口,JDK1.7由 数组 + 链表实现, 1.8后由 数组 + 链表 + 红黑树实现。是以 key,value 为形式的存储容器,且允许key 为null ,也允许 value 为 null 。该容器线程不安全。
JDK 1.7 的 HashMap 结构图
JDK 1.8 的 HashMap 结构图
HashMap 的基本元素
/**
* 默认的初始容量 1 << 4 == 2^4 = 16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大的容量 2^30
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的负载因子
* 负载因子表示了一个当前散列的使用程度
* 容器个数 size > 负载因子 * 数组长度 就需要进行扩容
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* JDK 1.8 新增
* 如果数组中某一个链表 >= 8 需要转化为红黑树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* JDK 1.8 新增
* 如果数组中某一个链表转化为红黑树后的节点 < 6 的时候 继续转为 链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* JDk 1.8 新增
* 如果当链表元素 >= 8 并且数组 > 64 的时候转化红黑树
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 由之前的 Entry 该变为 Node 类型,其实Node为Map.Entry的接口实现类。
* (Entry 为 Map 接口中的一个内部接口)
*/
transient Node<K,V>[] table;
// 记录键值对的个数
transient int size;
/**
* 记录集合中元素修改的次数
*/
transient int modCount;
/**
* 阈值 : 所能容纳的元素个数,当 size > threshold 的时候 就会扩容
* threshold = 负载因子 * 数组长度
*/
int threshold;
Node
static class Node<K,V> implements Map.Entry<K,V> {
// hash 值
final int hash;
final K key;
V value;
// 下节点指针
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
构造方法
HashMap()
public HashMap() {
// 默认负载因子为 0.75
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
HashMap(int initialCapacity)
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 指定初始容量值,默认负载因子 0.75
public HashMap(int initialCapacity, float loadFactor) {
// 初始容量不 < 0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 初始容量 > 2^30 则容量为 2^30
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 负载因子不能小0 || 没有值
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 负载因子
this.loadFactor = loadFactor;
// 阈值
this.threshold = tableSizeFor(initialCapacity);
}
HashMap(Map<? extends K, ? extends V> m)
// 基于Map 创建一个新的 HashMap
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
添加方法 put
public V put(K key, V value) {
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 为 null 新建一个table
if ((tab = table) == null || (n = tab.length) == 0)
// 采用 resize() 新建 table
n = (tab = resize()).length;
//根据 数据长度 和 hash 值 进行 与 运算得到一个数值下标,如果这个下标不存在元素则直接存储
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 已经存在元素
Node<K,V> e; K k;
// 存在冲突, 将指向第一个节点
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 {
// 循环链表
for (int binCount = 0; ; ++binCount) {
// 直到链表尾部还未有重复 key
if ((e = p.next) == null) {
// 新建节点存储
p.next = newNode(hash, key, value, null);
// 若链表长度 >= 指定值 8 - 1
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;
p = e;
}
}
// 有重复的 key , 则新值 替换 旧值
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;
}
若代码层面你还在懵懂,那么就用文字在梳理一下流程吧
调用 hash 获取 该 key 的 hash 值
判断 table 是否为 null。为 null 则新建一个 table 数组
根据 数组长度 和 哈希值 获取下标位置。如果该下标没有数据 则直接存储
(1)有数据的情况下 判断是否存在冲突,冲突直接获取此节点
(2)判断是否用红黑树存储,是则利用红黑树中的方法存储键值对。
(3)链表方式存储,循环链表,是否存在与其中数据冲突
I. 若循环结束没有数据冲突,则在尾部直接插入,同时判断 是否 >= 8 -1 转化红黑树
II. 若存在冲突的key, 则立刻结束循环,同时获取该节点
- 将新值 替换 旧值
注意一个点 : 如果是自定义类型作为 HashMap 的 key 时候,这个时候需要覆盖 hashCode 和 equals 方法。
原则:
覆盖hashCode方法 :须保证内容相同的元素返回相同的 hash 值,不同的元素,尽可能返回不同的hash值。
覆盖 equls 方法 : 内容相同的对象返回 true。
说了这么多,再来一点图吧,这样 三者 结合起来或许可以更透彻一些哦。话不多说,就直接上图吧。
当插入 key 为帅 的这条数据的时候,会通过 key 的 hash 和 数组长度 计算出一个下标。比如此时这个这个下标为 0 ,0下标位置此时刚好没有数据。那么这个数据就会直接插入在这里。 当第二次插入一个数据为 key帅子 的时候。又通过 key 的 hash 和 数组长度计算出下标为 2。此时这里数据没有数据,则直接插入。如图 1 所示。之前我们提到了一个链表。那么链表在哪里体现的呢。那么看下图 2 吧。 这个时候我们要插入一个 key 为 子帅的数据。在某种程序上。子帅 和 帅子 的 hash可能会一致。那么就会导致数据冲突。此时就采用了 拉链法 来解决冲突问题。这里需要特别注意一个问题: 上图展示的是 JDK 1.7 的插入方法 采用的是 头插法,也就是说 子帅 和 帅子 产生冲突后。 会将子帅放在头节点。它的后指针指向 帅子 。
为什么会在上面特意强调 JDK 1.7 采用的是头插呢 ? 难道之后不是这种插入了吗
这里就不在画 JDK 1.8 的图了。大家自行脑补,有点不厚道了。 1.8 和 上面的图没太大的区别,上面也强调了很多次。 链表 >= 8-1 && 数组长度 > 64 转化为 红黑树。 图大家脑补下吧。 这里重点来说说 尾插法
思考一个问题: 为什么要用尾插 ? 难道头插有缺点吗?
这个问题, 大家先思考下。在 resize() 的时候会带来详细的讲解。
获取方法 get
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// tab 不为 null 同时 table 长度 > 0 同时数组对应下对应下标内容不为 null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 如果数组对应下标的第一个节点 key 和 查找的 key相同
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
// 返回第一个节点
return first;
// 不同, 同时还有其他节点
if ((e = first.next) != null) {
// 采用红黑树存储
if (first instanceof TreeNode)
// 通过 红黑树获取 key 对用的 Node
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 链表形式, 遍历所有节点。
do {
// 如果查找到相同的 直接返回
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 没有则返回 null
return null;
}
- 如果 table 不为 null,且对用对应的存储下标不为 null
- 判断第一个节点是否相同。相同则返回
- 第一个节点不是所寻找数据。
- 是否为红黑树存储, 是则利用红黑树获取对应的 Node
- 链表存储,遍历链表,寻找相同的 key 返回 Node
- 如果 table 为 null , 或是没有对应的数据,返回 null
扩容 resize
JDK1.8 扩容
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// table 长度是否 > 0
if (oldCap > 0) {
// table 长度 >= 2^30 则无法扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
// 返回原有数组
return oldTab;
}
// 没有超过最大 空间,则扩大原有的 2 倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
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);
}
// 获取新的 阈值
threshold = newThr;
// 基于新的容量 创建新的 Node 数组 和 哈希表
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 遍历原有 table 重新计算每一个元素的位置
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 对应的下标元素不为null
if ((e = oldTab[j]) != null) {
oldTab[j] = 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;
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);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
JDK 1.7 扩容
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
// 扩容后的数组赋值
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
// 这里会重新计算 key 的值 。 Jdk 1.8 在这里做了改变
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
看来这么多源码, 是不是觉得这也太枯燥了吧,好像也就是似懂非懂的样子吧。很奇怪。没关系,还是用代码结合图文来看以看吧。在这里也会对之前的提到了为什么采用 尾插法 做出了解析
先看下单线程下的扩容吧
这是一个单线程下扩容。 如代码所讲,会将 数组扩容到原来的 2 倍,然后再遍历所有的节点,往新的数组赋值。这个时候有人会说了。为什么 C 跑到其他下标了 A 和 B 还在原来的下标呢
? 因为 下标是取决了 key 的 hash 和 数组长度的。数组都扩容了那么下标就会导致某种情况下的改动。 那是不是每一个Key都会进行一次哈希呢
原则上是这样的,但是在 1.8 后 做出了调整。不会导致去 rehash 因为每次都 hash 会消耗掉很多的性能。
多线程的扩容
先提前说明下上图中三个分别代表的意义
- 第一个是 线程 1 在执行完扩容后 被 CPU 调度挂起,开始在执行 线程2
- 第二个是 线程2 开始执行扩容并进行扩容后的数组赋值 执行完后 再去继续执行 线程1
- 第三个是 线程1的继续执行过程
那就来一一解释下吧
先提前说一个不愉快的事情,在看这段文字的时候,请先大致看一下扩容的源码还有上文中的 单线程以及多线程的扩容图。有个宏观的了解后再来品这段文字。某种程度上,小编不愿意将相同的事情重复很多次。请理解!!!
线程1执行完扩容方法后(这里是指执行完扩容那行代码,并不是整个扩容方法)被挂起,这里不多说自行看上面代码弥补。紧接着开始执行了线程2 。经过扩容,并且重新计算 下标的操作后。此时的状态变为了图2 右边所示的样子。原本的状态是 B 的 next 是指向 A 的经过线程2 的扩容导致 A 的next 指向了 B 此时。线程2 被挂起了,开始去执行线程1 此刻线程1 的 A 的next 已经不指向 null 了。经过 线程2 的处理 ,此刻 指向的是B 这个时候就产生了一个特别严重的问题 A 指向 B , B 指向A 形成了死循环。
所以就抛弃了头插法,引入了尾插法。为什么尾插法,不会引起这些问题呢。我想答案已经在你的脑海中了。如果你还没想明白。那么你就跟着小编这边文章的 添加 put 章节开始一步步的跟着用尾插法 插入值 再到扩容试试。所以这里我就不在过多介绍,想知道的小伙伴,已经跟着小编的思路在画图了哦。
后续
今天的文章就到这里了。我们下期再见。
上一篇: 我可以在目录中放入多少个文件?