jdk源码分析——HashMap
Posted 自由水鸟
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了jdk源码分析——HashMap相关的知识,希望对你有一定的参考价值。
一.基础概念
散列表:也叫哈希表,是我们常用的一种数据结构,它可以根据key值直接访问数据,从而在O(1)的时间复杂度内实现数据的写入和查找。
散列函数:将key值映射到散列表中的一个位置的函数。
碰撞:有时,不同的key值被映射到同一个散列表位置,这种情况叫做“碰撞”。
装载因子:散列表中已经添加的元素个数/散列表长度。它是衡量散列表可以被装满程度的一个参数。
当发生碰撞时,意味着多个key的散列函数值相同,需要花额外的开销去查找最终的位置。因此,理想的散列函数需要将key值均匀的分布到散列表中。
另外,散列表不能被装的太慢,如果装的太满,则发生碰撞的概率就会大大增加,但是如果装的太松散,则又太浪费空间,因此装载因子需要取一个合适的值,从而提高散列表的效率。
常见的碰撞解决方法有:
开放定址法:当冲突发生时,使用特定的公式计算新的位置,直到不发生冲突为止。例如:
H=(f(key) + d) % m
其中,f是散列函数,m是哈希表最大长度,d是增量,可以固定的值,也可以取随机值。
再哈希法:预先设定一组散列函数,当碰撞发生时,使用其他散列函数再计算,直到不发生碰撞。
二.类定义
HashMap
的类定义如下:
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
Cloneable
接口和Serializable
接口我们之前有提到过,都是声明式接口,本身没有任何方法。
Map
接口定义了
AbstractMap
也实现了Map
接口,因此HashMap
其实完全可以不再实现Map
接口了,这里依然实现Map
接口,个人理解可能有以下原因:
强制
HashMap
实现Map
中声明的方法。起到一种声明的作用,显示声明
HashMap
是Map
家族的一员。
三.存储结构
transient Entry[] table;
Entry
可以理解为是链表的节点,它的基础数据结构如下:
static class Entry<K,V> {
final K key; // key值
V value; // value值
Entry<K,V> next; // 指向链表下一个元素的引用
final int hash; // 元素的hash值
这里有两点需要注意:
类
Entry
被声明为static
字段
key
和hash
被声明为final
静态内部类和普通内部类的一个重要区别就是:静态内部类中不能引用外部类中的非静态属性,这样对外部类中的属性来说,更加安全。因此,当我们考虑使用内部类时,在可能的情况下,应当尽量使用静态内部类,除非内部类中需要用到外部类中的属性。
key
和hash
被声明为final
的,同样是出于安全考虑,即key
和hash
值被初始化后不允许被修改。
以上两点值得我们学习。
散列表的默认长度是16,最大长度是2的30次方,默认的装载因子是0.75。
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
四.核心方法
1.构造方法
我们先来看看最常用的构造方法:
public HashMap() {
// 将装载因子设置为默认值
this.loadFactor = DEFAULT_LOAD_FACTOR;
// 散列表中元素个数的阈值,超过阈值需要进行扩容
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
// 初始化散列表
table = new Entry[DEFAULT_INITIAL_CAPACITY];
init();
}
void init() {}
我们看到,init
方法其实是空的,它是留给HasMap
类的子类去实现的,例如LinkedHashMap
等。
我们再来看一个可以指定初始化参数的构造方法:
/**
* 通过初始化参数构造HashMap
* @param initialCapacity 初始容量
* @param loadFactor 装载因子
*/
public HashMap(int initialCapacity, float loadFactor) {
// 初试容量必须大于等于0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 初始容量不能超过2的30次方
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 检查装载因子是否合法
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 找到一个值,使得该值是2的次方,并且大于初始化容量
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)(capacity * loadFactor);
table = new Entry[capacity];
init();
}
从上述代码中,我们可以看到初始化容量总是2的次方。为什么散列表的长度一定得是2的次方呢?这个问题的答案我们稍后给出。
2.put方法
// 将key,value存储到散列表中
public V put(K key, V value) {
// key为空的情况,说明key可以为null
if (key == null)
return putForNullKey(value);
// 注意,这里对key的哈希值又进行了一次哈希
int hash = hash(key.hashCode());
// 计算该key需要保存的数组位置
int i = indexFor(hash, table.length);
// 该位置上可能已经有很多发生碰撞的元素了,因此需要遍历
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 即将存入的key和已经存在的key相等,则进行覆盖
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
// 将null作为key存入散列表
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;
}
// 扰动函数,主要目的就是使哈希值更随机的分布到哈希表中
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// 将hash值映射到散列表的某个位置
static int indexFor(int h, int length) {
return h & (length-1);
}
// 添加新的链表元素到链表头部
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
// 添加新的元素后,如果超过长度阈值,则扩容为原来的2倍
if (size++ >= threshold)
resize(2 * table.length);
}
// 扩容
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];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
// 将旧表转移到新表
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
// 遍历旧表中每一个元素,重新计算在新表中的位置,并添加到新表
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
这里需要注意的地方有几点:
put
方法中int hash = hash(key.hashCode());
这一句对key的哈希值又进行了一次哈希,这是为了防止有一些key的哈希函数实现的不好,使哈希值分布不够均匀,因此对原有的哈希值又进行了一次“扰动”,使其可以更均匀的分布到散列表中indexFor
(计算散列位置)方法,进行了一次按位与,是将哈希值与(length-1)进行按位与,这就是散列表长度是2的次方的原因所在,因为只有散列表的长度是2的次方,(length-1)才会是高位为0,低位都是1,方便进行与运算。
3.get方法
了解了put方法的原理后,我们再看get方法就比较简单了:
public V get(Object key) {
if (key == null)
return getForNullKey();
// 由于put的时候执行过hash,因此get的时候也需要再执行一次,才能对应上
int hash = hash(key.hashCode());
// 计算出散列表的对应位置,并遍历链表,如果找到,则返回
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.equals(k)))
return e.value;
}
// 如果未找到,则返回null
return null;
}
// key为null的元素,如果存在,在table[0]位置
private V getForNullKey() {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
参考资料:
1.Static nested class in Java, why?
2.JDK 源码中 HashMap 的 hash 方法原理是什么?
以上是关于jdk源码分析——HashMap的主要内容,如果未能解决你的问题,请参考以下文章
Java中HashMap底层实现原理(JDK1.8)源码分析
Java中HashMap底层实现原理(JDK1.8)源码分析