HashMap原理

在很多地方都会利用到hash表来提高查找效率。在Java的Object类中有一个方法:

1
2
3
4
5
6
7
8
9
public native int hashCode();
``` 

根据这个方法的声明可知,该方法返回一个int类型的数值,并且是本地方法,因此在Object类中并没有给出具体的实现。
为何Object类需要这样一个方法?它有什么作用呢?

### hashCode方法的作用

对于包含容器类型的程序设计语言来说,基本上都会涉及到hashCode。在Java中也一样,hashCode方法的主要作用是为了配合基于散列的集合一起正常运行,这样的散列集合包括HashSet、HashMap以及HashTable。hashCode 存在的第一重要的原因就是在 HashMap(HashSet 其实就是HashMap) 中使用(其实Object 类的 hashCode 方法注释已经说明了 ),我知道,HashMap 之所以速度快,因为他使用的是散列表,根据 key 的 hashcode 值生成数组下标(通过内存地址直接查找,没有任何判断),时间复杂度完美情况下可以达到 n1(和数组相同,但是比数组用着爽多了,但是需要多出很多内存,相当于以空间换时间)。为什么这么说呢?考虑一种情况,当向集合中插入对象时,如何判别在集合中是否已经存在该对象了?(注意:集合中不允许重复的元素存在)虽然可以通过调用equals方法来逐个进行比较,这个方法确实可行。但是如果集合中已经存在一万条数据或者更多的数据,如果采用equals方法去逐一比较,效率必然是一个问题。此时hashCode方法的作用就体现出来了,当集合要添加新的对象时,先调用这个对象的hashCode方法,得到对应的hashcode值,实际上在HashMap的具体实现中会用一个table保存已经存进去的对象的hashcode值,如果table中没有该hashcode值,它就可以直接存进去,不用再进行任何比较了;如果存在该hashcode值, 就调用它的equals方法与新元素进行比较,相同的话就不存了,不相同就散列其它的地址,所以这里存在一个冲突解决的问题,这样一来实际调用equals方法的次数就大大降低了,说通俗一点:Java中的hashCode方法就是根据一定的规则将与对象相关的信息(比如对象的存储地址,对象的字段等)映射成一个数值,这个数值称作为散列值。下面这段代码是java.util.HashMap的中put方法的具体实现:

public V put(K key, V value) { if (key == null) return putForNullKey(value); int hash = hash(key.hashCode()); 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;
}
1
2
3
4
5
6
7
put方法是用来向HashMap中添加新的元素,从put方法的具体实现可知,会先调用hashCode方法得到该元素的hashCode值,然后查看table中是否存在该hashCode值,如果存在则调用equals方法重新确定是否存在该元素,如果存在,则更新value值,否则将新的元素添加到HashMap中。从这里可以看出,hashCode方法的存在是为了减少equals方法的调用次数,从而提高程序效率。

> Hash表数据结构参考:
> 1. http://www.cnblogs.com/dolphin0520/archive/2012/09/28/2700000.html
> 2. http://www.cnblogs.com/jiewei915/archive/2010/08/09/1796042.html

hashCode返回的就是对象的存储地址,这种看法是不全面的,确实有些JVM在实现时是直接返回对象的存储地址,但是大多时候并不是这样,只能说可能存储地址有一定关联。下面是HotSpot JVM中生成hash散列值的实现:

static inline intptr_t get_next_hash(Thread * Self, oop obj) { intptr_t value = 0 ; if (hashCode == 0) { // This form uses an unguarded global Park-Miller RNG, // so it’s possible for two threads to race and generate the same RNG. // On MP system we’ll have lots of RW access to a global, so the // mechanism induces lots of coherency traffic. value = os::random() ; } else if (hashCode == 1) { // This variation has the property of being stable (idempotent) // between STW operations. This can be useful in some of the 1-0 // synchronization schemes. intptr_t addrBits = intptr_t(obj) >> 3 ; value = addrBits ^ (addrBits >> 5) ^ GVars.stwRandom ; } else if (hashCode == 2) { value = 1 ; // for sensitivity testing } else if (hashCode == 3) { value = ++GVars.hcSequence ; } else if (hashCode == 4) { value = intptr_t(obj) ; } else { // Marsaglia’s xor-shift scheme with thread-specific state // This is probably the best overall implementation – we’ll // likely make this the default in future releases. unsigned t = Self->_hashStateX ; t ^= (t << 11) ; Self->_hashStateX = Self->_hashStateY ; Self->_hashStateY = Self->_hashStateZ ; Self->_hashStateZ = Self->_hashStateW ; unsigned v = Self->_hashStateW ; v = (v ^ (v >> 19)) ^ (t ^ (t >> 8)) ; Self->_hashStateW = v ; value = v ; }

value &= markOopDesc::hash_mask; if (value == 0) value = 0xBAD ; assert (value != markOopDesc::no_hash, “invariant”) ; TEVENT (hashCode: GENERATE) ; return value; }

1
2
3
4
5
6
7
8
9
10
11
12
该实现位于`hotspot/src/share/vm/runtime/synchronizer.cpp`文件下。

可以直接根据hashcode值判断两个对象是否相等吗?肯定是不可以的,因为不同的对象可能会生成相同的hashcode值。虽然不能根据hashcode值判断两个对象是否相等,但是可以直接根据hashcode值判断两个对象不等,如果两个对象的hashcode值不等,则必定是两个不同的对象。如果要判断两个对象是否真正相等,必须通过equals方法。也就是说对于两个对象:
1. 如果调用equals方法得到的结果为true,则两个对象的hashcode值必定相等;
2. 如果equals方法得到的结果为false,则两个对象的hashcode值不一定不同;
3. 如果两个对象的hashcode值不等,则equals方法得到的结果必定为false;
4. 如果两个对象的hashcode值相等,则equals方法得到的结果未知。

### equals方法和hashCode方法
在有些情况下,程序设计者在设计一个类的时候为需要重写equals方法,比如String类,但是千万要注意,在重写equals方法的同时,必须重写hashCode方法。为什么这么说呢?

例如:

import java.util.HashMap; import java.util.HashSet; import java.util.Set; class People{ private String name; private int age;

public People(String name,int age) {
    this.name = name;
    this.age = age;
}  
public void setAge(int age){
    this.age = age;
}  
@Override
public boolean equals(Object obj) {
    // TODO Auto-generated method stub
    return this.name.equals(((People)obj).name) && this.age== ((People)obj).age;
}

}

public class Main {

public static void main(String[] args) {

    People p1 = new People("Jack", 12);
    System.out.println(p1.hashCode());
    HashMap<People, Integer> hashMap = new HashMap<People, Integer>();
    hashMap.put(p1, 1);

    System.out.println(hashMap.get(new People("Jack", 12)));
}

}

1
2
3
在这里只重写了equals方法,也就说如果两个People对象,如果它的姓名和年龄相等,则认为是同一个人。这段代码本来的意愿是想这段代码输出结果为“1”,但是事实上它输出的是“null”。为什么呢?原因就在于**重写equals方法的同时忘记重写hashCode方法**。

虽然通过重写equals方法使得逻辑上姓名和年龄相同的两个对象被判定为相等的对象(跟String类类似),但是要知道默认情况下,hashCode方法是将对象的存储地址进行映射。那么上述代码的输出结果为“null”就不足为奇了。原因很简单,p1指向的对象和`System.out.println(hashMap.get(new People("Jack", 12)));`这句中的`new People("Jack", 12)`生成的是两个对象,它们的存储地址肯定不同。下面是HashMap的get方法的具体实现:

public V get(Object key) { if (key == null) return getForNullKey(); int hash = hash(key.hashCode()); for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) return e.value; } return null; }

1
所以在hashmap进行get操作时,因为得到的hashcdoe值不同(注意,上述代码也许在某些情况下会得到相同的hashcode值,不过这种概率比较小,因为虽然两个对象的存储地址不同也有可能得到相同的hashcode值),所以导致在get方法中for循环不会执行,直接返回null。因此如果想上述代码输出结果为“1”,很简单,只需要重写hashCode方法,让equals方法和hashCode方法始终在逻辑上保持一致性。

@Override public int hashCode() { return name.hashCode()*37+age; }

1
2
3
4
5
6
7
8
9
10
11
12
13
这样一来的话,输出结果就为“1”了。


下面这段话摘自Effective Java一书:
1. 在程序执行期间,只要equals方法的比较操作用到的信息没有被修改,那么对这同一个对象调用多次,hashCode方法必须始终如一地返回同一个整数。
2. 如果两个对象根据equals方法比较是相等的,那么调用两个对象的hashCode方法必须返回相同的整数结果。
3. 如果两个对象根据equals方法比较是不等的,则hashCode方法不一定得返回不同的整数。

对于第二条和第三条很好理解,但是第一条,很多时候就会忽略。在《Java编程思想》一书中的P495页也有同第一条类似的一段话:

> “设计hashCode()时最重要的因素就是:无论何时,对同一个对象调用hashCode()都应该产生同样的值。如果在讲一个对象用put()添加进HashMap时产生一个hashCdoe值,而用get()取出时却产生了另一个hashCode值,那么就无法获取该对象了。所以如果你的hashCode方法依赖于对象中易变的数据,用户就要当心了,因为此数据发生变化时,hashCode()方法就会生成一个不同的散列码”。

举个例子:

import java.util.HashMap; import java.util.HashSet; import java.util.Set;

class People{ private String name; private int age;

public People(String name,int age) {
    this.name = name;
    this.age = age;
}      
public void setAge(int age){
    this.age = age;
}     
@Override
public int hashCode() {
    // TODO Auto-generated method stub
    return name.hashCode()*37+age;
}    
@Override
public boolean equals(Object obj) {
    // TODO Auto-generated method stub
    return this.name.equals(((People)obj).name) && this.age== ((People)obj).age;
}

}

public class Main {

public static void main(String[] args) {

    People p1 = new People("Jack", 12);
    System.out.println(p1.hashCode());

    HashMap<People, Integer> hashMap = new HashMap<People, Integer>();
    hashMap.put(p1, 1);

    p1.setAge(13);

    System.out.println(hashMap.get(p1));
}

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这段代码输出的结果为“null”,因此,在设计hashCode方法和equals方法的时候,如果对象中的数据易变,则最好在equals方法和hashCode方法中不要依赖于该字段。

### 为什么大部分 hashcode 方法使用 31
如果有使用 eclipse 的同学肯定知道,该工具默认生成的 hashCode 方法实现也和 String 类型差不多。都是使用的 31 ,那么有没有想过:为什么要使用 31 呢?

在名著 《Effective Java》第 42 页就有对 hashCode 为什么采用 31 做了说明:
> 之所以使用 31, 是因为他是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算(低位补0)。使用素数的好处并不很明显,但是习惯上使用素数来计算散列结果。 31 有个很好的性能,即用移位和减法来代替乘法,可以得到更好的性能: 31 * i == (i << 5) - i, 现代的 VM 可以自动完成这种优化。这个公式可以很简单的推导出来。

这个问题在 SO 上也有讨论: https://stackoverflow.com/questions/299304/why-does-javas-hashcode-in-string-use-31-as-a-multiplier)

可以看到,使用 31 最主要的还是为了性能。当然用 63 也可以。但是 63 的溢出风险就更大了。那么15 呢?仔细想想也可以。

在《Effective Java》也说道:编写这种散列函数是个研究课题,最好留给数学家和理论方面的计算机科学家来完成。我们此次最重要的是知道了为什么使用31。

### HashMap 的 hash 算法的实现原理(为什么右移 16 位,为什么要使用 ^ 位异或)
其实,这个也是数学的范畴,从我们的角度来讲,只要知道这是为了更好的均匀散列表的下标就好了,但是,就是耐不住好奇心啊! 能多知道一点就是一点,我们来看看 HashMap 的 hash 算法(JDK 8).
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
1
2
3
乍看一下就是简单的异或运算和右移运算,但是为什么要异或呢?为什么要移位呢?而且移位16?

在分析这个问题之前,我们需要先看看另一个事情,什么呢?就是 HashMap 如何根据 hash 值找到数组种的对象,我们看看 get 方法的代码:
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        // 我们需要关注下面这一行
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
我们看看代码中注释下方的一行代码:first = tab[(n - 1) & hash])。

使用数组长度减一 与运算 hash 值。这行代码就是为什么要让前面的 hash 方法移位并异或。

我们分析一下:

首先,假设有一种情况,对象 A 的 hashCode 为 1000010001110001000001111000000,对象 B 的 hashCode 为 0111011100111000101000010100000。

如果数组长度是16,也就是 15 与运算这两个数, 你会发现结果都是0。这样的散列结果太让人失望了。很明显不是一个好的散列算法。

但是如果我们将 hashCode 值右移 16 位,也就是取 int 类型的一半,刚好将该二进制数对半切开。并且使用位异或运算(如果两个数对应的位置相反,则结果为1,反之为0),这样的话,就能避免我们上面的情况的发生。

总的来说,使用位移 16 位和 异或 就是防止这种极端情况。但是,该方法在一些极端情况下还是有问题,比如:10000000000000000000000000 和 1000000000100000000000000 这两个数,如果数组长度是16,那么即使右移16位,在异或,hash 值还是会重复。但是为了性能,对这种极端情况,JDK 的作者选择了性能。毕竟这是少数情况,为了这种情况去增加 hash 时间,性价比不高。

### HashMap 为什么使用 & 与运算代替模运算?
看看刚刚说的那个根据hash计算下标的方法:

tab[(n - 1) & hash];

1
2
3
其中 n 是数组的长度。其实该算法的结果和模运算的结果是相同的。但是,对于现代的处理器来说,除法和求余数(模运算)是最慢的动作。

上面情况下和模运算相同呢?

a % b == (b-1) & a ,当b是2的指数时,等式成立。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

我们说 & 与运算的定义:与运算 第一个操作数的的第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。

### HashMap 的容量为什么建议是 2的幂次方?
到这里,我们提了一个关键的问题: HashMap 的容量为什么建议是 2的幂次方?正好可以和上面的话题接上。楼主就是这么设计的。

为什么要 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)。大大限制了散列的范围。

### 自定义 HashMap 容量最好是多少?
那我们如何自定义呢?自从有了阿里的规约插件,每次楼主都要初始化容量,如果我们预计我们的散列表中有2个数据,那么我就初始化容量为2嘛?

绝对不行,如果大家看过源码就会发现,如果Map中已有数据的容量达到了初始容量的 75%,那么散列表就会扩容,而扩容将会重新将所有的数据重新散列,性能损失严重,所以,我们可以必须要大于我们预计数据量的 1.34 倍,如果是2个数据的话,就需要初始化 2.68 个容量。当然这是开玩笑的,2.68 不可以,3 可不可以呢?肯定也是不可以的,我前面说了,如果不是2的幂次方,散列结果将会大大下降。导致出现大量链表。那么我可以将初始化容量设置为4。 当然了,如果你预计大概会插入 12 条数据的话,那么初始容量为16简直是完美,一点不浪费,而且也不会扩容。

总结
好了,分析完了 hashCode 和 hash 算法,让我们对 HashMap 又有了全新的认识。当然,HashMap 中还有很多有趣的东西值得挖掘,楼主会继续写下去。争取将 HashMap 的衣服扒光。

总的来说,通过今天的分析,对我们今后使用 HashMap 有了更多的把握,也能够排查一些问题,比如链表数很多,肯定是数组初始化长度不对,如果某个map很大,注意,肯定是事先没有定义好初始化长度,假设,某个Map存储了10000个数据,那么他会扩容到 20000,实际上,根本不用 20000,只需要 10000* 1.34= 13400 个,然后向上找到一个2 的幂次方,也就是 16384 初始容量足够。

### Python版本Hash计算

import sys

if len(sys.argv) <= 1: print(“python vv_hash.py property_name”) exit(0)

propertyName = sys.argv[1] if len(propertyName) == 0: print(“empty element name”) exit(0)

hashCode = 0 for i in range(0, len(propertyName)): hashCode = (31 * hashCode + ord(propertyName[i])) & 0xFFFFFFFF if hashCode > 0x7FFFFFFF: hashCode = hashCode - 0x100000000 print(“hash code: %d” % (hashCode)) ```

坚持原创技术分享,您的支持将鼓励我继续创作!