引言
HashMap
基于哈希表的Map
接口的实现。此实现提供所有可选的映射操作,并允许使用null
值和null
键。(除了不同步和允许使用null
之外,HashMap
类与Hashtable
大致相同)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
值得注意的是HashMap
不是线程安全的,如果想要线程安全的HashMap
,可以通过Collections
类的静态方法synchronizedMap
获得线程安全的HashMap
。
Map map = Collections.synchronizedMap(new HashMap());
一、数据结构与冲突
HashMap
的底层主要是基于数组和链表来实现的,它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap
中主要是通过key
的hashCode
来计算hash
值的,只要hashCode
相同,计算出来的hash
值就一样。如果存储的对象对多了,就有可能不同的对象所算出来的hash
值是相同的,这就出现了所谓的hash
冲突。解决hash
冲突的方法有很多,HashMap
底层是通过链表来解决hash
冲突的。
图中,左边部分代表哈希表,也称为哈希数组,数组的每个元素都是一个单链表的头节点,链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中。右边部分则显示数组内部结构,HashMap
其实就是一个Entry
数组,Entry
对象中包含了键和值,其中next
也是一个Entry
对象,它就是用来处理hash
冲突的,形成一个链表。
二、HashMap相关属性
transient Entry[] table;//存储元素的实体数组
transient int size;//存放元素的个数
int threshold; //临界值,当实际大小超过临界值时,会进行扩容,扩容大小为当前的2倍。threshold = 负载因子*容量
final float loadFactor; //负载因子
transient int modCount;//被修改的次数
其中比较重要的两个参数是容量(Capacity
) 和 负载因子(Load factor
)
Initial capacity: The capacity is the number of buckets in the hash table, The initial capacity is simply the capacity at the time the hash table is created.
Load factor: The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased.
简单的说,Capacity
就是bucket
的大小,loadFactor
就是bucket
填满程度的最大比例。
loadFactor
越大,填满的元素越多,好处是,空间利用率高了,但冲突的机会加大了,链表长度会越来越长,查找效率降低。
反之,loadFactor
越小,填满的元素越少,好处是冲突的机会减小了,但空间浪费多了,表中的数据将过于稀疏(很多空间还没用,就开始扩容了)
因此,必须在 “冲突的机会” 与 “空间利用率” 之间寻找一种平衡与折衷. 这种平衡与折衷本质上是数据结构中有名的 “时-空” 矛盾的平衡与折衷.
如果机器内存足够,并且想要提高查询速度的话可以将loadFactor
设置小一点;相反如果机器内存紧张,并且对查询速度没有什么要求的话可以将loadFactor
设置大一点。不过一般取默认值0.75就好。
三、使用频率最高的两个方法put
和get
put
函数大致的思路为:
1、对key
的hashCode()
做hash
,然后再计算index
;
2、如果没碰撞直接放到bucket
里;
3、如果碰撞了,以链表的形式存在buckets
后;
4、如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD
),就把链表转换成红黑树;
5、如果节点已经存在就替换old value
(保证key
的唯一性);
6、如果bucket
满了(超过loadFactor * current capacity
),就要resize
。
public V put(K key, V value)
{
// 对key的hashCode()做hash
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
{
Node<K,V>[] tab; Node<K,V> p; int n, i;
// tab为空则创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 计算index,并对null做处理
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
{
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;
}
}
// 写入
if (e != null)
{ // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 超过load factor*current capacity,resize
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
get
函数大致思路如下:
1、bucket
里的第一个节点,直接命中;
2、如果有冲突,则通过key.equals(k)
去查找对应的entry
;
3、若为树,则在树中通过key.equals(k)
查找,O(logn)
;
4、若为链表,则在链表中通过key.equals(k)
查找,O(n)
。
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;
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)
{
// 在树中get
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 在链表中get
do
{
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
四、resize
的实现
当put
时,如果发现目前的bucket
占用程度已经超过了loadFactor
所希望的比例,那么就会发生resize
。在resize
的过程,简单的说就是把bucket
扩充为2倍,之后重新计算index
,把节点再放到新的bucket
中。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.
大致意思就是说,当超过限制的时候会resize,然而又因为我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:
因此元素在重新计算hash
之后,因为n
变为2倍,那么n-1
的mask
范围在高位多1bit
(红色),因此新的index
就会发生这样的变化:
因此,我们在扩充HashMap
的时候,不需要重新计算hash
,只需要看看原来的hash
值新增的那个bit
是1还是0就好了,是0的话索引没变,是1的话索引变成原索引+oldCap
。可以看看下图为16扩充为32的resize
示意图:
这个设计非常巧妙,既省去了重新计算hash
值的时间,而且同时,由于新增的1bit
是0还是1可以认为是随机的,因此resize
的过程,均匀的把之前的冲突的节点分散到新的bucket
了。
总结
通过hash
的方法,通过put
和get
存储和获取对象。存储对象时,我们将K/V
传给put
方法时,它调用hashCode
计算hash
从而得到bucket
位置,进一步存储,HashMap
会根据当前bucket
的占用情况自动调整容量(超过loadFactor
则resize
为原来的2倍)。获取对象时,我们将K
传给get
,它调用hashCode
计算hash
从而得到bucket
位置,并进一步调用equals()
方法确定键值对。如果发生碰撞的时候,HashMap
通过链表将产生碰撞冲突的元素组织起来,在Java 8
中,如果一个bucket
中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。