/ 前言 /
HashMap是Java开发中最常用的集合之一 , 其独特数据结构使其适用于大部分场景 , 比ArrayList及HashSet有着更广泛的应用空间 , 但是也因为其独特的数据结构使其源码异常复杂 , 尤其是JDK1.8版本后的HashMap,使用了更加复杂的数据结构 , 本文主要讲解的是JDK1.8的HashMap, 本文会涉及到的内容如下所示
数据结构分析(红黑树) |
---|
源码解析 |
JDK1.8中hash碰撞的处理方式 |
计算hash值的方式 |
HashMap的容量长度为什么一定要是2的非零次幂 |
JDK1.7 和 JDK1.8中HashMap的不同 |
HashMap最常见的问题 |
/ 1 / 数据结构
在JDK1.7
中HashMap的数据结构是数组 + 链表
, 而在JDK1.8
中则演化成了数组 + 链表 + 红黑树
的结构 , 这也是1.8中最大的更新 , 下面我们来探究一下为何要演化为数组 + 链表 + 红黑树
这样的数据结构
我们知道在1.7中当产生了hash碰撞
时便会将当前Entry
变成链表 , 单向链表查找除了head
节点外的时间复杂度都是O(n)
, 如果频繁的发生了hash碰撞
每次查找元素都是非常耗费时间的 , 所以为了避免这一现象1.8中引入了红黑树
红黑树的插入、查找的时间复杂度都是O(log n)
, 假如你的红黑树里面有256个数据 , 此时只需要8次就能找到目标数据 , 即使是65536个数据也只需要16次即可 , 效率相比链表而言提升的非常大
我们来看下HashMap转为红黑树后存储的数据结构图
现在我们知道了为何要引入红黑树 , 但是这就又衍生了一个问题 , 为什么不在一开始就使用红黑树来直接替代链表呢? 这个问题的答案在源码解析的核心参数中会有详细的解释
这篇文章的重点在于HashMap,有关于红黑树的介绍就暂时到此为止了 , 有兴趣的朋友可以网上找找红黑树的资料 , 我的笔记还在整理中…
/ 2 / 源码解析
2 . 1 核心参数
//默认初始化table数组容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//table最大容量1073741824
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子, 即当现有数组长度达到容量的75%时会进行扩容操作
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//1.8新增 当链表的长度 >=8 - 1 时会转换为红黑树, 关于为什么要定义为8的详细解读在下面↓
static final int TREEIFY_THRESHOLD = 8;
//1.8新增 当红黑树的长度 <=6 时会转换为链表, 关于为什么红黑树 → 链表的阈值是6的详细解读在下面↓
static final int UNTREEIFY_THRESHOLD = 6;
//1.8新增 红黑树的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
//定义一个类型为Node<K,V>的table数组
transient Node<K,V>[] table;
//table数组的长度
transient int size;
//实际的扩容的阈值 threshold = 容量 * 加载因子
//在构造器中会被初始化为DEFAULT_INITIAL_CAPACITY的值16
//在第一次存储数据时会在inflateTable()方法中再次赋值threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
int threshold;
//实际的加载因子, 在构造器中进行初始化
//如果创建HashMap时没有指定loadFactor的大小则会初始化为DEFAULT_INITIAL_CAPACITY的值
final float loadFactor;
//HashMap更改的次数
//用来作为并发下判断是否有其它线程修改了该HashMap,抛出ConcurrentModificationException
transient int modCount;
//在初始化时指定初始长度及加载因子的构造器
public HashMap(int initialCapacity, float loadFactor) {
...
}
//在初始化时指定初始长度的构造器
public HashMap(int initialCapacity) {
//这里调用的其实还是上面的构造器
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//什么也不指定的构造器 , 这里不像1.7中还是去调用了有参构造器 , 具体原因下面会有分析
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
我们来重点介绍几个核心参数
TREEIFY_THRESHOLD
这个参数是链表转换成红黑树的阈值 , 但是为什么是8呢?以及我们来看一下上面提到的关于为何不在一开始就用红黑树来替代链表
-
问 : 为什么不在一开始就使用红黑树来替代链表?
答 : 我们来看一段HashMao源码中的注解
* Because TreeNodes are about twice the size of regular nodes, we * use them only when bins contain enough nodes to warrant use * (see TREEIFY_THRESHOLD). And when they become too small (due to * removal or resizing) they are converted back to plain bins.
在该注解中详细的说明了相同数据量下红黑树(TreeNode)占用的空间是链表(Node)的俩倍 , 考虑到时间和空间的权衡 , 只有当链表的长度达到阈值时才会将其转成红黑树
-
问 : 为什么链表 → 红黑树的阈值是8呢?
答 : 我们继续来看一段注解
* In usages with well-distributed user hashCodes, tree bins are * rarely used. Ideally, under random hashCodes, the frequency of * nodes in bins follows a Poisson distribution * (http://en.wikipedia.org/wiki/Poisson_distribution) with a * parameter of about 0.5 on average for the default resizing * threshold of 0.75, although with a large variance because of * resizing granularity. Ignoring variance, the expected * occurrences of list size k are (exp(-0.5) * pow(0.5, k) / * factorial(k)). The first values are: * * 0: 0.60653066 * 1: 0.30326533 * 2: 0.07581633 * 3: 0.01263606 * 4: 0.00157952 * 5: 0.00015795 * 6: 0.00001316 * 7: 0.00000094 * 8: 0.00000006 * more: less than 1 in ten million
HashMap的作者认为在理想的情况下随机hashCode算法下所有节点的分布频率会遵循泊松分布(Poisson distribution) , 上面也列举了链表长度达到8的概率是0.00000006,也就是说我们几乎不可能会使用到红黑树 , 所以作者使用8作为一个分水岭
UNTREEIFY_THRESHOLD
上面已经解释了为何链表 → 红黑树的阈值是8,这里我们来解释一下为何链表 → 红黑树的阈值却是6
问 : 为何链表 → 红黑树的阈值是6
答 : 假设UNTREEIFY_THRESHOLD的 = 7
, 当我们有频繁的添加和删除操作时 ,
hash碰撞产生的节点数量 一旦在7附件徘徊就会造成红黑树和链表的频繁转
换 , 此时我们大多数的性能就都耗费在了链表 → 红黑树和
红黑树 → 链表` ,
这样反而就得不偿失了 , 所以作者将长度为7作为一个缓存地段从而选取了6
作为红黑树 → 链表的阈值
loadFactor
我们通常使用的构造器都是最后一个构造器 , 什么都不会传 , 如果我们需要更改加载因子的话需要注意几个点
- 加载因子并不是越大越好的 , 虽然加载因子越大就意味着HashMap的实际容量越大 , 扩容的次数越少 , 但是因为实际存储的数据大了 , 俩个相同容量的HashMap加载因子越大的那个读取的速度更慢 , 所以我们需要根据自己的实际使用情况来进行判断 , 是要存储更多的数据呢 , 还是要更快的读取速度
- 加载因子是会影响到扩容的次数的 , 如果加载因子太小的话HashMap会频繁的进行扩容 , 导致在存储的时候性能下降
- 如果我们在创建HashMap时就已经知道了要存储的数据量 , 那么我们完全可以通过实际存储数量 ÷ 0.75来计算出我们初始化的HashMap容量 , 这样可以避免HashMap再进行扩容操作 , 提升代码效率
modCount
关于modCount这里做一下解释 , 这个元素是用来干什么的呢?
我们知道HashMap不是线程安全的 , 也就是说你在操作的同时可能会有其它的线程也在操作该map,那样会造成脏数据 , 所以为了避免这种情况发生HashMap、ArrayList等使用了fail-fast策略 , 用modCount来记录修改集合修改次数
我们在边迭代边删除集合元素时会碰到一个异常ConcurrentModificationException
, 原因是不管你使用entrySet()
方法也好 , keySet()
方法也好 , 其实在for循环的时候还是会使用该集合内置的Iterator
迭代器中的nextEntry()
方法 , 如果你没有使用Iterator
内置的remove()
方法 , 那么迭代器内部的记录更改次数的值便不会被同步 , 当你下一次循环时调用nextEntry()
方法便会抛出异常
//篇幅有限, 这里只贴出了部分源码
private abstract class HashIterator<E> implements Iterator<E> {
Entry<K,V> next; // next entry to return
int expectedModCount; // For fast-fail
int index; // current slot
Entry<K,V> current; // current entry
HashIterator() {
//在构造器中初始化了expectedModCount = modCount
expectedModCount = modCou