散列表(Hash Table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。这种数据结构在不考虑哈希碰撞的条件下,有前着O(1)的时间复杂度,因此效率很是高。Java中HashMap底层就使用了哈希表,因此一般咱们认为HashMap的时间复杂度也是O(1)。html
在JDK中,HashMap底层是由数组实现,该数组即为哈希表(由HashCode决定索引位置)。在不存在哈希碰撞的条件下,哈希表的性能最优,但在实际代码实现中不能不考虑这个问题。因此在HashMap中,每一个Bucket(哈希表中的节点)都是一个链表(JDK1.8中当链表元素超过8个时,会将链表转换为红黑树),当发生哈希碰撞时,该元素将被添加到链表的末端。因为链表中的时间复杂度是O(n),因此当Bucket所在链表过长时,会影响HashMap性能。java
JDK自1.6之后(之前的代码没读过)的版本HashMap的实现本质上没有太大差异(核心结构都是哈希表),这里以JDK1.7版本为例讲解,下面是HashMap的核心方法源码解读。数组
下面是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; threshold = initialCapacity; init(); }
方法只是初始化了一些属性,this.loadFactor
是扩容因子,即当实际使用容量比总容量为该因子时,将发生扩容。threshold
表示扩容阈值,由总容量乘以扩容因子计算得出,但在构造方法中,直接使用初始容量表示。在无参的构造方法中,初始容量为16,扩容因子为0.75。多线程
判断集合是否为空的方法,实际只是内部维护了一个计数器,若是计数器为0即为空,不然非空。并发
public boolean isEmpty() { return size == 0; }
同理集合实际大小也是由该计数器表示,该计数器将在添加、移除元素时被维护。app
public int size() { return size; }
put方法是HashMap最核心方法之一,其代码实现复杂之处也在于此。函数
public V put(K key, V value) { if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(hash, key, value, i); return null; }
代码中先判断哈希表是否为空,空则执行扩容(初始化哈希表的过程实际使用的扩容的逻辑,因此构造方法中用扩容阈值来表示初始容量,减小一个全局变量)。 初始化哈希表后,会判断键是否为空,空键不会使用哈希函数来计算哈希和索引,而是直接遍历哈希表找到空键所在Bucket,将元素加入该Bucket(该Bucket也是一个链表,但最多只能有一个元素,这就是HashMap中最多只能一个空键的缘由)。 若是键不为空,则计算它的哈希码,并根据哈希码找到其对应哈希表的索引。找到的Bucket是一个链表,遍历该链表,若是键已存在,则更新找到的节点值,将旧值返回,方法结束,若是没有找到该键,则做为一个新的节点加入链表末端。 上面解读中遗留了几个细节没讲,下面一一解读:性能
inflateTable(threshold);
逻辑private void inflateTable(int toSize) { // Find a power of 2 >= toSize int capacity = roundUpToPowerOf2(toSize); threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); table = new Entry[capacity]; initHashSeedAsNeeded(capacity); }
实际逻辑很简单,经过roundUpToPowerOf2
方法保证扩容容量值必定是恰好大于等于传入容量值的2的整数次幂(关于这点的缘由,后面会解释),而后根据扩容后的容量计算扩容后的扩容阈值threshold
,最后从新构造哈希表table
(最后一行根据容量生成哈希种子的逻辑不影响主逻辑,这里略过)。实际上这个方法只在哈希表空时执行,只是一个初始化的方法,后续在添加新元素的过程当中触发扩容是由其它方法实现。优化
private V putForNullKey(V value) { for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(0, null, value, 0); return null; }
由源码能够看出只是简单的遍历哈希表,找到空键对应的Bucket(没有就新增一个),更新找到节点值,返回旧值。
final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } static int indexFor(int h, int length) { // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2"; return h & (length-1); }
生成哈希码的过程略过(我会告诉你是由于实现得太过复杂,我也不是很懂?),重点讲一下根据哈希码计算索引的逻辑。代码只有一行h & (length-1)
,这行代码实际上保证了返回值在[0 ~ 哈希表长度 - 1]这个区间内,若是不用位运算,它的等效(注意:这里是说等效,非等价,二者的返回值未必是相同的,但效果是一致的)实现为:h % length
,但位运算的性能更好,因此使用了这种写法,另外使用位运算还有一个缘由,后面解释为何HashMap的容量必定是2的整数次幂里会讲到,这里先略过。 计算出索引后,直接从哈希表中获得Bucket(由于是经过下标查找,因此时间复杂度是O(1)),Bucket是一个链表,遍历这个链表找到对应的节点(键相同),更新节点值,返回旧值,若是未找到说明是一个新的节点,经过addEntry(hash, key, value, i);
添加一个节点到链表末端。
void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); } void createEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e); size++; }
实际上添加一个新节点很容易,只须要构造一个Entry节点,添加到链表末端便可,但这里须要考虑的时,若是实际容量到达扩容阈值时,须要触发扩容逻辑。 首先经过resize(2 * table.length);
将哈希表扩容为原来的两倍,而后从新计算哈希码和索引,最后经过createEntry(hash, key, value, bucketIndex);
建立一个新节点,将其加入链表末端。 这里重点须要介绍一下resize
方法(区别于inflateTable
方法,这个方法会被屡次调用)
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } Entry[] newTable = new Entry[newCapacity]; transfer(newTable, initHashSeedAsNeeded(newCapacity)); table = newTable; threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); } void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } }
使用扩容后的容量构造一个新的哈希表,将原哈希表中的数据复制到新表中,再将新表赋值给类的table
属性,从而完成扩容。
查找一个元素,在put
方法中已经体现了查找过程,先计算哈希码,再计算索引,找到Bucket后遍历链表,根据键找到目标元素便可
public V get(Object key) { if (key == null) return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); } final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; }
删除元素,先找到元素,将其从对应链表中删除便可,此时size
计数器会递减
public V remove(Object key) { Entry<K,V> e = removeEntryForKey(key); return (e == null ? null : e.value); } final Entry<K,V> removeEntryForKey(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); int i = indexFor(hash, table.length); Entry<K,V> prev = table[i]; Entry<K,V> e = prev; while (e != null) { Entry<K,V> next = e.next; Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { modCount++; size--; if (prev == e) table[i] = next; else prev.next = next; e.recordRemoval(this); return e; } prev = e; e = next; } return e; }
清空集合,只须要将哈希表(数组)全部元素置空,并将计数器归零便可
public void clear() { modCount++; Arrays.fill(table, null); size = 0; }
前面讲过,哈希表的时间复杂度为O(1),但前提是没有发生哈希碰撞的状况下。若是一个类在重写hashCode方法时直接返回了一个常数(下次不能偷懒了~),就会形成存入集合的全部元素的哈希码相同,也就是哈希碰撞,那么HashMap的存储结构就会退化为一个链表结构(只有一个Bucket),而链表的时间复杂度为O(n),所以一个好的hashCode实现,能够提高其在HashMap或者HashSet等数据结构中的性能的,而只返回一个常数是万不可取的。
以前提到过个问题,这时就要解释一下indexFor
这个方法了。h & (length-1)
使用了按位与运算,而该运算的特色是,参与计算的两个相同位都为1时输出1,不然输出0。参考一下下面的示例(假设当前容量为8):
# 8 - 1 == 7 转换二进制为 00000111 7 & 1 == 00000111 & 00000001 == 00000001 == 1 7 & 2 == 00000111 & 00000010 == 00000010 == 2 7 & 11 == 00000111 & 00001011 == 00000011 == 3
首先第一点,解释一下该方法是怎样保证返回索引必定在[0, length - 1]这个区间内。根据按位与运算的特色,(length - 1)中为1的项,在与任何数作按位与计算时才有可能为1,而(length - 1)中为0的项与任何数作按位与计算必定返回0,因此整个运算返回的最大值只能是(length - 1),而最小值是0,因此保证了[0, length - 1]这个区间范围。 HashMap实际容量必定是2的整数次幂也和这里的按位与运算有关。若是length是2的整数次幂(那么length的二进制必定是...1000...0
的形式),那么(length - 1)的二进制必定是...0001111...1
形式(这点读者自行去验证一下)。而在作按位与计算时,1才是会引发值变化的项,假设length为8,那么length-1的二进制就是00000111
,任意数与其作按位与计算能够获得[0000, 0111]即[0, 7]全部项,而若是length不是2的整数次冥,那么length-1必须中间会有0(空项)存在,这意味着这些位置没法表示,从而形成哈希表中存在空洞(空间浪费),实际可用空间减小,那么哈希碰撞的概率就更大,即浪费空间,也影响效率。
# 下面是一组length不为2的整数次幂时,length - 1的二进制值 length = 10, length - 1 == 9 == 00001001 length = 13, length - 1 == 12 == 00001100 length = 15, length - 1 == 14 == 00001110 ... ...
因此只有length值为2的整数次幂时,length - 1才会是...1111...1
的结构,作按位与计算才能表示全部值。
JDK1.8中对HashMap作了大量优化,代码细节调整很是多,但代码结构基本一致,也仍然使用哈希表,因此这里再也不展开, 有兴趣的自行阅读。JDK1.8中对HashMap作的最大调整是哈希表中单项(Bucket)再也不彻底使用链表结构了,当链表长度超过8时,将被转换为红黑树,而红黑树相比与链表有着更好的查询性能。
具体分析过程略,这里只说结论: 由于在HashMap中添加元素时,可能发生扩容,扩容过程当中会将遍历原来的链表数据,复制到新哈希表过,在多线程的状况下能够多个线程同时触发扩容,那么在这个过程当中颇有可能形成链表变成循环链表或者空表,致使数据get的时候死循环或者数据丢失的状况。 因此多线程(并发)场景下推荐使用ConcurrentHashMap,后面的文章中会介绍该集合类。
参考资料:
HashMap在实际开发中用得很是多,其代码实现所涉及到的知识点也比较多,并且应用普遍,因此它的源码仍是颇有必要读一读的。本文主要介绍的是JDK1.7的源码,JDK1.8作了至关多的调整,后续有时间会深刻阅读一下JDK1.8的源码,尤为是关于红黑树这一块。
编写本文除了阅读源码之外,了参考了其它博主的文章(其实都比我写得好,不会画图是硬伤):