HashMap源码阅读(Java8)
1. HashMap概述
HashMap 是一个存储键值对的集合类,其中的元素是无序的,且没有重复的 key 值;有点类似数学中的函数,x 对应一个 y 值。Java API 中对 HashMap 描述如下:
HashMap是基于哈希表的Map接口实现。此实现提供所有可选的映射操作,并允许空值和空键。 (HashMap类大致相当于Hashtable,除了它是不同步的并且允许空值。)这个类不保证Map的顺序;特别是,它不保证顺序会随着时间的推移保持不变。
HashMap 底层是哈希表,元素是无序的,允许 key 和 value 为 null 的情况。
假设散列函数在桶之间正确地分散元素,该实现为基本操作(get和put)提供了恒定时间性能。对集合视图的迭代需要与HashMap实例的“容量”(桶的数量)加上其大小(键 - 值映射的数量)成比例的时间。因此,如果迭代性能很重要,则不要将初始容量设置得太高(或负载因子太低)非常重要。
HashMap的一个实例有两个影响其性能的参数:初始容量和负载因子。容量是哈希表中的桶数,初始容量只是创建哈希表时的容量。加载因子是在自动增加容量之前允许哈希表获取的完整程度的度量。当哈希表中的条目数超过加载因子和当前容量的乘积时,哈希表将被重新哈希(即,重建内部数据结构),以便哈希表具有大约两倍的桶数。
HashMap 的初始容量和负载因子会影响其性能,设置的太大迭代会花费跟多的时间,设置的太小,又可能会频繁扩容;当元素个数超过当前容量*负载因子时,需要扩容,大约为原来的两倍。
作为一般规则,默认加载因子(0.75)在时间和空间成本之间提供了良好的权衡。较高的值会减少空间开销,但会增加查找成本(反映在HashMap类的大多数操作中,包括get和put)。在设置其初始容量时,应考虑映射中的预期条目数及其加载因子,以便最小化重新散列操作的数量。如果初始容量大于最大条目数除以加载因子,则不会发生重新加载操作。
负载因子默认情况下是0.75,太大能减少空间开销,但是会增加查找成本。
如果要将多个映射存储在HashMap实例中,则使用足够大的容量创建映射将允许映射更有效地存储,而不是根据需要执行自动重新散列来扩展表。请注意,使用具有相同hashCode()的许多键是减慢任何哈希表性能的可靠方法。为了改善影响,当键是Comparable时,此类可以使用键之间的比较顺序来帮助打破关系。
在知道容量的情况下,尽量初始化时设置足够的容量,避免扩容影响效率。
另外,HashMap 不是线程安全的,多线程使用时需要注意;同时,迭代器也是fail-fast的,也就是使用迭代器迭代过程中,不能对其进行修改,否则直接抛出异常。
2. 成员变量
静态成员变量:
// 默认容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大容量 2^30
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 桶 使用树的阈值,节点超过8个时,有链表改为树结构
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 桶 恢复为链表的阈值
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 容器转化为树的阈值,超过该容量,将桶转化为树,否则继续扩容
* 至少为 4 * TREEIFY_THRESHOLD,避免扩容和树形结构化之间的冲突
*/
static final int MIN_TREEIFY_CAPACITY = 64;
成员变量:
// 链表数组
transient Node<K,V>[] table;
// 缓存的键值对集合
transient Set<Map.Entry<K,V>> entrySet;
// 键值对元素个数
transient int size;
// 集合修改次数
transient int modCount;
// 扩容容量,capacity * load factor
int threshold;
// 加载因子
final float loadFactor;
3. 构造方法
// 传入初始化容量,加载因子
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);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
上边的构造方法中,传入的初始化容量,会使用tableSizeFor
方法处理,将初始化容量设置为2的幂,为了后边hash时,均匀分布。
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;
}
上边的方法,通过移位运算,通过不断的无符号右移和原值进行或运算,将二进制从最高位开始,每一位都置为1,也就是2N-1,所以,最后需要再加一,变为2N。上边的过程也很好理解,例如:传入的是9,n=8,转为二进制,000 000 0000 … 1000,只看后4位:
- 第一次操作之后变为 1100,
- 然后,右移两位0011,与1100进行或运算,变为1111;
后边两步此时计算了也不会改变,因此最后结果是16;为什么最后只移动到16位呢?因为我们知道int是32位,即时n=231,经过这几步也会变为 232-1;
其他构造方法:
// 只传入初始化容量
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 无参构造器,均使用默认值
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 初始化传入一个Map
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
4. 主要方法
4.1 添加元素 put(K key, V value)
HashMap中最核心的就是添加元素方法,涉及到了扩容,Java8 中还有数据接口转换。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
添加元素,首先对 key 进行 hash 操作,key 在数组中的索引为(n - 1) & hash(key)
;
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
上边的方法中,首先获取 key 的 hashCode 值,然后,高16位和低16位进行异或操作,得到hash值,最后计算索引时,又使用(n-1) & hash;而我们知道 n (HashMap容量)是2的N次方,相当于和长度取模操作,防止索引超过了容量。
hash计算中,为什么要使用高16位和低16位异或呢?因为元素的 hashCode 低位很多都是相同的,这样在和容量进行取模运算时,可能造成同一个索引元素过多,发生碰撞。因此,使用高位进行异或运算之后,再取模,尽可能使元素均匀分布。
回到 put 方法,其中主要调用了 putVal 方法:
/*
* onlyIfAbsent 如果为true, 添加的key如果存在,不改变原来的值
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// <1> 如果 table 为null,使用 resize()创建一个 哈希表
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果添加到数组中的索引处,节点为null, 直接在该索引位置创建一个新的节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果索引处,桶的第一个节点 key 和插入节点key相同,获取该节点;后边判断是否需要替换
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果,第一个节点不匹配,并且已经是一个树形结构,添加树节点
else if (p instanceof TreeNode)
// <2> 添加树节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 如果是链表结构,遍历链表
for (int binCount = 0; ; ++binCount) {
// 如果遍历到最后,都没有key相同的,在链表末尾添加节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// <3> 如果超过了树形结构阈值,转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果找到key匹配节点,获取节点,用于后边判断是否替换
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果,上边的过程中找到了key相同的节点
if (e != null) { // existing mapping for key
V oldValue = e.value;
// onlyIfAbsent 为false,即允许替换;或者旧值为null,替换节点处的值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 如果超过了容量,扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
上边代码展示了HashMap添加元素的基本过程:
- 首先,如果哈希表为空,需要创建存储元素的哈希表;
- 然后,计算key对应的索引,如果哈希表中该索引位置还未添加元素,直接在哈希表中添加一个节点;
- 如果,该索引位置已经有值,需要判断该节点的key是否和插入的key相同,如果相同获取节点;
- 如果不同,且桶仍是链表结构,遍历链表,找到key相同的节点,如果没有找到,就在链表末尾添加元素;
- 如果该处桶已经是红黑树结构,想红黑树中添加元素。
流程图大致如下:
(图片来源:Java 8系列之重新认识HashMap)
4.2 扩容操作resize()
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 如果扩容前容量大于0
if (oldCap > 0) {
// 超过了最大容量,直接返回
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 如果扩容前容量,超过默认容量16;新容量为原来的2倍;
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 如果扩容前容量大于0,
// 并且扩容容量threshold > 0(初始化时,为初始化传入的容量计算和的值)
else if (oldThr > 0) // initial capacity was placed in threshold
// 新容量等于 扩容容量,即初始化容量
newCap = oldThr;
// 如果初始化时,没有传入容量,设置为默认容量16
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 更新threshold的值
threshold = newThr;
// 新建一个长度为newCap的Node数组
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// table 指向新数组
table = newTab;
// 如果原哈希表不为空,将原来的数据复制过来
if (oldTab != null) {
// 遍历原哈希表
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 如果当前索引位置有节点,需要将节点添加到新哈希表中
// 首先,获取索引出节点
if ((e = oldTab[j]) != null) {
// 将原哈希表中节点设置为null,方便GC
oldTab[j] = null;
// 如果原哈希表中该位置,只有一个节点,直接将该节点重新rehash之后,插入新哈希表
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果该节点是一个树形节点,将树形结构中的节点,放到新哈希表中
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 遍历链表写入新哈希表中
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
上边扩容的过程,相比 JDK1.7 变化很大, 首先引入了红黑树,扩容时,需要处理红黑树;对于链表的处理也不一样了,但是,只是处理方式不同,最终结果还是相同的。JDK 1.7 中resize()
方法中,计算节点新的索引位置是通过 has & (newCapacity-1)
的方式来计算的,这和我们最开始添加元素时一样,很好理解。
在上边说过,每次扩容时扩容原来容量的两倍,因此上边操作过程,可以展示如下图:
上图展示了容量从16扩容为32的 rehash
过程,索引位置从5变为5或者21(5+16);从图中也可以看出来,之和最高一位有关,如果和最高一位与运算结果为0,那么还是原来位置,如果为1,就是原来的索引加上扩容的长度,即原长度;因此,上边的Java8 中的代码直接使用(e.hash & oldCap)
运算,判断索引是在原来位置,还是需要移动原来的长度。另外上边的代码中没有打乱链表的顺序,避免了原来多线程下出现死循环的问题;但是HashMap 仍是线程不安全的,多线程下建议使用ConcurrentHashMap。
4.3 获取元素
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 如果,哈希表table为空,直接返回null;如果key对应的索引处为空,也直接返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 如果索引处第一个节点匹配,直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 否则遍历链表查询
if ((e = first.next) != null) {
// 如果是树结构,查询树节点
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
上边的get(Key key)
方法,主要思路,在哈希表不为空的前提下,首先对 key 进行 hash 操作,然后根据 hash 值获取对应哈希表的索引,该过程和 put 方法中相同;找到哈希表中的索引后,先看第一个节点是否匹配,不匹配的话就开始遍历链表,需要注意链表已经转换为树的情况。
4.4 判断元素是否存在
判断key是否存在:
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
判断key是否存在,比较方便,直接通过key查询,如果查到元素就说明key是存在的。
判断value 是否存在:
public boolean containsValue(Object value) {
Node<K,V>[] tab; V v;
if ((tab = table) != null && size > 0) {
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next) {
if ((v = e.value) == value ||
(value != null && value.equals(v)))
return true;
}
}
}
return false;
}
判断是否包含 value 需要依次遍历哈希表的各个节点比对,相对而言比较麻烦,不过一般也很少使用。
4.5 遍历HashMap
之前遍历HashMap我们都是使用EntrySet,Java8 中新增了一个foreach方法,可以直接使用该方法遍历HashMap。
public void forEach(BiConsumer<? super K, ? super V> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.key, e.value);
}
// 遍历过程中修改,会抛出异常
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
使用示例:
public static void main(String[] args) {
HashMap<String, Object> hashMap = createHashMap();
hashMap.forEach((key, value) -> {
System.out.println(key+":"+value);
});
}
总结
HashMap 是一个存放键值对的集合,其中的元素是无序的,允许key 和 value 为null的情况,但是不存在重复的 key。HashMap 的容量都是2的次幂,为了是元素更加均匀的分布,另外,每次扩容时都是扩容为原来的2倍。Java8 中引入了红黑树数据结构,优化了 HashMap 的效率,解决了多线程扩容可能出现环的问题,但是 HashMap 仍然是线程不安全的,需要保证线程安全的情况下建议使用ConcurrentHashMap。
HashMap 细节还有很多,暂时整理到这里,文中如有错误请大家指正。
参考:
美团技术网:Java 8系列之重新认识HashMap