JDK11版本
今天看到篇文章如何阅读JDK源码,受益良多
今后读源码应该带着问题来读,而不是为了读而读!
思考问题既要横向比较(HashMap,ConcurrentHashMap或者其他map之间的区别),也要纵向比较(不同JDK版本的HashMap有什么变化)
先提出几个面试经常提到的问题,再带着问题来阅读:
1.HashMap是线程安全的吗?如果不是,不安全体现在哪里?
2.HashMap的Put,Get,Remove步骤是怎样?
3.扩容时候策略?
4.设计一个HashMap要考虑些什么?
5.hashmap允许null的key和value吗
- UML 图
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
继承图如上
好接下来我们看看基本参数:
为什么右移30位?
int有符号4byte,32位,第一个位是符号位,所以最多只能右移30位。
static final int MAXIMUM_CAPACITY = 1 << 30; // 哈希数组最大容量
为什么数组容量为2的幂次?初始值为何为16?
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 16; // 哈希数组默认容量
16其实是比较折中的做法,阿里巴巴JAVA开发手册有提到这个初始值是怎么设置的:
这就要从hashcode如何找到对应的槽说起了。
首先呢Key的hashcode会与自身高16位做异或操作得出hash值,就是调用下面这个hash函数。
/*
* 计算key的哈希值,在这个过程中会调用key的hashCode()方法
*
* key是一个对象的引用(可以看成地址)
* 理论上讲,key的值是否相等,跟计算出的哈希值是否相等,没有必然联系,一切都取决于hashCode()这个方法
*/
static final int hash(Object key) {
int h;
return (key == null)
? 0
: (h = key.hashCode()) ^ (h >>> 16);
}
为什么要这么设计啊?我从这篇博客找到了供参考的答案:
https://www.cnblogs.com/yuexiaoyun/p/12158914.html
由于和(length-1)运算,length 绝大多数情况小于2的16次方。所以始终是hashcode 的低16位(甚至更低)参与运算。要是高16位也参与运算,会让得到的下标更加散列。
所以这样高16位是用不到的,如何让高16也参与运算呢。所以才有hash(Object key)方法。让他的hashCode()和自己的高16位^运算。所以(h >>> 16)得到他的高16位与hashCode()进行^运算。
为什么用^而不用&和|
因为&和|都会使得结果偏向0或者1 ,并不是均匀的概念,所以用^。
然后怎么找到在哪个槽呢?
JDK8有个函数叫indexFor,JDK11删掉了
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
但是相同的逻辑依然可以在getNode()函数中找到
说白了就是槽的索引等于Key的hash与桶的容量-1做与操作
而2^n-1二进制表示都是1111111…
假如不是2的整数次方,length为15,则length-1为14,对应的二进制为1110,在于h与操作,最后一位都为0,而0001,0011,0101,1001,1011,0111,1101这几个位置(即最后一个位置为0的hash对应的槽)永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费。也就是说设置2的N次方,可以使得数据分布更加分散,减少碰撞
为什么承载因子为0.75?过低过高的缺点?
static final float DEFAULT_LOAD_FACTOR = 0.75f; // HashMap默认装载因子(负荷系数)
而0.75是比较综合考虑空间和时间的做法:
JDK官方文档中提到
As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs.
Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put).
loadFactor太大,比如等于1,那么就会有很高的哈希冲突的概率,会大大降低查询速度。
loadFactor太小,比如等于0.5,那么频繁扩容,用了一半就扩容,大大浪费空间。
而且0.75乘以2^n都是整数
注释有提到:
其实节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素个数和概率的对照表。当使用0.75作为承载因子的时候,桶中元素超过8个的几率已经是低于千万分之一了。也只有在超过8个的时候,链表才转成红黑树。所以其实转成红黑树的概率是非常非常低的。
红黑树链表转换阈值
static final int TREEIFY_THRESHOLD = 8; // 大于等于8转为红黑树
static final int MIN_TREEIFY_CAPACITY = 64; // 哈希数组的容量至少增加到此值,且满足TREEIFY_THRESHOLD的要求时,将链表转换为红黑树
static final int UNTREEIFY_THRESHOLD = 6; // 哈希槽(链)上的红黑树上的元素数量减少到此值时,将红黑树转换为链表
如果你只是背面试题的话一般会说大于等于8转成红黑树,这么说是不严谨的,其实要容量至少增加到64并且链表个数大于等于8才可以转成红黑树,如果少于64其实是尝试通过扩容来解决问题。
而且如果没看过源码,一般你会认为少于8个就转回链表,事实上少于6个才转回链表,而且并不是删除元素的时候转换!
分为两种情况讨论:
1.调用map的remove方法:
不会利用threshold来判断转换
if(root == null || (movable && (root.right == null || (rl = root.left) == null || rl.left == null))) {
// 遍历红黑树first上所有元素,创建一个链表
tab[index] = first.untreeify(map); // too small
return;
}
2.resize的时候对红黑树进行重新划分
这个时候才会利用threshold进行判断
if(lc<=UNTREEIFY_THRESHOLD) {
// 遍历红黑树loHead上所有元素,创建一个链表
tab[index] = loHead.untreeify(map);
为什么是8?
泊松分布,上面有讲,超过8的概率是百万分之一
那为什么是6呢?
我也找不到很好的从数学理论证明的答案,可以讨论讨论
哈希数组
我们重点看一下这个Node,这个是数组的基本存储单位
transient Node<K,V>[] table; // 哈希数组(注:哈希数组的容量跟HashMap可以存储的元素数量不是一回事)
每个Node的数据结构是这样的:
// HashMap中的普通结点信息,每个Node代表一个元素,里面包含了key和value的信息
static class Node<K, V> implements Map.Entry<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next;
构造函数
我们只挑重点的几个来看
// 初始化一个哈希数组容量为initialCapacity,装载因子为loadFactor的HashMap
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0) {
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
}
if (initialCapacity > MAXIMUM_CAPACITY) {
initialCapacity = MAXIMUM_CAPACITY;
}
if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
}
// 初始化装载因子
this