HashMap原理解读
HashMap原理,已安排!
前言
是你无情,还是我无知,痛心记录,点滴成长。
一.关于HashMap名词
- initialCapacity:初始容量。指的是 HashMap 集合初始化的时候自身的容量。可以在构造方法中指定;如果不指定的话,总容量默认值是 16 。需要注意的是初始容量必须是 2 的幂次方。
- size:当前 HashMap 中已经存储着的键值对数量,即 HashMap.size() 。
- loadFactor:加载因子。所谓的加载因子就是 HashMap (当前的容量/总容量) 到达一定值的时候,HashMap 会实施扩容。加载因子也可以通过构造方法中指定,默认的值是 0.75 。举个例子,假设有一个 HashMap 的初始容量为 16 ,那么扩容的阀值就是 0.75 * 16 = 12 。也就是说,在你打算存入第 13 个值的时候,HashMap 会先执行扩容。
- threshold:扩容阀值。即 扩容阀值 = HashMap 总容量 * 加载因子。当前 HashMap 的容量大于或等于扩容阀值的时候就会去执行扩容。扩容的容量为当前 HashMap 总容量的两倍。比如,当前 HashMap 的总容量为 16 ,那么扩容之后为 32
- table:Entry 数组。我们都知道 HashMap 内部存储 key/value 是通过 Entry 这个介质来实现的。而 table 就是 Entry 数组。
- 哈希碰撞:然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式
二.HashMap原理
put()
public V put(K key, V value) {
// 如果 table 数组为空时先创建数组,并且设置扩容阀值
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 如果 key 为空时,调用 putForNullKey 方法特殊处理
if (key == null)
return putForNullKey(value);
// 计算 key 的哈希值
int hash = hash(key);
// 根据计算出来的哈希值和当前数组的长度计算在数组中的索引
int i = indexFor(hash, table.length);
// 先遍历该数组索引下的整条链表
// 如果该 key 之前已经在 HashMap 中存储了的话,直接替换对应的 value 值即可
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++;
// 如果该 key 之前没有被存储过,那么就进入 addEntry 方法
addEntry(hash, key, value, i);
return null;
}
看了上面 put 方法的代码,大致分为了以下几个步骤:
- 如果 table 数组为空时先创建数组,并且设置扩容阀值
- 如果 key 为空时,调用 putForNullKey 方法特殊处理
- 计算 key 的哈希值
- 根据第三步计算出来的哈希值和当前数组的长度来计算得到该 key 在数组中的索引,其实索引最后的值就等于 hash%table.length
- 遍历该数组索引下的整条链表,如果之前已经有一样的 key ,那么直接覆盖 value
- 如果该 key 之前没有,那么就进入 addEntry 方法
addEntry()
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);
}
在 addEntry 方法中,有两个注意点需要我们去看:
- 如果当前 HashMap 的存储容量到达阀值的时候,会去进行 resize(int newCapacity) 扩容
- 在 createEntry 方法中增加新的节点
resize()
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 创建新的 entry 数组
Entry[] newTable = new Entry[newCapacity];
// 将旧 entry 数组中的数据复制到新 entry 数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
// 将新数组的引用赋给 table
table = newTable;
// 计算新的扩容阀值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
根据代码可以知道,扩容就是创建了一个新的数组,然后把数据全部复制过去,再把新数组的引用赋给 table 。
get()
public V get(Object key) {
// 如果 key 是空的,就调用 getForNullKey 方法特殊处理
if (key == null)
return getForNullKey();
// 获取 key 相对应的 entry
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
getEntry()
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
// 计算 key 的哈希值
int hash = (key == null) ? 0 : hash(key);
// 得到数组的索引,然后遍历链表,查看是否有相同 key 的 Entry
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
// 没有的话,返回 null
return null;
}
getEntry(Object key) 方法很简单,就是找到对应 key 的数组索引,然后遍历链表查找即可。
Java 1.8 中 HashMap 的不同
- 在 Java 1.8 中,如果链表的长度超过了 8 ,那么链表将转化为红黑树
- 发生 hash 碰撞时,Java 1.7 会在链表头部插入,而 Java 1.8 会在链表尾部插入
- 在 Java 1.8 中,Entry 被 Node 代替(换了一个马甲)
三.总结
- HashMap中数据是用一个叫table的数组来存的,table的索引在逻辑上叫做“桶”(bucket),它存储了链表的第一个元素
- HashMap有一个叫做Entry的内部类,它用来存储key-value对。table数组存的就是它。Entry用一个next属性实现多个Entry以单向链表存放,插入元素时,如果两条Key落在同一个桶,并且这两条key不equals,后入桶的Entry将next指向桶当前的Entry,否则后入桶的会将前面的给覆盖(确保key的唯一性)
- 使用HashMap的时候最好使用泛型,如果key放的是自己的对象,最好重写equals()和hashcode()