一、哈希表
HashMap的本质就是一个哈希表,所以我们先来讨论一下哈希表。
首先,哈希表的主干是一个数组。
既然哈希表的本质是一个数组,我们试试将存储的key映射为数组下标,存储value的值。那么我们将通过key定位到数组位置就需要一个函数,这个函数我们称之为哈希函数。假设一组数据{1,15},这组数据的值既是key也是value,哈希函数为f(x)=x%4。插入情况如下图所示:
哈希冲突
通过这个图我们很容易联想到,如果插入的两个数对映的映射相同怎么办?
这就是哈希冲突,一般来说我们有两种解决方案:1、将相同映射往数组后面移动,直到找到空的位置。
2、采用链表+数组的组合,每个数组上的元素都作为头节点,映射相同的话,就作为该元素在链表中指向的下一节点。
在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)。不难想象,冲突发生的越少,查找效率就越高,所以我们设计哈希函数的时候要尽量避免冲突
二、HashMap实现原理
HashMap采用链表+数组的组合,其有两个重要参数:初始容量和加载因子。
初始容量:哈希表创建时的容量。
加载因子:当前容量*加载因子=下次扩容前的容量。默认的加载因子是0.75,即数组存储的数据达到容量的四分之三的时候,下一次再插入时会进行扩容,默认扩容为当前容量的两倍。
HashMap的主干是一个table数组,存储的是Node类的对象:
transient Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //对key的HashCode值进行一定运算后的值
final K key;
V value;
Node<K,V> next; //指向下一个单链表的Node
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
可以看到每个Node类中,都有一个key-value对。我们很容易直到,链表的存在是为了解决冲突,但因为存在链表,在一定程度上会降低我们查找的速度,因为可能无法一次定位的指定的数。所以虽然设计了链表,但是链表还是越少越好,越短越好。
我们再来看看几个重要参数:
transient int size; //当前存储的key-value键值对个数
transient int modCount; //HashMap进行操作的次数,如put。这个属性存在的原因是为了处理并发问题。
int threshold; //阈值,初始为16,之后为capacity*loadFactory。
final float loadFactor; //负载因子
我们先来看看HashMap的其中一个构造器。
public HashMap(int initialCapacity, float loadFactor) {
//对初始容量进行校验,MAXIMUM_CAPACITY=1<<30。
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;
//使数组长度一定为2的次幂
this.threshold = tableSizeFor(initialCapacity);
}
这个构造器设置了初始容量和负载因子。代码也很好理解,不多做解释。
接下来我们看看put操作,这个操作是构建HashMap的核心。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//为数组table分配空间
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//(n-1)&hash:对hash值进行进一步计算,为确保散列均匀
//如果当前位置不存在,那么就将Node分配在当前位置;如果存在
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//以数组中的Node为头节点,往后查询,当某下节点的下一节点为null时,执行插入操作。
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果对应key已经存在,那么就将旧的value用新的value替代,并返回旧的value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//如果这时后的size大于阙值,执行扩容操作。
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
put操作中有一个非常重要的方法叫做resize(),这个操作的代码比较长,官方对它的解释是:“Initializes or doubles table size. If null, allocates in accord with initial capacity target held in field threshold. Otherwise, because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table.(初始化或者将table大小增加一倍。如果table为null,则根据字段阈值中保存的初始容量目标进行分配。否则,因为我们正在使用二次幂扩展,每个位置的元素必须和之前保持相同的索引,或者在新table中以2次幂偏移的方式移动)”
这样看起来事实上,扩容操作我们还是有点没有明白。我讲下我自己对这句话的理解。首先table的初始容量为16(2^4),之后我们每次以两倍的扩容,从二进制上去考虑,就是将那个0和1中的那个1向左移动过了一位。当进行扩容时,我们hash值与length-1进行&操作(至于为什么这么做之后进行讨论)。我们可以举个例子理解,我们将12和20分别插入容量为16和32的table中:
我们可以看到,12不管是插入到容量为16还是32的table中,进行&操作后的结果都是01100。但20插入到容量为16的table中结果为00100,插入到容量为32的table中结果为10100。因为在进行扩容时,就是多出了最左边的一个1,只有一位的差异,如果我们的hash在该位为0,进行与操作后还是0,即最后与操作的结果相同,那么该Node扩容后的索引和扩容前相同,但如果我们的hash在该位为1,进行与操作后还是为1,那么最后我的操作结果会在左边多出一个1,相当于“在新table中以2次幂偏移的方式移动”。
我们再来看一下,hash是如何获得的:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
我们现在可以给出获取存储方式的流程:
为什么最大容量要为2的次幂?
其实一部分原因之前的内容已经讲到。
一、当最大容量为2的次幂的时候,根据我们之前的说法,我们确定Index的时候会对hash值和length-1进行与操作,length-1右边为全1,我们每次扩容都是以两倍进行扩容,这样子就能保证每次扩容后有一半的几率索引不会改变,即使改变也只有一位之差(原因上面已经交代)。
二、我们可以想象一下,如果不为2的次幂,也就无法保证右边部分为全1,假设我们将hash值和1101进行与操作,我们可以想象,1101的第三位因为是0的原因,会造成这一位进行与操作的结果一定也是0,这样一定会造成一部分空间无法使用,因为与操作结果永远不可能出现在该位为1的索引上。