之前虽然很频繁使用java的hashmap,但一直都是纯用,至于里面怎么实现的,一直都是糊里糊涂的。今年4月份跳槽找工作,大概看了一下HashMap的源码,在面试过程中也被多位面试官问到HashMap的相关问题,有些问题也没回答出来。本来几个月前就想着写一篇相关源码解析的博客(主要是加深自己的理解),一直拖到现在,接下来就跟我一起看下HashMap的实现(基于jdk8)。
HashMap实现了Map,Cloneable,Serializable三个接口,Map定义了必要方法,Cloneable意味着HashMap是可被clone的,Serializable以为这HashMap可以被序列化。但是AbstractMap是什么鬼?打开AbstractMap的源码就会发现注释第一句就告诉你,AbstractMap给Map提供了一个骨架,事实上它实现了Map的几乎所有的方法,除了entrySet()。AbstractMap既然实现了Map接口,那为什么HashMap还要实现Map接口? 好吧关于这个问题,有人说是个作者的一个失误,见StackOverflow,我觉得这个说法站不住脚啊,如果是个失误,为啥到jdk10还没被修复,跳过这个问题。
打开HashMap的源码,跳过一堆注释,最先看到的是几个常量,这几个常量是缺省时的默认值,非常重要,它们决定这HashMap在使用过程中内部的发生的一些行为,我们挨个看一下。
long serialVersionUID = 362498820763181265L; //用来在序列化时校验用的
int DEFAULT_INITIAL_CAPACITY = 1 << 4; //缺省时HashMap的初始容量 16
int MAXIMUM_CAPACITY = 1 << 30; //最大容量
float DEFAULT_LOAD_FACTOR = 0.75f; //缺省时的默认负载因子,当map中的存储量占总容量的75%以上,hashmap就会把自身容量扩张一倍
int TREEIFY_THRESHOLD = 8; // 触发树化阀值
int MIN_TREEIFY_CAPACITY = 64; // 真正树化阀值
了解一个类,应该最先从其构造函数开始,HashMap可选的构造参数并不多,最核心的就这一个构造方法,如下。
public HashMap(int initialCapacity, float loadFactor) {
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);
}
这个构造函数做的事很简单,只是初始化了两个值,一个是负载因子,如果用户用自定义切校验通过就会抛弃默认的0.75负载因子。另一个是threshold,loadFactor是个负载因子的比例,threhold才是实际值,HashMap会根据threhold具体值来做resize()。这里我们顺带看下tableSizeFor()的实现。
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;
}
这个函数就就是找到最小那个比cap大的2^n。
如上所示,其实构造函数什么都没做。内部空间初始化也没做,那什么时候做呢?其实HashMap存储初始化的动作都是插入值的时候做的,这样设计有个好处,可以避免你虽然new了一个HashMap,但写入数据导致的资源浪费。
接下来我们看下HashMap内部是怎么存储数据的。HashMap中有个内部静态类Node<K,V>
,这个用来存储数据的实体,看下代码。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
/**
* 省略一些简单方法
*/
}
这个类里除了用户可见的K,V之外,还额外存储了hash值,这个hash值就是K的hash值,单独存下是为了优化,不需要每次用到的时候都计算K的hash值。next这个就是单链表的next指针了,HashMap解决hash值冲突的方式就是是开一条单链表,Jdk8之后就不仅仅是单链表了,后续我们会详解介绍。
HashMap中有个Node<K,V>[] table;
这个数组就是所有数据存储的地方了。它会在第一次插入数据时发现table为null候才被初始化。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
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;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
既然贴出来了这段代码了,我们就先来看下这段,再看HashMap的resize()。最开始就检查table,如果table是null,就用resize做初始化,然后在安插新节点。如果遇到hash值碰撞,如果发现是同一个key,就覆盖。否在有两种情况,要么单链表插入,要么红黑树插入,一个HashMap的内部存储可能长这个样子,树化后的某些单链表可能会变成一颗树(示意图,并不代表真实情况)。
这里就遇到jdk对于hashmap指针碰撞的优化了,如果过多的K,V都有同样的hash值,就会变成一条非常长的链,增删查的性能就非常低了。所以jdk8在链表长度达到8的时候就会触发链表的树话,把单链表变成一个红黑树,可以把各种操作的时间复杂度从O(n)降低到O(logn)。
事实上,链表长度到8,虽然会触发树化,但并不一定会树化,而是会优先选择resize(),只有链表长度达到64才会树化,其实我并不理解为什么要再设个64的阀值才会树化,可能是在节点树少的时候,树的平局操作时间复杂度不会比单链表的优太多吧。
我觉得在HashMap中resize()是HashMap的核心,也是很多问题的根源。虽然resize()对用户不可见,但它是保证HashMap性能最重要的一般,当然它也是HashMap并发问题的罪魁祸首。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
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 = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
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;
}
大体上就是新建一个newTab,是原来table大小的两倍,然后把原有数据做放到newTab中。先不管Tree是怎么裂变的(还没搞懂),我们来看下单链表怎么裂变的。 这里直接把一个单链表裂变成了两个。在odltab中第j位置的单链表,裂变成两条单链表,一条放newtab中j位置,另一条放newtab中i+oldcap的位置,每个j都是这么做的,就是这么神奇, 很长时间我一直没理解这么简单处理,如何保证在同一条链表中的节点都有相同的hash值?
其实也很简单,就是按hash值第oldCap那位0和1做了区分。因为我们cap每次都是以2为系数增长的,然后按cap-1为掩码来计算位置的,当容量扩大一倍后,只会有一部分最高位是1的才需要移动。比如hash值最后4位都是是0101的节点,其在newtab中新的位置要么是10101,要么是00101。
今天最后一个问题,为什么HashMap不是线程安全的? 罪魁祸首还是resize(),如果在多线程插入的情况下,多个线程有可能同时执行resize(),多线程操作一个单链表,很大几率会出现数据一致性的问题,甚至有可能会在链表中出现环。