历史热门文章:
前言
在一场面试中最能打动面试官的其实是细节,候选人对细节的了解程度决定了留给面试官的印象到底是“基础扎实”还是“基础薄弱”,如果候选人能够举一反三主动阐述自己对一些技术细节的理解和总结,那无疑是面试过程中的一大亮点。HashMap
是一个看着简单,但其实里面有很多技术细节的数据结构,在一场高端的面试中即使不问任何红黑树(Java 8
中HashMap
引入了红黑树来处理极端情况下的哈希碰撞)相关的问题,也会有很多的技术细节值得挖掘。
把书读薄
在Java 7
中HashMap
实现有1000多行,到了Java 8
中增长为2000多行,虽然代码行数不多,但代码中有比较多的位运算,以及其他的一些细枝末节,导致这部分代码看起来很复杂,理解起来比较困难。但是如果我们跳出来看,HashMap
这个数据结构是非常基础的,我们大脑中首先要有这样一幅图:
图片来源:https://www.cnblogs.com/tianzhihensu/p/11972780.html
这张图囊括了HashMap中最基础的几个点:
Java
中HashMap
的实现的基础数据结构是数组,每一对key
->value
的键值对组成Entity
类以双向链表的形式存放到这个数组中- 元素在数组中的位置由
key.hashCode()
的值决定,如果两个key
的哈希值相等,即发生了哈希碰撞,则这两个key
对应的Entity
将以链表的形式存放在数组中 - 调用
HashMap.get()
的时候会首先计算key
的值,继而在数组中找到key
对应的位置,然后遍历该位置上的链表找相应的值。
当然这张图中没有体现出来的有两点:
- 为了提升整个
HashMap
的读取效率,当HashMap
中存储的元素大小等于桶数组大小乘以负载因子的时候整个HashMap
就要扩容,以减小哈希碰撞,具体细节我们在后文中讲代码会说到 - 在
Java 8
中如果桶数组的同一个位置上的链表数量超过一个定值,则整个链表有一定概率会转为一棵红黑树。
整体来看,整个HashMap
中最重要的点有四个:初始化,数据寻址-hash
方法,数据存储-put
方法,扩容-resize
方法,只要理解了这四个点的原理和调用时机,也就理解了整个HashMap
的设计。
把书读厚
在理解了HashMap
的整体架构的基础上,我们可以试着回答一下下面的几个问题,如果对其中的某几个问题还有疑惑,那就说明我们还需要深入代码,把书读厚。
HashMap
内部的bucket
数组长度为什么一直都是2的整数次幂HashMap
默认的bucket
数组是多大HashMap
什么时候开辟bucket
数组占用内存HashMap
何时扩容?- 桶中的元素链表何时转换为红黑树,什么时候转回链表,为什么要这么设计?
Java 8
中为什么要引进红黑树,是为了解决什么场景的问题?HashMap
如何处理key
为null
的键值对?
new HashMap()
在JDK 8
中,在调用new HashMap()
的时候并没有分配数组堆内存,只是做了一些参数校验,初始化了一些常量
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
tableSizeFor
的作用是找到大于cap
的最小的2的整数幂,我们假设n(注意是n,不是cap哈)对应的二进制为000001xxxxxx,其中x代表的二进制位是0是1我们不关心,
n |= n >>> 1;
执行后n
的值为:
可以看到此时n
的二进制最高两位已经变成了1(1和0或1异或都是1),再接着执行第二行代码:
可见n
的二进制最高四位已经变成了1,等到执行完代码n |= n >>> 16;
之后,n
的二进制最低位全都变成了1,也就是n = 2^x - 1
其中x和n的值有关,如果没有超过MAXIMUM_CAPACITY
,最后会返回一个2的正整数次幂,因此tableSizeFor
的作用就是保证返回一个比入参大的最小的2的正整数次幂。
在JDK 7
中初始化的代码大体一致,在HashMap
第一次put
的时候会调用inflateTable
计算桶数组的长度,但其算法并没有变:
// 第一次put时,初始化table
private void inflateTable(int toSize) {
// Find an power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry(capacity);
initHashSeedAsNeeded(capacity);
}
这里我们也回答了开头提出来的问题:
HashMap
什么时候开辟bucket
数组占用内存?答案是在HashMap
第一次put
的时候,无论Java 8
还是Java 7
都是这样实现的。这里我们可以看到两个版本的实现中,桶数组的大小都是2的正整数幂,至于为什么这么设计,看完后文你就明白了。
hash
在HashMap
这个特殊的数据结构中,hash
函数承担着寻址定址的作用,其性能对整个HashMap
的性能影响巨大,那什么才是一个好的hash
函数呢?
- 计算出来的哈希值足够散列,能够有效减少哈希碰撞
- 本身能够快速计算得出,因为
HashMap
每次调用get
和put
的时候都会调用hash
方法
下面是Java 8
中的实现:
static final int hash(Object key) {
int h;
return (key == null)