hashCode 为什么乘以 31?深入理解 hashCode 和 hash 算法
1. 二进制计算的一些基础知识
1. << : 左移运算符,num << 1,相当于num乘以2 低位补0
2. >> : 右移运算符,num >> 1,相当于num除以2 高位补0
3. >>> : 无符号右移,忽略符号位,空位都以0补齐
4. % : 模运算 取余
5. ^ : 位异或 第一个操作数的的第n位于第二个操作数的第n位相反,那么结果的第n为也为1,否则为0
6. & : 与运算 第一个操作数的的第n位于第二个操作数的第n位如果都是1,那么结果的第n为也为1,否则为0
7. | : 或运算 第一个操作数的的第n位于第二个操作数的第n位 只要有一个是1,那么结果的第n为也为1,否则为0
8. ~ : 非运算 操作数的第n位为1,那么结果的第n位为0,反之,也就是取反运算(一元操作符:只操作一个数)
2. 为什么使用 hashcode
hashCode 存在的第一重要的原因就是在 HashMap(HashSet 其实就是HashMap) 中使用(其实Object 类的 hashCode 方法注释已经说明了 ),我知道,HashMap 之所以速度快,因为他使用的是散列表,根据 key 的 hashcode 值生成数组下标(通过内存地址直接查找,没有任何判断),时间复杂度完美情况下可以达到 n1(和数组相同,但是比数组用着爽多了,但是需要多出很多内存,相当于以空间换时间)
3. String 类型的 hashcode 方法
在 JDK 中,Object 的 hashcode 方法是本地方法,也就是用 c 语言或 c++ 实现的,该方法直接返回对象的 内存地址。
1 class User { 2 String name; 3 4 public User(String name) { 5 this.name = name; 6 } 7 8 public static void main(String[] args) { 9 Mapmap = new HashMap<>(); 10 map.put(new User("hello"), "hello"); 11 String hello = map.get(new User("hello")); 12 System.out.println(hello); 13 } 14 } 15 16 String name; 17 18 public User(String name) { 19 this.name = name; 20 } 21 22 public static void main(String[] args) { 23 Mapmap = new HashMap<>(); 24 map.put(new User("hello"), "hello"); 25 String hello = map.get(new User("hello")); 26 System.out.println(hello); 27 } 28 } 29
此上的结果为null,
从某个角度说,这两个对象是一样的,因为名称一样,name 属性都是 hello,当我们使用这个 key 时,按照逻辑,应该返回 hello 给我们。但是,由于没有重写 hashcode 方法,JDK 默认使用 Object 类 native 的 hashCode 方法,返回的是什么呢?
首先一个对象肯定有物理地址,网上有人把对象的hashcode说成是对象的地址,事实上这种看法是不全面的,确实有些JVM在实现时是直接返回对象的存储地址,但是大多时候并不是这样,只能说可能存储地址有一定关联。
如果我们重写 hashcode 和 equals 方法:
1 @Overridepublic boolean equals(Object o) { 2 if (this == o) { 3 return true; 4 } 5 if (o == null || getClass() != o.getClass()) { 6 return false; 7 } 8 Test1 test1 = (Test1) o; 9 return Objects.equals(name, test1.name); 10 } 11 @Overridepublic 12 int hashCode() { 13 return Objects.hash(name); 14 } 15 if (this == o) { 16 return true; 17 } 18 if (o == null || getClass() != o.getClass()) { 19 return false; 20 } 21 Test1 test1 = (Test1) o; 22 return Objects.equals(name, test1.name); 23 } 24 25 @Override 26 public int hashCode() { 27 return Objects.hash(name); 28 }
再次运行:得到的结果就不是 null 了,而是 hello。
这才是比较符合逻辑,符合直觉的。
4. 为什么大部分 hashcode 方法使用 31
之所以使用 31, 是因为他是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算(低位补0)。使用素数的好处并不很明显,但是习惯上使用素数来计算散列结果。 31 有个很好的性能,即用移位和减法来代替乘法,可以得到更好的性能: 31 * i == (i << 5)- i, 现代的 VM 可以自动完成这种优化。这个公式可以很简单的推导出来。
所谓素数:质数又称素数,指在一个大于1的自然数中,除了1和此整数自身外,没法被其他自然数整除的数。
素数在使用的时候有一个作用就是,如果我用一个数字来乘以这个素数,那么最终的出来的结果只能被素数本身和被乘数还有1来整除!如:我们选择素数3来做系数,那么3*n只能被3和n或者1来整除,我们可以很容易的通过3n来计算出这个n来。这应该也是一个原因!
HashMap在存储数据计算hash地址的时候,我们希望尽量减少有同样的hash地址,所谓“Hash冲突”。如果使用相同hash地址的数据过多,那么这些数据所组成的hash链就更长,从而降低了查询效率!所以在选择系数的时候要选择尽量长的系数并且让乘法尽量不要溢出的系数,因为如果计算出来的hash地址越大,所
谓的“冲突”就越少,查找起来效率也会提高。
31可以由31 * i == (i << 5) - i来表示,现在很多虚拟机里面都有做相关优化,使用31的原因可能是为了更好的分配hash地址,并且31只占用5bits!在java乘法中如果数字相乘过大会导致溢出的问题,从而导致数据的丢失,而31则是素数(质数)而且不是很长的数字,最终它被选择为相乘的系数的原因。
可以看到,使用 31 最主要的还是为了性能。
5. HashMap 为什么使用 & 与运算代替模运算?
hash计算下标的方法:
tab[(n - 1) & hash]
其中 n 是数组的长度。其实该算法的结果和模运算的结果是相同的。但是,对于现代的处理器来说,除法和求余数(模运算)是最慢的动作。
上面情况下和模运算相同呢?
a % n == (n-1) & a,当n是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。
6. HashMap 的容量为什么建议是 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)。大大限制了散列的范围。
7. 我们自定义 HashMap 容量最好是多少?
看过源码就会发现,如果Map中已有数据的容量达到了初始容量的 75%,那么散列表就会扩容,而扩容将会重新将所有的数据重新散列,性能损失严重,所以,我们可以必须要大于我们预计数据量的 1.34 倍,如果是2个数据的话,就需要初始化 2.68 个容量。当然这是开玩笑的,2.68 不可以,3 可不可以呢?肯定也是不可以的,我前面说了,如果不是2的幂次方,散列结果将会大大下降。导致出现大量链表。那么我可以将初始化容量设置为4。 当然了,如果你预计大概会插入 12 条数据的话,那么初始容量为16简直是完美,一点不浪费,而且也不会扩容。