HashMap 简介
HashMap 是基于哈希表实现的映射表。并允许使用 null 值和 null 键。(除了不同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
值得注意的是HashMap不是线程安全的,如果想要线程安全的HashMap,可以通过Collections类的静态方法synchronizedMap获得线程安全的HashMap。
1 | Map map = Collections.synchronizedMap(new HashMap()); |
HashMap 属性
HashMap 中有两个比较重要的属性:容量和加载因子。这两个变量能影响到 HashMap 的性能。容量是哈希表中桶的数量,初始容量只是哈希表在创建时的容量,默认初始容量等于 16。加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构)。
可以注意到源码中要求容量必须为 2 次幂。由于 HashMap 是基于哈希实现的,为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;这里是用了取模来实现 hash % length
。但是直接求余效率不如位移运算,源码中做了优化,改成了 hash & (length-1)
。而这种做法下,要保证取出来的值分布尽量均匀,length-1
二进制上的 1 必须尽可能的多,可知 length 是 2 次幂时成立。
例如长度为 9 时候,3&(9-1)=0 2&(9-1)=0 ,都在0上,碰撞了;
例如长度为 8 时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞;
1 | public class HashMap<K,V> |
HashMap 构造方法
HashMap(int initialCapacity, float loadFactor)
这个构造方法指定了容量和加载因子,内部实现基本就是参数检查和赋值。
1 | public HashMap(int initialCapacity, float loadFactor) { |
HashMap(int initialCapacity)
调用了第一种的构造方法,传入指定的容量和默认加载因子。
HashMap()
调用了第一种的构造方法,传入默认容量 16 和默认加载因子 0.75。
HashMap(Map<? extends K, ? extends V> m)
构造一个映射关系与指定 Map 相同的新 HashMap。
这里先调用 HashMap(int initialCapacity, float loadFactor)
方法指定合适的容量,
而后再调用 inflateTable
方法把容量变为 2 次幂,并创建桶数组实例,
最后遍历参数中的 map,在新 HashMap 中写入元素。
1 | public HashMap(Map<? extends K, ? extends V> m) { |
1 | /** |
取出目标映射表的视图,迭代调用 putForCreate
。
1 | private void putAllForCreate(Map<? extends K, ? extends V> m) { |
取得桶下标 h & (length-1)
,若已存在,则更新,不存在则调用 createEntry
创建键值对到对应桶中。
注: 由于哈希碰撞总是无法完全避免的,因此为了解决这一问题,HashMap 用到了 拉链法
。拉链法
的实现比较简单,将链表和数组相结合。数组中的元素称为桶,桶中装的是链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
1 | private void putForCreate(K key, V value) { |
创建键值对到桶中。
1 | void createEntry(int hash, K key, V value, int bucketIndex) { |
注: 在 createEntry
方法中,可以注意到创建新的键值对时,新的键值对会插入到桶中链表的头部,而非尾部。之所以选择插入到头部,是为了避免尾部遍历。理论上认为新插入的元素权重更高。
常用方法
get 方法
由于 HashMap 键值均支持 null,由于 null 不支持 equals 方法,因此需要指定的方法来获取 键 为 null 的键值对。
1 | public V get(Object key) { |
在 HashMap 中用下标为 0 的桶存放键为 null 的键值对。
1 | private V getForNullKey() { |
取出对应的桶下标,遍历桶中链表得到键值对。
1 | final Entry<K,V> getEntry(Object key) { |
put 方法
在此映射中关联指定值与指定键。如果该映射以前包含了一个该键的映射关系,则旧值被替换。内部实现流程如下:
- 若为空表,则调用
inflateTable
扩容数组。 - 若键为 null,则调用 putForNullKey 来进行 put 操作。
- 取得桶下标,若已存在键值对,则更新值。
- 桶中不存在该键的映射,则创建新的键值对实例到桶中。由于此时映射表结构发生改变,modCount自增。
1 | public V put(K key, V value) { |
针对键为 nul 的 put 方法。除了是指定从下标为 0 的桶中操作以外,和前面没有区别。
1 | private V putForNullKey(V value) { |
添加键值对。首先判断当前长度是否超过了阈值,若超过则动态扩充到现有数组长度的 2 倍。然后调用 createEntry
创建键值对。createEntry
已经在 HashMap(Map<? extends K, ? extends V> m)
讲过,这里不再赘述。
1 | void addEntry(int hash, K key, V value, int bucketIndex) { |
动态扩充方法。首先当前容量若超过容量最大值,则把触发扩充的阈值置为 int 最大值。
创建一个新的指定容量 newCapacity 大小的数组,键值对拷贝到新数组中,更新阈值。
1 | void resize(int newCapacity) { |
拷贝键值对到新数组中。
1 | void transfer(Entry[] newTable, boolean rehash) { |
putAll 方法
该方法用来追加另一个 Map 对象到当前 Map 集合对象,它会把另一个 Map 集合对象中的所有内容添加到当前 Map 集合对象。
1 | public void putAll(Map<? extends K, ? extends V> m) { |
remove方法
1 | public V remove(Object key) { |
取得桶下标,遍历链表找到键值对,从链表中删除该键值对。modCount 自增,size 自减。
1 | final Entry<K,V> removeEntryForKey(Object key) { |
containsKey
判断映射表中是否存在该键。调用 getEntry
取出对应的桶下标,遍历桶中链表得到键值对(代码在 get 方法
中已贴出)。
1 | public boolean containsKey(Object key) { |
containsValue
遍历所有键值对,若值存在于映射表中,则返回 true。
1 | public boolean containsValue(Object value) { |
clone 方法
调用父类的 clone 方法,扩充数组,初始化变量,浅拷贝键值对。
1 | public Object clone() { |
clear 方法
modCount 自增,数组中不再引用链表,指向 null。(不再引用使得 JVM 垃圾回收发生作用),置 size 为 0。
1 | public void clear() { |
HashMap 视图相关
Collection<V> values()
和 Set<K> keySet()
两个视图方法的迭代方法都是通过继承内部抽象类HashIterator
实现。
内部抽象类 HashIterator
1 | private abstract class HashIterator<E> implements Iterator<E> { |
HashIterator
创建实例时,取得第一个键值对和桶下标。
迭代方法 nextEntry
遍历链表和桶得到下一个键值对。
迭代器安全的删除键值对的方法 remove
,保持了 modCount 一致。
小结
HashMap 由于是基于哈希表实现的,因此为了避免哈希碰撞冲突,让数据分布均匀,保证映射表的容量必须为 2 次幂。
另外,由于哈希碰撞总是无法完全避免,采用了 数组 + 链表的形式,即 “拉链法”, 让哈希碰撞的键值对组成链表。同时为了避免尾部遍历,新插入的键值对是插入到链表头部的。
容量和加载因子是 HashMap 中很重要的两个参数,会影响到 HashMap 的性能。当数组长度超过了 容量 * 加载因子
时,HashMap 会触发自动扩充,从而哈希表将具有大约两倍的桶数。