一、HashMap使用的数据结构是哈希表
哈希表在JDK 8之前由数组和链表组成,JDK 8之后由数组、链表和红黑树组成
我们先来简单了解下哈希表的原理,下图中有一个数组,数组元素是键值对(为了对接HashMap,哈希表不一定存储键值对,其实可以简单的理解为:存储键值对的哈希表就是HashMap),当一个键值对到来时(西瓜是键,10块是值),通过一个哈希函数,计算出键(西瓜)所对应的哈希值(为3),将该键值对存储在数组下标为3的位置,按此原理继续添加几个键值对
一般来说,不同的键计算出来的哈希值是不相同的,但是也会有相同的情况,这种情况我们称为哈希碰撞,此时就会使用到链表将多个拥有相同哈希值的键值对连接起来
JDK 8以前:新元素存入数组,老元素挂在新元素后面
JDK 8以前:新元素直接挂在老元素后面
而在JDK 8之后,为了防止链表过长,导致降低查找效率,当链表长度大于8并且数组长度大于等于64时,链表就会自动的转化为红黑树
为什么需要哈希表?
我们想,如果上面的数组是一个水果的价格表,当我们要查找某个水果时,我们是不是只能从上到下逐一查看,才能找到我们要的那个水果的价格,此时的时间复杂度为O(n),如果通过哈希函数,直接计算出这个水果的哈希值,就直接确定了该水果在价格表中的位置,此时的时间复杂度就是O(1),效率大大提高
二、HashMap源码分析
上面讲到了HashMap的基本原理,那具体该如何通过代码实现呢?接下来就分析HashMap源码
HashMap 有个静态内部类 Node ,在HashMap中每个元素都是一个 Node ,并且 Node 实现了 Map中的Entry接口,Node中有四个变量
- hash:通过键计算出来的哈希值
- key、value:键、值
- next:用于形成链表的时候指向下一个Node
当链表转换为红黑树后,Node也会转换为TreeNode
- parent、left、right:分别记录父节点、左节点、右节点(prev先不用了解)
- red:记录当前节点是红色还是黑色
并且 TreeNode 继承了 LinkedHashMap 里面的 Entry,而LinkedHashMap.Entry又继承了HashMap.Node 因此 TreeNode 内部也含有键值对
在HashMap中有一个成员变量table,是一个Node数组,对应的就是上面讲的那个数组,
接下来看HashMap中定义的几个常量
- DEFAULT_INITIAL_CAPACITY:table的初始长度,1 << 4 , 1左移4位,就是16
- MAXIMUM_CAPACITY:table的最大长度
- DEFAULT_LOAD_FACTOR:扩容因子,0.75 表示当table的使用量达到 3/4 时,就进行扩容,扩容到之前的两倍
下面是HashMap的无参构造函数,只进行了一个赋值操作,并未创建table数组
table数组是在 put 的时候创建的,put方法调用了putVal方法,在putVal方法中传入了hash(key),这是调用hash方法计算出key的哈希值,参数onlyIfAbsent 选择 false 表示当传入一个表中已存在的键(值可以不同)时,覆盖掉原来的键值对,选择true不覆盖
方法的返回值是V,发生覆盖的时候,put返回被覆盖的值,没有发生覆盖则返回null
putVal的源码如下, 我在源码中通过注释进行了一些说明
为什么在HashMap里面就有一个全局table,在这还要一个局部的tab呢?
这是因为全局的table在对象中,需要跑到堆中访问,而方法中的局部变量存在于栈中,访问较块
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
//定义一个局部变量,用来记录哈希表中数组的地址值
Node<K,V>[] tab;
//临时的第三方变量,用来记录键值对对象的地址值
Node<K,V> p;
//表示当前数组长度
int n;
//表示索引
int i;
//这一步将哈希表中数组的地址值,赋值给局部变量tab
if ((tab = table) == null || (n = tab.length) == 0)
/**
数组对象的创建就是在resize方法中完成
resize方法主要完成了以下几个工作:
1.如果当前是第一次添加数据,底层会创建一个长度为16,加载因子为0.75的数组
2.如果不是第一次添加数据,会看数组中的元素是否到达了扩容的条件
如果没达到,不做任何操作,
如果达到,会新创建一个大小为原先两倍的新数组,并把数据全部转移到新数组中
*/
n = (tab = resize()).length;
/**
i = (n - 1) & hash 将 数组的长度减一 和 键的哈希值 进行 与运算,
得到当前键值对对象在数组中应存入的位置
如果该位置是null,则通过newNode方法创建Node对象直接放入数组
如果不是则走else
*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//判断要添加的键值对的键是否和数组中原本的键值对的键一样
//如果相等则说明需要覆盖,用e记录下被覆盖的键值对
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//判断数组中获取出来的键值对是不是红黑树中的节点
//如果是,则调用方法putTreeVal,把当前的节点按红黑树的规则添加到树当中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//如果不是红黑树,那就是链表,则按照下面的方法添加
else {
//这个for就是不断的往后查看节点
for (int binCount = 0; ; ++binCount) {
//如果已经走到最后一个节点,则直接创建节点挂在后面
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
/**
判断当前链表长度是否超过8,如果超过8,就会调用方法treeifyBin
treeifyBin方法的底层会继续判断数组的长度是否大于等于64
如果同时满足这两个条件,就会把这个链表转成红黑树
*/
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//判断当前正在查看的键值对的键跟要插入的键值对的键是否一样
//如果一样,则表明需要进行覆盖,执行break,此时e记录要被覆盖的键值对
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果e为null,表示当前不需要覆盖任何元素
//如果e不为null,那么记录的就是要被覆盖的键值对
//然后按照以下的方法进行覆盖,return 被替换的 值
if (e != null) { // existing mapping for key
V oldValue = e.value;
//onlyIfAbsent 就是传入的第四个参数
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//跟并发修改异常有关
++modCount;
//threshold:记录的就是数组的长度 * 0.75 ,可以理解为哈希表的扩容时机
if (++size > threshold)
resize();
//跟LinkHashMap有关
afterNodeInsertion(evict);
//表示当前没有覆盖任何元素,返回null
return null;
}