HashMap是怎样存储和快速查找的

参考:廖雪峰老师的java教程
我们都知道Map是一种键值对映射表,可以通过key快速查找对应的value.

以HashMap为例,观察下面的代码:

        Map<String ,Integer> map = new HashMap<>();
        map.put("apple",12);
        map.put("pear",10);
        map.put("origin",5);
        map.get("apple");  //12

HashMap之所以能根据key直接拿到value,,原因是它内部通过空间换时间的方法,用一个大数组存储所有的value,并根据key直接计算出value应该存储在那个索引:

┌───┐
0 │ │
├───┤
1 │ ●─┼───> Person("Xiao Ming")
├───┤
2 │ │
├───┤
3 │ │
├───┤
4 │ │
├───┤
5 │ ●─┼───> Person("Xiao Hong")
├───┤
6 │ ●─┼───> Person("Xiao Jun")
├───┤
7 │ │
└───┘
如果key的值为"a",计算得到的索引总为 1 ,因此返回value为Person("Xiao Ming"),如果key的值为 "b",计算得到的索引总为5,因此返回value为Person("Xiao Hong"),这样就不必遍历整个数组,即可以直接读取key对应的value.

当我们使用 key 存取value的时候,就会引起一个问题:

我们放入map的可以是字符串 "a",但是,当我们获取Map的value时,不一定就是放入的那个key对象.

换句话讲,两个key应该是内容相同,但不一定是同一个对象.

@Test
    public void testHashMap(){
        String key1 = "a";
        Map<String,Integer> map = new HashMap<>();
        map.put(key1,123);
        String key2 = new String("a");
        int i = map.get(key2);
        System.out.println(i);  //123
        System.out.println(key1 == key2); // false  说明key1和key2是两个对象
        System.out.println(key1.equals(key2));  //true    说明key1的内容和key2相同
    }

因为在Map内部,对key做比较是通过equals()实现的,这一点和List查找元素需要正确覆写equals()是一样的,即正确的Map必须保证:作为key的对象必须正确覆写equals方法.

我们经常使用String 作为 key,因为String 已经正确覆写equals方法.但如果我们放入的key 是一个自己写的类,就必须保证正确覆写equals方法.

我们再思考一下 HashMap 为什么能通过 key 直接计算出 value 存储的索引.相同的key 对象(使用equals判断时返回true)必须要计算出相同的索引,否则,相同的key每次取出value就不一定对.

通过key计算索引的方式就是调用key对象的hashCode()方法,它返回一个int整数.HashMap正是通过这个方法直接定位key对应的value的索引,继而直接返回value.

因此,正确使用Map必须保证:

  • 作为key的对象必须正确覆写equals()方法,相等的两个key实例调用equals()必须返回True;
  • 作为key的对象还必须正确覆写hashCode()方法,且hashCode()方法要严格遵循一下规范:
    • 如果两个对象相等,则两个对象的hashCode()必须相等;
    • 如果两个对象不相等,则两个对象的hashCode()尽量不要相等.

扩展

既然HashMap内部使用了数组,通过计算key的HashCode()直接定位value所在的索引,那么第一个问题就来了:hashCode()返回的int返回高达±21亿,先不考虑负数,HashMap内部使用的数组得有多大?

实际上 HashMap初始化默认的数组大小只有 16,任何key,无论它的hashCode()有多大,都可以简单地通过:

int index = key.hashCode() & 0xf;  //0xf = 15

把索引确定为0~15之间,即永远不会超出数组范围,上述算法只是一种最简单的实现.

第二个问题:如果添加超过16个key-value到HashMap,数组不够用怎么办?

添加超过一定数量的key-value时,HashMap会在内部自动扩容,每次扩容一倍,即长度为16的数组扩展为长度为32,相应的,需要重新计算hashCode() 索引位置.例如:对长度为32的数组计算hashCode()对应的索引,计算方式要改为:

int index  = key.hashCode() & 0x1f; // 0x1f = 31

由于扩容会导致重新分布已有的key-value,所以,频繁扩容对HashMap的性能影响很大.如果我们确定要使用一个10000个key-value的HashMap,更好的方式是创建HashMap时就指定容量:

Map<String,Integer> map = new HashMap<>(10000);

虽然指定容量是10000,但是HashMap内部的数组长度总是 (2^n),因此,实际数组长度被初始化为比10000大的16384((2^14))

最后一个为题:如果两个不相同的key,例如"a" 和"b",他们的hashCode()恰好是相同的(这种情况是完全有可能的,因为不相等的两个实例,只要求hashCode()尽量不相等),那么,当我们放入:

map.put("a", new Person("Xiao Ming"));
map.put("b", new Person("Xiao Hong"));

由于计算出的数组索引相同,后面放入"Xiao Hong"会不会把"Xiao Ming"覆盖了?

当然不会!使用Map的时候,只要key不相同,他们映射的value就不会互不干扰.但是,在hashMap内部,确实可能存在不同的key,映射到相同的hashCode(),即相同的数组索引上怎么办?

我们就假设"a" 和"b" 这两个key 最终计算出的索引都是5,那么,在HashMap的数组中,实际存储的不是一个Person实例,而是一个List,它包含 两个Entry,一个是"a"的映射,一个是"b"的映射:

┌───┐
0 │ │
├───┤
1 │ │
├───┤
2 │ │
├───┤
3 │ │
├───┤
4 │ │
├───┤
5 │ ●─┼───> List<Entry<String, Person>>
├───┤
6 │ │
├───┤
7 │ │
└───┘
在查找的时候,例如:

Person p = map.get("a");

HashMap内部通过"a"找到的实际上是List<Entry<String,Person>>,它还需要遍历这个list,并找到一个Entry,它的key字段是"a",才能返回对应的Person实例.

我们把不同的key具有相同的hashCode()的情况称之为哈希冲突.在冲突的时候,一种最简单的解决办法是用List存储hashCode()相同的key-value.显然冲突的概率越大,这个list就越长,map的get()方法效率就越低,这就是为什么要尽量满足条件二:

如果两个对象不相等,则两个对象的hashCode()尽量不要相等

hashCode()方法编写得号,HashMap的工作效率就越高.