深入理解HashMap(JDK1.7 )
一、HashMap的基本结构
HashMap是Map接口的实现类,是一个双列集合,内部使用的是“键值对”存储数据,允许null做为“键”。这个是以前在上学的时候,可以摇头晃脑的说出来的。今天,我们就来探索一下HashMap的源码,解开HashMap神秘的面纱。
首先大致描述一下HashMap在JDK1.7版本及之前的数据结构。HashMap内部使用“数组+链表”的数据结构。在Java中,链表上的每一个节点,它都是一个对象。对象是用来封装数据的,当我们存储数据的时候,数据被封装到这个对象中。这个对象是HashMap的一个内部类Entry。它是一个嵌套类,在其构造的时候需要给定它下一个Entry类的对象的内存地址,这些就会形成一个像单向的链表。每一个Entry类对象可以视为一个节点。每个链表的头节点存放在数组中,这个数组我们通常叫它“哈希表”。哈希表在代码中的体现是Entry<K,V>[] table。下面的图片可以帮助理解。图片来源:点击打开链接
紫色代表着“哈希表”。绿色代表的是Entry对象。Entry对象形成的链表的第一个头节点存放在“哈希表”中,也就是存放在table数组中。可以清楚的知道每一个Entry对象拥有指向下一个Entry对象的指针。
下面这张图也能很好的解释HashMap内部的结构。Entry[]数组就是“哈希表”,Entry链表的头节点存放在数组中。图片来源:点击打开链接。
不同的KEY计算出来不同的Hash值。而对Hsah值的特殊计算决定了 K-V 在哈希表数组中的位置。如图,哈希表数组有5个位置。存放在哈希表数组中的Entry都是最新的元素,旧的元素挂在最新Entry上。
Entry类究竟是什么样子的?上代码。可以看到,用来封装数据的实体类Entry,它的构造函数接收四个参数。分别是通过key计算出来的hash值、键、值、下一个Entry节点对象的引用。
static class Entry<K, V> implements Map.Entry<K, V> {
final K key;//键值对的 “键”
V value; //键值对的 “值”
Entry<K, V> next; //指向下一个节点的指针
int hash; //通过key计算出来的hash值
/**
* 构造方法,创建一个Entry
* 参数:哈希值h,键值k,值v和下一个节点n
*/
Entry(int h, K k, V v, Entry<K, V> n) {
value = v;
next = n;
key = k;
hash = h;
}
//省略部分代码
}
二、HashMap的核心参数
//默认初始容量是16,“<<”代表左移四位,二进制下左移四位相当于乘以2^4=16。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // default init capacity = 16
//默认加载因子,加载因子是用于衡量哈希表的元素个数的饱满程度,默认达到75%就进行扩容。如果是默认的情况,初始化一个HashMap,它的初始容量是16,如果表中的元素已经达到了16*0.75 = 12个,就必须进行扩容。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//存储Entry的默认空数组
static final Entry<?,?>[] EMPTY_TABLE = {};
// 存储Entry的数组,这个就是哈希表table,看到了吧,使用的是 “数组数据结构”
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
// HashMap的实际元素个数,即HashMap存储的键值对数量
transient int size;
//HashMap的阈值,如果实际元素个数size到达此阙值,那么HashMap就需要进行扩容。
int threshold;
//加载因子实际大小,由构造函数传入。
final float loadFactor;
//一个标记,用于多线程下的“快速失败”机制。
transient int modCount;
加载因子在API文档中的解释:“加载因子” 是哈希表在扩容之前可以达到多满的一种尺度。知道了加载因子的解释,我们就知道了加载因子对HashMap的影响。当加载因子变大的时候,HashMap的空间利用率增加,但是代价就是每一个链表的长度也随着增加,那么我们去查询某个“键值对”的时候,所付出的时间也随之增加。如果加载因子变小,HashMap将会在并没有多少元素的时候就去扩容,虽然查询速度变快了,但是很多数组的内存空间没有得到使用。这就是有名的“时-空矛盾”。
关于“加载因子”影响的精辟解释:若加载因子设置过大,则填满的元素越多,无疑空间利用率变高了,但是冲突的机会增加了,冲突的越多,链表就会变得越长,那么查找效率就会变得更低;若加载因子设置过小,则填满的元素越少,那么空间利用率变低了,表中数据将变得更加稀疏,但是冲突的机会减小了,这样链表就不会太长,查找效率变得更高。(出自:点击打开链接)
接着分析构造函数。当我读到这段代码的时候,真的很佩服作者。大师的手笔,虽然看似十分简单,但是这三种构造函数的复用性非常高。在空参构造函数中使用默认值调用有参构造函数。在自定义初始容量和加载因子的构造函数中,进行了健壮性判断,并且抛出异常。以前的自己只知道抛出RuntimeException,但是跟着大师去学代码,真的收获很多。IllegalArgumentException是非法参数的异常。
在第一个构造函数中,先进行了严格的健壮性判断。我们可以看到我们自定义的容量被当成了阙值,并没有去初始化“哈希表”table数组。
public HashMap(int initialCapacity, float loadFactor) { //健壮性判断有条不紊,不是在一个if语句里面进行全部的判断,抛出异常十分到位。
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;
threshold = initialCapacity; //初始化容量 赋给 阙值
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); //空参构造函数调用有参构造函数
}
三、核心方法put方法
首先先说一下结论,因为哈希表位置有限,不同的 hash值 可能算出来的 index相同,那么把最新的K-V 放在 内部数组上,以前的数据挂在最新的K-V键值对上。
存放元素方法 put(K key, V value)。先讲一下存放思路。首先通过put方法获取到要被存放的key-value。参数中,键是key,值是value。然后把key的哈希值hash计算出来,根据hash值找到此键值对应该存放在哈希表table中的位置,即根据hash值找到键值对应该存放在数组中的索引。将key和value封装成键值对Entry对象,存放到table数组中。
添加流程:key ==> hash ==>index ==>封装成Entry ==> Entry存放到 哈希表table 中
通过key计算出hash值
通过hash值算出在内部数组中存放的索引
遍历头节点为指定索引的链表,检查当前的key是否已经存在。若存在,则替换value。
不存在,则添加新的节点,存放到链表的头部,也就是数组的中,并用一个引用指向以前的链表。
public V put(K key, V value) {
//table为空,就先初始化
if (table == EMPTY_TABLE) {
//初始化哈希表table数组,将{} 初始化为 {null,null,....,null},threshold在构造函数中被定义为initialCapacity
inflateTable(threshold);
}
//key 为null的情况, 只允许有一个为null的key
if (key == null)
//此函数效果:将哈希表table数组的第一个元素table[0]用于存放 key==null的元素,并且此Entry的hash值是0
return putForNullKey(value);
//根据key计算出它的hash值,算法就不需要我们关心了
int hash = hash(key);
//根据指定hash,找出在table数组中的索引的位置。具体算法我不想关心,有兴趣的可以查看源码研究。
int i = indexFor(hash, table.length);
//循环遍历Entry数组,若该key对应的键值对已经存在,则用新的value取代旧的value
//如果key已经存在,那么直接设置新值
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;
return oldValue;
}
}
//快速失败机制标记
modCount++;
//如果哈希表中没有当前的key,那么就创建新的Entry对象,将其插入到哈希表中
addEntry(hash, key, value, i);
return null;
}
真正初始化HashMap的时刻是第一次向HashMap中添加元素的时候。就在此inflateTable方法中进行了初始化。有new就有内存空间的分配。
//初始化哈希表table数组
private void inflateTable(int toSize) {
//省略部分代码
//int capacity 是最接近toSize的一个2的若干次幂
table = new Entry[capacity]; //用该容量初始化table
}
允许key为null。处理key为null的情况。效果是将key为空的元素,存放到哈希表table数组的第一个,并且其它的哈希值是0。
//当key为null 的处理情况
private V putForNullKey(V value) {
//哈希表的第一个元素用于存放key==null的Entry
for (Entry<K, V> e = table[0]; e != null; e = e.next) {
//如果有key为null的Entry,用新值替换旧值,并返回旧值
if (e.key == null) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
}
modCount++;、
//哈希表的第一个元素当前什么都没有,那么创建一个新的Entry放到table数组的角标为0的位置
//第一个0代表的是key=null的Entry的hash值是0,第二个0代表的是key=null的Entry在table数组中的索引
addEntry(0, null, value, 0);
return null;
}
在哈希表中添加Entry的 addEntry方法。
//在哈希表table中增加Entry,四个参数分别是hash值,键,值,应在索引
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,这个方法有点意思,也是为什么会产生链表的原因。
void createEntry(int hash, K key, V value, int bucketIndex) {
//先获取原来的指定索引上的键值对Entry
Entry<K, V> e = table[bucketIndex];
//再新创建一个键值对Entry对象放到哈希表的指定索引上,并且将原来的Entry挂到该Entry的next
table[bucketIndex] = new Entry<>(hash, key, value, e);
//所以table中的每个位置永远只保存一个最新加进来的Entry,其他Entry是一个挂一个,这样挂上去的
//实际元素+1
size++;
}
四、根据HashMap源码来分析其特点
首先上我的终极口诀:“序重步+数据结构”。
HashMap存放的元素是无序的。在put的代码中,我们知道HashMap是通过计算key的哈希值,再通过哈希值来计算索引的,与元素放入的先后顺序没有什么关系。key ==>hash ==>index。
HashMap中元素不可以重复。在上述put方法代码的第17行开始可以证明。如果key已经存在,那么就用新值替代旧值。如果新值跟旧值相同的话,那么“新的”键值对与“旧的”键值对一模一样。
HashMap是不同步的。源代码中处处可见到modCount这样的“快速失败”标记。HashMap是在java.util包下的。此包下的所有集合都是不同步的。
HashMap底层使用的是“数组+链表”的数据结构。使用链地址法解决哈希冲突。链地址法的表现就在于相同的hash值的键值对组成一个链表,每一个键值对都有它后一个键值对的引用。
五、要点
无序,不可重复,不同步。采用“数组+链表”数据结构,并使用“链地址法”解决哈希冲突。
影响HashMap的两个参数是“初始容量”和“加载因子"。
加载因子 是哈希表在扩容之前可以达到多满的一种尺度。
HashMap每次扩容,新长度是旧长度的两倍。(在addEntry方法中的第5行可证明)
真正初始化HashMap的时刻是第一次向HashMap中添加元素的时候。
HashMap允许存放键为null的元素,并且此键值对的hash值是0,放在哈希表数组table的第一个。
存放流程: key ==> hash ==>index ==>封装成Entry ==> Entry存放到 哈希表table 中 。
哈希表中的每个位置永远只保存一个最新加进来的键值对,其他键值对是一个挂一个,这样挂上去的。