深入了解 HashMap

3,991 阅读9分钟

概述

HashMap对于做Java的小伙伴来说太熟悉了。估计你们每天都在使用它。它为什么叫做HashMap?它的内部是怎么实现的呢?为什么我们使用的时候很多情况都是用String作为它的key呢?带着这些疑问让我们来了解HashMap!

HashMap介绍

1、介绍

HashMap是一个用”KEY”-“VALUE”来实现数据存储的类。你可以用一个”key”去存储数据。当你想获得数据的时候,你可以通过”key”去得到数据。所以你可以把HashMap当作一个字典。 那么HashMap的名字从何而来呢?其实HashMap的由来是基于Hasing技术(Hasing),Hasing就是将很大的字符串或者任何对象转换成一个用来代表它们的很小的值,这些更短的值就可以很方便的用来方便索引、加快搜索。

在讲解HashMap的存储过程之前还需要提到一个知识点
我们都知道在Java中每个对象都有一个hashcode()方法用来返回该对象的 hash值。HashMap中将会用到对象的hashcode方法来获取对象的hash值。

2、关系

图1展示了HashMap的类结构关系。

图1

HashMap继承了AbstractMap,并且支持序列化和反序列化。由于实现了Clonable接口,也就支持clone()方法来复制一个对象。今天主要说HashMap的内部实现,这里就不对序列化和clone做讲解了。

3、内部介绍

HashMap内部实现原理图

上面的图很清晰的说明了HashMap内部的实现原理。就好比一个篮子,篮子里装了很多苹果,苹果里包含了自己的信息和另外一个苹果的引用

1、和上图显示的一样,HashMap内部包含了一个Entry类型的数组table, table里的每一个数据都是一个Entry对象。

2、再来看table里面存储的Entry类型,Entry类里包含了hashcode变量,key,value 和另外一个Entry对象。为什么要有一个Entry对象呢?其实如果你看过linkedList的源码,你可能会知道这就是一个链表结构。通过我找到你,你再找到他。不过这里的Entry并不是LinkedList,它是单独为HashMap服务的一个内部单链表结构的类。

3、那么Entry是一个单链表结构的意义又是什么呢?在我们了解了HashMap的存储过程之后,你就会很清楚了,接着让我们来看HashMap怎么工作的。

HashMap的存储过程

下面分析一段代码的HashMap存储过程。(这里只是作为演示的例子,并没有真实的去取到了Hash值,如果你有需要可以通过Debug来得到key的Hash值)

        HashMap hashMap = new HashMap()
        hashMap.put("one","hello1")
        hashMap.put("two","hello2")
        hashMap.put("three","hello3")
        hashMap.put("four","hello4")
        hashMap.put("five","hello5")
        hashMap.put("six","hello6")
        hashMap.put("seven","hello7")

put操作的伪代码可以表示如下:

public V put(K key, V value){
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    
}

下面我们来看上面代码的过程
1、line1创建了一个HashMap,所以我们来看构造函数

/**
     * Constructs an empty HashMap with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

空构造函数调用了它自己的另一个构造函数,注释说明了构建了一个初始容量的空HashMap,那我们就来看它另外一个构造函数。

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();
    }

void init() {
    }

上面的代码只是简单的给loadFactor(其实是数组不够用来扩容的)和threshold(内部数组的初始化容量),init()是一个空方法。所以现在数组table还是一个空数组。

 /**
     * An empty table instance to share when the table is not inflated.
     */
    static final Entry[] EMPTY_TABLE = {};

    /**
     * The table, resized as necessary. Length MUST Always be a power of two.
     */
    transient Entry[] table = (Entry[]) EMPTY_TABLE;

2、接下来到了line2的地方, hashMap.put(“one”,”hello1”);在这里先提一下put方法源码:

public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);获取hash值
        int i = indexFor(hash, table.length);生成索引
        for (Entry 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;
    }

源码很简单,先判断table如果是空的,就初始化数组table,接着如果key是null就单独处理。否则的话就得到key的hash值再生成索引,这里用了indexFor()方法生成索引是因为:hash值一般都很大,是不适合我们的数组的。来看indexFor方法

