文章目录
1. 概述
HashMap 是 Java 集合框架中的一个重要的数据结构,在日常开发过程中的使用频率是非常高的。
2. HashMap 详解
2.1 为什么需要 HashMap?
HashMap 是基于数组+链表(红黑树)实现的。为什么需要 HashMap 呢? 我们先从数组和链表的数据结构特性看起。由下表可见,数组和链表的表现各有优劣。那么有没有一种数据结构,使元素增、删、改、查的效率都很高呢?有的!它就是我们今天要介绍的 HashMap。
数据结构类型 | 存储 | 增 | 删 | 查 |
---|---|---|---|---|
数组 | 空间大小固定,需要连续内存 | O(n) | O(n) | O(1) |
链表 | 空间动态扩展 | O(1) | O(1) | O(n) |
2.2 什么是 HashMap?
见名知意,HashMap 即哈希表。HashMap 拆开来看,一部分是 Hash,一部分是 Map。
- HashMap 实现了 Map 接口,我们知道 Map 是 key (键)和 value (值)的映射,所以 HashMap 也提供了所有可选的键值映射操作,并且允许空值和空键。
- HashMap 根据 key 的 HashCode 值存储数据。
综上,HashMap 的特点如下:
- HashMap 有数组和链表的优点,它的查询效率比链表高,动态可扩展性比数组好。
- 在理想情况下,即未发生任何的哈希碰撞,那么 get/put 方法的时间复杂度是 O(1)。
- 如果发生了哈希碰撞,那么 get 方法的时间复杂度是:链表- O(n) 或 红黑树-O(logn)。
- HashMap 不是同步的,即是非线程安全的。
- HashMap 中的映射不是有序的。
2.3 HashMap 的重要参数
HashMap 有2个重要参数会影响它的性能,分别是:初始容量(initial capacity)和负载因子(load factor)。
- 初始容量(initial capacity):哈希表创建时的容量。默认值 16。
- 负载因子(load factor):负载因子用于表示 HashMap 中数据的填满程度。默认值 0.75。
HashMap 中元素数量超过[HashMap容量]和[负载因子]的乘积时,会触发扩容(rehash)将容量翻倍。扩容是一个非常耗时的操作,因为扩容后会将之前小数组中的元素转移到大数组中,这个操作非常耗时。
Q:负载因子默认值为什么是 0.75?
负载因子的默认值是在时间和空间成本上寻求的一种折中。
- 负载因子越小,哈希表中能填满的数据就越少,哈希冲突的概率也就随之减少了。但这样会增大内存开销,而且还会使触发扩容的几率变大。
- 负载因子越大,哈希表中能填满的数据就越多,可以降低内存开销。但这样哈希冲突的概率会随之增大了,查询效率也就降低了。
为什么负载因子的默认值是 0.75 ,而不是 0.7 、0.6 呢? jdk 1.8 源码介绍如下。
因为TreeNode的大小约为链表节点的两倍,所以我们只有在一个拉链已经拉了足够节点的时候才会转为tree(参考TREEIFY_THRESHOLD)。并且,当这个hash桶的节点因为移除或者扩容后resize数量变小的时候,我们会将树再转为拉链。如果一个用户的数据他的hashcode值分布十分好的情况下,就会很少使用到tree结构。在理想情况下,我们使用随机的hashcode值,loadfactor为0.75情况,尽管由于粒度调整会产生较大的方差,桶中的Node的分布频率服从参数为0.5的泊松分布。下面就是计算的结果:桶里出现1个的概率到为8的概率。桶里面大于8的概率已经小于一千万分之一了。
What is the significance of load factor in HashMap?
3. HashMap 常见问题解析
3.1 HashMap 为什么要用 Hash 计算?
使用 Hash 算法可以提高存储空间的利用率,并提高数据的查询效率。HashMap 采用 Entry 数组来存储 key-value 对。如果哈希表中的元素按顺序存储,那么每次查询数据的时候都需要遍历表,时间复杂度是 O(n)。为了提升查询效率,HashMap 会根据 key 的 HashCode 来存储数据,这样通过 key 可以直接定位到其存储的数组下标 index,使得在没有哈希碰撞的情况下查询时间复杂度可达到 O(1)。
3.2 HashMap 扰动函数的作用?
- 扰动函数 下图1是 jdk 1.8 中 HashMap 的扰动函数。你可能会有疑问,直接取 hashCode 不就可以了吗?为什么还要这样复杂计算呢?先上结论:使用扰动函数的目的是为了增加随机性,让数据元素更加均衡的散列,减少碰撞。 在 HashMap 的 get 、 put 方法中,主要有两段代码来计算 key 的位置,分别是(1)扰动函数—图1 hash()(2)与运算-图2 indexFor。如果不用扰动函数,直接 hashCode 后进行 & 运算, 那么碰撞会很严重。将 h >>> 16 后,h 右移16位刚好为 32 bit 的一半,相当于 h 自己的高半区和低半区做异或,这样混合了原始哈希码的高位和低位,加大了低位的随机性。而且混合后的低位掺杂了高位的部分特征,使高位的信息也被保留了下来。
- 为什么 indexFor() 需要进行与&运算呢?理论上来说,字符串的 hashCode 是一个 int 类型的值,其取值范围在[-231, 231-1],前后加起来一共 40 多亿的映射空间,只要哈希值映射的比较松散,一般是不会发生哈希碰撞的。但 40 多亿长度的数组,内存是放不下的,因此这个 hashCode 值不能直接拿来用,而是需要和数组的长度做取模运算后才可以使用,用作访问数组的下标。读到这里你可能又要问,取模运算不应该用 % 吗,为什么是 & ? 这是因为 & 运算比 % 更加高效。当 b = 2n时,下方表达式成立。这也正好解释了为什么 HashMap 的数组长度是2的整次方。
a % b = a & ( b − 1 ) a \% b = a \& (b-1) a%b=a&(b−1)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
3.3 为什么 HashMap 的数组长度是2的整次方?
因为当数组长度是 2n时,HashMap 的取模操作可以转化为与运算,与运算(&)比 取模运算(%) 更加高效。表达式如下。除此之外,2的整次幂刚好是偶数,偶数-1是奇数,奇数的二进制最后以为是1,保证了 h & (length-1) 的最后一位可能是 0 也可能是 1,这样便保证了哈希值的均匀性。
a
%
b
=
a
&
(
b
−
1
)
a \% b = a \& (b-1)
a%b=a&(b−1)
操作符 & :如果相对应位都是 1,则结果为1,否则为 0 。
3.4 HashMap 的扩容机制
扩容的过程如下:
当我们 put 一个元素到哈希表时会调用 addEntry 方法,在 addEntry 方法中会判断是否需要扩容,如果当前size>=阈值就会创建一个是原数组两倍长度的新数组(即 resize方法),在 resize 方法中会通过 transfer 方法将原数组中的 Entry 重新哈希(rehash)到新数组中,最后将原数组引用指向新数组,同时计算下新的阈值。
- 为什么要扩容?因为 HashMap 的底层使用的是数组,当数组无法承载更多元素时,就需要扩容,以便装入更多的元素。
- 扩容的触发条件?当哈希表的大小超过了 capacity * loadFactor 时,会触发自动扩容。
/**jdk7 中的 put 方法**/
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
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;
}
/**jdk7 中的 addEntry 方法**/
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
/**jdk7 中的 resize 方法**/
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
3.5 HashMap 为什么不是线程安全的?
主要有三方面原因:
- 多线程下扩容可能会死循环
- 多线程下 put 可能会导致元素丢失
- put 和 get 并发时可能导致 get 为 null