为什么学习 HashMap 源码?
作为一名 java 开发,基本上最常用的数据结构就是 HashMap 和 List,jdk 的 HashMap 设计还是非常值得深入学习的。
无论是在面试还是工作中,知道原理都对会我们有很大的帮助。
本篇的内容较长,建议先收藏,再细细品味。
不同于网上简单的源码分析,更多的是实现背后的设计思想。
涉及的内容比较广泛,从统计学中的泊松分布,到计算机基础的位运算,经典的红黑树、链表、数组等数据结构,也谈到了 Hash 函数的相关介绍,文末也引入了美团对于 HashMap 的源码分析,所以整体深度和广度都比较大。
思维导图如下:
本文是两年前整理的,文中不免有疏漏过时的地方,欢迎大家提出宝贵的意见。
之所以这里拿出来,有以下几个目的:
(1)让读者理解 HashMap 的设计思想,知道 rehash 的过程。下一节我们将自己实现一个 HashMap
(2)为什么要自己实现 HashMap?
最近在手写 redis 框架,都说 redis 是一个特性更加强大的 Map,自然 HashMap 就是入门基础。Redis 高性能中一个过人之处的设计就是渐进式 rehash,和大家一起实现一个渐进式 rehash 的 map,更能体会和理解作者设计的巧妙。
想把常见的数据结构独立为一个开源工具,便于后期使用。比如这次手写 redis,循环链表,LRU map 等都是从零开始写的,不利于复用,还容易有 BUG。
好了,下面就让我们一起开始 HashMap 的源码之旅吧~
HashMap 源码
HashMap 是平时使用到非常多的一个集合类,感觉有必要深入学习一下。
首先尝试自己阅读一遍源码。
java 版本
$ java -version
java version "1.8.0_91"
Java(TM) SE Runtime Environment (build 1.8.0_91-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.91-b14, mixed mode)
数据结构
从结构实现来讲,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的。
对于当前类的官方说明
基于哈希表实现的映射接口。这个实现提供了所有可选的映射操作,并允许空值和空键。(HashMap类大致相当于Hashtable,但它是非同步的,并且允许为空。)
这个类不保证映射的顺序;特别地,它不能保证顺序会随时间保持不变。
这个实现为基本操作(get和put)提供了恒定时间的性能,假设哈希函数将元素适当地分散在各个桶中。对集合视图的迭代需要与HashMap实例的“容量”(桶数)及其大小(键-值映射数)成比例的时间。因此,如果迭代性能很重要,那么不要将初始容量设置得太高(或者负载系数太低),这是非常重要的。
HashMap实例有两个影响其性能的参数: 初始容量和负载因子。
容量是哈希表中的桶数,初始容量只是创建哈希表时的容量。负载因子是在哈希表的容量自动增加之前,哈希表被允许达到的最大容量的度量。当哈希表中的条目数量超过负载因子和当前容量的乘积时,哈希表就会被重新哈希(也就是说,重新构建内部数据结构),这样哈希表的桶数大约是原来的两倍。
一般来说,默认的负载因子(0.75
)在时间和空间成本之间提供了很好的权衡。
较高的值减少了空间开销,但增加了查找成本(反映在HashMap类的大多数操作中,包括get和put)。在设置映射的初始容量时,应该考虑映射中的期望条目数及其负载因子,以最小化重哈希操作的数量。如果初始容量大于条目的最大数量除以负载因子,就不会发生重哈希操作。
如果要将许多映射存储在HashMap实例中,那么使用足够大的容量创建映射将使映射存储的效率更高,而不是让它根据需要执行自动重哈希以增长表。
注意,使用具有相同hashCode()的多个键确实可以降低任何散列表的性能。为了改善影响,当键具有可比性时,这个类可以使用键之间的比较顺序来帮助断开连接。
注意,这个实现不是同步的。如果多个线程并发地访问散列映射,并且至少有一个线程在结构上修改了映射,那么它必须在外部同步。(结构修改是添加或删除一个或多个映射的任何操作;仅更改与实例已经包含的键关联的值并不是结构修改。这通常是通过对自然封装映射的对象进行同步来完成的。
如果不存在这样的对象,则应该使用集合“包装” Collections.synchronizedMap 方法。这最好在创建时完成,以防止意外的对映射的非同步访问:
Map m = Collections.synchronizedMap(new HashMap(...));
这个类的所有“集合视图方法”返回的迭代器都是快速失败的:如果在创建迭代器之后的任何时候对映射进行结构上的修改,除了通过迭代器自己的remove方法,迭代器将抛出ConcurrentModificationException。因此,在并发修改的情况下,迭代器会快速而干净地失败,而不是在未来的不确定时间内冒着任意的、不确定的行为的风险。
注意,迭代器的快速故障行为不能得到保证,因为一般来说,在存在非同步并发修改的情况下,不可能做出任何硬性保证。快速失败迭代器以最佳的方式抛出ConcurrentModificationException。因此,编写依赖于此异常的程序来保证其正确性是错误的:迭代器的快速故障行为应该仅用于检测错误。
其他基础信息
这个类是Java集合框架的成员。
@since 1.2
java.util 包下
源码初探
接口
public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable {}
当前类实现了三个接口,我们主要关心 Map
接口即可。
继承了一个抽象类 AbstractMap
,这个暂时放在本节后面学习。
常量定义
默认初始化容量
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 <4; // aka 16
- 为什么不直接使用 16?
看了下 statckoverflow,感觉比较靠谱的解释是:
为了避免使用魔法数字,使得常量定义本身就具有自我解释的含义。
强调这个数必须是 2 的幂。
- 为什么要是 2 的幂?
它是这样设计的,因为它允许使用快速位和操作将每个键的哈希代码包装到表的容量范围内,正如您在访问表的方法中看到的:
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) { ///
...
最大容量
隐式指定较高值时使用的最大容量。
由任何带有参数的构造函数。
必须是2的幂且小于 1<<30。
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 <30;
- 为什么是 1 << 30?
当然了 interger 的最大容量为 2^31-1
除此之外,2**31是20亿,每个哈希条目需要一个对象作为条目本身,一个对象作为键,一个对象作为值。
在为应用程序中的其他内容分配空间之前,最小对象大小通常为24字节左右,因此这将是1440亿字节。
可以肯定地说,最大容量限制只是理论上的。
感觉实际内存也没这么大!