简介
- 基于Map接口实现的哈希表,这个实现提供了所有可选的哈希桶的操作,允许null值和null键,除了HashMap是非同步和允许null之外,HashMap大致等同于Hashtable。HashMap不保证哈希桶的顺序,特别是它不保证哈希桶的顺序会随着时间的推移保持不变。
- 假设散列函数在桶之间正确地分散元素,则HashMap为基本的get和put操作提供了恒定时间性能在集合视图上的迭代器需要花费与(HashMap实例的capacity加上键值映射的数量)成比例的时间,因此如果迭代性能很重要,就要减少capacity的大小,具体为不要将初始容量设置太高或加载因子设置太低。
- 术语解释:
实体:哈希表中的实体即放置了键值对的哈希桶就为一个实体
哈希桶:哈希表中的一个个单元空间就是一个哈希桶,对应数组中的一个位置
capacity:哈希桶的数量,即数组的大小
键值对的数量:每个哈希桶中的键值对的总数(链表法解决哈希冲突,每个桶中都是一个链表,因此每个桶中键值对的数量不确定) - HashMap的实例有两个影响其性能的参数:初始容量capacity和加载因子load factor,capacity是哈希表中桶的数量,初始容量只是创建哈希表时的容量,load factor是哈希表的容量阙值,达到这个阙值,它的容量就会自动增长
- 当哈希表中的键值对数量超过阙值时,哈希表会重新哈希(即重建内部结构),哈希桶的数量会变为两倍
- 默认的加载因子0.75在时间和空间成本之间提供了良好的折衷,较高的值会减少空间开销,但会增加查找成本,在设置HashMap初始容量时,应考虑映射中的预期条目数及其负载因子,以便最小化重新散列操作的数量。 如果初始容量大于最大条目数除以加载因子,则不会发生重新加哈希操作。
- 如果要将多个键值对存储在HashMap实例中,那么使用足够大的容量创建HashMap将可以减少重新哈希的次数,重新哈希会带来一定的开销。使用很多具有相同hashcode值的key会降低哈希表的性能,表现为哈希冲突,为了改善影响,当keys是Comparable,HashMap可以使用keys之间的比较顺序来帮助打破关系(hashcode相同,equals不一定为true,equals不为true的话就可以定义大小关系了)。
- HashMap是非同步的,如果多个线程同时访问HashMap,并且至少有一个线程在结构上修改了映射,则必须在外部进行同步。(结构修改是添加或删除一个或多个映射的任何操作还有resize操作;仅更改与实例已包含的键相关联的值不是结构修改),外部同步常用方法就是加锁。或者使用Collections的synchronizedMap方法对HashMap进行包装
- HashMap所有的“collection view方法”返回的iterator 都是 fail-fast :如果在创建iterator之后的任何时候对HashMap进行结构修改,除了通过iterator 自己的remove方法,迭代器将抛出一个ConcurrentModificationException。 因此,在并发修改的情况下,迭代器快速而干净地失败,而不是在未来的未确定时间冒任意,非确定性行为的风险。
- 请注意,迭代器的快速失败行为无法得到保证,因为一般来说,在存在不同步的并发修改时,不可能做出任何硬性保证。 失败快速的迭代器会尽最大努力抛出 ConcurrentModificationException。 因此,编写依赖于此异常的程序是错误的:迭代器的故障快速行为应仅用于检测错误。
源码解读
哈希表内部使用数组+链表(解决哈希冲突)+红黑树(哈希冲突大量存在时,提高查找性能)
DEFAULT_INITIAL_CAPACITY:默认的HashMap初始容量大小,桶的个数,默认为16
MAXIMUM_CAPACITY:最大容量大小2的30次方
DEFAULT_LOAD_FACTOR:默认的加载因子0.75
TREEIFY_THRESHOLD:桶中链表的节点数阙值,超过这个阙值,桶中的链表会转换成红黑树,这个值是8
UNTREEIFY_THRESHOLD:在resize操作期间,桶中的节点树变为链表的阙值,这个值为6
MIN_TREEIFY_CAPACITY:HashMap的桶可以树化的最小容量,否则,如果桶中的节点太多,则会resize而不是去树化,这个值为64
table:Node类型的数组,为哈希表的实体,数组中每个元素都是一个桶,保存着一个链表或树,哈希表在第一次使用时才初始化!!!创建HashMap实例时,并不会初始化。根据需要resize调整大小,长度始终是2的整数次幂,长度允许为0,以表示当前不需要使用HashMap
方法介绍:
containsKey:根据key在哈希表中查找相应的键值对
containsValue:扫描哈希表中每一个键值对,是否有键值对中有这个value
get:根据key返回对应的value
getNode:根据哈希值和key在哈希表中寻找相应的节点,这个方法在HashMap中大多数方法内部使用,相当于一个辅助方法,查找步骤如下:
根据hash定位数组的索引,总是查找第一个节点是不是要找的那个key(对链表和树化都适用),没找到的话,就对桶中的键值对是否用红黑树进行存储分情况进行相应的查找,如果用红黑树存储,则查找时间复杂度O(logN),链表存储,则遍历链表进行查找(判断方式e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k)))
)
hash:key为null时,hash为0,因为null没有hashcode()方法,不能计算hashcode值,而HashMap允许null为key,所以默认为0,对于非null的key,采用hashcode值低16位异或XOR它的高16位得到hash值,散列函数计算是hash值对表长进行取模,而表长的特殊性,2的整数次幂,对其进行取模运算可以采用位运算代替,速度更快,比如表长为8,散列函数hash%8等价于位运算hash&(8-1)即hash&0111,但是这样会增加哈希冲突的概率,因为散列函数值只取hash的二进制的最后三位,只要hash后三位相同,其他不相同,那么得到的索引位置也是一样的,比如hash值为101111和100011111,位运算得到的后三位都是111,那么就产生哈希冲突,而实际上这两个hash并不相同,本质上是hash的后三位以前的数字没有参与散列函数的计算,增大了哈希冲突的概率。解决办法是尽量让所有位都参与散列函数的计算,所以采用高16位异或低16位得到hash,使得高位比特的影响向下传播,这是在速度、哈希冲突概率之间做的权衡
因为很多常见的哈希表散列函数都设计得很合理,元素也合理地分布,因此高低位异或对它们来说没什么用处,而提高哈希表性能的办法就转移到索引的计算效率了,因为索引计算采用的取模运算,而取模运算效率是很低的,在哈希函数设计合理,哈希冲突的概率都差不多的时候,取模运算的消耗就成了哈希表的性能瓶颈,因此采用位运算代替取模运算,来大幅度提升取模运算的性能,对于采用位运算而使表长为2的幂带来的哈希冲突概率的提升,从两个方面来降低哈希冲突:一是高低位异或,充分利用高位的差异来降低哈希冲突(这只是一种廉价的方式降低哈希冲突的概率,效果不是非常明显);二是对于产生了大量哈希冲突的情况下,做了一个优化,把链表转为红黑树,使得大量哈希冲突的情况下,查找性能得到很大的提升,从链表的O(N)降低为红黑树的O(logN)。
总体来说:JDK1.8以后的HashMap做了两个方面的优化:提升索引位置计算的性能(采用位运算);在大量哈希冲突的情况下,红黑树提升查找性能,1.8之前是链表。也有一个稍微小的缺点:哈希冲突的概率增大了一些。但是综合来看HashMap性能是比1.7提升了很多
put:把键值对添加到哈希表中,如果之前存在这个key,则把value进行替换,如果不存在则添加,返回key先前对应的value,如果返回null,则说明之前不存在key,或者之前的key存在,但是key对应的值为null
putVal:和getNode一样为基础方法,被很多方法调用。作用:添加一个键值对,如果之前存在则替换value。步骤如下:
- 如果哈希表为null或者长度为0,则哈希表不存在或之前没被使用过,那么进行resize初始化表
- 初始化之后,定位key的位置,如果这个位置没有键值对,那么直接生成一个结点插入
- 如果存在键值对,那么先检查第一个结点的key是否和参数相同,相同则替换它的value,不相同则判断第一个结点是不是红黑树类型的实例,根据结果分别使用链表查询法和红黑树查询法
- 介绍链表查询法:
- 从链表第二个结点开始找,如果找到key存在,则替换value并返回
- 如果key不存在,则生成一个结点,插到链表的末尾,并判断链表的长度是否达到树化阙值,是否要从链表转为红黑树
- 接着如果key不存在,生成结点是一种结构性修改,则++modCount,而且键值对数量会增加,此时要判断是否大于扩容阙值,并进行扩容
注意:1.8之后多了一种方法putIfAbsent,如果key在哈希表中存在,则不会替换key的值,即如果key不存在则put
removeNode:由remove方法调用,属于基础方法。步骤如下:
- 根据key计算索引位置,从头结点向下查找,如果头结点找不到,则第二个结点非null的话,根据结点是否红黑树的类型,选择对应的查找方式去查找结点,找到结点之后,选择链表删除或者红黑树删除法,删除之后,结构性修改,++modCount,同时键值对减少一,–size,返回删除的结点。删除的时候不会执行树转链表的操作。
resize:初始化HashMap(哈希表只在第一次调用的时候初始化)或者在键值对数量达到阙值时执行扩容操作,扩容*2,如果表之前没初始化,则按阙值threshold进行哈希表容量的分配,扩容步骤如下: - 先确定threshold和capacity,然后生成一个链表数组
- 如果之前的哈希表为null,则说明此次resize是为了初始化哈希表,所以不会执行键值对的复制
- 如果之前的哈希表不为null,则此次resize是为了扩容,那么就要转移键值对。转移步骤如下:
- 遍历数组的每一个位置,如果存在链表,先保存头结点,则先把位置上的链表置为null
- 如果链表只有一个结点,则直接把结点转移到新的哈希表中
- 根据结点的类型,选择链表转移和红黑树转移。
- 链表转移过程:
- 尾插法,每次把结点插入链表尾部,使得原链表结点的相对顺序不变。
- 链表转移时,采用双链表转移,一条链表保存链表中结点转移到新哈希表中的索引位置和原哈希表位置一样,另一条链表保存着结点的索引位置为原哈希表位置+原哈希表的长度,根据结点的新索引位置来选择保存到哪条链表上
- 最后把两条链表直接放到原哈希表的索引位置和原哈希表位置+原哈希表的长度这两个位置上,即完成一个索引位置上链表的转移,重复完成所有索引位置上的链表转移
tableSizeFor:把一个数转换成最接近2的整数次幂
treeifyBin:把哈希表中hash对应的位置上的链表转换成红黑树,如果表为null或者表长小于最小树化容量MIN_TREEIFY_CAPACITY,则进行resize操作,而不是树化。
树化步骤:
- 把原链表结点转换成红黑树结点,再把红黑树结点添加到由红黑树结点组成的双向链表的末尾,最后调用双向链表的头结点的treeify方法把哈希表的双向链表转换成红黑树。