导读:本文分析的是源码,所以至少读者要熟悉他们的接口使用,同时,对于并发,读者至少要知道 CAS、ReentrantLock、UNSAFE操作这几个基本知识,文中不会对这些知识进行介绍。Java8 用到了红黑树,不过本文不会进行展开,感兴趣的读者请自行查找相关资料。
Hash 表
在讲HashMap之前,我们先来了解下他们底层实现的一种数据结构——Hash 表。
Hash表,是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。存放记录的数组叫做哈希表。
在HashMap中,就是将所给的“键”通过哈希函数得到“索引”,然后把内容存在数组中,这样就形成了“键”和内容的映射关系。
哈希表
上图可以发现,“键”转换为“索引”的过程就是哈希函数,为了尽可能保证每一个“键”通过哈希函数的转换对应不同的“索引”,就需要对哈希函数进行选择了,使其得到的“索引”分布越均匀越好。
哈希函数的均匀性:
哈希函数设计
通过前辈的研究加上实践表明,当把哈希函数得到hashcode值对素数取模时,这样得到的索引是最为均匀的。但是,在HashMap源码中,并不是取模素数的,而是一种等效取模2的n次方的位运算,hash&(length-1)。hash%length==hash&(length-1)的前提是length是2的n次方;之所以使用位运算替代取模,是因为位运算的效率更高,所以也就要求数组的长度必须是2的n次方(索引的分布也是很均匀的)。
哈希函数的一致性:
哈希函数设计原则
哈希函数的一致性原则是:当两个对象的equals相等,那么他们的hashcode一定相等。
这就要求我们在重写了equals方法时,必须重写hashcode方法。如果不重写hashcode,则会使用Object的hashcode方法,该方法是以我们创建的对象的地址作为参数求hash的。所以,如果不重写hashcode,两个equals相等的对象会导致hashcode不同(因为不同的对象),这个是不允许的,因为违背了hash函数的一致性原则。
哈希冲突:
当两个不同的元素,通过哈希函数得到了同一个hashcode,则会产生哈希冲突。HashMap的处理方式是,JDK8之前,每一个位置对应一个链表,链式的存放哈希冲突的元素;JDK8开始,当哈希冲突达到一定程度(8个),每一个位置从链表转换成红黑树。因为红黑树的时间复杂度是O(log n)的,效率优于链表。
链地址法
哈希表小结:
哈希表,均摊复杂度是O(1),因为第一步通过数组索引找到数组位置是O(1),然后到链表中查找元素的均摊复杂度是O(size/length),size为元素个数,length为数组长度。由于Hash表的容量是动态扩容的,也就是说随着size和length成正比的,即size/length是一个常数,于是也是O(1)的复杂度,即总的来说,均摊复杂度是O(1)。但是哈希表是没有顺序性的,即无法对元素进行排序。
哈希表的缺点
Java7 HashMap
HashMap 是最简单的,一来我们非常熟悉,二来就是它不支持并发操作,所以源码也非常简单。
首先,我们用下面的这张图来介绍下HashMap的结构。
Java7 HashMap结构图
大方向上,HashMap里面是一个数组,然后每个数组中的每个元素是一个单向链表。
上图中,每个绿色的实体是嵌套类Entry的实例,Entry包含四个属性:key,value,hash值和用于单向链表的next。
capacity:当前数组容量,始终保持2^n,可以扩容,扩容后的大小为当前的2倍,默认为16。
loadFactor:负载因子,默认为 0.75。
threshold:扩容的阈值,等于 capatity * loadFactor。
put 过程分析
put 源码
1、数组初始化:在第一个元素插入 HashMap 的时候做一次数组的初始化,就是先确定初始的数组大小,并计算数组的扩容阈值。这里将数组保持在 2 的 n 次方的做法,java7 和 java8 的HashMap 和 ConcurrentHashMap 都有相应的要求。
注意:new HashMap时,并未做数组初始化操作,而是简单为loadFactor ,threshold赋值;同时为了保证将数组保持在 2 的 n 次方,会对 capacity的值进行处理,取大于capacity且最近的2 的 n 次方值作为数组大小。
2、计算具体数组的下标:使用 key的 hash值对数组长度进行取模(源码中是 hash & (length -1),当length是2^n 时,此时(length - 1) 的二进制全是1, hash & (length -1) 相当于取 hash值的低 n位, 结果和 hash mod length一样的)。
3、找到数组下标后,先进行 key 判重(== || equals),如果没有重复,则将新值放入链表的表头,如果有重复,直接用新值覆盖旧值。
4、数组扩容:如果当前size >= threshold,那么就会触发扩容;扩容就是用一个新的大数组替换原来的小数组,并将原来数组中的值迁移到新的数组中。
既然对 key的判重依据是hash值和equals方法,故必须对key进行重写hashcode和equals方法,因为如果不重写,将直接用Object的hashcode和equals方法,而其必须是同一个对象才能满足key相同。
hashcode方法的一般规定:如果两个对象能够equals相等,那么他们的hashcode必须相等。
所以,重写了equals必须重写hashcode。
get 过程分析
java7 HashMap get源码
相对于put 过程,get 过程是非常简单的。
1、根据key 计算 hash 值。
2、找到相应的数组下标:hash & (length - 1)。
3、遍历该数组位置处的链表,直到找到相等(== || equals)的 key。
java7 ConcurrentHashMap
ConcurrentHashMap和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一些。
整个ConcurrentHashMap 由一个个 Segment 组成,Segment 代表“部分” 或 “一段”的意思,所以很多地方都会将其描述为分段锁。
简单的理解,ConcurrentHashMap 是一个 Segment数组,Segment 通过继承 ReentrantLock来进行加锁,所以每次需要加锁的操作锁住的是一个 Segment,这样只要保证每个 Segment是线程安全的,也就实现了全局的线程安全性。
Java7 ConcurrentHashMap 结构
concurrencyLevel:并行级别、并发数、Segment 数。默认是16,也就是说 ConcurrentHashMap 有16个 Segment,所以理论上,最多同时支持16个线程并发写。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它就不可以扩容了。
再具体到每个 Segment内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。
初始化
initialCapacity:初始容量,这个值指的是整个 ConcurrentHashMap的初始容量,实际操作的时候需要平均分给每个 Segment。
loadFactor:负载因子,之前我们说了,Segment 数组不可以扩容,所以这个负载因子是给每个 Segment 内部使用的。
new ConcurrentHashMap()无参进行初始化以后:
1、Segment 数组长度为16,不可以扩容。
2、Sement[i]的默认大小为2,负载因子为0.75,得出初始阈值为1.5,也就插入第一个元素不会触发扩容,插入第二个进行一次扩容。
3、初始化了 Segment[0],其他位置还是null。
put 过程分析
作者:habit_learning
链接:https://www.jianshu.com/p/39a57484932e