文章目录
前言:
如果面试官问你:
说说你对HashMap的理解?
是不是现在感觉有些摸不着头绪,没事,接下来,笔者就带着大家一起深度剖析Java7和Java8中HashMap的底层实现。
Java8的HashMap源码分析链接
版本声明:本文的Java版本:jdk-7u7-windows-x64
一.HashMap底层是怎么存储的?Entry是什么?
1.默认大小:
下面的代码就是Java中HashMap的底层实现——一个Entry类型的数组,默认长度是16.
/**
* The hash table data.
*/
private transient Entry<K,V>[] table;
static final int DEFAULT_INITIAL_CAPACITY = 16;
2.Entry是什么?
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
上面的代码来自Java源码,可以看出Entry是一个静态内部类 (外部类是HashMap),并且提供了如下的四个成员变量。
(1)、一个带final的key值 (不可变) ,类型是一个泛型K
(2)、一个参数类型是泛型V的 value值
(3)、一个Entry<K,V>类型的next变量,(确切的理解一个指针,或者是一个地址引用)。
(4)、一个int类型的哈希值。
3.为什么Java7的HashMap是数组+链表
通过阅读这一段短暂的源码,发现HashMap底层是一个数组,而数值中存储的是一个一个的Entry对象,而且每一个Entry对象都有一个next的成员变量,这个成员变量,存储的是一个Entry对象的引用。
总结
- 1.因为底层有一个Entry类型的数组,所以HashMap底层是由数组构成。
- 2.因为数组中每一个存储的Entry对象,其对象的成员变量中都有一个 next的成员变量,所以也就构成了链表。
疑问 ?
- 有的读者可能听说过,HashMap不是数组+链表+红黑树吗?
- Java7是数组+链表
- Java8是数组+链表+红黑树
- 因为是链表结构,所以新元素总会有一个存储的位置问题。
- Java7新添加的元素在最上面,就是新添加的元素在数组上,然后把原来数组上的引用赋值给新添加的next成员变量:即:新添加的的元素取代数组中Entry1的位置,而新添加的元素的next成员变量存储Entry1的地址值。
- Java8是直接将新元素的成员变量,直接赋值给链表上next成员变量为null的。即:新添加的元素的地址值赋值给Entry2的next成员变量。
二、构造器
1.调用无参构造器,底层数组长度是多少?
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
分析:当我们调用无参数的构造器的时候,我们实际上携带两个默认参数去调用两个参数对应的构造器。
那么,我i们就看看这两个默认参数是什么?
- 底层数组的默认长度
- 认参数为加载因子,后续谈到扩容条件的时候,会用到它,现在知道即可.
//底层数组的默认长度
static final int DEFAULT_INITIAL_CAPACITY = 16;
//默认加载因子,后续的扩容中会用到它,暂且记住即可。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
2.调用一个参数的构造器,指定长度为15,底层数组长度是15吗?
分析:当我们调用一个参数的构造器的时候,我们实际上携带一个指定的参数,一个默认参数去调用两个参数对应的构造器。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
- 指定的参数即为:我们指定的默认长度
- 默认参数为加载因子,后续谈到扩容条件的时候,会用到它,现在知道即可.
//默认加载因子,后续的扩容中会用到它,暂且记住即可。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
3.核心构造器,答案就在其中。。
提示:我们发现上面的两个构造器,其实最终都是调用的两个参数的构造器 (第一个参数是底层数组的默认长度,第二个长度是加载因子) ,那么我们就来看看这个两个参数的构造器里面有什么特别之处。
为了提示方便,所以把代码块分成了5部分,即为代码块0到代码块4.
public HashMap(int initialCapacity, float loadFactor) {
//代码块0开始========================================
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);
//代码块1开始========================================
// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
//代码块2开始========================================
this.loadFactor = loadFactor;
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//代码块3开始========================================
table = new Entry[capacity];
useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
init();
}
源码解析 :
- 前面的三个if判断主要是对输入的数据进行校验。
- 然后进入正题,分析一下最核心的代码。
int capacity = 1;
//一只循环,知道capacity 大于你输出的初始长度为止
while (capacity < initialCapacity)
capacity <<= 1;
//将加载因子赋值给该变量
this.loadFactor = loadFactor;
//取初始长度乘以加载因子(16*0.75)和MAXIMUM_CAPACITY+1的最小值,(其实就是临界吞吐量)赋值给threshold
//那么MAXIMUM_CAPACITY是多少呢?:其实就是最大容量,详细请看下一块代码块
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//实例化一个Entry长度的数组
//注意:该长度并不是你传入的长度,而是比你传入长度大的最小的2的倍数。
table = new Entry[capacity];
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
上面的代码,如果读者看不懂,不要着急,我在和大家一起从头梳理一下。
- 第一步:会对传入进来的数据进行校验,不符合规范,直接抛异常——代码块0
- 第二步:一直进行左移运算 (左移一位扩大2倍),然后直到大于你传入的初始长度位置:简单点说,就是大于等于传入初始长度且是2的整数倍。——代码块1
- 第三步:求得该HashMap的临界吞吐量=(初始长度*默认加载因子),和最大容量,取二者的最小值。——代码块2
- 第四步:创建一个Entry类型的数组,初始化的长度即为:第二步处理结束后的长度,即为2的整数倍数。——代码块3
4.总结
- 问题一:调用无参构造器,底层数组长度是多少?
- 答案:底层数组的默认长度为16.
-
问题二:调用一个参数的构造器,指定长度为15,底层数组长度是15吗?
- 答案:不一定是15,因为会取大于等于你传入初始长度的最小的2的整数倍。
-
扩展:
- 什么是加载因子?
- 因为HashMap在底层是 数组+链表 (Java7中),因为在很多情况下,数组上总有那么一个或两个位置上不会有元素,所以,经过大量测试,以及考虑数组的利用率以及链表的长度不能太长,所以大量的测试,取值范围应该是0.7~0.75之间,最后取得0.75.
- 什么是临界值?
- 临界值即为:加载因子(即上一个问题) * 底层数组的当前长度(注意:是当前长度,不是默认长度)。也是判断底层Entry类型的table数组是否需要扩容的重要标准。
- 什么是加载因子?
三、put方法
1.put方法流程图:
下图put方法的整个流程图,如果有些迷茫,没关系,继续读下面的。。。。
接下来, 带着大家一起学习HashMap的底层源码。
public V put(K key, V value) {
//代码块0开始========================================
if (key == null)
return putForNullKey(value);
//代码块1开始========================================
int hash = hash(key);
//代码块2开始========================================
int i = indexFor(hash, table.length);
//代码块3开始========================================
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//代码块4开始========================================
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
代码块5开始========================================
modCount++;
addEntry(hash, key, value, i);
return null;
}
源码分析:这段代码是HashMap的put(),为了方便阅读,笔者把代码块分成了6块:即代码块0到代码块5
2.为什么HashMap可以添加null元素?(强烈建议从put方法开始阅读)
答案:因为代码块0部分判断要添加的key是否为null,如果为null,是什么样子呢?请看下一块代码。
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
大意为:
- 第一步:.循环判断每一个元素,直到table(也就是HashMap的底层存储的数组:也就是table中已经没有元素了)
- 第二步:判断取出的每一个元素的key值是否为null,
- 如果为空,就继续到下一个,继续判断。
- 如果不为空,则就将旧的value值取出,把新的value值赋值给旧的value值,然后返回旧的value值,
- 3.如果遍历所有的元素,依然没有找到key为null的元素,那么就调用addEntry(0, null, value, 0);方法,将该元素添加到table中。(添加部分的代码,在下面的扩容部分会讲解)。
3.为什么HashMap中的元素一定要重写hashCode()和equals()?(强烈建议从put方法开始阅读)
答案:因为代码块1和代码块.4分别调用了hashCode()和equals()
下图为代码块1调用的方法
final int hash(Object k) {
int h = 0;
if (useAltHashing) {
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
大意为:
- 第一步:.将调用传入参数 (即要添加元素的key)** 的hashCode(),计算哈希值,然后根据求得的哈希值,经过一些位运算,获得一个数据。
- 第一步:将第一步计算的值和底层数组table的长度作为参数调用indexFor方法——也就是代码块2开始的部分。
加油,HashMap的源码就要看完了。。。
4.要添加的元素,如何找到对应存储的位置?
static int indexFor(int h, int length) {
return h & (length-1);
}
大意为:传入的形参h(即代码块1 的运算结果)和形参length(即底层数组table的长度 )进行按位与运算。
例如:初始化时,table的长度为16,所以形参h与16-1即15进行按位与,15的二进制表示是1111H,所以结果就是取形参h的后四位,进行返回。
5.为什么HashMap中key作为是否重复的标准?(强烈建议从put方法开始阅读)
答案:因为每次比较的时候,都是因为采用的元素的key进行比较。
那么,一起结合源码来看一下。 我把代码块3和代码块4拿过来,咱们一起好好品品。
//代码块3开始========================================
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//代码块4开始========================================
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
源码大意:
- 第一步:遍历table中每一个元素,直到该元素的next的为null
- 第二步:如果遍历的元素(记作元素A)与要添加的元素(记作元素B)构成:
- 元素A的哈希值等于元素B的哈希值并且(元素A的key值的地址值等于元素Bkey值的地址 或者元素A的key与元素B的key进行equals()返回true)
- 如果找到了,则返回旧的value值,将新的value值覆盖旧的value值
- 如果遍历所有的元素后,都没有找到,则执行代码块5的部分。
- 元素A的哈希值等于元素B的哈希值并且(元素A的key值的地址值等于元素Bkey值的地址 或者元素A的key与元素B的key进行equals()返回true)
五.HashMap的扩容
1.HashMap什么情况下开始扩容?
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
源码大意:如果当前底层Entry类型的table数组中元素的个数大于临界值(即:数组的当前)且 当前元素要添加的数组的位置上有元素,即开始扩容。
2.HashMap的库容规则是什么?
仅仅截取上面代码块中if中的部分代码。。。
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
即
- 第一步:调用resize(int newCapacity)方法,且传入的newCapacity参数为当前数组长度的二倍。
- 第二步:然后重新计算数组中元素的位置**
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];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
transfer(newTable, rehash);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
源码大意:
- 第一步:对传入进来的长度进行判断
- 判断是否大于最大值
- 第二步:实例化一个新的数组,并且数组的长度为第一步处理过的值
- 第三步:笔者暂时也看不懂,不过也希望其他读者有了解的,给笔者讲解一下。
- 第四步:将新实例化的数组赋值给底层的table数组
- 第五步:重新计算临界值:临界值={ (现在的数组长度*加载因子) 和 MAXIMUM_CAPACITY + 1 取最小值}
六.如何体现HashMap的链式结构
相信大家都已经知道了HashMap(Java7)底层是由数组+链表,且新的next指针是上一个元素的地址值,那么接下来,就一起用源码解释一下真相。
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
源码分析:
- 第一步:取出添加前的元素。
- 第二步:实例化一个Entry,赋值给table中第一步取出的哪个元素在数组中的位置,并且传入了四个参数 ,
- 参数一:要添加元素的哈希值
- 参数二: 要添加元素的key值
- 参数三:要添加元素的value值
- 参数四:第一步取出的元素的地址值。
接下来,咱们看一下构造器。咱们看构造器的最后一个形参是Entry<K,V> n,在构造器中,把形参n赋值给新实例化的Entry对象的next成员变量(也就是next成员变量指向第一步取出的元素)
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
七.总结
恭喜读者,成功阅读完笔者的精心编写的博客,相信到此你也彻底理解了Java7中HashMap的底层数组的原理。是不是想问Java7的我懂了,但是Java8的呢?他们有什么区别呢?别着急,点击下面的链接,后面的内容更加精彩。。。
八.jdk中采用数组+链表+红黑树的HashMap源码分析链接
九.【Java集合篇】对比JDK7和8深度剖析ArrayList(只要看,就能懂)
【Java集合篇】对比JDK7和8深度剖析ArrayList(只要看,就能懂)
感谢您的阅读。。