之前虽然很频繁使用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(),多线程操作一个单链表,很大几率会出现数据一致性的问题,甚至有可能会在链表中出现环。