做为一个有抱负的 Java 程序员,在通过长期的CRUD 和 HTML 填空以后必须有所思考,由于好奇心是驱动人类进步的动力之一,咱们好奇,好比咱们经常使用的 HashMap 究竟是如何实现的?我想,说到这里,稍微有点经验的大佬都会说:擦,面试必问好嘛?怎么可能不知道?java
可是,咱们真的了解他吗?node
咱们知道 HashMap 依赖的 hashcode 和 hash 算法究竟是怎么实现的嘛?若是大佬说:早他么知道了。那就装不知道,听楼主吹吹牛逼好不啦。。。。c++
今天楼主不会讲 HashMap 的 put 方法实现和 get 方法实现,楼主要讲的是 HashMap 高度依赖的 hashcode 和 hash 算法,虽然在不少书里面,都说这是数学家应该去研究的事情,但我想,程序员也应该了解他是怎么实现的。为何这么作?就像娶老婆,你可能作不到创造老婆,可是你得知道你老婆是怎么来的?家是哪的?为何喜欢你?扯远了,回来,那么今天咱们就开始吧!程序员
首先,由于今天的文章会涉及到一些位运算,所以楼主怕你们忘了(其实楼主本身也忘了),所以贴出一些位运算符号的意思,以避免看代码的时候懵逼。面试
<< : 左移运算符,num << 1,至关于num乘以2 低位补0 >> : 右移运算符,num >> 1,至关于num除以2 高位补0 >>> : 无符号右移,忽略符号位,空位都以0补齐 % : 模运算 取余 ^ : 位异或 第一个操做数的的第n位于第二个操做数的第n位相反,那么结果的第n为也为1,不然为0 & : 与运算 第一个操做数的的第n位于第二个操做数的第n位若是都是1,那么结果的第n为也为1,不然为0 | : 或运算 第一个操做数的的第n位于第二个操做数的第n位 只要有一个是1,那么结果的第n为也为1,不然为0 ~ : 非运算 操做数的第n位为1,那么结果的第n位为0,反之,也就是取反运算(一元操做符:只操做一个数)
好了,大概了解一下就行了,由于位运算平时在项目里真的用不上,在咱们普通的业务项目里,代码易读性比这点位运算性能要重要的多。可是,在框架中,位运算的必要性就显示出来的了。由于须要服务大量的运算,性能要求也极高,若是性能渣渣,谁还用你?算法
那么咱们就说说为何使用 hashcode ,hashCode 存在的第一重要的缘由就是在 HashMap(HashSet 其实就是HashMap) 中使用(其实Object 类的 hashCode 方法注释已经说明了 ),我知道,HashMap 之因此速度快,由于他使用的是散列表,根据 key 的 hashcode 值生成数组下标(经过内存地址直接查找,没有任何判断),时间复杂度完美状况下能够达到 n1(和数组相同,可是比数组用着爽多了,可是须要多出不少内存,至关于以空间换时间)。数组
在 JDK 中,Object 的 hashcode 方法是本地方法,也就是用 c 语言或 c++ 实现的,该方法直接返回对象的 内存地址。这么作会有说明问题呢?咱们用代码看看:markdown
class Test1{
String name;
public Test1(String name) { this.name = name; } public static void main(String[] args) { Map<Test1, String> map = new HashMap<>(4); map.put(new Test1("hello"), "hello"); String hello = map.get(new Test1("hello")); System.out.println(hello); } }
这段代码打印出来的会是什么呢? 答: null。由于咱们没有重写 hashCode 方法,全部,HashMap 内部使用的是该对象的内存地址,那么确定不同。咱们第一个对象根本就没有存,所以,返回就是 null。这里就能够看出来重写 hashCode 的重要性。框架
JDK 中,咱们常常把 String 类型做为 key,那么 String 类型是如何重写 hashCode 方法的呢?eclipse
咱们看看代码:
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
代码很是简单,就是使用 String 的 char 数组的数字每次乘以 31 再叠加最后返回,所以,每一个不一样的字符串,返回的 hashCode 确定不同。那么为何使用 31 呢?
若是有使用 eclipse 的同窗确定知道,该工具默认生成的 hashCode 方法实现也和 String 类型差很少。都是使用的 31 ,那么有没有想过:为何要使用 31 呢?
在名著 《Effective Java》第 42 页就有对 hashCode 为何采用 31 作了说明:
之因此使用 31, 是由于他是一个奇素数。若是乘数是偶数,而且乘法溢出的话,信息就会丢失,由于与2相乘等价于移位运算(低位补0)。使用素数的好处并不很明显,可是习惯上使用素数来计算散列结果。 31 有个很好的性能,即用移位和减法来代替乘法,能够获得更好的性能: 31 * i == (i << 5) - i, 现代的 VM 能够自动完成这种优化。这个公式能够很简单的推导出来。
这个问题在 SO 上也有讨论: https://stackoverflow.com/questions/299304/why-does-javas-hashcode-in-string-use-31-as-a-multiplier%EF%BC%89
能够看到,使用 31 最主要的仍是为了性能。固然用 63 也能够。可是 63 的溢出风险就更大了。那么15 呢?仔细想一想也能够。
在《Effective Java》也说道:编写这种散列函数是个研究课题,最好留给数学家和理论方面的计算机科学家来完成。咱们这次最重要的是知道了为何使用31。
好了,知道了 hashCode 的生成原理了,咱们要看看今天的主角,hash 算法。
其实,这个也是数学的范畴,从咱们的角度来说,只要知道这是为了更好的均匀散列表的下标就行了,可是,就是耐不住好奇心啊! 能多知道一点就是一点,咱们来看看 HashMap 的 hash 算法(JDK 8).
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
乍看一下就是简单的异或运算和右移运算,可是为何要异或呢?为何要移位呢?并且移位16?
在分析这个问题以前,咱们须要先看看另外一个事情,什么呢?就是 HashMap 如何根据 hash 值找到数组种的对象,咱们看看 get 方法的代码:
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) { if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
咱们看看代码中注释下方的一行代码:first = tab[(n - 1) & hash])。
使用数组长度减一 与运算 hash 值。这行代码就是为何要让前面的 hash 方法移位并异或。
咱们分析一下:
首先,假设有一种状况,对象 A 的 hashCode 为 1000010001110001000001111000000,对象 B 的 hashCode 为 0111011100111000101000010100000。
若是数组长度是16,也就是 15 与运算这两个数, 你会发现结果都是0。这样的散列结果太让人失望了。很明显不是一个好的散列算法。
可是若是咱们将 hashCode 值右移 16 位,也就是取 int 类型的一半,恰好将该二进制数对半切开。而且使用位异或运算(若是两个数对应的位置相反,则结果为1,反之为0),这样的话,就能避免咱们上面的状况的发生。
总的来讲,使用位移 16 位和 异或 就是防止这种极端状况。可是,该方法在一些极端状况下仍是有问题,好比:10000000000000000000000000 和 1000000000100000000000000 这两个数,若是数组长度是16,那么即便右移16位,在异或,hash 值仍是会重复。可是为了性能,对这种极端状况,JDK 的做者选择了性能。毕竟这是少数状况,为了这种状况去增长 hash 时间,性价比不高。
好了,知道了 hash 算法的实现原理还有他的一些取舍,咱们再看看刚刚说的那个根据hash计算下标的方法:
tab[(n - 1) & hash];
其中 n 是数组的长度。其实该算法的结果和模运算的结果是相同的。可是,对于现代的处理器来讲,除法和求余数(模运算)是最慢的动做。
上面状况下和模运算相同呢?
a % b == (b-1) & a ,当b是2的指数时,等式成立。
咱们说 & 与运算的定义:与运算 第一个操做数的的第n位于第二个操做数的第n位若是都是1,那么结果的第n为也为1,不然为0;
当 n 为 16 时, 与运算 101010100101001001101 时,也就是
1111 & 101010100101001001000 结果:1000 = 8
1111 & 101000101101001001001 结果:1001 = 9
1111 & 101010101101101001010 结果: 1010 = 10
1111 & 101100100111001101100 结果: 1100 = 12
能够看到,当 n 为 2 的幂次方的时候,减一以后就会获得 1111* 的数字,这个数字正好能够掩码。而且获得的结果取决于 hash 值。由于 hash 值是1,那么最终的结果也是1 ,hash 值是0,最终的结果也是0。
到这里,咱们提了一个关键的问题: HashMap 的容量为何建议是 2的幂次方?正好能够和上面的话题接上。楼主就是这么设计的。
为何要 2 的幂次方呢?
咱们说,hash 算法的目的是为了让hash值均匀的分布在桶中(数组),那么,如何作到呢?试想一下,若是不使用 2 的幂次方做为数组的长度会怎么样?
假设咱们的数组长度是10,仍是上面的公式:
1010 & 101010100101001001000 结果:1000 = 8
1010 & 101000101101001001001 结果:1000 = 8
1010 & 101010101101101001010 结果: 1010 = 10
1010 & 101100100111001101100 结果: 1000 = 8
看到结果咱们惊呆了,这种散列结果,会致使这些不一样的key值所有进入到相同的插槽中,造成链表,性能急剧降低。
因此说,咱们必定要保证 & 中的二进制位全为 1,才能最大限度的利用 hash 值,并更好的散列,只有全是1 ,才能有更多的散列结果。若是是 1010,有的散列结果是永远都不会出现的,好比 0111,0101,1111,1110…….,只要 & 以前的数有 0, 对应的 1 确定就不会出现(由于只有都是1才会为1)。大大限制了散列的范围。
那咱们如何自定义呢?自从有了阿里的规约插件,每次楼主都要初始化容量,若是咱们预计咱们的散列表中有2个数据,那么我就初始化容量为2嘛?
绝对不行,若是你们看过源码就会发现,若是Map中已有数据的容量达到了初始容量的 75%,那么散列表就会扩容,而扩容将会从新将全部的数据从新散列,性能损失严重,因此,咱们能够必需要大于咱们预计数据量的 1.34 倍,若是是2个数据的话,就须要初始化 2.68 个容量。固然这是开玩笑的,2.68 不能够,3 可不能够呢?确定也是不能够的,我前面说了,若是不是2的幂次方,散列结果将会大大降低。致使出现大量链表。那么我能够将初始化容量设置为4。 固然了,若是你预计大概会插入 12 条数据的话,那么初始容量为16简直是完美,一点不浪费,并且也不会扩容。
好了,分析完了 hashCode 和 hash 算法,让咱们对 HashMap 又有了全新的认识。固然,HashMap 中还有不少有趣的东西值得挖掘,楼主会继续写下去。争取将 HashMap 的衣服扒光。
总的来讲,经过今天的分析,对咱们从此使用 HashMap 有了更多的把握,也可以排查一些问题,好比链表数不少,确定是数组初始化长度不对,若是某个map很大,注意,确定是事先没有定义好初始化长度,假设,某个Map存储了10000个数据,那么他会扩容到 20000,实际上,根本不用 20000,只须要 10000* 1.34= 13400 个,而后向上找到一个2 的幂次方,也就是 16384 初始容量足够。
good luck !!!!