本文主要是从jdk源码入手, 结合常用操作, 图文并茂, 探讨Java中HashMap的一些设计与实现原理.
1.HashMap集合简介(初探)
HashMap基于哈希表的Map接口实现,是以key-value存储形式存,及主要用来存放键值对. HashMap的实现不是同步的,这意味着它不是线程安全的. 它的key,value都可以为null.此外,HashMap中的映射不是有序的.
- jdk1.8之前HashMap由数组+链表组成, 数组是HashMap的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同)而存在的(采用"拉链法解决冲突")
- jdk1.8之后在解决哈希冲突时有了较大的变化, 当链表长度大于阈值(或者红黑树的边界值, 默认值为8) 并且 当前数组的长度大于64时, 此时此索引位置上的所有数据改为使用红黑树存储.
补充: 将链表转换成红黑树前会判断, 即使阈值大于8, 但是数组长度小于64, 此时并不会将链表变为红黑树. 而是选择进行数组扩容.
这样做的目的是因为数组比较小, 尽量避开红黑树结构,这种情况下变为红黑树结构,反而会降低效率,因为红黑树需要进行 左旋,右旋, 变色 这些操作来保持平衡. 同时数组长度小于64时, 搜索时间要相对快些.
所以综上所述为了提高性能和减少搜索时间, 底层在阈值大于8并且数组长度大于64时,链表才转换为红黑树.具体可以参考 treeifyBin
方法.
当然虽然增了红黑树作为底层数据, 结构变得复杂了,但是阈值大于8并且数组长度大于64时,链表转换为红黑树时,效率也变得更高效.
HashMap特点:
- 存取无序
- 键和值都可以是null,但是键位置只能是一个null
- 键位置是唯一的,底层的数据结构控制键的
- jdk1.8 之前数据结构是: 链表+数组
jdk1.8之后是: 链表+数组+红黑树 - 阈值 > 8 and 数组长度大于64,才将链表转换为红黑树,变为红黑树的目的是为了更高效地查询
2. HashMap集合底层的数据结构
2.1 数据结构
- jdk1.8之前 HashMap由 数组+链表 数据结构组成
- jdk1.8之后 HashMap由 数组+链表+红黑树 数据结构组成
2.2 HashMap 底层的数据结构存储数据的过程
pulic static void main(String[] args){
//创建HashMap集合对象
HashMap<String, Integer> hm = new HashMap<>();
hm.put("柳岩",18);
hm.put("杨幂",28);
hm.put("刘德华",40);
//hm.put("柳岩",18);
hm.put("柳岩",20);
System.out.println(hm);
}
{杨幂=28, 柳岩=20, 刘德华=40}
-
HashMap<String,Integer> hm = new HashMap<>();
当创建HashMap集合对象的时候.
- jdk8之前: 构造方法中创建一个长度为16的
Entry[] table
用来存储键值对数据的. - jdk8之后: 构造方法中不创建数组了,而是在第一次调用put方法时创建的数组
Node[] table
用来存储键值对数据的
- jdk8之前: 构造方法中创建一个长度为16的
-
假设向hm中存储
柳岩-18
数据,根据柳岩调用String类中重写hashCode()方法计算出值, 然后结合数组长度采用某种算法(散列算法)计算出向Node数组中存储数据的空间的索引值.如果计算出的索引空间没有数据,则直接将
柳岩-18
存储到数组中, 举例:计算出的索引位3面试题: 哈希表底层采用何种算法计算hash值? 还有哪些算法可以计算出hash值? 底层采用的key的hashCode方法的值结合数组长度进行无符号右移(>>>),按位异或^,按位与& 计算出索引号 还可以采用: 平方取中法,取余数,伪随机法 10%8 ==> 2, 11%8 ==>3
-
向哈希表中存储数据
刘德华-40
,假设"刘德华"
计算出的hashCode方法结合数组长度计算出的索引值为3,那么此时数组空间不是null,此时底层会比较"柳岩"
和"刘德华"
的hash值是否一致, 若不一致,则在此空间上划出一个结点来存储键值对数据刘德华-40
(拉链法) -
假设向哈希表中存储数据
柳岩-20
,那么首先根据柳岩调用hashCode方法结合数组长度计算出的索引肯定是3. 此时比较后存储的数据柳岩
和已经存在的数据的hash值是否相等, 如果hash值相等,此时发生哈希碰撞
那么底层会调用柳岩所属类String
的equals方法比较两个内容是否相等:
相等: 则将后面添加的数据的value覆盖之前的value
不相等: 那么继续向下和其他的数据的key进行比较,若都不相等, 则划出一个结点存储数据
哪怕string不同也有可能hashCode方法值相等:
String a = "重地"; String b = "通话"; System.out.println(a.hashCode()+ " " + b.hashCode()); System.out.println(a.equals(b)); /* 1179395 1179395 false */
如果结点个数(链表长度)大于阈值8并且数组长度大于64 则将链表变为 红黑树.
2. 当两个对象的hashCode相等会怎么样?
产生冲突(哈希碰撞),如key值内容相同则替换就得value值,不然连接到链表后面,链表长度超过阈值8就转换为红黑树存储.
3. 何时发生哈希碰撞和什么是哈希碰撞?
只要两个元素的key计算的hashcode相同就会发生冲突.
jdk8前使用链表解决哈希碰撞.jdk8后使用链表+红黑树解决
4. 如果两个键的hashcode相同,如何存储键值对?
hashCode相等. 通过equals方法比较内容是否相等.
相同: 则新的value覆盖老的value值
不想同: 则将新的键值对添加到哈希表中.
在不断地添加数据的过程中, 会涉及到扩容的问题, 当超出临界值(且要存放的位置非空时)时,扩容 .默认的扩容方法为: 扩容为原来容量的2倍,并将原有的数据复制过来.
通过上述描述,当位于一个链表中元素众多,即hash值相等但是内容不等的元素较多时,通过key值依次查找的效率较低. 而jdk1.8中,哈希表存储在链表长度大于8并且数组长度大于64时将链表转换为红黑树.jdk8在hash表中引入红黑树主要是为了 查找效率更更高.
传统HashMap的缺点,1.8为什么引入红黑树? 这样结构不就变得更麻烦了嘛? 为何阈值大于8才换成红黑树?
1.8之前HashMap的实现是数组+链表, 即使哈希函数取得再好,也很难达到元素的百分百均匀分布.当HashMap中有大量的元素都放在同一个桶中时,这个桶下有一条长长的链表, 这个时候HashMap就相当于一个单链表, 假如单链表有n个元素, 遍历的时间就是O(n).
1.8为解决这一问题, 使用 `红黑树(查找时间复杂度为O(logn)) 来优化这个问题.当链表长度很小的时候,即使遍历,速度也很快,但是当链表长度不断变长,对查询也存在影响.
一些说明:
size
表示HashMap中K-V的实时数量, 注意这个不等于数组的长度.threshold
(临界值) =capacity
(容量) * loaFactor(加载因子). 这个值是当前已占用数组长度的最大值. size 超过这个临界值就会重新reszie
. 扩容后的HashMap容量是之前容量的两倍.
3. HashMap的继承关系
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
说明
- Cloneable 空接口,表示可以克隆.创建并返回HashMap对象的一个副本.
- Serializable 序列化接口. 属于标记性接口. HashMap对象可以被序列化和反序列化.
- AbstractMap 父类提供了Map实现接口. 以最大限度地减少实现此接口所需要的工作.
补充: 为甚HashMap基础AbstractMap而AbstractMap类实现了Map接口, 那为啥HashMap还要去实现Map接口呢? 同样ArrayList也是如此.
这是一个失误. 最开始写Java框架时, 以为会有一些1价值, 直到其意识到毫无价值.
4 HashMap 集合类的成员
4.1 成员变量
1. 序列化版本号
private static final long serialVersionUID = 362498820763181265L;
由于实现了序列化接口, 所以需要一个默认的序列化版本号.
2. 集合的初始化容量(必须是2的n次幂)
/**
* The default initial capacity - MUST be a power of two.
*/
// 1<< 4 相当于 1*(2^4)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
问题: 为啥是2的次幂?如果输入的值并非2的n次幂而是比如10 会怎样?
3. HashMap 构造方法还可以指定集合的初始化容量的大小:
public HashMap(int initialCapacity) //构造一个带指定初始容量和默认加载因子(0.75)的空HashMap
根据上述我们知道了, 当向HashMap中添加一个元素的时候, 需要根据key的hash值,去确定其在数组中的具体位置.HashMap 为了存取高效 ,要尽量较少碰撞,就是要尽量把数据分配均匀, 每个链表长度大致相同, 这个实现就在把数据存到哪个链表上的算法.
这个算法实际就是取模, hash % length
, 计算机中直接求余的效率不如位运算. 所以源码中做了优化,使用 hash&(length -1)
, 而实际上 hash % length
等于hash&(length -1)
的前提就是length是2的 n 次幂.
为什么这样能均匀分布减少碰撞呢?
- 2的n次方实际就是 1后面n个0,
- 2的n次方-1 实际就是n个1
举例:
说明: 按位与运算: 相同的二进制位上都是1的时候,结果才为1, 否则为0
例如长度为8:
3 & (8-1) = 3
0000 0011
0000 0111
----------
0000 0011
13 & (8-1) = 5
0000 1101
0000 0111
---------
0000 0101
例如长度为9:
3 & (9-1)
0000 0011
0000 1000
---------
0
2 & (9-1)
0000 0010
0000 1000
---------
0 碰撞,而当length为8时不会
13 & (9-1)
0000 1101
0000 1000
---------
0000 1000
如果不是2的n次幂,计算出的索引特别容易相同, 及其容易发生哈希碰撞,造成其余数组空间很大程度上并没有存储数据,链表或者红黑树过长,效率较低
小结:
-
由上可看出,当我们根据key的hash确定其在数组的位置时,如果n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会有数据,浪费数组空间,加大冲突的可能.
-
一般我们会想通过 % 取余来确定位置, 这样也行, 只不过性能不如 & 运算.而且当n是2的幂次方时:
hash & (length-1) =hash % length
-
因此, HashMap容量为2的n次方的原因,就是为了数据的均匀分布,减少hasn冲突. 毕竟hash冲突也多,代表数组中的一个链的长度就会越大,这样的话会降低hashmap的性能.
-
如果创建的HashMap对象输入的数组长度不是2的n次方时,HashMap会通过移位运算和或运算得到2的n次方数, 并且是距离那个数最近的数字(比如输入10, 获得16), 源代码如下:
public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) //最大2^30 initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }
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; }
说明:
如果给定了initialCapacity(假设为10), 由于HashMap的capacity必须都是2的幂,因此这个方法用于找到大于等于initialCapacity的最小的2的次幂(此处为16),然后返回.下面分析这个算法:
-
为什么要对cap减1操作呢?
int n = cap - 1;
这是为了防止,cap本身就是2的n次幂, 若不进行此操作,则执行完该方法则会得到这个cap的二倍,比如输入8, 不进行-1的话返回16
-
现在来看这些个无符号右移. 若果n这时为0了(经过了cap-1),则经过后面几次无符号右移依然是0,最后返回capacity的值为1(最后有个n+1的操作). 这里讨论不为0的情况.
-
注意: | 按位或运算: 相同位置上都是0的时候才为0, 否则为1
cap = 10 int n =cap-1; == > 9 n |= n >>> 1 00000000 00000000 00000000 00001001 9 >>> 1 00000000 00000000 00000000 00000100 4 -------------------------------------- 00000000 00000000 00000000 00001101 13 最高位右边相邻位为1 n=13 n |= n >>> 2 00000000 00000000 00000000 00001101 13 >>>2 00000000 00000000 00000000 00000011 3 --------------------------------------- 00000000 00000000 00000000 00001111 15 最高两位右边相邻两位为1 -- 此时最高4位为1 n=15 00000000 00000000 00000000 00001111 15 >>> 4 00000000 00000000 00000000 00000000 0 ---------------------------------------- 00000000 00000000 00000000 00001111 15 最高位有8个连续的1, 但是这里没有8位,不变...
以此类推, 容量最大也就是32bit的正数, 最后一次
>>> 16
将变为连续的32个1(但这已经是负数了. 在执行tableSizeFor之前, 对initialCapacity做了判断, 如果大于MAXIMUM_CAPACITY = 2^30
,则取MAXIMUM_CAPACITY.所以这里的移位操作之后,最大30个1,不会大于等于MAXIMUM_CAPACITY. 30个1,加1后为2^30
综上, 10 变成 16就是这样得到的~
-
4. 默认的负载因子, 默认值为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
5. 集合最大容量
//集合最大容量的上限是: 2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;
当链表的值超过8, 则会转红黑树(1.8之后)
//当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8
面试题: 为什么Map桶中结点个数超过8 才转为红黑树 ?
8 这个阈值定义在HashMap中, 在源码注释中只说明了8是bin(bin就是bucket桶)从链转换成红黑树的阈值,但是并没有说为什么是8:
在HashMap中174行有一段说明
* 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)). The first values are:
因为树的结点大约是普通结点的两倍(有指向), 我们只在箱子包含足够多结点时才使用树结点(参考 TREEIFY_THRESHOLD
). 当他们变得太小(由于删除或者调整)时,就会被转换为普通的桶. 在使用分布良好的用户HashCodes时, 很少使用树箱.理想情况下,箱子中的结点的频率服从泊松分布
(http://en.wikipedia.org/wiki/Poisson_distribution) ,默认调整阈值为0.75,平均参数约为0.5 ,尽管由于调整粒度的差异很大.忽略方差,列表大小k的预期出现次数(exp(-0.5) * pow(0.5, k) / factorial(k)): 第一个值为:
* 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中结点变少时(长度降到6)就又转为普通bin.
这样就解释了为什么不是一开始就转换为TreeNodes, 而是需要一定结点数才转为TreeNodes,说白了就是权衡,空间和时间
这段内容还说: 当HashCode离散性很好时,树形bin用到的概率很小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度达到阈值. 但是在随机hashcode下,离散性可能会变差,然而jdk又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布.不过理想情况下随机hashCode方法下所有bin中结点分布频率满足泊松分布.可以看到,一个bin中链表长度达到8个元素的概率为0.00000006
. 几乎是不可能事件.所以,之所以选择8,不是随便决定的,而是根据概率统计得到.
简而言之,选择8是因为符号泊松分布,超过8的时候,概率已经非常小了.所以选择8
另外还有如下说法:
红黑树的平均查找长度为
log(n)
, 如果长度为8,平均查找长度为log(8)=3,链表平均查找长度为n/2
,当长度为8时,平均查找长度为4,这才有转换为树的必要;链表长度若为小于等于6.6/2=3,而log(6)=2.6,虽然速度也快些,但转化为树和生成树的时间并不会太短.
6. 当链表的值小于6会从红黑树转回链表
//当桶bucket上的结点数小于这个值时树转换为链表
static final int UNTERRIFY_THRESHOLD = 6;
7.
当前Map里面的数量超过这个值时, 表中的桶才能进行树形化,否则桶内元素太多时会扩容,而不是树形化为了避免进行扩容,树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESOLD(8)
//桶中结构转化为红黑树对应的数组长度最小值
static final int MIN_TREEIFY_CAPACITY = 64
8. table用来初始化(必须是2的n次幂)
重点
//存储元素的数组
transient Node<K,V>[] table;
table 在jdk8中我们了解到HashMap是由数组加链表加红黑树来组成的结构. 其中tale就是HashMap中的数组,8之前为
Entry<K,V>
类型. 1.8之后只是换乐观名字Node<K,V>
,都实现一样的接口: Map.Entry<K,V>
负责村村键值对数据.
9. 用来存放缓存 (不那么重要)
//存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySets;
10. HashMap中存放元素的个数
重点
//存放元素的个数,注意这不等于数组的长度
transient int size;
size为HashMap中K-V的实时数量,不是table的长度.
11. 用来记录HashMap的修改次数
//每次扩容和更改HashMap的修改次数
transient int modCount;
12 . 用来调整大小下一个容量的阈值
计算方式为(容量 *负载因子)
//临界值 当实际大小([容量capatocy=16]*[负载因子0.75])超过临界值[threshold]时,会进行扩容(翻倍)
int threshold;
13. 哈希表的加载因子
重点
//加载因子
final float loadFactor;
说明:
-
loadFactor
加载因子,是用来衡量HashMap的满的程度, 表示HashMap的疏密程度, 影响hash操作到同一个位置的概率,计算HashMap的实时加载因子的方法为: size/capacity, 而不是占用桶的数量去除以capacity. capacity是桶的数量,也即是table.lengthloadFactor太大导致查找元素效率低,太小导致数组利用率低,存放的数据会很分散. loadFactor的默认值0.75f是官方给出的比较好的临界值.
当HashMap里面容纳的元素达到HashMap数组长度的0.75时,表示HashMap太挤,需要扩容,而这个过程涉及到rehash,数据复制等操作,非常消耗性能. 所以开发中尽量减少扩容次数,可以通过创建集合对象时指定初始容量来尽量避免.
另外在HashMap构造器中也可以指定loadFactor
面试题:为啥默认0.75的threshold啊? 0.4 那么16*0.4 ---> 6 如果满6个就进行扩容会造成数组利用率太低 0.9 那么16*0.9 ---> 14 那么这样导致链表有点多了,导查找元素效率低
- threshold计算公式:
capacity(数组默认长度16)*loadFactor(负载因子默认0.75)
. 这个值是当前占用数组长度的最大值.当Size >= threshold时,那么就要考虑对数组进行扩容.也就是说,这个数用来衡量数组是否需要扩容的一个标准.
- threshold计算公式:
4.2 构造方法
HashMap中重要的构造方法如下:
1, 构造个空的HashMap,默认初始容量(16) 和默认负载因子(0.75)
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // 默认因子0.75赋给loadFactor,并没有创建数组
}
2, 构造一个具有指定的初始容量和默认loadFactor的HashMap
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and the default load factor (0.75).
*
* @param initialCapacity the initial capacity.
* @throws IllegalArgumentException if the initial capacity is negative.
*/
public HashMap(int initialCapacity) {
//指定容量
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
3, 构件一个具有指定初始容量和loadFactor的hashMap
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new