前言
最近阅读了许多HashMap实现及源码分析的文章,特意此文记录HashMap的知识点。
HashMap 底层由 数组 + 链表 组成,在 jdk1.7 和 1.8 中具体略有不同。
JDK1.7的HashMap
数据结构:图片来源
核心成员变量
- 初始化桶大小(1<<4,即:16),因为底层是数组,所以这是数组默认的大小。
- 桶容量最大值。
- 默认的负载因子(0.75)
- table 真正存放数据的数组。
- Map 中存放元素数量。
- 桶的容量大小,可在初始化时显式指定。
- 负载因子,可在初始化时显式指定。
负载因子
当 存放的键值对数量(size) = 桶容量(threshold) * 负载因子(loadFactor)
时,会发生扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。因此最好提前预估 HashMap 的大小,尽量的减少扩容带来的性能损耗。
Entry
Entry是HashMap的一个内部类,用于保存键值对,实现HashMap中的链表,主要成员变量:
- key:写入的键。
- value: 写入的值。
- next:开始的时候就提到 HashMap 是由数组和链表组成,所以这个 next 就是用于实现链表结构。
- hash: 存放的是当前 key 的 hashcode。
桶初始大小为16的原因
要解释这个问题,首先要知道这个容量的用途。容量就是一个HashMap中"桶"的个数(数组的大小),当想要往一个HashMap中put一个元素的时候,需要通过一定的算法计算出应该把他放到哪个桶中。HashMap中通过以下两个方法实现计算一个元素对应的桶(数组的索引)
int hash(Object k)
:该方法主要是将Object转换成一个整型。int indexFor(int h, int length)
:该方法主要是将hash生成的整型转换成链表数组中的下标。jdk1.8没有此方法,不过计算的方式相同。
static int indexFor(int h, int length) {
return h & (length-1);
}
在保证length(容量)是2^n 的前提下,h & (length-1)
相当于h % (length-1)
,即用位运算(&)代替取模运算(%)
Java之所有使用位运算(&)来代替取模运算(%),最主要的考虑就是效率。
位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。
为什么保证容量为2^n即使用位运算(&)来实现取模运算(%)
总结:因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高,所以HashMap在计算元素要存放在数组中的index的时候,使用位运算代替了取模运算。而等价代替,前提是要求HashMap的容量一定要是2^n。
由上述分析,容量只要为2^n即可,HashMap选择16的原因可能是个经验值。
既然一定要设置一个默认的2^n 作为初始值,那么就需要在效率和内存使用上做一个权衡。这个值既不能太小,也不能太大。太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。(官方未给出原因)
扩容
由上述分析:HashMap必须保证容量为2^n。因此在扩容时,HashMap会进行成倍的扩容(容量变为原来的2倍)。
扩容的步骤为:
- 新建数组:创建一个新的Entry空数组,长度是原数组的2倍。
- 重新计算hash:遍历原Entry数组,把所有的Entry重新Hash到新数组。
Put方法(头插法)
JDK1.7下的put方法添加新元素时使用头插法:即新来的值会成为头节点。
public V put(K key, V value) {
//判断当前数组是否需要初始化
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果 key 为空,则 put 一个空值进去。
if (key == null)
return putForNullKey(value);
//计算键值hash值
int hash = hash(key);
//查找对应的桶的索引
int i = indexFor(hash, table.length);
//遍历链表
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//遍历判断里面的 hashcode、key 是否和传入 key 相等,
//如果相等则进行覆盖,并返回原来的值。
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//添加新键值对(头插法),会判断是否需要扩容
addEntry(hash, key, value, i);
return null;
}
头插法的问题
使用头插法,在扩容时会反转链表上元素的顺序。在多线程及需要扩容的条件下,可能出现环形链表,造成死循环。
jdk1.7HashMap出现环路(有个例子,但我感觉不是特别清楚)
JDK1.8的HashMap
JDK1.7的HashMap在 Hash 冲突严重时,桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)。
因此JDK1.8重点解决的此问题。
数据结构:图片来源
主要区别
- 新的成员变量
TREEIFY_THRESHOLD
用于判断是否需要将链表转换为红黑树的阈值。链表长度大于等于该值时,会尝试转为红黑树(还需判断数组长度是否大于MIN_TREEIFY_CAPACITY
) - 新的成员变量
UNTREEIFY_THRESHOLD
用于判断是否需要红黑树转为链表的阈值。 - 用Node代替Entry,在达到红黑树阈值时,将链表转为红黑树提高查询效率。
- put方法添加新的元素时,由头插法改为尾插法。使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题。但在 HashMap 扩容的时候会调用 resize() 方法,此时并发操作仍然可能在一个桶上形成环形链表。
JDK1.8下的HashMap依旧是线程不安全的,只是用尾插法代替头插法解决了JDK1.7时,容易出现环形链表的问题。
转为红黑树的条件
默认情况下:链表长度大于 8(TREEIFY_THRESHOLD), 表的长度大于 64(MIN_TREEIFY_CAPACITY) 的时候会转化红黑树。