注:提取的源码片段为jdk8
对HashMap有了解的人都知道,HashMap默认维护的数组大小是1 << 4,八位二进制表示就是00010000,十进制就是16,也就是2^4。
当我们创建一个HashMap对象的时候,如果我们指定了容量大小,但是却不是2^n,那底层也会帮我们进行处理,源码:
/**
* 有参构造,参数为自定义容量大小
*/
public HashMap(int initialCapacity) {
// 调用了重载方法,用了默认的负载因子
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 上面调用的重载方法
*/
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;
// 此方法用来将我们输入的大小处理为2的n次幂
this.threshold = tableSizeFor(initialCapacity);
}
/**
* 将我们输入的大小处理为2的n次幂
* 例如:
* 输入15,则为16
* 输入17,则为32
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
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;
}
为什么要设置成2的n次幂呢?
原因就是HashMap计算元素在数组中位置的算法所致。
散列查找表
1.什么是散列表
散列表是一个Key/Value键值对的存储结构,是数据结构中很基础的一个逻辑存储结构,通常实现上使用的是顺序存储,比如在我们的java中就是数组。因为顺序存储结构在实际物理内存中的地址是连续的,所以可以达到随机存取的效果,达到查找指定位置的元素时间复杂度为O(1),所以散列表对元素查找的时间复杂度可以达到O(1)。
2.散列表的概括实现思路
- 散列表维护一个一维的数组,用于存储元素。
- 当我们向散列表中存储一个元素的时候,会根据Key进行存储,需要为Key提供一个唯一的关键码值,就跟人的指纹一样,通常会是数值型。接下来,会将这个关键码值与散列表所维护的一维数组的空间大小做取模运算(也就是小学数学的求余),得到的值就是此次的Value在数组中的下标,然后将Key、关键码值、Value什么的包装成一个节点存储到数组中。对应到我们java中的实现,关键码值就是Object中的hashCode()方法,如果我们不覆盖这个方法,它的默认实现是根据当前对象在内存中的地址计算出一个Int值。包装数据的节点就是弄个对象出来。
- 当我们从散列表中取出一个元素的时候,会根据Key进行查找,具体就是根据这个Key的关键码值计算出Value在数组中的位置,找到就返回,找不到该怎么处理,那就是这个散列表的自由实现了,比如抛个异常、返回空值等等。
HashMap的概括实现
我们上边已经简单介绍了散列表的实现思路,就是对关键码值取模计算数组位置。
十进制的方式的取模虽好,但对于只认识0和1的计算机来说,计算速度终是不及二进制形式的运算的。
各位看官应该有嗅觉了,没错,巧用二进制的与运算,可以达到与十进制取模相同的效果!
巧用二进制与运算
十进制求余。
求余的结果意味着什么?如n % 16,那么计算结果范围就是0 - 15,意味着我们要的效果就是要保证结果是一个大于等于0,但是小于
16的整数,也就是小于除数。以八位二进制举例二进制与运算。
n & 00001111的结果是多少呢?范围为00000000 - 00001111,意味着计算结果是一个大于等于0,小于等于
00001111的数,这结果范围比十进制多了一个上界的等于是吧,那我们可以把00001111加个1,不就变成了计算结果大于等于0,小于(00001111 +1)了吗?是不是跟十进制的范围效果是一样的?00001111加1的结果就是00010000。举例归纳:
// 充当个随机的hashcode吧
long hash = System.currentTimeMillis();
/**
* 运算后范围在 00000000 <= result <= 00000001
* 我们将00000001加1,结果是00000010,十进制就是2,也就是2^1。
* 00000000 <= result < 00000010,也就是 00000000 <= ((2^1-1)&hash) < 2^1,也就等价于0 <= (hash%2^1) < 2^1
*/
System.out.println(0B00000001 & hash);
/**
* 运算后范围在 00000000 <= result <= 000000011
* 我们将000000011加1,结果是00000100,十进制就是4,也就是2^2。
*/
System.out.println(0B00000011 & hash);
/**
* 运算后范围在 00000000 <= result <= 00000111
* 我们将00000111加1,结果是00001000,十进制就是8,也就是2^3。
*/
System.out.println(0B00000111 & hash);
/**
* 运算后范围在 00000000 <= result <= 00001111
* 我们将00001111加1,结果是00010000,十进制就是16,也就是2^4。
*/
System.out.println(0B00001111 & hash);
// ......后续不在演示
/**
* 结论:
* 1.当我们用诸如00000001,00000010,00000100,00001000,00010000,00100000减去1进行二进制的与运算,
* 计算结果的效果等同于十进制下的取模运算,即a%b = a&(b-1),需保证b为1的向左n位移,即2^n。
* 2.hashmap默认数组大小是1 << 4,也就是00000001向左移4位,变成00010000,也就是十进制的16,2^4,
* 计算索引位置就是hash & (00010000-1)即hash&00001111,范围在0到15
*/
HashMap的hash()方法
HashMap会对键的hashCode进行hash(),目的就是为了让键的hashCode更加散列,充分利用数组空间,减少碰撞。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
java中hashCode()返回的是int,int长度为32个比特。
那为何不直接用这32个比特的int进行取模计算呢?
想象其中一种情况,你运气极差,往HashMap中添加的一批元素,Key的hashCode()方法返回的整数的32位二进制低16位都是0,1都集中在高16位,例:
- KeyA hashCode:10101011110011010000000000000000
- KeyB hashCode: 11111011110011010000000000000000
- KeyC hashCode: 01111011010011010000000000000000
- …
此时,数组的空间大小十进制假如是32,八位的二进制足够表示为00100000,那要是让(32-1)也就是00011111跟上边那些东西做与运算,那结果就是00000000000…0啊,那都落在了一个索引上啊,这不行啊,这挂成了一条大链表了,这tm不成了线性表了吗?为了防止类似的情况发生,HashMap对Key的hashCode方法所返回的整数值进行了处理,目的就是让这些hashCode更分散,最后与运算后的索引也就更分散。比如,KeyA的hashCode经过HashMap的hashCode方法那么一弄,就变成了10101011110011011010101111001101。
当然了,通过HashMap的再哈希处理,只是尽量让数据能够分散一些,但这也只是远远不够的,主要的精力还是要放在如何处理碰撞,如链表、红黑树。
此文旨在讨论HashMap如何计算索引的位置,为何空间大小定义为2^n,关于HashMap更多的诸如如何处理碰撞、如何扩容、为何jdk8以后要进行树化、并发版本的HashMap(ConcurrentHashMap)的同步策略是怎样的?笔者有时间会进行整理。