HashMap底层源码解析上(超详细图解+面试题)
Posted 温文艾尔
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap底层源码解析上(超详细图解+面试题)相关的知识,希望对你有一定的参考价值。
文章目录
最近看了黑马和刘老师的源码视频,故总结一篇HashMap的源码解析,文章分为上下两部分
HashMap集合简介
HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,即主要用来存放键值对。HashMap的实现是不同步的,这意味着他
不是线程安全的
,它的key,value都可以为null
此外,HashMap中的映射不是有序的
- JDK1.8之前HashMap由数组+链表组成。数组是HshMap的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode方法计算的哈希值一致导致计算的数组索引值相同(哈希寻址算法[hash&length-1]))而存在的(“拉链法”解决冲突)
- JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为8,且数组中桶的数量大于64,此时此索引位置上的所有数据改为使用红黑树存储)
图示:
hashMap扩容简介
- 初始数组长度为16,若链表节点数量小于等于8时,会将节点连接到链表的下一个位置
- 若某个链表上阈值大于8时,会根据cap(容量)进行判断,若此时数组容量小于64,则会进行数组扩容(从32扩容为64)
- 若链表节阈值大于8,且数组容量大于64,则链表进化为红黑树
问题:为什么要在数组长度大于64之后,链表才会进化为红黑树?
- 在数组比较小时如果出现红黑树结构,反而会降低效率,因为红黑树需要进行左旋右旋,变色这些操作来保持平衡,同时数组长度小于64时,搜索时间相对要快些,所以综上所述为了提高性能和减少搜索时间。
hashMap特点总结
- hashMap存取是无序
- 键和值位置都可以是null,但是键位置只能是一个null
- 键位置是唯一的,底层的数据结构是控制键的
- jdk1.8前数据结构是:链表+数组jdk1.8之后是:数组+链表+红黑树
- 阈值(边界值)>8并且数组长度大于64,才将链表转换成红黑树,变成红黑手的目的是为了高效的查询’
hashMap底层数据结构存储数据的过程
在了解源码之前,我们通过一个案例来说明hashMap底层数据结构存储数据的过程
我们先创建一个hashMap实例
HashMap<String, Integer> MapTest = new HashMap<>();
MapTest.put("a",1);
MapTest.put("b",2);
MapTest.put("c",3);
MapTest.put("a",44);
System.out.println(MapTest);
结果:
a=44, b=2, c=3
存储过程分析
HashMap<String, Integer> MapTest = new HashMap<>();
1.在创建集合对象的时候,在jdk8前,在构造方法中创建一个长度为16的Entry[] table数组用来存储键值对数据,在jdk8以后不是在HashMap构造方法底层创建数组了,是在第一次调用put方法时创建的数组Node[] table
不在构造方法中创建的原因是:创建散列表需要耗费内存,而有些时候我们只是创建hashMap,并不向其中put元素
2.假设向哈希表中存储“a”-1数据,根据"a"调用String类中重写之后的hashCode方法计算出hash值,利用寻址算法(hash&(cap-1))cap为其容量,来寻找在哈希表中的索引,如果该索引空间没有数据,则直接将数据存储到数组中
3.向哈希表中存储数据"b"-2,假设"b"计算出的hashCode方法结合数组长度计算出的索引值也是3,如果此时数组空间不是null,此时底层会比较a和b的hash值是否一致,如果不一致,则在此空间上划出一个节点来存储键值对数据“b”-2,这种方式称为“拉链法”
4.假设向哈希表中存储数据"a"-44,根据“a”调用hashCode方法结合数组长度计算出索引值为3,此时数组空间不为null,此时比较后存储的数据留言和已经存在的数据的hash值是否相等,如果相等,此时发生hash碰撞,那么底层会调用“a”所属类String中的equals方法比较两个内容是否相等
- 相等:则将后添加的数据的value覆盖之前的value
- 不相等,此时出现hash冲突,继续向下和其他的数据key比较,如果都不相等,则划出一个节点存储数据
相同hash值的元素内容可能不同,例如"重地"和"通话"
System.out.println("重地".hashCode()=="通话".hashCode());
true
索引值->hash值->key值
为什么引入红黑树的进一步解答
JDK1.8以前HashMap的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当HashMap中有大量的元素都存放在同一个桶中时,这个桶下有一条长长的链表,此时HashMap就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就从O(1)退化成O(n),完全失去了它的优势,针对此种情况,JDK1.8中引入了红黑树(查找的时间复杂度为O(logn))来优化这种问题。
总结图
说明:
- size表示HashMap中K-V的实时数量,注意这个不等于数组的长度
- threshold(临界值)=capacity(容量)*loadFactory(加载因子),这个值是当前已占用数组长度的最大值。size超过这个临界值就重新resize扩容,扩容后的HashMap容量是之前容量的两倍
了解了hashMap的存储流程之后我们来看几道面试题加深印象
面试题
面试题1:哈希表底层采用何种算法计算hash值?还有哪些算法可以计算出hash值?
- 底层采用的是key的hashCode方法的值结合数组长度进行无符号右移(>>>),按位异或(^),按位与(&)计算出索引。还可以采用平方取中法,取余数,伪随机数法
面试题2:当两个对象的hashCode相等时会怎么样?
- 会产生hash碰撞,若key值内容相等则替换旧的value,反之则连接到链表后面,链表长度超过阈值8就转换成红黑树存储
面试题3:何时发生哈希碰撞和什么是哈希碰撞,如何解决哈希碰撞
- 只要两个元素的key计算的hash码值相同就会发生hash碰撞,jdk8前使用链表解决哈希碰撞,jdk8之后使用链表+红黑树解决哈希碰撞
面试题4:如果两个键的hashcode相同,如何存储键值对
- hashcode相同则会调用equals比较内容是否相同,如果相同则会用新value值覆盖老value值,如果不相同则会连接到链表后面
HashMap集合的继承关系
从图中可以看出HashMap实现了Map,Cloneable,Serializable接口
- Cloneable空接口,表示可以克隆,创建并返回HashMap对象的一个副本。
- Serializable序列化接口,属于标记性接口,HashMap对象可以被序列化和反序列化
- AbstracMap父类提供了Map实现接口,以最大限度的减少实现此接口所需的工作
知识补充:
通过上述继承关系我们发现一个很奇怪的现象,HashMap已经继承了AbstractMap而AbstractMap已经实现了Map接口,那为什么HashMap还要再实现Map接口呢?同样在ArrayList中LinkedList中都是这种结构
据java集合框架的创始人Josh Bloch描述,这样的写法是一个失误,在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些地方可能是有价值的,直到他意识错了。显然的,JDK的维护者后来不认为这个小小的失误值得去修改,所以就这样存在下来了
代码如下(示例):
HashMap集合类的成员
1.序列化版本号
private static final long serialVersionUID = 362498820763181265L;
2.集合的初始化容量(必须是2的n次幂)
//默认的初始容量是16 -- 1<<4相当于1*2的4次方---1*16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
问题:为什么必须是2的n次幂?如果输入值不是2的n次幂比如10会怎样
HashMap的构造方法可以指定集合的初始化容量大小:
public HashMap(int initialCapacity)
this(initialCapacity, DEFAULT_LOAD_FACTOR);
根据上述讲解我们已经知道,当向HashMap中添加一个元素的时候,需要根据key的hash值去确定其在数组中的具体位置。HashMap为了存取高效,要尽量减少碰撞,把数据均匀分配,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法中
这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算(这点上述已经讲解)。所以源码中做了优化,使用hash&(length-1),而实际上hash%length等于hash&(length-1)的前提是length是2的n次幂
为什么使用hash&(length-1)能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n-1次方实际就是n个1
举例:
说明:按位与运算:相同的二进制数位上,都是1的时候,结果为1,否则为0。
如果我们设置的数组大小不是2的幂会怎么样
public HashMap(int initialCapacity)
this(initialCapacity, DEFAULT_LOAD_FACTOR);
我们以设置initialCapacity为10为例,他会在itableSizeFor方法中将非2次幂的数转换成离他最近且比它大的二次幂数
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;
下面分析这个算法
1.首先,为什么要对cap做减1操作。int n = cap - 1;
这是为了防止cap已经是2的幂,如果cap已经是2的幂,没有减1操作的话,方法最后返回的capacity将是这个cap的2倍
2.如果这时n为0,经过cap-1之后,则经过后面的几次无符号右移依然是0,最后返回的capacity是1(最后有个n+1的操作),我们这里只讨论n不等于0的情况
3.|(按位或运算):运算规则,相同的二进制数位上,都是0的时候,结果为0,否则为1。
- 第一次右移n |= n >>> 1;
- 第二次右移n |= n >>> 2;
- 第三次右移n |= n >>> 4;
- 以此类推,无论我们再右移,也只是与0做|运算,结果不变
- 请看下面一个完整的例子
所以执行完tableSizeFor方法后,我们所传入的非2次幂cap会被转化成大于等于它的一个离他最近的2次幂数
3.默认的负载因子,默认值是0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- 我们的扩容阈值为cap*DEFAULT_LOAD_FACTOR,当容量为16时,他会在容量达到12时便触发扩容
4.集合的最大容量
//集合最大容量的上限是:2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;
5.链表的值超过8则会转成红黑树
//当桶(bucket)上的节点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
问题:为什么Map桶中节点个数超过8才转为红黑树
8这个阈值定义在HashMap中,针对这个成员变量,在源码的注释中只说明了8是bin(bin就是nucket桶)从链表转成树的阈值,但是并没有说明为什么是8
在HashMap中有一段注释说明:我们继续往下看:
* 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. 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)).
翻译:
因为树节点的大小大约是普通节点的两倍,所以我们只在箱子包含足够的节点时才使用树节点(参见TREEIFY_THRESHOLD)。
当他们边的太小(由于删除或调整大小)时,就会被转换回普通的桶,在使用分布良好的hashcode时,很少使用树箱。
理想情况下,在随机哈希码下,箱子中节点的频率服从泊松分布
第一个值是:
* 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
TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转化成TreeNodes,而是否足够多就是由TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin,并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin。
这样就解释了不是一开始就将其转换成TreeNodes而是需要一定节点才转成TreeNodes,说白了就是空间和时间的权衡,红黑树节点占用空间比较大,如果一开始就是用红黑树,就会消耗大量的空间资源
这段内容还说到:当hashCode离散性很好的时候,树形bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件,所以之所以选择8,不是随便决定的,而是根据概率统计决定的。
补充:
泊松分布
泊松分布的参数
是单位时间(或单位面积)内随机事件的平均发生次数。泊松分布适合于描述单位时间内随机事件发生的次数
2.以下是我在研究问题时,在一些资料上面翻看的为什么长度为8时链表进化为红黑树,长度小于等于6时红黑树又退化成链表的解释:供大家参考
红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,
平均查找长度为4,此时才有转换成树的必要,链表长度如果是小于等于6,链表的平均查找长度为6/2=3,而红黑树此
时为log(6)=2.6,虽然速度也很快
但是转化为树结构和生成树的时间并不会太短,两者此时所用时间相差无几
6.当链表中的值小于6则会从红黑树转回链表
//当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
7.链表转红黑树时数组最大容量
当Map里面的数量超过这个值时,表中的桶才能进行树形化,否则桶内元素太多时会扩容,而不是树形化,为了避免进行扩容、树形化的选择的冲突,这个值不能小于4*TREEIFY_THRESHOLD(8)
//桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;
8.table用来初始化(必须是二的n次幂)(重点)
//存储元素的数组
transient Node<K,V>[] table;
在JDK1.8中我们了解到HashMap是由数组+链表+红黑树来组成的结构,其中table就是HashMap中的数组,jdk8之前数组类型是Entry<K,V>类型。从jdk1.8之后是Node<K,V>类型。只是换了个名字,都实现了一样的接口:Map.Entry<K,V>,负责存储键值对数据
9.存放缓存
//存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;
10.HashMap中存放元素的个数(重点)
//存放元素的个数,注意这个不等于数组的长度
transient int size;
size为HashMap中K-V的实时数量,不是数组table的长度
11.用来记录HashMap的修改次数
//每次扩容和更改map结构的计数器
transient int modCount;
12.用来调整大小下一个容量的值,计算方式为(容量*负载因子)
//临界值,当实际大小(容量*负载因子)超过临界值时,会进行扩容
int threshold;
13.哈希表的加载因子(重点)
//加载因子
final float loadFactor;
loadFactor说明:
- loadFactory加载因子,是用来衡量HashMap满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率,计算HashMap的实施加载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。capacity是桶的数量,capacity是桶的数量,也就是table的长度length
- - loadFactory太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactory的默认值为0.75f是官方给出的一个比较好的临界值
- 当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要进行扩容,而扩容这个过程涉及到rehash,复制数据等操作,非常消耗性能。所以开发中尽量减少扩容的次数,可以通过创建HashMap集合对象时指定初始容量来尽量避免
- 同时在HashMap的构造器中可以定制loadFactory
public HashMap(int initialCapacity, float loadFactor) //构造一个带指定初始容量和加载因子的空HashMap
为什么加载因子设置为0.75,初始化临界值是12?
loadFactory越趋近于1,那么数组中存放的数据(entry也就越多),也就越密集,也就会有更多的链表长度处于一个更长的数值,导致查询效率降低,每当我们添加数据,产生hash冲突的概率也会更高
loadFactory越小,也就是越趋近于0,数组中存放的数据(entry)也就越少,表现得更加稀疏
如果希望链表尽可能少些,要提前扩容,有的数组空间有可能一直没有存储数据,加载因子尽可能小一些
- 加载因子是0.4,那么16*0.4=6如果数组中满6个空间就扩容,很有可能许多空间内并没有元素或元素很少,会造成大量的空间浪费
- 加载因子是9,16*0.9=14,这样就会导致扩容之前查找元素的效率很低
HashMap构造方法
HashMap中最重要的构造方法,他们分别如下:
HashMap()
构造一个空的HashMap,默认初始容量(16)和默认负载因子(0.75)
public HashMap()
this.loadFactor = DEFAULT_LOAD_FACTOR; // 将默认的加载因子0.75赋值给loadFactory,并没有创建数组
HashMap(int initialCapacity)
构造一个具有指定的初始容量和默认负载因子的HashMap
public HashMap(int initialCapacity)
this(initialCapacity, DEFAULT_LOAD_FACTOR);
HashMap(int initialCapacity, float 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.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
对于this.threshold = tableSizeFor(initialCapacity)的问题:
tableSizeFor(initialCapacity)判断指定的初始化容量是否是2的n次幂,如果不是那么会变为
比指定初始化容量大的最小的2的n次幂,但是注意,在tableSizeFor方法体内部将计算后的数
据返回给调用这里了,并且直接赋值给threshold边界值了,有些人会觉得这里是一个bug,应该这样书写,
this.threshold = tableSizeFor(initialCapacity)*this.loadFactor;
这样才符合threshold的意思(HashMap的size到达threshold这个阈值时会扩容)
但是请注意,在jdk8以后的构造方法中并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算
public HashMap(Map<? extends K, ? extends V> m)
包含另一个Map的构造函数
public HashMap(Map<? extends K, ? extends V> m)
//将loadFactor赋值为默认的负载因子
this.loadFactor = DEFAULT_LOAD_FACTOR;
//如果传进来map中有数据,把数据移植到新的map集合中
putMapEntries(m, false);
最后调用了putMapEntries,来看一下方法实现
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict)
//记录传进来的map的大小
int s = m.size();
//证明里面有数据
if (s > 0)
//判断table是否进行过初始化
if (table == null) // pre-size
//为求数组最大容量最准备,为避免除出来是小数,1.0F强转
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
//如果s数据数量超过新集合阈值,则进行扩容
else if (s > threshold)
resize();
//如果s数据数量小于新集合阈值,则将s集合中的数据添加到新集合中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
面试问题:
注意float ft = ((float)s / loadFactor) + 1.0F;这一行代码为什么要加1.0F?
- s/loadFactor的结果是小数,加1.0F与(int)ft相当于是对小数做一个向上取整以尽可能地保证更大容量,更大的容量能够减少resize的调用次数,所以+1.0F是为了获取更大的容量
- 例如:原来集合的元素个数是6个,那么6/0.75是8,是2的n次幂,那么新数组大小就是8,然后原来数组的数据就会存储到长度是8的新的数组中了,这样会导致在存储元素的时候,容量极有可能不够,需要继续扩容,那么性能降低了,如果加1,数组长度则直接为16,就能避免这种情况的发生减少数组的扩容次数
以上是关于HashMap底层源码解析上(超详细图解+面试题)的主要内容,如果未能解决你的问题,请参考以下文章
图解面试题:ConcurrentHashMap是如何保证线程安全的
Java高薪面试宝典Day3图解HashMap高频面试及底层实现架构!