解决冲突的方法
提高效率
特殊情况扰动算法
当冲突大于一定量时需要扩容
在JDK1.7中,HashMap是由数组+单向链表实现的,原理图如下:
HashMap基本用法
public static void main(String[] args) {
//key-value, 数组存储头指针的引用地址,链表上存储Entry对象,Entry对象中存在一个next属性
HashMap<String, String> hashMap = new HashMap<String, String>();
//put大致流程: key--->key.hashcode()--->46792751--->46792751 % table.length--->index∈[0-7]
hashMap.put("123", "2");
Object key = hashMap.get("123");
System.out.println(key);
//当hash值和key值相同则返回覆盖的value值
String result = hashMap.put("123", "3");
System.out.println(result);
System.out.println("12341".hashCode()); //46792751
}
PUT/GET源码分析
HashMap map = new HashMap(); //伪初始化
map.put("键", "值"); //真初始化
HashMap的构造方法在执行时会初始化一个数组table,大小为0。
/**
* 默认的初始容量—必须是2的幂。
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大容量(1073741824)
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 在构造函数中没有指定时使用的负载因子。
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 当表没有扩容时共享的空表实例。
*/
static final Entry<?,?>[] EMPTY_TABLE = {};
/**
* HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{}。
* 根据需要调整表的大小。长度永远是2的幂。
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/**
* 这个映射中包含的键值映射的数量。
*/
transient int size;
/**
* 阈值和扩容参考值
* 要调整大小的下一个大小值。
*/
// 如果table == EMPTY_TABLE,则这是初始容量
// threshold一般为 capacity*loadFactor。HashMap在进行扩容时需要参考threshold,后面会详细谈到
int threshold;
/**
* 哈希表的加载因子,代表了table的填充度有多少,默认是0.75
* 加载因子存在的原因,还是因为减缓哈希冲突,
* 如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。
* 所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。
*/
final float loadFactor;
/**
* 与此实例关联的随机值,应用于键的哈希代码,使哈希冲突更难发现。如果为0,则可选哈希被禁用。
*/
transient int hashSeed = 0;
/**
* 使用默认初始容量构造一个空的HashMap(16)和默认负载因子(0.75)。
*/
public HashMap() {
// 调用HashMap(int initialCapacity, float loadFactor)这个构造函数
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
/**
* 使用指定的初始值构造一个空的HashMap容量和默认负载因子(0.75)。
*
* @param initialCapacity初始容量。
* 如果初始容量为负数,@抛出IllegalArgumentException。
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 使用指定的初始值构造一个空的HashMap容量和负载系数。
*
* @param initialCapacity 初始容量
* @param loadFactor 加载因子
* 如果初始容量为负数或负载因子非正,@抛出IllegalArgumentException
*/
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;
threshold = initialCapacity;
init();
}
/**
* 之所以需要这个init()钩子,是因为HashMap是可序列化的,
* 而反序列化方法(readObject())是一个跟构造器性质相似、但却不是构造器的奇怪的东西。
* 为了让子类能方便规整地实现构造初始化与反序列初始化的功能,
* HashMap就在构造器末尾和反序列化方法末尾都埋了这个init()钩子,这样子类就不用为这两种不同的初始化需求而重复头疼了。
*
* LinkedHashMap,作为HashMap的子类,就有覆写init()方法来做一些自己特定的初始化动作:
* LinkedHashMap要维持插入顺序,为此它会把所有插入的节点(键值对)用双向链表串在一起。
* 而在它的init()实现里,它就创建并初始化了该双向链表的头节点。
*/
void init() {
}
HashMap的PUT方法在执行时首先会判断table的大小是否为0,如果为0则会进行真初始化,也叫延迟初始化。
当进行真初始化时,数组的默认大小为16,当然也可以调用HashMap的有参构造方法由你来指定一个数组的初始化容量,但是注意,并不是你真正说了算,比如你现在想让数组的初始化容量为6,那么HashMap会生成一个大小为8的数组,如果你想数组的初始化容量为20,那么HashMap会生成一个大小为32的数组,也就是你想初始化一个大小为n的数组,但是HashMap会初始化一个大小大于等于n的二次方数的一个数组。至于为什么要这样,我们等会再说。
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
/**
* 扩容
*/
private void inflateTable(int toSize) {
// 找到一个大于等于toSize的最小2的幂次方数
int capacity = roundUpToPowerOf2(toSize);
//阈值 = 容量*加载因子
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
private static int roundUpToPowerOf2(int number) {
// 这里的number一定是非负的
/*
Integer.highestOneBit(num)的过程(源码放到文章最后)
即找到一个小于等于num的最大2的幂次方数
num 17 0001 0001
>> 1 0000 1000
| 0001 1001
>> 2 0000 0110
| 0001 1110
>> 4 0000 1000
| 0001 1111
>> 8 0000 0000
| 0001 1111
>>16 0000 0000
| 0001 1111
>>>1 0000 1111
- 0001 0000------>16
(number - 1) << 1 number减1再翻倍(举个特殊的例子16)
为什么要减1?若number=16,则返回小于等于[(16-1)*2=30]的最大2的幂次方数是16
若不减1,若number=16,则返回小于等于[16*2=32]的最大2的幂次方数是32
*/
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
/**
* 将key为null的entry放置在table[0]的链表上
*/
private V putForNullKey(V value) {
//遍历链表覆盖原来的value并返回原来的value
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
/**
* 将具有指定键、值和散列代码的新条目添加到指定的桶。
* 这是我们的责任方法来调整表的大小。
*
* 子类重写它来改变put方法的行为。
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
/**
* “头插法 + 移动”操作
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
/**
* 扩容
*/
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
/*
initHashSeedAsNeeded方法:实际上就是比较capacity和ALTERNATIVE_HASHING_THRESHOLD的大小,
若设置了VM Options(jdk.map.althashing.threshold):Holder.ALTERNATIVE_HASHING_THRESHOLD的值就是设置的值,否则为Integer.MAX_VALUE。
扩容的话,容量发生变化,要重新比较容量与ALTERNATIVE_HASHING_THRESHOLD的大小。
如果capacity>=ALTERNATIVE_HASHING_THRESHOLD,那么在initHashSeedAsNeeded方法中会重新生成hashSeed,
那么肯定要重新计算hash(此时transfer第二个参数为true)。
*/
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
// 重新计算阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
* 将当前表中的所有项转移到newTable。
* newCapacity: 容量翻倍的新的空数组
* 双重循环遍历数组+链表
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
对于PUT方法,当无需对table进行初始化或已经初始化完了之后,它接下来的主要任务是将key和value存到数组或链表中。那么怎么将一个key-value给存到数组中去呢?
我们知道,如果我们想往数组中存入数据,首先需要一个数组下标,而我们在进行PUT的时候并不需要再传一个参数来作为数组的下标,那么HASHMAP中下标是怎么获取的呢?答案为哈希算法,这也是为什么叫HASHMAP而不叫其他MAP。
对于哈希算法 来说它需要一个哈希函数,这个函数接受一个参数返回一个HashCode,哈希函数的特点是对于相同的参数那么返回的HashCode肯定是相同的,对于不相同的参数,函数会尽可能的去返回不相同的HashCode,所以换一个角度理解,对于哈希函数,给不相同的参数可能会返回相同的HashCode,这个就叫哈希冲突或哈希碰撞。
那么我们能直接把这个HashCode来作为数组下标吗,另外一个很重要的问题是我们到底应该对key做哈希运算还是对value做哈希运算,还是对key-value同时做哈希运算?
那么这个时候就要考虑到GET方法了,因为GET只需要传入一个key作为参数,而实际上GET方法的逻辑就是通过把key进行哈希运算快速地得到数组下标,从而快速的找到key所对应的value。所以对于PUT方法虽然传入了两个参数,但是只能对key进行哈希算法得到数组下标,这样才能方便GET方法快速查找。
但是还有一个问题就是,HashCode它能直接作为数组下标吗?
HashCode它通常是一个比较大的数组,比如:
System.out.println("键".hashCode()); //38190
// 为什么是这个结果,可以参考String类中的hashCode方法
所以我们不可能把这么大的一个数字作为数组下标,那么怎么办?
大家可能通常会想到取模运算,但是HashMap没有用取模,而是:
/**
* 返回哈希码h的索引或下标。
*/
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length必须为2的非零次幂";
// 为什么是 & 不是 % 运算呢? &运算更快
return h & (length-1);
}
这个方法就是JDK1.7HashMap中PUT和GET方法中获取数组下标的方法(PUT和GET两个方法都要去获取下标?是的),这个方法中h代表hashcode,length代表数组长度。我们发现它是用的逻辑与操作,那么问题就来了,逻辑与操作真的能准确的算出来一个数组下标吗?
计算数组下标存在要求
1)不能越界 [0, length - 1]
2)0~length - 1中出现的频率要均匀(避免有的链表过长)
取余% 刚好能够满足这两个条件。与运算&不行。
假设hashcode是01010101(二进制表示),length为00010000(16的二进制表示),那么h & (length - 1)
则为:
h: 0101 0101
15: 0000 1111
&
0000 0101
对于上面这个运算结果的取值方法我们来讨论一下:因为15的高四位都是0,低四位都是1,而与操作的逻辑是两个运算位都为1结果才为1,所以对于上面这个运算结果的高四位肯定都为0,而低四位和h的低四位是一样的,所以结果的取值范围就是h的低四位的一个取值范围:0000-1111,也就是0至15,所以这个结果是符号数组下标的取值范围的。
那么假设length为17呢?那么h & (length - 1)
则为:
h: 0101 0101
16: 0001 0000
&
0001 0000
当length为17时,上面的运算的结果取值范围只有两个值,要么是0000 0000,要么是0001 0000,这样是不太好的(比如:造成链表长度过长)。
所以我们发现,如果我们想把HashCode转换为覆盖数组下标取值范围的下标,跟我们的length是非常相关的,length如果是16,那么减一之后就是15(0000 1111),正是这种高位都0,低位都为1的二进制数才保证了可以对任意一个hashcode经过逻辑与操作后得到的结果是我们想要的数组下标。这就是为什么在真初始化HashMap的时候,对于数组的长度一定要是二次方数,二次方数和算数组下标是息息相关的,而计算机中的位运算是要比取模更快。
利用与操作,若一个数n是2的幂次方,则2进制表达式一定为某一位为1,其余为0。 则n-1则会变成后面的数全部变成1,原来1的位置变成0 例子:n=16的2进制(000010000),则n-1=15的二进制(00001111),则 (n & n-1)=0。
所以到此我们可以理一下:在调用PUT方法时,会对传入的key进行哈希算法得到一个hashcode,然后再通过逻辑与操作得到一个数组下标,最后将key-value存在数组下标处。
确定了key-value该存储的位置之后,上文说过,对于不同的参数可能会得到相同的HashCode,也就是发生哈希冲突,反映到HashMap中就是,当PUT两个不同的key时可能会得到相同的HashCode从而得到相同的数组下标,其实在HashMap中就算key对应的HashCode不一样,那么也有可能在经过逻辑与操作之后得到相同数组下标,那么这时候HashMap是如何处理冲突的呢?对,是链表,具体是怎么实现的呢?
HashMap处理冲突
上文解释了HashMap(JDK1.7)在PUT的时候会发生冲突,而解决冲突的方式就是使用链表,那么我们假设HashMap存储结果如下图:
那么节点1和节点2组成了一个链表,那么现在如果再来PUT一个节点3,假设节点3也需要插在这个链表中,我们考虑链表的插入效率,将节点3插在链表的头部是最快的,那么就会如下图所示:
那么按照上图这种插入办法,会出现一个问题:
- 当我们需要get(节点2)时,我们先将节点2的key进行哈希然后算出下标,拿到下标后可以定位到数组中的节点1,但是发现节点1不等于节点2,所以不是最终的结果,但是节点1存在下一个节点,所以可以顺着向下的指针找到节点2.
- 那么当我们需要get(节点3)时呢,我们发现时找不到节点3的,所以当我们把节点简单的插在链表的头部是不行的。
那HashMap是怎么实现的呢?HashMap确实是将节点插在链表的头部,但是在查完之后HashMap会将整个链表向下移动一位,移动完之后就会变成:
那么现在PUT的时候插入一个元素的思路就是:将新节点插在链表的头部,此时新节点就是当前这个链表的头节点,接下来把头节点移动到数组位置即可。
当我们在使用HashMap的时候,还可能会出现下面的使用方式:
HashMap<String, String> hashMap = new HashMap<String, String>();
hashMap.put("1", "2");
String value = hashMap.put("1", "3");
System.out.println(value);
第三行代码也是PUT,而这个时候在HashMap里会将value覆盖,也就是key=“1”对应的value最终为"3",而第三行代码返回的value将会是2。
我们现在来考虑这个PUT它是如何实现的,其实很简单,第三行代码的逻辑也是先对“1”计算哈希值以及对应的数组下标,有了数组下标之后就可以找到对应的位置的链表,而在将新节点插入到链表之前,还需要判断一下当前新节点的key值是不是已经在这个链表上存在,所以需要先去遍历当前这个位置的链表,在遍历的过程中如果找到了相同的key则会进行value的覆盖,并且返回oldvalue。
好,写到这里其实对于HashMap的PUT的主要逻辑也差不多了,总结一下:
- PUT(key, value)
- int hashcode = key.hashCode();
- int index = hashcode & (数组长度 - 1)
- 遍历index位置的链表,如果存在相同的key,则进行value覆盖,并且返回之前的value值
- 将key,value封装为节点对象(Entry)
- 将节点插在index位置上的链表的头部
- 将链表头节点移动到数组上
这是核心的7步,然后在这个过程中还有很重要的一步就是扩容,而扩容是发生在插入节点之前的,也就是步骤4和5之间的。
那么关于JDK1.7里面HashMap的扩容时会出现“死锁”问题的,我们下篇文章继续。
hashCode返回int类型32位的整数
为什么计算hash值的时候还要进行异或操作和右移运算呢?
尽管hashCode产生的每一位都是随机数,但如果不做任何操作直接使用hashCode的值,那么2^32个数会存储到[0, length - 1]中,那么冲突就会增加。如下两个hashcode分别代表两个key
0111 0101--key1
0110 0101--key2
当数组长度为16的时候
hashcode 0111 0101
length - 1 0000 1111
& 0000 0101
我们会发现高28位未参加到运算当中来
initHashSeedAsNeeded()的详细分析
/**
* 初始化哈希种子,哈希种子的作用是让生成的哈希码更复杂,使散列表更为散列
* 这个方法是判断是否进行重哈希的关键,这里我们重点关注其什么时候返回true什么时候返回false
*/
final boolean initHashSeedAsNeeded(int capacity) {
//hashSeed哈希种子默认为0,返回false
boolean currentAltHashing = hashSeed != 0;
//sun.misc.VM.isBooted()方法返回一个boolean值(源码放到文章最后)
//那么经过确定,sun.misc.VM.isBooted()返回的值为true;Holder.ALTERNATIVE_HASHING_THRESHOLD的值为2147483647
//capacity初始值为16 > 2147483647
//那么useAltHashing = false = true && false
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
//进行异或操作,当两个不相等的情况下才会返回true
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
通过上面的流程以后,我们知道hashSeed
并没有重新赋值,最终hashSeed
的值为0。
initHashSeedAsNeeded的目的是什么呢?
它的结果是boolean
,在transfer()
中被用到,如果是true
就重新计算key
的hash
值,如果false
则不用重新计算key
的hash
值,为什么呢?
解决哈希冲突,默认是Integer
的最大值,就是为了防止解决哈希冲突,因为要重新计算所有的key
,效率比较低,
你可以自己去设置VM Options(jdk.map.althashing.threshold):Holder.ALTERNATIVE_HASHING_THRESHOLD
的值就是设置的值,你觉得多少适合做哈希冲突。冲突多了形成链表 ,也会降低查询效率。
sun.misc.VM.isBooted()的值分析
sun.misc.VM类的源码
private static volatile boolean booted = false;
public static boolean isBooted() {
return booted;
}
这里返回的是booted
的值,但是booted
默认为false
,但是我们不知道VM启动的时候是否对它又赋了新值,怎么办呢?我们可以用一个土办法来测试如下:
我们可以看到在Map执行前booted
的值为true
,然而我们HashMap执行的时候并没有给它赋值,所以它为true
。
Holder.ALTERNATIVE_HASHING_THRESHOLD的值分析
/**
* map容量的默认阈值,超过这个阈值将用于String键。可选哈希减少了由于字符串键的弱哈希码计算而导致的冲突的发生率。
*
* 这个值可以通过定义系统属性jdk.map.althashing.threshold来覆盖。属性值为1将强制始终使用备用哈希,而-1值将确保备用哈希从未使用。
*
* 值为2147483647
*/
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
/**
* 保存的值在虚拟机启动后才能初始化。
*/
private static class Holder {
/**
* 要切换到使用替代散列的表容量。
* 这里定义了我们需要的常量,但是它没赋值,我们看看它是怎么赋值的?
*/
static final int ALTERNATIVE_HASHING_THRESHOLD;
static {
String altThreshold = java.security.AccessController.doPrivileged(
new sun.security.action.GetPropertyAction(
"jdk.map.althashing.threshold"));
int threshold;
try {
threshold = (null != altThreshold)
? Integer.parseInt(altThreshold)
: ALTERNATIVE_HASHING_THRESHOLD_DEFAULT;
// disable alternative hashing if -1
if (threshold == -1) {
threshold = Integer.MAX_VALUE;
}
if (threshold < 0) {
throw new IllegalArgumentException("value must be positive integer.");
}
} catch(IllegalArgumentException failed) {
throw new Error("Illegal value for 'jdk.map.althashing.threshold'", failed);
}
ALTERNATIVE_HASHING_THRESHOLD = threshold;
}
}
我们通过代码看到ALTERNATIVE_HASHING_THRESHOLD
来自threshold
,threshold
哪里呢?看上面得知来自判断条件里面。那我们就来看看判断条件altThreshold
,altThreshold
来自一个本地方法,我们还是用老方法,看看它的值为什么。
@CallerSensitive
public static native <T> T doPrivileged(PrivilegedAction<T> action);
这里值为null
,我们就可以看上面的代码,最后可以看见我们的ALTERNATIVE_HASHING_THRESHOLD = 2147483647
。
HashMap中hash方法的原理
在上代码之前,我们先来做个简单分析。我们知道,hash方法的功能是根据Key来定位这个K-V在链表数组中的位置的。也就是hash方法的输入应该是个Object类型的Key,输出应该是个int类型的数组下标。如果让你设计这个方法,你会怎么做?
其实简单,我们只要调用Object对象的hashCode()方法,该方法会返回一个整数,然后用这个数对HashMap或者HashTable的容量进行取模就行了。没错,其实基本原理就是这个,只不过,在具体实现上,由两个方法int hash(Object k)和int indexFor(int h, int length)来实现。但是考虑到效率等问题,HashMap的实现会稍微复杂一点。
hash :该方法主要是将Object转换成一个整型。
indexFor :该方法主要是将hash生成的整型转换成链表数组中的下标。
/**
* 检索对象哈希代码,并对结果哈希应用一个补充的哈希函数,以防止低质量的哈希函数。
* 这是至关重要的,因为HashMap使用两倍长度的哈希表,否则hashcode在较低位没有差异时就会遇到冲突。
* 注意:空键总是映射到散列0,因此索引0。
*/
final int hash(Object k) {
//hashSeed: 为了让hash算法计算出的hash值更散列一点
//hashSeed默认为0
int h = hashSeed;
//如果为String类型,并且hashSeed不等于0,则会调用sun.misc.Hashing.stringHash32()进行hash值计算
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// 这个函数确保hashcode在每个位位置的差异仅为常数倍,冲突的次数是有限制的(默认加载因子约为8)。
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
为了考虑性能,Java总采用按位与操作实现取模操作。indexFor是这样的,hash亦然。
接下来我们会发现,无论是用取模运算还是位运算都无法直接解决冲突较大的问题。比如:CA11 0000和0001 0000在对0000 1111进行按位与运算后的值是相等的。
两个不同的键值,在对数组长度进行按位与运算后得到的结果相同,这不就发生了冲突吗。那么如何解决这种冲突呢,来看下Java是如何做的。
其中的主要代码部分如下:
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
这段代码是为了对key的hashCode进行扰动计算,防止不同hashCode的高位不同但低位相同导致的hash冲突。简单点说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。
举个例子来说,我们现在想向一个HashMap中put一个K-V对,Key的值为“hollischuang”,经过简单的获取hashcode后,得到的值为“1011000110101110011111010011011”,如果当前HashMap的大小为16,即在不进行扰动计算的情况下,他最终得到的index结果值为11。由于15的二进制扩展到32位为“00000000000000000000000000001111”,所以,一个数字在和他进行按位与操作的时候,前28位无论是什么,计算结果都一样(因为0和任何数做与,结果都为0)。如下图所示。
可以看到,后面的两个hashcode经过位运算之后得到的值也是11 ,虽然我们不知道哪个key的hashcode是上面例子中的那两个,但是肯定存在这样的key,这就产生了冲突。
那么,接下来,我看看一下经过扰动的算法最终的计算结果会如何。
从上面图中可以看到,之前会产生冲突的两个hashcode,经过扰动计算之后,最终得到的index的值不一样了,这就很好的避免了冲突。
其实,使用位运算代替取模运算,除了性能之外,还有一个好处就是可以很好的解决负数的问题。因为我们知道,hashcode的结果是int类型,而int的取值范围是-2^31 ~ 2^31 - 1,即[ -2147483648, 2147483647];这里面是包含负数的,我们知道,对于一个负数取模还是有些麻烦的。如果使用二进制的位运算的话就可以很好的避免这个问题。首先,不管hashcode的值是正数还是负数。length-1这个值一定是个正数。那么,他的二进制的第一位一定是0(有符号数用最高位作为符号位,“0”代表“+”,“1”代表“-”),这样里两个数做按位与运算之后,第一位一定是个0,也就是,得到的结果一定是个正数。
Integer.highestOneBit源码
/**
* Returns an {@code int} value with at most a single one-bit, in the
* position of the highest-order ("leftmost") one-bit in the specified
* {@code int} value. Returns zero if the specified value has no
* one-bits in its two's complement binary representation, that is, if it
* is equal to zero.
*
* @return an {@code int} value with a single one-bit, in the position
* of the highest-order one-bit in the specified value, or zero if
* the specified value is itself equal to zero.
* @since 1.5
*/
public static int highestOneBit(int i) {
// HD, Figure 3-1
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return i - (i >>> 1);
}
参考文献
- HashMap的hashSeed的问题
- HashMap中hash方法的原理
- JDK7-hashmap源码