关于 java.util.HashMap<K, V> 的部分笔记,HashMap是一个遍历顺序无序,不允许key重复的存储键值对映射的集合实现类。底层依赖了一个数组集合,数组中的每个元素维护了具有需要存储在相同下标位置的键值对实体链表结构。本文演示代码段的执行环境基于JDK版本1.7。
概述
HashMap是Map接口的实现类,提供了接口声明的所有方法的具体实现。HashMap允许存储key为null、value为null的元素。HashMap可以被视为等同于散列表(hash table),但是不同的是,散列表是线程安全的,主要方法都被synchronized关键字做了修饰,而HashMap是非线程安全的。Hashmap不保证元素遍历的顺序。
HashMap执行get()和put()操作时可以保证常量时间的性能开销,如果需要遍历map集合,时间开销与map数组容量和存储的键值对数量之和成比例。
HashMap中有两个参数会影响map的操作性能:initial capacity和load factor。initial capacity决定了map集合中桶(键值对实体数组)的大小,而load factor决定了当桶填充到什么程度时会执行数组扩容处理。如果执行了扩容操作,那么需要将当前map集合的键值对实体重新计算hashcode值并分配在扩容后数组中的位置。默认的加载因子是0.75,这个值保证了map的操作性能可以在时间和空间利用方面达到一个理想的平衡。
如果在向map集合中插入元素前可以估计出插入的元素数量,可以提前完成集合空间分配,避免在加入过程中频繁重新分配空间导致性能降低。
Hashmap是一个非线程安全的实现类。所以如果需要在多线程环境中使用hashmap,需要付诸额外的操作来保证多线程环境下的线程安全特性。一个常用的执行方式如下:
1 | Map m = Collections.synchronizedMap(new HashMap(...)); |
HashMap同样支持快速失败检查,但是并不保证可以一定捕捉到快速失败检查。
HashMap底层数据结构如图1所示:
继承关系
1 | // HashMap<E> |
实现接口
类名 | 实现接口 |
---|---|
HashMap<E> | Serializable, Cloneable,Map<K,V> |
HashMap
Constructor Summary
public HashMap(int initialCapacity, float loadFactor)
1 | public HashMap(int initialCapacity, float loadFactor) { |
初始化一个空HashMap集合,集合的初始容量和加载因子由initialCapacity和loadFactor确认。
第2 ~ 6行代码检查initialCapacity参数是否有效并确定最后的initialCapacity值,initialCapacity的最大值是MAXIMUM_CAPACITY(1<< 30或者$2^{30}$)。第7 ~ 9行代码则用来检查loadFactor的有效性。
最后会调用一个init()方法。该方法在HashMap中是个方法体为空的方法,主要是给子类调用的,可以让子类在集合创建后,插入元素前执行一些想要的操作。
public HashMap(int initialCapacity)
1 | public HashMap(int initialCapacity) { |
初始化一个空HashMap集合,集合的初始容量由initialCapacity和确认,加载因子默认为0.75。调用构造器方法HashMap(int initialCapacity, float loadFactor)完成初始化操作。
public HashMap()
1 | public HashMap() { |
初始化一个空HashMap集合,集合的初始容量默认为16,加载因子默认为0.75。调用构造器方法HashMap(int initialCapacity, float loadFactor)完成初始化操作。
ublic HashMap(Map<? extends K, ? extends V> m)
1 | public HashMap(Map<? extends K, ? extends V> m) { |
初始化一个空HashMap集合,并将m中的键值对保存到初始化的map中。在初始化过程中,集合的容量大小由m存储的键值对数量决定,保证空map集合可以将m中的键值对全部存储下来,且默认加载因子是0.75。初始化完成后通过inflateTable(int toSize)方法完成map集合底层存储数据结构的初始化操作。最后通过调用方法putAllForCreate(Map<? extends K, ? extends V> m)将m集合中的键值对保存到初始化的集合中。
部分方法
void init()
1 | void init() { |
空方法,留给子类发挥。
private void inflateTable(int toSize)
1 | private void inflateTable(int toSize) { |
完成map底层存储结构初始化。首先根据传入的toSize寻找一个大于等于toSize的最小2次幂结果。第5行代码确定最终的初始空间容量并在第6行代码中初始化一个存储空间结构 — Entry
private static int roundUpToPowerOf2(int number)
1 | private static int roundUpToPowerOf2(int number) { |
计算求得一个大于等于number的最小2次幂结果。如果number比最大容量还大,那么就返回最大容量;如果number = 1,那么直接返回1,否则返回大于number的最小2次幂结果。在计算过程:
中,number -1 是为了保证当number自身为2次幂时可以取到自身而不是大于自身的最小2次幂结果。因为方法实现的返回大于等于number的最小2次幂结果。所以number -1保证了这个条件,然后计算(number - 1) << 1)过程,并通过Integer.highestOneBit()返回一个满足条件的数字。
final boolean initHashSeedAsNeeded(int capacity)
1 | final boolean initHashSeedAsNeeded(int capacity) { |
初始化一个散列掩码(hashing mask value)。只有在实际需要的时候才会初始化这个值,也就是延迟初始化。在初始化时hashSeed的默认值为0。在我们尚未给hashmap赋值时,sun.misc.VM.isBooted()得到的是true,ALTERNATIVE_HASHING_THRESHOLD的值为Integer.MAX_VALUE = 2147483647 [0x7fffffff]。所以useAltHashing的值为false。第5行代码执行得到false。
该方法用来计算一个hashSeed,hashSeed不为0的时候,对 String 类型的key 采用sun.misc.Hashing.stringHash32的 hash算法;对非 String 类型的 key,多一次和hashSeed的异或,这样可以一定程度上减少碰撞的概率。但是由于在产生hashSeed的过程中调用了Romdum.nextInt()方法,而该方法内部使用的AtomicLong,其操作类型是CAS(Compare And Swap),所以多线程环境中性能无法满足要求,所以在JDK 7u40之后被移除,且在JDK 8中也不再使用该字段。
private void putAllForCreate(Map<? extends K, ? extends V> m)
1 | private void putAllForCreate(Map<? extends K, ? extends V> m) { |
将m集合中的键值对保存到当前map集合中。调用的是putForCreate(K key, V value)方法。
private void putForCreate(K key, V value)
1 | private void putForCreate(K key, V value) { |
将key-value键值对映射存储到当前map集合中。在构造器和伪构造器方法(clone,readObject)中替换put方法完成键值对插入操作。该方法不会对map集合做扩容处理,检查修改状态等。而且该方法调用createEntry,而不是addEntry完成操作。
第2行代码根据key完成hashCode值计算。之后根据indexFor()方法返回该hashcode在散列表中的下标位置。
第10 ~ 17行代码遍历当前map集合,如果当前map集合中存在和参数key相同的键值对映射,那么就替换键值对的valu值,否则调用createEntry(int hash, K key, V value, int bucketIndex)将键值对加入到当前map集合中。
final int hash(Object k)
1 | final int hash(Object k) { |
一种补充的计算hashcode的方法。计算得到的随机hashSeed,来降低冲突发生的几率,如果hashSeed不等于0,且key是String字符串,那么通过方法sun.misc.Hashing.stringHash32()完成hashcode计算。否则
这个方法的目的是对key的hashcode进行扰动计算,防止不同hashcode的高位不同但低位相同导致的hash冲突,尽量做到key的hashcode任何一位的变化都能对最终结果产生影响。
static int indexFor(int h, int length)
1 | static int indexFor(int h, int length) { |
将hashcode转换成散列表中的下标。利用&做取模运算得到hashcode在散列表中的下标。
void createEntry(int hash, K key, V value, int bucketIndex)
1 | void createEntry(int hash, K key, V value, int bucketIndex) { |
创建一个键值对映射。table[buckedIndex]维护的是一个Entry结构的链表,得到这个链表结构,同时第3行代码会在第bucketIndex位置上创建一个新的对象实体,该对象实体的next节点是当前Entry结构的首部元素节点,最后同步size值,该值维护了当前map集合中存储的键值对数量。操作示意图如图2所示:
Alpha为createEntry方法执行前map集合的状态,Bravo为createEntry方法执行后的场景,如果根据key计算hashcode得到数组下标bucketIndex位置尚未存储元素,那么新创建的键值对实体会是该下标位置的第一个元素,如图2Bravo里的键值对“A11=123”。如果数组下标bucketIndex位置已经存储了元素,那么第2代码和第三行代码中的new Entry<>(hash, key, value, e)部分执行的是Bravo阶段中步骤(1)的结果,新创建了一个键值对实体“B11=12”,且将键值对“B11=12”的next节点指向了键值对实体”B12=123“。第3行代码执行完后,Bravo阶段中步骤(2)也就执行完成了,新创建的键值对实体“B11=12”成为下标位置N-3处的链表结构的首部节点。
public V put(K key, V value)
1 | public V put(K key, V value) { |
将传入的key-value映射保存到当前map集合中。如果当前map集合中已经存在了key对应的键值对映射,那么原有映射的value值会被传入的value替换。
如果当前map集合为空,那么调用inflateTable(int toSize)方法完成map底层数组初始化操作。
如果key为null,那么调用putForNullKey(V value)加入一个key为null的键值对映射。
计算key的hashcode值和在底层数组中的下标位置。取得该下标位置的映射实体对象,如果对象为空,那么调用addEntry(int hash, K key, V value, int bucketIndex)完成键值对添加。否则执行第9 ~ 17行代码替换已存在键值对映射的value值并返回被替换的值。因为put操作属于结构化修改,所以更新modCount的值。
private V putForNullKey(V value)
1 | private V putForNullKey(V value) { |
向当前map集合中存储一个key为null的键值对映射。由于key为null时其hashcode为0,所以其在数组中的下标为0。所以取得下标为0的键值对实体,如果实体存在,直接替换下标为0的实体的value值,并返回被替换的值。否则调用addEntry(int hash, K key, V value, int bucketIndex)完成键值对添加。
void addEntry(int hash, K key, V value, int bucketIndex)
1 | void addEntry(int hash, K key, V value, int bucketIndex) { |
向当前map集合中加入一个键值对映射。
如果当前map存储的键值对数量达到了扩容阈值限制(capacity*load factor),且计算得到的bucketIndex下标位置处已经被占用,那么首先执行扩容处理。扩容规则是按照当前容量的两倍进行扩容。扩容完成后重新计算key的hashcode值和在数组中的下标位置。
通过createEntry(int hash, K key, V value, int bucketIndex)完成键值对的创建和保存。
public void putAll(Map<? extends K, ? extends V> m)
1 | public void putAll(Map<? extends K, ? extends V> m) { |
将集合m中的所有键值对映射都存储到当前map集合中。
如果m集合为空,那么不做任何处理直接返回。如果当前map集合为空,那么先完成初始化操作。如果存储m集合中的键值对需要的空间超过了当前map集合的扩容限制阈值threshold,那么第20行代码会计算存储m集合需要的空间大小,第21 ~ 22行代码计算最终确定的目标空间大小,第23 ~ 25行代码以当前map空间的两倍逐次扩容直至满足目标空间大小的要求。第26 ~ 27行代码如果需要的空间超过了当前map集合的数组物理空间大小,那么就执行扩容处理。
扩容完成后,遍历集合m,将m中的每个键值对映射加入到当前map集合中。
void resize(int newCapacity)
1 | void resize(int newCapacity) { |
扩容当前map集合。扩容之后重新计算集合中已有键值对映射的hashcode和下标值并完成键值对存储位置的更新。如果当前map集合的容量已经达到了最大容量限制($2^{30} $),那么不再扩容,并返回扩容限制阈值。
初始化一个新的数组,调用transfer(Entry[] newTable, boolean rehash)将当前map集合中的键值对都迁移到扩容后的新数组中,最后计算更新扩容限制阈值threshold。
void transfer(Entry[] newTable, boolean rehash)
1 | void transfer(Entry[] newTable, boolean rehash) { |
将当前map集合中的键值对映射从旧数组迁移到新数组中。
遍历旧数组中的每个键值对实体,如果需要重新计算hashcode值,那么调用hash(Object k)重新计算hashcode值。之后重新计算每个键值对在新数组中的下标位置。第10行代码会将每个键值对的后继节点统一置为null,表示每个链表只会有一个元素。第11行代码将键值对实体存储到新数组中对应下标位置上。操作过程如图3所示:
遍历旧数组table的每个键值对映射实体,首先得到某个下标位置链表结构的首部节点和首部节点的后继节点,并决定是否需要重新计算hashcode值。之后根据hashcode和新数组容量计算出当前节点在新数组的下标位置,这里有两种情况需要考虑,一种是新数组的下标位置为空,尚未存储元素,另外一种是新数组的下标位置已经有键值对实体存储了。
如果是前一种情况,第10行代码执行后,在图3中是步骤(1)的情况,第11行代码执行后新数组下标位置的链表首部元素就是当前节点,即步骤(2)。之后将next指针指示的节点赋值给当前节点循环计算直至到达链表尾部。
如果是后一种情况,第10行代码执行过程中,首先经历步骤(4.1),该过程会将现有新数组下标位置的首部元素指针从数组上断开,继而经历步骤(4.2),将当前遍历节点的后继指针指向链表首部元素。第11行代码执行后会经历步骤(5),当前元素会被加入到新数组下标位置链表的首部。之后将next指针指向的元素赋值给当前遍历节点循环遍历直至链表遍历结束。
public V remove(Object key)
1 | public V remove(Object key) { |
删除当前map集合中和入参key匹配的键值对映射,并返回被删除的value值。底层调用removeEntryForKey(Object key)方法完成操作。
final Entry<K,V> removeEntryForKey(Object key)
1 | final Entry<K,V> removeEntryForKey(Object key) { |
删除当前map集合中和入参key匹配的键值对映射,并返回被删除的键值对实体。如果当前map集合为空,那么直接返回null。
根据key计算出key对应的hashcode和在数组中的下标值。实际流程如图4所示:
第5 ~ 9行代码计算当前入参key的hashcode值以及根据hashcode值计算匹配key的键值对在数组中的下标位置。取得该下标处链表结构的首部节点,开始遍历,找到hashcode值一致且key相等节点。如果该节点正好是链表的第一个节点,那么执行第17 ~ 18行代码后图3中键值对实体“B12=123”会被移除链表并返回。如果该节点是链表中的某个节点,那么执行第17 ~ 18行代码后图3中键值对实体“B13=123”会被移除链表并返回。
如果没有找到匹配的key,那么返回null。
final Entry<K,V> removeMapping(Object o)
1 | final Entry<K,V> removeMapping(Object o) { |
从当前集合中删除实体o(o被期望为一个键值对实体对象)。第5行代码将对象o转成了一个键值对实体,并计算出该键值对key的hashcode值以及其在当前map集合中的下标位置,然后根据下标找到该下标位置处的链表结构,找到与o的key匹配的键值对实体(比较整个entry实体的hashcode值),并删除。最后返回被删除的键值对实体对象。
public void clear()
1 | public void clear() { |
清空整个数组实现清空map集合的功能。
public V get(Object key)
1 | public V get(Object key) { |
获取map集合中key对应的value值,如果没有对应映射,那么返回null。如果key为null,那么调用getForNullKey()方法得到null对应的键值对映射的value信息。否则调用getEntry(Object key)完成查找。最后返回结果给方法调用方。
private V getForNullKey()
1 | private V getForNullKey() { |
map集合中key为null的元素只有一个,且其hashcode为0,所以在散列表中的下标为0。所以第5 ~ 8行代码会直接取下标为0的元素实体,并返回。
public boolean containsKey(Object key)
1 | public boolean containsKey(Object key) { |
判断当前map集合中是否含有key对应的键值对映射,如果有,那么返回true。否则返回false。
public boolean containsValue(Object value)
1 | public boolean containsValue(Object value) { |
判断当前map集合中是否含有入参value匹配的键值对实体。如果有返回true,否则返回false。
如果value为null,那么调用containsNullValue()方法完成操作并返回结果。否则第6 ~ 10行代码中外层遍历底层数组中的每个位置的链表集合,里层遍历每个链表集合中的每个键值对实体,检查是否有匹配value的键值对实体对象,如果有返回true,否则返回false。
private boolean containsNullValue()
1 | private boolean containsNullValue() { |
判断当前map集合中是否含有value为null的键值对实体。如果有返回true,否则返回false。
第3 ~ 6行代码中外层遍历底层数组中的每个位置的链表集合,里层遍历每个链表集合中的每个键值对实体,检查是否有value为null的键值对实体对象,如果有返回true,否则返回false。
final Entry<K,V> getEntry(Object key)
1 | final Entry<K,V> getEntry(Object key) { |
返回key对应的键值对映射对象实体。如果当前map集合为空,那么直接返回null。调用hash(Object k)计算key的hashcode。第7行代码根据计算得到的hashcode值得到底层数组中对应下标位置处的链表集合,取出存储的value值并返回。
public int size()
1 | public int size() { |
返回当前map集合中存储的键值对数量。
public boolean isEmpty()
1 | public boolean isEmpty() { |
判断当前map集合是否为空。
public Object clone()
1 | public Object clone() { |
克隆复制一个当前map集合副本并返回。该副本是当前map集合的浅度复制对象。
Iterator<K> newKeyIterator()
1 | Iterator<K> newKeyIterator() { |
返回一个基于当前map集合的key的迭代器。
Iterator<V> newValueIterator()
1 | Iterator<V> newValueIterator() { |
返回一个基于当前map集合的value的迭代器。
Iterator<Map.Entry<K,V>> newEntryIterator()
1 | Iterator<Map.Entry<K,V>> newEntryIterator() { |
返回一个基于当前map集合的键值对的迭代器。
public Set<K> keySet()
1 | public Set<K> keySet() { |
返回当前map集合包含的所有key的一个Set形式集合。对这个集合做的修改会同步出现在当前map集合中,反之亦然。KeySet的实现如下:
1 | private final class KeySet extends AbstractSet<K> { |
public Collection<V> values()
1 | public Collection<V> values() { |
返回当前map集合中包含的所有value的一个Collection形式集合。对这个集合做的修改会同步出现在当前map集合中,反之亦然。Values的实现如下:
1 | private final class Values extends AbstractCollection<V> { |
public Set<Map.Entry<K,V>> entrySet()
1 | public Set<Map.Entry<K,V>> entrySet() { |
返回当前map集合中包含的所有键值对实体的Set表示形式。对这个集合做的修改会同步出现在当前map集合中,反之亦然。EntrySet的实现如下:
1 | private final class EntrySet extends AbstractSet<Map.Entry<K,V>> { |
private void writeObject(java.io.ObjectOutputStream s)
1 | private void writeObject(java.io.ObjectOutputStream s) |
hashmap自定义序列化操作。
private void readObject(java.io.ObjectInputStream s)
1 | private void readObject(java.io.ObjectInputStream s) |
hashmap自定义反序列操作。
int capacity()
1 | int capacity() { return table.length; } |
float loadFactor()
1 | float loadFactor() { return loadFactor; } |
Entry<K,V>
1 | static class Entry<K,V> implements Map.Entry<K,V> { |
Entry
Entry
实体自身的hashcode计算方式是将当前实体的key的hashcode和value的hashcode求异或运算得到结果。
比较两个实体是否相等,首先判断两个实体的类型是否一致,其次分别判断两个实体的key和value是否都相等。
HashIterator<E>
1 | private abstract class HashIterator<E> implements Iterator<E> { |
HashIterator是基于当前map集合的键值对实体的一个迭代器实现。构造函数HashIterator()会定位到当前map集合中第一个不为null的键值对实体上。
根据next是否为null来判断是否可以继续向后遍历。
在nextEntry()方法中,执行逻辑如图5所示:
在完成HashIterator初始化完成之后,next指针会指向当前数组中第一个非空的键值对实体对象。如果当前指向处理的键值对实体的后继节点为空,那么就继续向后遍历数组,直到找到下一个非空的键值对实体链表结构并将next指针指向该链表的首部节点。得到当前处理的键值对实体对象节点并返回该对象。
借助remove()方法完成删除当前节点的操作。取得当前键值对实体对象中的key值信息,调用HashMap的removeEntryForKey()方法将该节点删除,同时更新expectedModCount。
涉及基础知识点
为什么要把数组的Size设置为2的N次方
- indexFor利用&做取模运算计算散列表下标值,a & b中要求b的二进制位全部由1构成,所以需要散列表长度为$ 2^n $ 。此外,利用位操作实现取模运算比算术取模运算效率非常好,所以这么做可以提高运行效率;
- 不同的hash值发生碰撞的概率相对来说比较小,可以让键值对实体在数组中均匀分布;
加载因子对map集合查找效率的影响
- 加载因子越大,填满的数组元素越多,分配的内存空间空闲小,链表长度增加,查找效率变低;
- 加载因子越小,填满的数组元素越少,分配的内存空间空闲多,链表长度减少,查找效率变高;
因为在addEntry()方法中,由两个因素决定是否需要进行扩容操作:(size >= threshold) 和 (null != table[bucketIndex])。即使(null != table[bucketIndex])条件满足了,由于扩容限制阈值太大导致前一个条件size >= threshold一直无法满足,因此无法执行扩容操作,只能在链表首部不停的追加元素,导致链表长度变大。
参考文献
- ChiuCheng. 深入理解HashMap(二): 关键源码逐行分析之hash算法 [E]
- csfreebird. Java HashMap 分析之三:放入元素 [E]
- Yikun. Java HashMap工作原理及实现 [E]
- ImportNew. HashMap的工作原理 [E]
- Liujiacai. Java HashMap 源码解析 [E]
- Stack Overflow. Understanding strange Java hash function [E]
- Stack Overflow. Can anybody explain how java design HashMap’s hash() function? [E]
- Hollis. 全网把Map中的hash()分析的最透彻的文章,别无二家 [E]
- Carson_Ho. Java:这是一份详细&全面的HashMap 1.7 源码分析 [E]
- zhihu. JDK 源码中 HashMap 的 hash 方法原理是什么? [E]
- miaoLoveCode. Java中的HashMap [E]