签名(Signature)
|
|
可以看到HashMap实现了:
- 接口Cloneable,用于表明HashMap对象会重写
java.lang.Object.clone()
方法,HashMap实现的是浅拷贝 - 接口Serializable:表明HashMap对象可以被序列化
Map接口
Map接口里包含的成员方法不外乎是“增删改查”,Map虽然并不是Collection,但它提供了三种“集合视角”,与下面三个方法一一对应:
Set<key> keySet()
,提供key的集合视角Collection<V> values()
,提供value的集合视角Set<Map.Entry<K,V>> entrySet()
,提供key-value键值对的集合视角
设计理念
哈希表(hash table)
HashMap是一种基于哈希表实现的Map,哈希表是一种通用的数据结构,其概念是:key经过hash函数作用后得到一个槽(buckets)的索引(index),槽中保存着我们想要获取的值,如下图所示:
一些不同的key经过同一hash函数后可能产生相同的索引,也就会产生冲突,所以利用哈希表这种数据结构实现具体类时,需要注意两个问题:
- 设计一个好的hash函数,使冲突尽可能的减少
- 需要解决发生冲突后的处理
HashMap的特点
- 线程非安全,并且允许key与value都为null值,HashTable与之相反,为线程安全,key与value都不允许null值
- 不保证其内部元素的顺序,而且随着时间的推移,同一元素的位置也可能改变(resize的情况)
- put、get操作的时间复杂度为O(1)
- 遍历其集合视角的时间复杂度与其容量和现有元素的大小成正比,如果遍历的性能要求很高,不要把capacity设置的过高或者把平衡因子设置的过低。
- 由于HashMap是线程非安全的,意味着如果有多个线程同时对同一HashMap试图做迭代时有结构上的改变(添加、删除entry,只改变entry的value值不算结构改变),那么会报ConcurrentModificationException异常,专业术语叫fail-fast,尽早报错对应多线程程序来说是很有必要的。
Map m = Collections.synchronizedMap(new HashMap(...))
;通过这种方式可以得到一个线程安全的Map。
实现原理
构造函数
HashMap遵循集合框架的约束,提供一个参数为空的构造函数与有一个参数且参数类型为Map的构造函数。除此之外,还提供了两个构造函数,用于设置HashMap的容量(capacity)和平衡因子(loadFactor)。
|
|
容量与平衡因子都有个默认值,并且容量有个最大值
|
|
可以看到,默认的平衡因子为0.75,这是权衡了时间复杂度与空间复杂度之后的最好取值(官方说法),过高的因子会降低存储空间但是查找的时间就会增加。
此外,我们注意到容量必须为2的指数被(默认16),这是为什么呢?解答这个问题,需要了解HashMap中哈希函数的设计原理
哈希函数的设计原理
|
|
在哈希表容量为length的情况下,为了使key都能在冲突最小的情况下映射到[0,length)的索引(index)内,HasMap让length为2的指数倍,然后用`hashCode(key) & (length -1)的方法得到索引。
因为length为2的指数倍,所以length-1所对应的二进制位都为1,然后与hashCode(key)坐与运算,即可得到[0,length)内的索引。
但是这里有个问题,如果HashCode(key)的值大于length的值,举个例子:
Java中对象的哈希值都是32位整数,而HashMap的默认大小为16,那么如果有两个对象的哈希值为:0xABAB0000与0xBABA0000,它们的后四位都是一样,那么与16异或后得到结果都是一样的为0,也就是产生了冲突。
造成冲突的原因关键在于16限制了只能用低位来计算,高位直接舍弃了,所以我们需要额外的哈希函数而不只是简单的对象的hashCode方法了。具体来说就是HashMap中hash()函数所实现的功能了。
首先有个随机的hashSeed来降低冲突发生的几率
然后如果是字符串。则用了sun.misc.Hashing.stringHash32((String) k)来获取索引值
最后通过一系列的无符号右移操作,来把高位与地位进行异或操作,来降低冲突发生的几率。
右移的偏移量20,12,7是怎么来的呢?因为Java中对象的哈希值是32位的,所以这几个数应该就是把高位与地位做异或运算,至于这几个数是如何选取的,就不清楚了。
HashMap.Entry
HashMap中存放的是HashMap.Entry对象,它继承自Map.Entry,其比较重要的构造函数
|
|
可以看到,Entry实现了单向链表的功能,用next成员变量来级联起来。也就是说HashMap的底层结构是一个数组,而数组的元素是一个单向链表。
介绍完Entry,下面介绍一个重要的成员变量
|
|
Entry是单链表,怎么这里又需要个数组类型的tabl呢?其实这是解决冲突的一个方式:链地址法(开散列法),效果如下:
就是相同索引值的Entry会以单向链表的形式存在。
HashMap采用将相同的散列值存储到一个链表中,也就是说在一个链表中的元素他们的散列值绝对是相同的。
put操作
因为put操作有可能需要对HashMap进行resize,所以实现较复杂
|
|
Map中的元素越多,hash冲突的几率也就越大,数组长度是固定的,所以导致链表越来越长,那么查询的效率当然也就越地下了。HasMap的扩容resize,需要将所有的元素重新计算后,一个个重新排列到新的数组中去,这是非常低效的,和ArrayList一样,在可预知容量大小的情况下,提前预设容量会减少HashMap的扩容,提高性能。
get操作
|
|
remove操作
|
|
一般而言,认为HashMap的这四种操作时间复杂度O(1),因为它hash函数性质较好,保证了冲突发生的几率较小。
从删除和查找操作可以看出,在根据key查找元素的时候,还是需要通过遍历,但是由于已经通过hash函数对key散列,要遍历的只是发生冲突后生成的链表,这样遍历的结果就已经少很多了,比完全遍历效率提升了N倍。
fast-fail的HashIterator
集合类用Iterator类来遍历其包含的元素,接口Enumeration以及不推荐使用。相比Enumeration,Iterator有下面两个优势:
- Iterator允许调用者在遍历集合类时删除集合类中包含的元素
- 比Enumeration命名更简单
HashMap中提供的三种集合视角,底层都是用HashIterator是实现的。
|
|
序列化
从源码可知,保存Entry的table数组为transient的,也就是说在进行序列化时并不会包含该成员,这是为什么呢?
|
|
为了解答这个问题,我们需要明确下面事实:Object.hasCode方法对于一个类的两个实例返回的是不同的哈希值。
我们可以试想下面的场景:
我们在机器A上算出对象A的哈希值与索引,然后把它插入到HashMap中,然后把该HashMap序列化后,在机器B上重新算出对象的哈希值与索引,这与机器A上算出的是不一样的,所以我们在机器B上get对象A时,会得到错误的结果。
所以说,当序列化一个HashMap对象时,保存Entry的table是不需要序列化进来的,因为它在另一台机器上是错误的。
因为这个原因,HashMap重写了writeObject和readObject方法
HashMap遍历
|
|
从上面的结果来看:
- HashMap遍历,如果既需要可以也需要value,直接用
|
|
- 如果只是遍历key而无需value的话,可以直接用1234for(String key : map.keySet()) {}
参考文章: