写在前面:
最近在查漏补缺中,我以为自己对于Java集合部分掌握的已经不错了,结果很偶然的看到了几道面试题,害,打击到了自己,决定重新回归本质,好好研究一下源码。
直接给看几道面试题(能完美回答的说明这部分知识掌握的不错)
- HashMap的实现原理了解吗?说说为什么要这样实现?
- table的初始化时机是什么时候?初始化时table.length的长度是多少?
- 什么时候触发扩容?阈值时多少?
- table会发生扩容,那么为什么每次扩容都是2的次方数?
- HashMap中是如何求索引的位置的?通过什么算法(table.length - 1 & hash)?为什么这样设计?
- HashMap初始化时传入的参数是1000,那么我们实际上添加多少个元素的时候就会发生扩容?
看完这个几个面试题,我受到了暴击,有些东西都懂一点,但是深入到内部,就又有点不太清晰,于是这篇博文就来了,从源码级别剖析HashMap的实现原理。
最后多嘟嘟一句:我啥时候才能受到offer啊,害,我太难了
基础入门
1.数组的优劣势
- 内存是连续的,内存的大小是固定的
- 能通过索引访问随机位置的值
- 查找快,插入元素慢
2.链表的优劣势
- 内存不连续,可以动态扩容内存
- 链表中不能通过索引访问,维护的是指向下一个链表的地址值
- 链表关于插入删除的操作速度快,但是对于查找的效率比较低,因为不能通过索引访问,需要遍历链表
3.有没有一种数据结构能够整合数组和链表的优点
有,通过散列表的方式。
散列表整合了数组能够通过索引访问的特点
和链表能够动态扩容
的特点
4.散列表有什么特点
散列表的特点:
- 既能够通过索引访问
- 还能动态的扩容
因此索引查找和插入,添加的时候的速度都是很高效的
5.什么是哈希
核心理论:
Hash也称为散列,哈希,对应的英文字母是都是Hash基本原理就是把任意长度
的输入,通过Hash算法变成固定长度
的输出。这个映射规则对应的就是Hash算法,而原始数据映射后的二进制串
就是哈希值。
Hash的特点:
- 从hash值不可以
反向推导
出原始的数据(因为不同的原始数据可能对应的hash值是相同的) - 输入数据的
微小变化
会得到不同的Hash值,相同的数据会得到相同的Hash值 - 哈希算法的执行效率要
高效
,长的文本也能快速地计算出哈希值 - hash算法的
冲突
概率要小
由于hash的原理是将输入空间
的值映射成hash空间
内,而hash值的空间远小于输入空间。
根据抽屉原理
,一定会存在不同的输入空间的值对应的是同一个hash值。
抽屉原理:10个苹果放在9个抽屉里,那么至少有一个抽屉放不少于2个苹果。
Hashmap原理
1.HashMap的继承体系
2.Node数据结构分析
在HashMap的内部持有一个静态内部类Node,分析一下这个静态内部类
主要关注他的属性:
hash
:存储的是key的哈希值经过扰动之后的结果
key
:就是我们HashMap执行put操作时的key
value
:存储的是原始数据的value值
next
:因为哈希是存在哈希碰撞的情况的,在哈希碰撞时,我们的散列表会将发生碰撞的元素,存储在链表的下一个元素,该Node就是存储这个下一个元素的节点。
3.底层数据结构
HashMap的底层实现在JDK1.8之后就是数组+链表+红黑树
4.put数据原理分析
5.什么是哈希碰撞?
哈希碰撞就是不同的原始数据通过了哈希算法之后得到了相同的桶的位置,也就是不同的数据的通过哈希算法之后得到的哈希值通过路由寻址公式得到的地址值是相同的,那么就发生了hash碰撞。
6.什么是链化?
发生了哈希碰撞之后,将新发生了hash碰撞的位置的元素插入到该位置的下一个位置,让之前的位置的元素的next指向新的元素。
7.JDK8为什么引入红黑树
如果链表链化程度过高,那么查询元素的时间复杂度就为O(n)的,不能高效的实现查询操作,为了提高查询的效率,引入了红黑树,能够更好的实现查询。
8.HashMap扩容原理
首先谈一下为什么要扩容,是因为如果在hashMap中的数组的长度比较短,那么链化程度和树化程度比较高,而我们期望的是查找的效率能尽可能的接近数组的查询效率(O(1)),因此选择扩容数组的长度,当数组的长度发生了扩容时,那么路由算法能够分配的地址值变多,那么链化和树化的可能性减小,发生哈希碰撞的可能性减小,那么查询的效率就更高。思路就是空间换时间的思路
总结:扩容就是为了减少哈希碰撞,提高查询的效率
HashMap可以自动的发生动态的扩容,每次扩容的大小是将原数组的大小左移1,也就是扩容为原来的2倍。
那么既然扩容数组的长度会让查询的效率提高,那么为什么不初始化一个很大的数组?
其实这个问题就又回到了为什么不直接选用数组作为hashmap的存储的数据结构,因为数组虽然说查询的效率很高,但是插入和删除的效率很低,如果仅仅为了查询的效率那么插入和删除等操作的时间复杂度就退化为O(n)的
源码解析
重要的常量
DEFAULT_INITAL_CAPACTTY
:默认初始化大小,默认是1<<4,也就是16MAXMUM_CAPACITY
:数组的最大容量 1<<30DEFAULT_LOAD_FACTOR
:默认负载因子TREEIFY_THRESHOLD
:树化阈值(哈希碰撞之后会形成链表,那么将链表要转化为树也是有条件的,条件之一就是链表的长度大于8)UNTREEIFY_THRESHOLD
:链化阈值(数据结构树化之后,我们对hashmap可能有删除操作,让其的树的节点数小于6,那么就转为链表)MIN_TREEIFY_CAPACITY
:最小树化的数组大小,树化的另一个参数(也就是说要链表树化,那么既需要链表的节点数大于8,同样需要数组的长度大于64)
重要的属性:
table
:就是hashMap中维护的散列表size
:哈希表中的元素个数modcount
:当前哈希表结构
的修改次数,结构修改指的是插入,删除元素,修改(替换)并不是结构的修改threshold
:扩容阈值,当哈希表中的元素超过阈值时,就会触发扩容loadFactor
:负载因子,作用就是
threshold = capacity * loadFactor
扩容阈值 = 当前哈希表的数组长度 * 负载因子
比如 当前哈希表的数组长度是16 * 0.75
那么扩容阈值就是12,而每次数组扩容都会扩容为原数组长度的2倍
hashMap的构造方法
共重载了4个重要的初始化方法
- HashMap(int initialCapacity,float loadFactor)
initialCapacity
:初始化数组长度loadFactor
:给出负载因子
这里要注意,并不是将我们传入的初始化数组长度直接当做了HashMap的初始化数组长度,因为我们的数组长度要求必须是2的次方数
tableSizeFor()方法的作用:返回一个大于等于当前数组长度的一个数字,该数组一定是2的次方数,返回的这个数作为了扩容阈值。
/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
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;
}
注意该tableSizeFor()方法传入的就是我们初始化数组(我们自己传入的数组),返回的结果是数组的扩容阈值
之所以要将传入的数先减1,是因为如果不减1,那么返回的数会是我们期望得到的数的2倍的数
具体得到该数的算法是根据位运算,分别右移1,2,4,8,16
- HashMap(int initialCapacity)
只给出了初始化数组长度,使用的是默认的负载因子
- HashMap()
- 不指定初始化数组长度,但是使用的是默认的负载因子0.75
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
- HashMap(Map<? extends k,? extemds V>)
- 使用map去构建一个map
/**
* Constructs a new <tt>HashMap</tt> with the same mappings as the
* specified <tt>Map</tt>. The <tt>HashMap</tt> is created with
* default load factor (0.75) and an initial capacity sufficient to
* hold the mappings in the specified <tt>Map</tt>.
*
* @param m the map whose mappings are to be placed in this map
* @throws NullPointerException if the specified map is null
*/
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
HashMap的put方法==>putVal方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab 代表的是hashMap的散列表
//n代表的是散列表的长度
//i代表的是经过哈希函数扰动 路由算法之后得到的桶的位置
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果散列表还没进行初始化,那么先初始化散列表(调用的是resize方法)
//这也就说明了其实HashMap是懒加载机制,只有第一次向其中put元素的时候才发生扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//根据key值计算哈希函数并扰动之后 通过路由算法得到桶的位置,也就是要插入的散列表的位置索引
//如果该位置是null,也就是表示当前桶位置没有元素,那么直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//当前桶位置是有元素的
else {
Node<K,V> e; K k;
//如果桶位置有元素,并且key值相同,那么就直接覆盖该位置的元素
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果该位置是红黑树的结果,那么就调用红黑树的put方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//如果key不相同,而且不属于树型结构,那么肯定是属于链表的结构
else {
for (int binCount = 0; ; ++binCount) {
//遍历链表,当链表的next节点是null,也就是链表达到了末尾时,那么就将当前Node元素插入
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;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//结构变化计数+1
++modCount;
//如果添加之后桶的长度大于扩容阈值,那么触发扩容方法
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
HashMap的resize方法(扩容方法)
先谈一下为什么要扩容?
这其实是一个比较傻的问题,因为容量不够才要扩容。其实也不全是因为这样,当我们第一次向HashMap中添加元素的时候会发生扩容,这个时候是因为HashMap的容量是不够的。那么之后的扩容又是因为什么呢?
先思考一下HashMap的结构:
是由数组+链表+红黑树实现的
但是我们初始化HashMap的时候(也就是第一次扩容Node数组之后),数组的长度是16,这个时候如果我们需要大量的添加元素,这时候链化和树化的程度会很高,我们期望的是HashMap的查找效率接近O(1),而当添加很多元素时,必然会发生哈希碰撞导致链化和树化程度很高,这时我们期望的是降低哈希碰撞发生的几率,从而使查询的效率更高。
扩容方法是如何实现的?
final Node<K,V>[] resize() {
//oldTab指的是散列表的数组
Node<K,V>[] oldTab = table;
//数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//数组的扩容阈值
int oldThr = threshold;
int newCap, newThr = 0;
//如果HashMap的散列表已经初始化过了
if (oldCap > 0) {
//如果初始化散列表的数组的长度大于散列表允许的最大长度
//那么就将最大的长度赋值,并不会发生扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果hashMap初始化过,而且新的数组长度(通过旧数组长度左移1位(*2)之后的长度小于2)并且酒数组的长度大于等于16(也就是已经经过了初始化),那么新数组就扩容为之前的2倍,并且将新的扩容阈值也变为旧扩容阈值的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//计算新的扩容阈值(该分支对应的是HashMap构造是传入了初始化数组大小)
//既然传入了初始化扩容的大小,我们就计算出再次触发扩容时的大小
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// HashMap还没有被初始化过 那么就执行HashMap的初始化
//具体的就是扩容为 16 然后计算新的扩容阈值
//oldCap == 0
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;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//如果HashMap已经初始化过了(因为已经初始化过了,那么再次扩容的话需要将原来HashMap的元素重排)
//为什么要重排?重排,让元素分布的更加均匀,查询的效率会更高
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
//e代表当前节点(头结点也就是桶的第一个元素节点)
Node<K,V> e;
//遍历HashMap的散列表,说明桶中有数据,但是具体是单个元素,链表还是红黑树不知道
if ((e = oldTab[j]) != null) {
//将原数组中的桶都赋值为null,方便JVM回收
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;
//如果链表中的元素的哈希值 通过& 旧数组的结果只能是 0或者其他数
//如果结果是0 ,那么它就是低位链
//比如 hash-> .... 0 1111
// hash-> .... 1 1111
//我们的数组长度假如是16 1 0000
//那么计算的结果只能是 0 或者16
//如果是0 ,那么就插入到低位链中
//如果是其他说那么就插入到高位链中
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;
}
关于HashMap扩容桶的结构时链表时
其实就是要看key高一位的哈希值是0 还是1,如果是1就放入到高位链,如果是0,那就放在低位链
HashMap的get方法
get方法的思路的思路比较简单:
当桶不为null时
- 查找的元素是桶位的元素(也就是遍历数组得到的元素就是我们要查询的元素),那么直接返回
- 查找的元素是树型,就调用树的查找的方法
- 如果是链表就调用链表的查找方法(遍历链表)
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* Implements Map.get and related methods
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
//tab代表的是当前的数组
//first代表桶位的第一个元素
//e临时变量 存储的是first节点的下一个元素
//n数组的长度
Node<K,V>[] tab; Node<K,V> first, e; 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) {
//如果是树型实现就调用红黑树查找元素的方法
if (first instanceof TreeNode)
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;
}
题外话
身边一些报培训班的同学都陆续找到工作了,而且很多人还拿到了很高薪的offer,我真的羡慕了。害,搞的我这自学的最近都有点迷茫了,不知道自己该怎么努力了。害,什么时候才有属于我的offer,春招吗?