因为热爱所以坚持,因为热爱所以等待。熬过漫长无戏可演的日子,终于换来了人生的春天,共勉!!!
1.HashMap概述
从继承体系可以看出:
- HashMap 实现了Cloneable接口,可以被克隆
- HashMap 实现了Serializable接口,属于标记性接口,HashMap 对象可以被序列化和反序列化。
- HashMap 继承了AbstractMap,父类提供了 Map 实现接口,具有Map的所有功能,以最大限度地减少实现此接口所需的工作。
/*
* 序列化版本号
*/
private static final long serialVersionUID = 362498820763181265L;
/**
* HashMap的初始化容量(必须是 2 的 n 次幂)默认的初始容量为16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/**
* 最大的容量为2的30次方
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的装载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 树化阈值,当一个桶中的元素个数大于等于8时进行树化
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 树降级为链表的阈值,当一个桶中的元素个数小于等于6时把树转化为链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 当桶的个数达到64的时候才进行树化
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* Node数组,又叫作桶(bucket)
*/
transient Node<K,V>[] table;
/**
* 作为entrySet()的缓存
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* 元素的数量
*/
transient int size;
/**
* 修改次数,用于在迭代的时候执行快速失败策略
*/
transient int modCount;
/**
* 当桶的使用数量达到多少时进行扩容,threshold = capacity * loadFactor
*/
int threshold;
/**
* 装载因子
*/
final float loadFactor;
(1)容量:容量为数组的长度,亦即桶的个数,默认为16 ,最大为2的30次方,当容量达到64时才可以树化。
(2)装载因子:装载因子用来计算容量达到多少时才进行扩容,默认装载因子为0.75。
(3)树化:树化,当容量达到64且链表的长度大于8时进行树化,当链表的长度小于6时可能反树化。
面试问题:
- 为什么集合的初始化容量(DEFAULT_INITIAL_CAPACITY)必须是 2 的 n 次幂?
// 默认的初始容量是16 1 << 4 相当于 1*2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
HashMap 构造方法可以指定集合的初始化容量大小,如:
// 构造一个带指定初始容量和默认负载因子(0.75)的空 HashMap。
HashMap(int initialCapacity)
根据上述讲解我们已经知道,当向 HashMap 中添加一个元素的时候,需要根据 key 的 hash 值,去确定其在数组中的具体位置。HashMap 为了存取高效,减少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现的关键就在把数据存到哪个链表中的算法。
这个算法实际就是取模,hash % length,而计算机中直接求余效率不如位移运算。所以源码中做了优化,使用 hash & (length - 1),而实际上 hash % length 等于 hash & ( length - 1) 的前提是 length 是 2 的 n 次幂
。
例如,数组长度为 8 的时候,3 & (8 - 1) = 3,2 & (8 - 1) = 2,桶的位置是(数组索引)3和2,不同位置上,不碰撞。
再来看一个数组长度(桶位数)不是2的n次幂的情况:
从上图可以看出,当数组长度为9(非2 的n次幂)的时候,不同的哈希值hash, hash & (length - 1) 所得到的数组下标相等(很容易出现哈希碰撞)。
小结一下HashMap数组容量使用2的n次幂的原因:
- 如果创建HashMap对象时,输入的数组长度length是10,而不是2的n次幂会怎么样呢?
HashMap<String, Integer> hashMap = new HashMap(10);
HashMap双参构造函数会通过tableSizeFor(initialCapacity)方法,得到一个最接近length且大于length的2的n次幂数(比如最接近10且大于10的2的n次幂数是16)
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;
}
说明:
当在实例化 HashMap 实例时,如果给定了 initialCapacity,由于 HashMap 的 capacity 必须是 2 的幂,因此这个方法tableSizeFor(initialCapacity);用于找到大于等于 initialCapacity 的最小的 2 的幂。
分析:
1.int n = cap - 1;为什么要减去1呢?
防止 cap 已经是 2 的幂。如果 cap 已经是 2 的幂,又没有这个减 1 操作,则执行完后面的几条无符号操作之后,返回的 capacity 将是这个 cap 的 2 倍(后面还会再举个例子讲这个)。
2.最后为什么有个 n + 1 的操作呢?
如果 n 这时为 0 了(经过了cap - 1后),则经过后面的几次无符号右移依然是 0,返回0是肯定不行的,所以最后返回n+1最终得到的 capacity 是1。
3.注意:容量最大也就是 32bit 的正数,因此最后 n |= n >>> 16;最多也就 32 个 1(但是这已经是负数了,在执行 tableSizeFor 之前,对 initialCapacity 做了判断,如果大于MAXIMUM_CAPACITY(2 ^ 30),则取 MAXIMUM_CAPACITY。如果等于MAXIMUM_CAPACITY,会执行位移操作。所以这里面的位移操作之后,最大 30 个 1,不会大于等于 MAXIMUM_CAPACITY。30 个 1,加 1 后得 2 ^ 30)。
完整例子:
所以由结果可得,当执行完tableSizeFor(initialCapacity);方法后,得到的新capacity是最接近initialCapacity且大于initialCapacity的2的n次幂的数。
-
存储结构:
-
HashMap 基于哈希表的 Map 接口实现,是以 key-value 存储形式存在,即主要用来存放键值对。HashMap 的实现不是同步的,这意味着它不是线程安全的。它的 key、value 都可以为 null,此外,HashMap 中的映射不是有序的。
-
jdk1.8 之前 HashMap 由 数组 + 链表 组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突(两个对象调用的 hashCode 方法计算的哈希值经哈希函数算出来的地址被别的元素占用)而存在的(“拉链法”解决冲突)。jdk1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为 8 )并且当前数组的长度大于 64 时,此时此索引位置上的所有数据改为使用红黑树存储。
-
2.HashMap扩容机制
-
HashMap 默认初始桶位数16,如果某个桶中的链表长度大于8,则先进行判断:
-
如果桶位数小于64,则先进行扩容(2倍),扩容之后重新计算哈希值,这样桶中的链表长度就变短了。【定位桶的方式:通过数组下标 i 定位,添加元素时,目标桶位置 i 的计算公式,i = hash & (cap - 1),cap为容量】
-
如果桶位数大于等于64,且某个桶中的链表长度大于8,则对链表进行树化(红黑树,即自平衡的二叉树)
-
如果红黑树的节点数在小于等于6时,红黑树可能会重新变会链表。
我们来分析分析上面这个逻辑,进入这个untreeify() 的要求是,root == null, root.right null, root.leftnull, root.left.left==null四种情况,我们以7个节点的红黑树来分析,A为root节点。
1.最多节点情况:当我们删除节点D时,满足root.left.left==null这个条件,此时节点数为6,这棵树要进行非树化;而如果选择删除G节点,这时节点数也为6,但是不用退树化,最大节点数为6。
2.最少节点情况:当EFG不存在时,在A,B,C,D中删除任意一个节点,都会满足上述四种规则中的一种。则存在最少节点情况,有4个节点,此时不会树化。
- 以上情况都是会将树转化成链表,此时的节点是 4<= nodes <=6 ,由此可以看出,当节点数在小于6时,是可能转化成链表,但不是绝对情况, 所以使用定义的变量(固定数量6)也不正确。只好通过判断去动态获取节点数。
节点数量原因分析
为什么在小于6的时候可能转换成链表,而在大于8的时候转化成红黑树?
主要通过时间查询节点分析,红黑树的平均查询时间为 log(n), 而链表是O(n),平均是O(n)/2。
- 当节点数为8时,红黑树查询时间3,链表查询时间是4, 可以看出来当红黑树查询效率大于了链表。(两个函数曲线问题,当节点更多是,比红黑树需要的时间更多)
- 当节点数为6时,为什么转换成链表,我认为主要时因为节点数太少,如果还是用红黑树,为了维持红黑树的特点,则需要翻转,左旋,右旋,等,更消耗性能。
3.为什么优先扩容桶位数(数组长度),而不是直接树化?
原因:
- 当桶位数(数组长度)比较小时,应尽量避开红黑树结构,这种情况下变为红黑树结构,反而会降低效率。因为红黑树需要逬行左旋,右旋,变色这些操作来保持平衡。同时数组长度小于64时,搜索时间相对要快些。所以结上所述为了提高性能和减少搜索时间,底层阈值大于8并且数组长度大于64时,链表才转换为红黑树
- 而当阈值大于 8 并且数组长度大于 64 时,虽然增了红黑树作为底层数据结构,结构变得复杂了,但是,长度较长的链表转换为红黑树时,效率也变高了。
4.HashMap存储数据的过程详解
-
首先,HashMap<String, Integer> hashMap = new HashMap();当创建 HashMap 集合对象的时候,HashMap 的构造方法并没有创建数组,而是在第一次调用 put 方法时创建一个长度是16 的数组(即,16个桶) ,Node[] table (jdk1.8 之前是 Entry[] table)用来存储键值对数据。
-
当向哈希表中存储put(“a”, 3) 的数据时,根据"a"字符串调用 String 类中重写之后的 hashCode() 方法计算出哈希值,然后结合数组长度(桶数量)采用某种算法计算出向 Node 数组中存储数据的空间索引值(比如table[i],这里的i就是该Node数组的空间索引)。如果计算出的索引空间没有数据(即,这个桶是空的),则直接将<“a”, 3>存储到数组中。
-
当向哈希表中存储数据<“b”, 4>时,假设算出的 hashCode() 方法结合数祖长度计算出的索引值也是3,那么此时数组空间不是 null(即,这个桶目前不为空),此时底层会比较 "a"和 “b” 的 hash 值是否一致,如果不一致,则在空间上划出一个结点来存储键值对数据对 <“b”, 4>,这种方式称为拉链法。
-
当向哈希表中存储数据 <“a”, 88888> 时,那么首先根据 "a"调用 hashCode() 方法结合数组长度计算出索引肯定是 3,此时比较后存储的数据"a"和已经存在的数据的 hash 值是否相等,如果 hash 值相等,此时发生哈希碰撞。那么底层会调用 "a"所属类 String 中的 equals() 方法比较两个内容是否相等:
-
相等:将后添加的数据的 value 覆盖之前的 value。
-
不相等:继续向下和其他的数据的 key 进行比较,如果都不相等,则划出一个结点存储数据,如果结点长度即链表长度大于阈值 8 并且数组长度大于 64 则将链表变为红黑树。
-
-
综上描述,当位于一个表中的元素较多,即 hash 值相等但是内容不相等的元素较多时,通过 key 值依次查找的效率较低。
而 jdk1.8 中,哈希表存储采用数组+链表+红黑树实现,当链表长度(阈值)超过8且当前数组的长度大于64时,将链表转换为红黑树,这样大大减少了查找时间。
简单的来说,哈希表是由数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的。如下图所示:
-
jdk1.8 中引入红黑树的进一步原因:
-
jdk1.8 以前 HashMap 的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就是O(n),完全失去了它的优势。
-
针对这种情况,jdk1.8 中引入了红黑树(查找时间复杂度为 O(logn))来优化这个问题。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。
-
-
put方法的图示
- size 表示 HashMap 中键值对的实时数量(即,所存储元素的数量),注意这个不等于数组的长度。
- threshold(临界值)=
capacity(容量)* loadFactor(负载因子)
。这个值是当前已占用数组长度的最大值。size 超过这个值就重新 resize(扩容),扩容后的 HashMap 容量是之前容量的2倍。
5.HashMap1.7和1.8的区别
1.resize扩容优化
2.引入了红黑树
3.解决了多线程死循环问题,但仍然是非线程安全
差异 | JDK1.7 | JDK1.8 |
---|---|---|
存储结构 | 数组+链表 | 数据+链表+红黑树 |
初始化方式 | 单独函数:inflateTable() | 直接集成到了resize()中 |
hash值计算方式 | 9次扰动 = 4次位运算 + 5次异或运算 | 2次扰动=1次位运算+一次异或 |
存放数据的规则 | 无冲突时,存放在数组上,有冲突时,用拉链法形成一条链表,头结点在数组上 | 无冲突时,存放在数组上,有冲突时,如果数组长度小于64,先扩容;如果数组长度大于等于64且链表的长度大于8,将该链表转化为红黑树结构 |
插入数据的方式 | 头插法 | 尾插法(在链表/红黑树尾部插入) |
扩容后存储位置的计算 | 遍历全部元素重新hash计算位置 | 扩容后:1.如果是单个元素,重新hash运算一次;2.旧元素 e.hash & oldCap = 0,新表中与旧表中位置一样;3.旧元素 e.hash & oldCap != 0, 位置为旧表位置+旧表容量 |
6.HashMap与Hashtable的区别
1.线程是否安全方面
HashMap是非线程安全的,HashTable是线程安全的;HashTable内部的方法基本都经过synchronized修饰。(如果要保证线程安全的话就使用ConcurrentHashMap吧!)
2.效率方面
HashMap要比HashTable(使用synchronized加锁)效率高。另外,HashTable基本被淘汰,请不要在代码中使用它
3.对key为null或者value为null的支持方面
HashMap支持一个key为null,当key为null时,直接hash方法直接返回零值,这样的键只可以有一个,value可以有一个或多个;hashTable中key-value都不能为null,如果为null会抛出空指针异常(NullPointerException)
4.原始容量大小与每次扩充容量大小的不同方面
①创建时如果不指定容量初始值, Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1.;HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。
②创建时如果给定了容量初始值,那么Hashtable会直接使用你给定的大小,而HashMap会将其扩充为2的幂次方大小(HashMap 中的tableSizeFor()方法保证)。也就是说HashMap总是使用2的幂作为哈希表的大小,后面会介绍到为什么是2的幂次方。
5.底层数据结构方面
JDK1.8以后的HashMap在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。