HashMap原理解析

本来想继续翻译simple Java的,不过看到The contract between hashCode() and equals()这篇觉得突然发现自己对HashMap背后的实现并不是很了解。查了一天的资料,自己也参照jdk6源码试着写了个简洁版的HashMap,这里总结下吧。

HashMap的结构

我们都明白,HashMap里面存放的是键值对,可以通过键(key)来获取到值(value),那么它背后实现的结构是什么呢?
这里写图片描述

从图中可以知道,HashMap的键值对存放在一个table数组里,而数组中的键值对又指向下一个键值对依次形成了链表(也有一个称呼“桶”)。

这个结构咋看上去很怪异,我们分析下HashMap的取值方法的过程就明白了。

首先,我们要知道java是通过hash值以及equal()方法来保证HashMap的Key值不重复的。每次调用get(String key)这个方法来获取对应值时,有两步要走:

  1. 计算出key的hash值,对hash值进行特殊运算后,我们可以得到目标键值对所在桶在table数组中的下标
  2. 找到桶后,我们再迭代桶中的元素,对比key值是否相同(equal方法),若相同则返回键值对的value

也就说说,table数组的每个位置放的是“key的hash值计算出的索引”相同的键值对的集合。我们获得这个集合后还要继续比较key值才能找出真正要的值。


如何保证快速的取值?

知道HashMap的结构后,我们会对几个细节产生疑惑:

  1. 如何保证key的hash值生成的索引在一个数组的范围内的?因为初始化数组的时候必须设定数组的大小,总不能无穷大吧
  2. 因为get(String key)时我们需要依次迭代数组,如何保证放入元素时它们距离的合理性?要是只有两个元素,一个table中的下标为0,另一个是100;或者两个计算出的下标都是100。这样显然会造成取值速度的大大降低
  3. 放入的元素多后,链表会增加,利用数组下标检索的优势会明显降低,HashMap是否会扩容?

下面我们带着这些问题来看代码

//根据hash值和table数组长度,获得下标
static int indexFor(int h, int length) {
    return h & (length - 1);
}

通过(key的hash值)和(数组长度-1)相与,我们得到了table数组中的下标,这个算法看似很简单,其实暗藏玄机:

  1. 保证了下标永远在数组范围内。因为0&1=0,所有A&B<=min(A,B)。例如5&15=5,16&15=0。(可以转换成二进制自己慢慢体会)
  2. 看源代码我们会发现,数组的长度都是设置的2的指数。HashMap默认的数组大小是16。原因是2^n-1得到的二进制为全1,相与产生相同值的几率会降低。例如1000(8)&1110(14)=1000,而1001(9)&1110(14)=1000,产生了重复。而1000(8)&1111(15)=1000,而1001(9)&1111(15)=1001。


上面已经分析过取值的过程,我们再结合代码来看下放值的过程,其实差不多

public V put(K key, V value) {
    int hash = hash(key.hashCode());
    // 根据key的hash值计算出应放入的table数组下标
    int index = indexFor(hash, table.length);
    // 找到table数组中对应的下标的桶,迭代桶中的键值对元素
    for (Entry<K, V> e = table[index]; e != null; e = e.next) {
        Object ek = e.key;
        // 如果该元素的key值和要放入元素的key值相同,覆盖该元素的value值
        if (e.hash == hash && (key == ek || key.equals(ek))) {
            V oldValue = e.value;
            e.value = value;
            return oldValue;
        }
    }
    // 如果没有找到相同key值的元素,新放入这个元素进map
    addEntry(hash, key, value, index);
    return null;
}

先通过key的hash值获得table数组下标,再迭代该下标所在的链表中,如果存在key值相等的,则覆盖value,不存在则新添一个键值对放入table。

我们来看下新添的过程:

void addEntry(int hash, K key, V value, int bucketIndex) {
    // 获取对应下标的桶的头节点
    Entry<K, V> e = table[bucketIndex];
    // 设置头节点为新的元素,并设定新元素的next为旧头节点
    table[bucketIndex] = new Entry<K, V>(key, value, hash, e);
    // 如果map中的元素>容量*负载因子,进行数组空间扩容
    if (size++ >= (capacity * loadFactor))
        //每次扩大为原来容量的两倍
        reSize(2 * table.length);
}

