HashMap 是非常常用的 java 数据结构,但一直没了解过他的内部实现,近来想锻炼一下自己的源码阅读能力,就硬着头皮啃了一下源码。入坑以后,发现这个源码虽然可读性很差,但设计理念真的骚得不行,非常值得学习。我将分三期内容详细讲述 HashMap 的关键源码,内容包括基本原理、元素定位、数组扩容、红黑树实现等知识。下面开始我们第一期内容。
1、概述
HashMap本质就是一个Node<K, V>数组,当put一个键值对到HashMap,首先将这个键值对封装成Node,然后根据key的hashcode计算出该Node应该放在数组哪个位置,如果这个位置已经被占,那么就在这个位置以链表的形式存储多个Node对象。HashMap大致的结构图如下:
1.8之后,HashMap引入了红黑树。当链表的长度达到阈值之后,链表就会被树化,形成红黑树结构。大致结构图如下:
2、定位:
HashMap之所以有很好的查询效率,是因为它使用的是散列表。Node元素并不是一个一个按顺序插入数组中的,而是按照一定的算法对key进行处理,算出下标,进而将该Node放到数组对应的位置上。于是,在查询的时候,我们就不需要遍历整个数组一个个作比较最终拿到目标值,只需要按照上述的算法直接就能根据key算出下标。
这个算法需要最大程度保证数据的离散,不然每次计算出来的下标都一样,那数组的元素就都变成了链表甚至红黑树,数据的增删改查的效率将大幅降低,失去了使用数组意义。算法也要保证元素之间不能太稀疏,避免数组太大,空闲空间过多,浪费内存资源。直接看源码:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}
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) { 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);........源码中我们可以看出,首先根据 key 的 hashCode 计算出 hash,然后(tab.length-1)& hash 就是这对键值对应该存放的位置。
例如,对象A的hashCode的二进制是00010101110110111001011101000010,将其往右移一半,得到00000000000000000001010111011011,然后做位异或运算得到hash值00010101110110111000001010011001。如果数组长度是16,将15(00000000000000000000000000001111)与 hash 值做位与运算,得到最终下标为 9。
为什么要这么大费周章呢?直接用 key 的 hashCode 取模(%)数组长度不就行了?这种方案虽然实施简单,但对于CPU来说,执行取模运算的效率是很差的,能用位运算解决问题还是优先选用位运算。所以hashCode % n 用位运算做就是 hashCode & (n-1)。
那为什么还要用 hashCode 的二进制的前半部分和后半部分做位异或操作的结果跟(n-1)做位与操作呢?这么做是为了最大程度避免得到的下标值的重复。因为 HashMap 的数组扩容机制规定数组大小永远是2^n,2^n的二进制是第n+1位是1,其余全是0,于是2^n -1的二进制就是1~n位为1,其余全是0。所以m & (2^n-1)本质上就是取二进制m的1~n位。如果仅仅使用hashCode做位与运算,那么二进制hashCode就只有后半段参与计算,如果出现某些hashCode的二进制的前半段不同,后半段相同,那就会出现冲突,在数组容量小时这种情况还不容易发生,如果容量极大,就会发生大量冲突。所以,我们选择前半段与后半段做位异或操作的方式来使得前半段也参与到下标的运算中,最大程度避免冲突。
题外话:
重写equals方法必须同时重写hashCode方法。Object类的equals方法就是使用==,源码如下:
public boolean equals(Object obj) { return (this == obj);}大多数人会以为==的本质是比较两个对象的hashCode,这种观点是错误的。两个对象hashCode相同,==不一定为true,但==为true,那两个对象的hashCode一定相同。举个例子:
public class Demo { public static void main(String[] args) { Demo ss = new Demo(); for (;;) { Demo kk = new Demo(); if (ss.hashCode() == kk.hashCode()) { System.out.println("成功找到,ss==kk吗?" + (ss == kk));//一定能找到hashCode碰撞的对象,而且ss==kk为false break; } } }}Java中,对象的 hashCode 存在的意义就是为HashMap、HashTable等基于散列算法的数据结构提供对象的唯一性标识。HashCode通过对key的hashCode来决定将键值对放入数组的哪个位置,如果我们只覆写key类的equals方法,不覆写 hashCode 方法,那即使两个 key 可以被equals方法判定相等,在 HashMap 中也不会认为这两个key相等。举个例子:
public class Student { String name; Student (String name) { this.name = name; } public boolean equals (Object obj) { Student stu = (Student)obj; return this.name.equals(stu.name); } public int hashCode() { return this.name.hashCode(); } public static void main(String[] args) { Student n1 = new Student("张三"); Student n2 = new Student("张三"); System.out.println(n1.equals(n2));//true HashMap<Student, String> map = new HashMap<Student, String>(); map.put(n1, "hello"); map.put(n2, "world"); System.out.println(map.get(n1));//world }}3、扩容:
HashMap 有无参构造函数,创建的 HashMap 对象的数组容量默认为16;也有带参数的构造函数,以允许开发人员自定义创建的 HashMap 的数组容量,但开发人员自定义的容量 cap 会经 tableSizeFor 方法处理,生成一个最接近 cap 的 2 的整数次幂的数作为数组容量。也就是说HashMap 的数组容量一定能表示成 2^n。
static final int tableSizeFor(int cap) {
int n = cap - 1;
/**
**下面代码的本质就是将n的所有二进制位转化成1
**比如n=1000,转化完之后就是 1111,所以最后返回 n + 1 就是 10000
**/
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;
}那么当 HashMap 数组容量不够时,就需要扩容,扩容就是将原数组扩容 2 倍,依然要保证 HashMap 的数组容量是 2 的整数次幂。 resize 方法实现了数组的初始化和扩容,源码解析如下:
final Node<K,V>[] resize() { //oldTab为当前table Node<K,V>[] oldTab = table; //当前table长度 int oldCap = (oldTab == null) ? 0 : oldTab.length; //记录阈值threshold。threshold表示当HashMap内键值对超过该值,则触发数组扩容 int oldThr = threshold; //newCap是扩容后数组的大小,newThr是触发下次扩容的阈值 int newCap, newThr = 0; if (oldCap > 0) {//本次扩容不是初始化 if (oldCap >= MAXIMUM_CAPACITY) { //如果当前table的大小已经>=数组最大容量(2^30 = 1073741824),标记下次扩容阈值为2147483647,也就是不再扩容 threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //当前数组长度扩大一倍,即是新数组的大小。如果新数组带大小小于1073741824 并且老数组大小>=数组初始化的大小16,则将oldThr*2作为数组下次扩容阈值 newThr = oldThr << 1; } else if (oldThr > 0) {//当前数组为空,需要初始化数组。如果定义了oldThr,那就将新数组的大小定为oldThr newCap = oldThr; } else { //如果当前数组为空,也没有指定扩容阈值,那就将新数组大小设置为默认值16 newCap = DEFAULT_INITIAL_CAPACITY; //扩容阈值 = 负载因子 * 数组长度 = 0.75*16 = 12 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) {//进到这个if,说明本次扩容是数组初始化,并且已经设定好了扩容阈值 float ft = (float)newCap * loadFactor;//新数组长度*负载因子得到扩容阈值(loadFactor = size/capacity) 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)/***这里可能会有疑问,我们通过node的hash值与table.length-1做位与运算,得到下标。那这里算出下标后直接将e放在这个位置,不用考虑这个位置上已经有数据?不需要将这些数据做链化、树化?*答案是确实不用。上面的代码我们可以知道,数组的初始容量是16,之后每次扩容都是乘以2,所以数组的容量永远是2^n,所以数组最大的下标即为2^n-1,转化成二进制后最低几位一定全都是1。*将Node的hash值与(table.length-1)做位与,本质上就是取二进制hash的后面几位。如果数组容量是16,那就是取二进制hash的后四位,扩容后容量就是32,那就是取二进制hash的后5位,如果后四位都没有相同的,那后5位也肯定只有这一个Node。 **/ newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) //红黑树结构 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { //链表结构 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;}至此,第一期内容讲解结束,更多源码解析请关注个人公众号“代码凌凌漆”,这里有HashMap更进一步的解析(例如红黑树的实现、transient 关键字的使用)、以及ConcurrentHashMap全网最详细解析等等干货内容,欢迎关注。