/**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        
        return h & (length-1);
    }

就是一个&操作,这样返回的值比较小适合我们的数组。

继续 line2put操作,因为开始table是空数组,所以会进入 inflateTable(threshold)方法,其实这个方法就是出实话数组容量,初始化长度是16,这个长度是在开始的构造方法赋值的。
所以,现在空数组变成了长度16的数组了,就像下图一样。
这里写图片描述

接着由于我们的key不为null,到了获取hash值和索引,这里假设int hash = hash(key)和int i = indexFor(hash, table.length)生成的索引i为hash=2306996,i = 4;那么就会在table索引为4的位置新建一个Entry,对应的代码是addEntry(hash, key, value, i);到此结果如下图:
这里写图片描述

新建的Entry内部的变量分别是,hash,key,value,和指向下一节点的next Entry。

3、继续来看line3,line3和line2一样,而且数组不为空直接hash(key)和index。所以直接看图了
这里写图片描述

4、到了line4,这里line4情况有点特殊,我们假设line4里key生成的hashcode产生的index也为4,比如hash(“three”) 的值 63281940
hash&(15)产生的index为4。这种情况由于之前的位置已经有Entry了,所以遍历Entry如果key和hashcode都相同,就直接替换,否则新添加一个Entry,来看一下对应源码

public V put(K key, V value) {
        ...
        for (Entry 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;
    }

上面代码先判断是否需要替换,不需要就调用了addEntry方法。来看addEntry

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);
    }

里面又调用了createEntry

void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
        
    }

到这里相信大家很清楚了。来看看图:
这里写图片描述

5、到这里之后的代码都在上面的分析情况当中。我就不一一画图了,直接给出程序执行到最后的图
line5到line8

代码 hashcode index key value next
hashMap.put(“four”,”hello4”); 54378290 9 four hello4 null
hashMap.put(“five”,”hello5”); 39821723 8 five hello5 null
hashMap.put(“six”,”hello6”); 86726537 4 six hello6 line4产生的Entry
hashMap.put(“seven”,”hello7”); 28789082 2 seven hello7 line3产生的Entry

结果图如下:
这里写图片描述

到此put 操作就结束了,再来看看取

HashMap的取值过程

我们通过hashMap.get(K key) 来获取存入的值,key的取值很简单了。我们通过数组的index直接找到Entry,然后再遍历Entry,当hashcode和key都一样就是我们当初存入的值啦。看源码:

 public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }

调用getEntry(key)拿到entry ,然后返回entry的value,来看getEntry(key)方法

final Entry getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        int hash = (key == null) ? 0 : hash(key);
        for (Entry e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

按什么规则存的就按什么规则取,获取到hash,再获取index,然后拿到Entry遍历,hash相等的情况下,如果key相等就知道了我们想要的值。

再get方法中有null的判断,null取hash值总是0,再getNullKey(K key)方法中,也是按照遍历方法来查找的。

到这你肯定明白了为什么HashMap可以用null做key。

了解的存储取值过程和内部实现,其它的方法自己看看源码很好理解,在此就不一一解释了。

几个问题

问题1、HashMap是基于key的hashcode的存储的,如果两个不同的key产生的hashcode一样取值怎么办?
看了上面的分析,你肯定知道,再数组里面有链表结构的Entry来实现,通过遍历所有的Entry,比较key来确定到底是哪一个value;

问题2、HashMap是基于key的hashcode的存储的,如果两个key一样产生的hashcode一样怎么办?
在put操作的时候会遍历所有Entry,如果有key相等的则替换。所以get的时候只会有一个

问题3、我们总是习惯用一个String作为HashMap的key,这是为什么呢?其它的类可以做为HashMap的key吗?
这里因为String是不可以变的,并且java为它实现了hashcode的缓存技术。我们在put和get中都需要获取key的hashcode,这些方法的效率很大程度上取决于获取hashcode的,所以用String的原因:1、它是不可变的。2、它实现了hashcode的缓存,效率更高。如果你对String不了解可以看:Java你可能不知道的事-String

问题4、可变的对象能作为HashMap的key吗?
可变的对象是可以当做HashMap的key的,只是你要确保你可变变量的改变不会改变hashcode。比如以下代码

public class TestMemory {

    public static void main(String[] args) {
        HashMap hashMap = new HashMap();
        TestKey testKey = new TestKey();
        testKey.setAddress("sdfdsf");
        hashMap.put(testKey,"hello");
        testKey.setAddress("sdfsdffds");
        System.out.println(hashMap.get(testKey));
    }
}

public class TestKey {
    String name;
    String address;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    @Override
    public int hashCode() {
        if (name==null){
            return 0;
        }
        return name.hashCode();
    }
}

上面的代码line3到line5对象里的address做了改变,但是由于hashCode是基于name来生成的,name没变,所以依然能够正常找到value。但是如果把setAdress换成name,get就会返回null。这就是为什么我们选择String的原因。

到这里相信你对HashMap内部已经非常清楚了,如果本篇文章对你有帮助记得点赞和评论,或者关注我,我会继续更新文章,感谢支持!