HashMap本质上Java中的一种数据结构,他是由数组+链表的形式组织而成的,当然了在jdk1.8后,当链表长度大于8的时候为了快速寻址,将链表修改成了红黑树。
既然本质上是一个数组,那我们应该把对应的键值对放到数组的哪个位置就成了重中之重,因为要保证这个算法对同一个key在同一个数组中每次计算出来的结果的一致性进行保证(幂等性)。那有的同学就要说了,很简单啊,我们获取key的hashCode然后将结果和数组长度进行取模就可以了。
这个办法的确可行,但是jdk中是这样做的吗?为什么?这就要对源码进行分析了。
首先,只要用过hashMap的同学肯定知道,hashMap是put操作的时候才会将键值对放在数组上,那我们看一下HashMap的put源码吧。
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 可以看到put操作执行了putValue这个方法,参数有五个,但是第一个参数是获取的hash(Object key) 方法的返回结果,那我们一起来看一下这个方法
说一说JDK1.8中HashMap中的hash算法
hash算法源码如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法非常简单,首先声明了一个int类型的局部变量h;然后判断key是否为null,如果是null的话直接返回0,否则进行计算,计算公式为
获取key的hashCode值并赋值给局部变量h,然后将h与h右移16的结果进行异或运算
简单来说就是这样的,假设之前的hashcode值为:
1111 1111 1111 1111 1001 1101 1100 0011
右移16位为:
0000 0000 0000 0000 1111 1111 1111 1111
进行异或运算(这里简单科普一下异或运算的计算公式,如果a、b两个值不相同,则异或结果为1。如果a、b两个值相同,异或结果为0。)后的结果为:
1111 1111 1111 1111 1001 1101 1100 0011
0000 0000 0000 0000 1111 1111 1111 1111
1111 1111 1111 1111 0110 0010 0011 1100
相信这个方法大家都能看得懂,但是看懂归看懂,可是我不明白为什么要这么做。这里先卖个关子,我们带着疑问继续往下看,put的实现。
JDK1.8以后Hash'Map中的寻址算法
我们回到hashMap中的put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
可以看到其调用了putVal方法 这段代码有点长,我们一点一点分析
/**
* Implements Map.put and related methods.
* 实现Map.put和相关的方法
* @param hash key的hash值(这里是前面hash()方法算出来的值)
* @param key put的key
* @param value put的value
* @param onlyIfAbsent 如果为true的话,不改变现有的值
* @param evict 如果为false,则表处于创建模式。
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 声明节点类数组tab 节点p 数组长度n 键值对存放位置i
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果没有初始化table,则初始化,长度默认设置为16 --> n
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 这里就声明了我们put的键值对应该存放在数组的什么位置
// p = tab[i = (n - 1) & hash]) i = 数组位置
// 如果这个数组下标对应的是null,就直接新建node写入到这个位置上,否则就得组织链表或红黑树了
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
从源码可发现hashMap在put的时候,寻址算法的公式是:tab[i = (n - 1) & hash ])
这里就出现了疑问,为什么是这么些,用hashCode直接取模不香吗?这里先抛出一个公式如果n为2的次方数,那么 hascode%n = (n - 1) & hash,我们可以测试一下,如果大家也感兴趣,可以自己试试:
public static void main(String[] args) {
String[] strings = new String[16];
String key = "qq1";
int hash = key.hashCode();
int length = strings.length;
System.out.println(hash % length);
System.out.println((length - 1) & hash);
}
// out true
public static void main(String[] args) {
String[] strings = new String[64];
String key = "zxl1";
int hash = key.hashCode();
int length = strings.length;
System.out.println((hash % length) == ((length - 1) & hash));
}
// out true
那又出现了一个问题,为什么不用hashCode对数组长度取模的方式呢?我们来做个简单的测试
public static void main(String[] args) {
String[] strings = new String[64];
String key = "zxl1";
int hash = key.hashCode();
int length = strings.length;
long startTime = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
int k = hash % length;
}
System.out.println("----取模算法耗时 : " + (System.currentTimeMillis() - startTime) + "ms ----");
startTime = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
int k = (length - 1) & hash;
}
System.out.println("----与运算耗时 : " + (System.currentTimeMillis() - startTime) + "ms ----");
}
/**
* output
* ----取模算法耗时 : 2ms ----
* ----与运算耗时 : 1ms ----
*/
相对取模运算来说,使用与运算效率更高,所以jdk的hashmap的寻址算法采用了与算法来参与运算。
看到这里很多同学就要说了,这里只是说了为什么采用与运算,可是没有说为什么hashMap中的hash是自己运算的一个结果,而不是直接使用hashcode啊。其实这和(n - 1) & hash有关。我们还是拿上面举的例子来说:
hashCode
1111 1111 1111 1111 1001 1101 1100 0011
hashCode右移16位
0000 0000 0000 0000 1111 1111 1111 1111
异或运算后的结果
1111 1111 1111 1111 0110 0010 0011 1100
如果我们直接用hashcode与n-1(假设使用默认长度16)进行与运算(两位同时为“1”,结果才为“1”,否则为0)的话,结果是这样的
1111 1111 1111 1111 1001 1101 1100 0011
0000 0000 0000 0000 0000 0000 0000 1111
0000 0000 0000 0000 0000 0000 0000 0011
ok,我们可以获取到这个key在数组中的位置应该是3,那么问题来了,假设我们还有一个key的hashcode后16位和我们现在这个key的hashcode完全一致,但是前16位略有不同例如
1111 1111 1001 1010 1001 1101 1100 0011
0000 0000 0000 0000 0000 0000 0000 1111
0000 0000 0000 0000 0000 0000 0000 0011
我们发现两个完全不一致的hashcode算出来的结果是一样的,都是3。原因是因为在与运算的时候,前面的16位根本没有发挥作用,所以会导致大量的结果是一致的。
这样就可以解释为什么hash方法里面要将hashcode的值右移16位再进行异或预算了,这样的目的是为了让前16位和后16位都参与运算。
还是刚才的例子,我们来看一下区别:
1111 1111 1111 1111 1001 1101 1100 0011 hashcode
0000 0000 0000 0000 1111 1111 1111 1111 hashcode右移16位
1111 1111 1111 1111 0110 0010 0011 1100 hash算法结果
0000 0000 0000 0000 0000 0000 0000 1111 n-1
0000 0000 0000 0000 0000 0000 0000 1100 与预算结果=12
----------------------------------------------------------------------------------
1111 1111 1001 1010 1001 1101 1100 0011 hashcode
0000 0000 0000 0000 1111 1111 1001 1010 hashcode右移16位
1111 1111 1001 1010 0110 0010 0111 1101 hash算法结果
0000 0000 0000 0000 0000 0000 0000 1111 n-1
0000 0000 0000 0000 0000 0000 0000 1101 与预算结果=13
这样就能尽可能地保证不同的hashcode能均匀的分配在数组上。
未完待续