/**
 * table数组的扩容
 */
void reSize(int newCapacity) {
    Entry[] oldTable = table;
    // 创建新容量的table数组
    Entry[] newTable = new Entry[newCapacity];
    // 把旧talbe数组中的元素重新放入新table数组
    transfer(newTable);
    // 设置map的table为新数组
    table = newTable;
    // 更新map的容量
    capacity = newCapacity;
}

由于我们已经知道了数组下标,所以添加的过程就是在下标所在的链表上添加结点而已。通过代码我们知道,每次新添的结点都是头结点,它指向旧头结点。

值得注意的是,添加后会进行数组扩容的判断。如果map中的元素>数组长度*负载因子(默认为0.75),则进行数组空间扩容。扩容会极大的耗费时间,所以我们要尽量的避免扩容,即初始化HashMap时指定合理的容量大小(table数组长度)


HashMap的使用优化

前面已经提到,HashMap的扩容会带来极大的性能消耗(要重新创建新的table数组并把旧元素重新放入),那么学会创建合理容量负载因子的HashMap会给我们带来很大效率性能的提高。

容量的确定

如果我确定自己要使用HashMap存放1000个元素,那显然不能创建默认容量(16)的HashMap,不然会扩容7次(16->32->64->128->256->512->1024->2048),啊,那简直是噩梦。

那设置多少呢?既然我们已经很确定将存放的元素为1000左右,那把容量设置成1001不就好了吗?还可以多一个保险哈哈。但显然不行,因为还有负载因子,当size>=容量*负载因子时才会扩容。这里默认为0.75,也就意味1000*0.75=750,要扩容到2048*0.75=1536时才行。

负载因子的确定

现在,我们再来谈谈负载因子。
如果负载因子设置的低,那么扩容会更加容易,hash表(table数组)的空间会变大。但同样,下标碰撞的几率降低,迭代链表的几率降低,检索速度提高。
如果负载因子设置的高,则hash表空间减少。但下标碰撞几率增加,迭代链表几率提高,检索速度降低。
默认的0.75是时间和空间的一种折中。

最后,补充equal()和hashCode

先看一下equal()和hashCode的设计原则
1. 如果两个对象有同样的hash code,那它们不一定equal()相等
2. 如果两个对象equal()相等,那它们一定有相同的hash code

为什么这么设计?试想两个对象equal返回true,但hashCode却不相等那不很怪异吗?
所以key值的判重是同时根据equal()方法和hashCode的,改变某对象key值的判重时,必须重写两个方法。来确保equal()相等时,hashCode也相等


最后,放一下参照源码实现的HashMap代码:

/**
 * 手写HashMap,实现了put和get方法,可以自动扩容
 * 
 * @param <K>
 * @param <V>
 * @author wsz
 */
public class MyHashMap<K, V> {
    /** 默认的table数组大小 */
    static final int DEFAULTCAPACITY = 2;
    int capacity;
    /** 默认的负载因子 */
    final float loadFactor;
    /** 键值对桶数组 */
    Entry[] table;
    /** 放入的键值对数目 */
    int size;

    /**
     * 默认构造方法
     */
    public MyHashMap() {
        capacity = DEFAULTCAPACITY;
        this.table = new Entry[capacity];
        this.loadFactor = 0.75f;
        this.size = 0;
    }

    /**
     * 指定table大小和负载因子的构造方法
     */
    public MyHashMap(int capacity, float loadFactor) {
        this.capacity = capacity;
        this.table = new Entry[capacity];
        this.loadFactor = loadFactor;
        this.size = 0;
    }

