HashMap为什么会是面试中的常客呢?我觉得有以下几点原因:
* 考察你阅读源码的能力
* 是否了解内部数据结构
* 是否了解其存储和查询逻辑
* 对非线程安全情况下的使用考虑
前段时间一同事面试蚂蚁金服,就被问到了这个问题;其实很多情况下都是从hashMap,hashTable,ConcurrentHahMap三者之间的关系衍生而出,当然也有直接就针对hashMap原理直接进行考察的。实际上本质都一样,就是为了考察你是否对集合中这些常用集合的原理、实现和使用场景是否清楚。一方面是我们开发中用的多,当然用的人也就多,但是用的好的人却不多(我也用的多,用的也不好)。所以就借此机会(强行蹭一波)再来捋一捋这个HashMap。
本文基于jdk1.7.0_80;jdk 1.8之后略有改动,这个后面细说。
继承关系
1 | 复制代码public class HashMap<K,V> |
hashMap实现了Map、Cloneable、Serializable三个接口,并且继承了AbstractMap这个抽象类。hashTable继承的是Dictionary这个类,同时也实现了Map、Cloneable、Serializable三个接口。
主要属性
- DEFAULT_INITIAL_CAPACITY 默认初始容量 16 (hashtable 是11) 常量
1 | 复制代码 /** |
- MAXIMUM_CAPACITY 默认最大容量 常量
1 | 复制代码/** |
- DEFAULT_LOAD_FACTOR 负载因子(默认0.75) 常量
1 | 复制代码/** |
- EMPTY_TABLE 默认的空表
1 | 复制代码/** |
- table 表,必要时调整大小。长度必须是两个幂。
这个也是hashmap中的核心的存储结构
1 | 复制代码/** |
- size 表示HashMap中存放KV的数量(为链表/树中的KV的总和)
1 | 复制代码/** |
- threshold 扩容变量,表示当HashMap的size大于threshold时会执行resize操作。
threshold=capacity*loadFactor
1 | 复制代码/** |
- loadFactor 负载因子 负载因子用来衡量HashMap满的程度。loadFactor的默认值为0.75f。计算HashMap的实时装载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。(桶的概念后续介绍)
1 | 复制代码 /** |
- modCount
这个HashMap的结构修改的次数是那些改变HashMap中的映射数量或修改其内部结构(例如rehash)的那些。这个字段用于使迭代器对HashMap失败快速的集合视图。(见ConcurrentModificationException)。
1 | 复制代码/** |
- hashSeed 与此实例相关联的随机值,用于哈希键的散列代码,使散列冲突更难找到。如果0,那么替代哈希是禁用的。
1 | 复制代码/** |
结构分析
1 | 复制代码static class Entry<K,V> implements Map.Entry<K,V> |
hashmap中是通过使用一个继承自Map中内部类Entry的Entry静态内部类来存储每一个K-V值的。看下具体代码:
1 | 复制代码static class Entry<K,V> implements Map.Entry<K,V> { |
HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干(也就是上面的table–桶)。
看一张图:
hashmap初始化时各个空间的默认值为null,当插入元素时(具体插入下面分析),根据key值来计算出具体的索引位置,如果重复,则使用尾插入法进行插入后面链表中。
- 尾插法
之前我是通过插入17条数据来试验的(具体数据数目随意,越大重复的几率越高)
1 | 复制代码public static void main(String[] args) throws Exception { |
通过断点查看next,可以得出我们上面的结论:
1.索引冲突时会使用链表来存储;
2.插入链表的方式是从尾部开始插入的(官方的解释是一般情况下,后来插入的数据被使用的频次较高),这样的话有利于查找。
主要方法
我们平时在开发是最常用的hashMap中的方法无非就是先创建一个HashMap对象,然后存,接着取;对应的方法就是:
- 构造函数
- put函数
- get函数
构造函数
1 | 复制代码 /** |
另外两个构造方法实际上都是对上面这个构造方法的调用:
1 | 复制代码//只制定默认容量 |
还有一个是:
1 | 复制代码public HashMap(Map<? extends K, ? extends V> m) { |
构造一个映射关系与指定 Map 相同的新 HashMap。所创建的 HashMap 具有默认加载因子 (0.75) 和足以容纳指定 Map 中映射关系的初始容量。
put方法
首先,我们都知道hashmap中的key是允许为null的,这一点也是面试中最常问到的点。那我先看下为什么可以存null作为key值。
1 | 复制代码public V put(K key, V value) { |
hash方法:
检索对象哈希代码,并将附加哈希函数应用于结果哈希,该哈希函数防止质量差的哈希函数。 这是至关重要的,因为HashMap使用两个长度的哈希表,否则会碰到hashCode的冲突,这些hashCodes在低位上没有区别。 注意:空键总是映射到散列0,因此索引为0。
1 | 复制代码/** |
冲突具体过程描述:
- 一个空的hashmap表
- 插入元素,通过hash计算得出索引为3,因为当前3的位置没有元素,因此直接插入进去即可
- 再次插入元素,通过hash计算得出索引还是3,发生冲突,则将当前新插入的元素放在原来的已有的元素位置,并将其next指向原来已经存在的元素。
get方法
返回指定键映射到的值;如果此映射不包含键映射,则返回null。
1 | 复制代码 public V get(Object key) { |
getEntry方法
1 | 复制代码 final Entry<K,V> getEntry(Object key) { |
扩容机制
在前面提到过threshold,扩容变量,表示当HashMap的size大于threshold时会执行resize操作。其计算方式是:threshold=capacity*loadFactor。
从上面的式子中我们可以得知hashmap的扩容时机是当前当前size的值超过容量乘以负载因子时就会触发扩容。来看下源码:
1 | 复制代码void addEntry(int hash, K key, V value, int bucketIndex) { |
需要注意的是,扩容并不是在hashmap满了之后才进行的,看下面断点:
通过默认构造函数new了一个map对象出来,通过for循环插入12条数据,断点到执行结束,我们看到当前table的容量是16,扩容变量threshold为12(16x0.75),现在我们将12改为13.
此时13还是小于16的,但是还是触发了hashmap 的扩容。当前table容量为32(扩容为了之前的两倍),threshold为24(32x0.75),通过这两种图我们得知:
- 每次扩容之后的容量为原有容量的两倍(2n)
- 触发扩容并不是因为当前hashmap对象已经满了,而是通过threhold扩容变量来控制触发时机的。
小结
本文就单纯的扒了一波源码,并对源码中的注释并结合自己的理解进行了翻译,通过断点调试简单的介绍了尾插法在hashmap的应用。最后通过几张图描述了下hashmap发生索引冲突时的解决方案。hashmap在面试时真的是可深可浅,但是源码的阅读还是很有必要的,下面推荐两篇博客给大家。
- 1.关于hashmap与hashtable的具体对比可以参考这个博客:
- 2.关于为什么hashmap中的容量必须是2的幂,这篇博客大家可以看下:
- 3.关于hashmap非线程安全的解释
本文转载自: 掘金