环境:jdk1.8
构造函数
首先我们看下HashMap构造函数,以及默认容量DEFAULT_INITIAL_CAPACITY设置,指定初始化容量的构造函数中对初始化容量做了2的幂处理,例如:指定17,处理后会变成32(向上取幂)。默认容量16也是2的幂,并且注释中写明了必须为2的幂。
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
...
public HashMap(int initialCapacity, float loadFactor) {
...
this.threshold = tableSizeFor(initialCapacity);
}
很多朋友可能会感觉到奇怪,为什么必须要是2的幂呢?我们继续看它的源码来挖掘原因
put
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
第一步是取key的hash值,我们知道一个hash算法的好坏主要看其hash后元素分布的均匀性,越是均匀,hash冲突也就是越少。我们看下hashmap如何实现的hash算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- hashCode()函数是Object提供的native函数,调用系统函数返回一个内存地址转换而来的int值
- h ^ (h >>> 16),该函数什么意思呢?首先h无符号右移16位,刚好32位的一半,然后h与移位的结果做异或运算。异或算法也就是同假异真或不进行进位的加法。右移16位后高16位全部补0,与原高位16位异或结果不会改变原高16位。低16位与移位后的高16位异或运算
第二步其实就是将Object提供的hashCode返回值的低16位变成它的低16位与高16位异或运算后的值。这么做有什么好处呢?
如果低16位的两个位为00,高16位对应位置的两个位异或运算后两个位相同的概率是2/4(00或11),也就是有2/4的概率不同,异或后结果不是00或11的概率有2/4(01或10),显然对于一个两位的二进制01比00更均匀。所有场景如下表。可以看出异或运算后结果更加倾向均匀分布。正式我们期待的结果
两位长度二进制组合 | 异或运算后两个位数字相同的概率 | 异或运算后两个位数字不相同的概率 |
---|---|---|
00 | 2/4 | 2/4 |
01 | 1/4 | 3/4 |
10 | 1/4 | 3/4 |
11 | 2/4 | 2/4 |
第二步putVal
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);
...
}
tab就是map的hash表,根据hash判断表中是否存在数据,也就是根据hash转换为hash表的下标,然后判断下标处是否有数据即可。hash值不能直接作为下标吗?为什么要转换?因为我们的hash表的大小默认是16,通常指定hash表的大小也不会是Integer最大值,因为我们的hash函数返回的是一个int值,那么就会存在下标越界问题,如何处理下标越界问题呢?直接使用hash值对hash表的大小取余数即可。那么我们看下HashMap如何处理的?显然不是直接用的取余算法,而是按位与运算:hash表大小-1 & hash值。
命题:X % (2^n) = [(2^n) - 1] & X
上面我们看到HashMap中的处理:(hash表大小-1) & hash值等同于取余运算
首先,我们回忆下2进制换算10进制的方法:
12的二进制表示:1100=12^3 + 12^2 + 02^1 + 02^0
取余数算法:
13%3=1:3+3+3+3+1
13%11=2:11+2
13%8=5:8+5
13%2=1:2+2+2+2+2+2+1
13转为2进制的另一种表示方法:12^3 + 12^2 + 02^1 + 12^0
对2取余,2转为2进制的另一种表示方法:12^1 + 020=1*20
对4取余,4转为2进制的另一种表示方法:12^2 + 02^1 + 020=0*21 + 12^0
对8取余,8转为2进制的另一种表示方法:12^3 + 02^2 + 02^1 + 020=1*22 + 02^1 + 12^0
…
写程序验证可以得出结论,即:X∈Integer,2^n∈Integer
结论
任意10进制数字对2的幂求模运算,等于10进制转为2进制后第(2^n) - 1右边的所有位转成10进制后的数字。
如何获取10进制转二进制后,第(2^n) - 1位的右边的所有位。没错就是对(2^n) - 1按位与运算。如下表:
2^n | 13的二进制 | [(2^n)-1]的二进制 | 按位与远算 | 余数 | |
---|---|---|---|---|---|
n=1 | 2 | 1101 | 0001 | 0001 | 1 |
n=2 | 4 | 1101 | 0011 | 0001 | 1 |
n=3 | 8 | 1101 | 0111 | 0101 | 5 |
性能比较
小于等于1千万次,模运算性能高于位运算
大于等于1亿次,模运算性能低于位运算
位运算耗时 | 模运算耗时 | 模运算耗时/位运算耗时 | 位运算耗时/模运算耗时 | ||
---|---|---|---|---|---|
100000次位运算耗时 | 13 | 100000次模运算耗时 | 8 | 0.615384615 | 1.625 |
1000000次位运算耗时 | 86 | 1000000次模运算耗时 | 59 | 0.686046512 | 1.457627119 |
10000000次位运算耗时 | 628 | 10000000次模运算耗时 | 559 | 0.890127389 | 1.123434705 |
100000000次位运算耗时 | 5125 | 100000000次模运算耗时 | 5830 | 1.137560976 | 0.879073756 |
1000000000次位运算耗时 | 60795 | 1000000000次模运算耗时 | 70724 | 1.163319352 | 0.859609185 |
性能比较代码
package com.mytest;
import java.util.Random;
/**
* @author 会灰翔的灰机
* @date 2020/3/15
*/
public class BitAndModular {
public static void main(String[] args) {
bit();
modular();
}
public static void bit() {
int power = 10;
int number = 10000;
while(true) {
number *= power;
if (number <= 1000000000) {
long start = System.currentTimeMillis();
int result = 0;
for (int j = 1; j < number; j++) {
result = (16777216 - 1) & new Object().hashCode();
}
long end = System.currentTimeMillis();
System.out.println(String.format("%s次位运算耗时\t%s", number, (end - start)));
} else {
break;
}
}
}
public static void modular() {
int power = 10;
int number = 10000;
for(;;) {
number *= power;
if (number <= 1000000000) {
long start = System.currentTimeMillis();
int result = 0;
for (int j = 1; j < number; j++) {
result = new Object().hashCode() % 16777216;
}
long end = System.currentTimeMillis();
System.out.println(String.format("%s次模运算耗时\t%s", number, (end - start)));
} else {
break;
}
}
}
}