    /**
     * 获取哈希值
     */
    static int hash(int h) {
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

    /**
     * 根据对象的哈希值和table数组长度,计算出对象在table数组中的下标
     * 
     * @param h
     *            对象的哈希值
     * @param length
     *            table数组长度
     * @return 计算出的table数组中的下标
     */
    static int indexFor(int h, int length) {
        // 源代码中一直保持length为2的倍数,这样可使算出的index相同的几率较小
        return h & (length - 1);
    }

    /**
     * 获取map中key值对应的value
     */
    public V get(K key) {
        int hash = hash(key.hashCode());
        // 根据key的hash值计算出应放入的table数组下标
        int index = indexFor(hash, table.length);
        // 找到table数组中对应的下标的桶,迭代桶中的键值对元素
        for (Entry<K, V> e = table[index]; e != null; e = e.next) {
            Object ek = e.key;
            // 如果该元素的key值和要放入元素的key值相同,返回value值
            if (e.hash == hash && (key == ek || key.equals(ek))) {
                return e.value;
            }
        }
        // 没有找到,返回Null
        return null;
    }

    /**
     * 向map中放值
     */
    public V put(K key, V value) {
        int hash = hash(key.hashCode());
        // 根据key的hash值计算出应放入的table数组下标
        int index = indexFor(hash, table.length);
        // 找到table数组中对应的下标的桶,迭代桶中的键值对元素
        for (Entry<K, V> e = table[index]; e != null; e = e.next) {
            Object ek = e.key;
            // 如果该元素的key值和要放入元素的key值相同,覆盖该元素的value值
            if (e.hash == hash && (key == ek || key.equals(ek))) {
                V oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }
        // 如果没有找到相同key值的元素,新放入这个元素进map
        addEntry(hash, key, value, index);
        return null;
    }

    /**
     * 将键值对放入table中,若元素个数>=capacity * loadFactor,进行table数组的扩容
     */
    void addEntry(int hash, K key, V value, int bucketIndex) {
        // 获取对应下标的桶的头节点
        Entry<K, V> e = table[bucketIndex];
        // 设置头节点为新的元素,并设定新元素的next为旧头节点
        table[bucketIndex] = new Entry<K, V>(key, value, hash, e);
        // 如果map中的元素>容量*负载因子,进行数组空间扩容
        if (size++ >= (capacity * loadFactor))
            reSize(2 * table.length);
    }

    /**
     * table数组的扩容
     */
    void reSize(int newCapacity) {
        Entry[] oldTable = table;
        // 创建新容量的table数组
        Entry[] newTable = new Entry[newCapacity];
        // 把旧talbe数组中的元素重新放入新table数组
        transfer(newTable);
        // 设置map的table为新数组
        table = newTable;
        // 更新map的容量
        capacity = newCapacity;
    }

    /**
     * 把旧table数组中的元素放入扩容后的table数组中
     */
    void transfer(Entry[] newTable) {
        Entry[] oldTable = table;
        int newCapacity = newTable.length;
        // 迭代旧table中的桶
        for (int j = 0; j < oldTable.length; j++) {
            // 旧桶的第一个节点
            Entry<K, V> e = oldTable[j];
            if (e != null) {
                oldTable[j] = null;
                // 迭代旧桶,把旧桶中的元素放入新数组
                do {
                    // 保留下旧节点的下一个节点
                    Entry<K, V> next = e.next;
                    // 获取扩容后该节点的数组下标
                    int i = indexFor(e.hash, newCapacity);
                    // 设置该节点的下个节点指向新桶的头节点
                    e.next = newTable[i];
                    // 设置新桶的第一个节点为该节点
                    newTable[i] = e;
                    // e为旧桶的下一个节点
                    e = next;
                } while (e != null);
            }
        }
    }

    public static void main(String[] args) {
        MyHashMap<String, String> map = new MyHashMap<String, String>();
        map.put("a", "wsza");
        map.put("b", "wszb");
        map.put("c", "wszc");
        map.put("a", "wszaa");
        System.out.println(map.get("a"));
        System.out.println(map.get("b"));
        System.out.println(map.get("c"));
        System.out.println(map.capacity);
    }

    /**
     * 键值对类,由于每个类有指向下一个类的引用,所以也称为"桶"(可以看作键值对的链表)
     * 
     * @param <K>
     *            key值类型
     * @param <V>
     *            value值类型
     * @author wsz
     */
    static class Entry<K, V> {
        final K key;
        V value;
        final int hash; // key值的hash值
        Entry<K, V> next; // 下一个键值对

        public Entry(K key, V value, int hash, Entry<K, V> next) {
            this.key = key;
            this.value = value;
            this.hash = hash;
            this.next = next;
        }
    }
}/**output:
wszaa
wszb
wszc
4
*/
  • 6
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值