HashMap原理分析

  HashMap是一种Java开发过程中使用频率非常高的容器,本文将对HashMap底层存储结构和源代码进行解读和分析,源代码依据的JDK的版本是JDK7,小版本是80,JDK7中各个小版本的HashMap源代码可能是不同的,这一点要注意。

  • 什么是哈希?

  通常我们说的哈希函数(英语:Hash function)又称散列算法、散列函数,是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成【摘要】,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值的指纹。散列值通常用一个短的随机字母和数字组成的字符串来代表。

  哈希表是一种能实现关联数组的抽象数据结构,能把很多【value】映射到很多【key】上。哈希函数的一个使用场景就是哈希表,哈希表被广泛用于快速搜索数据,它的时间复杂度是O(1)。

  哈希函数的构造方法包括:除留余数法、随机数法、平方取中法、折叠法、直接定址法和数字分析法。这里就不再对哈希函数进行展开解读了,有空会专门写一篇介绍哈希函数的总结。

  有一种现象叫做哈希冲突,指的是,当不同的数据用同一哈希函数计算出的值相同的场景。好的哈希函数在输入域中很少出现哈希冲突。在哈希表和数据处理中,不抑制冲突来区别数据,会使得数据记录更难找到。解决哈希冲突的方法通常有下面几种:

方法
方法描述
备注
线性探查法
当产生哈希冲突时,则去寻找下一个空位。从当前位置开始搜索,当搜索到最后一个位置时,再从哈希表表首开始依次搜索,直到搜索到空位为止。只要哈希表足够大,并且有空位肯定能搜索到位置。
fi(key) = (f(key) + di) mod m,m代表哈希表的长度,di = m-1,
di的取值范围可以保证搜索完整个哈希表
平方探查法
当产生哈希冲突时,则去寻找下一个空位位置。从当前位置增加平方项,再对哈希表的长度取模。增加平方项的目的是不让关键字集中在同一个区域,避免不同的关键字争夺同一位置。该方法并不能搜索所有的位置,通常能搜索哈希表一半的位置,如果在一半的位置都没有找到合适的空位,则代表此哈希表需要重建。
fi(key) = (f(key) + di) mod m,m代表哈希表的长度,di=1^2,-1^2,2^2,
-2^2....p^2,-p^2, p<=m/2
双散列函数探查法
当产生哈希冲突时,则去寻找下一个空位。在当前的位置基础上,增加一个由随机函数产生的数值。
fi(key) =(f(key) + di) mod m,m代表哈希表的长度,di由一个随机函数产生。
链地址法
基础是哈希表,哈希表的每一个元素都可能加挂一个链表,也即是同义词存储在同一个列表中。
链地址法是HashMap解决哈希冲突使用的方法之一。JDK7完全使用此方法,
在JDK8中使用混合的方式解决哈希冲突,当同一个链表的元素大于8的时候,
自动转化为红黑树,也防止HashMap查询元素时出现O(n)的可能。
再哈希法
同时准备多个哈希函数,当一个哈希函数得出的值出现冲突时,使用其他的哈希函数,直到获取到空位为止。
优点:不容易产生聚集,缺点时:增加了计算时间。
建立公共溢出区
取两个哈希表,例如表a和表b,当出现表a的下标冲突时,把该元素都移动到表b中。
 
  • 哈希值和内存地址的关系

  

package java.lang;


public class Object {

    。。。
    
    public native int hashCode();

    。。。

}

  hashCode方法是Java中所有类共有的方法,是一个原生态方法,参考源代码中的注释

  This is typically implemented by converting the internal address of the object into an integer, but this implementation technique is not required by the Java programming language。

  翻译过来是:这通常通过将对象的内部地址转换为整数来实现,但Java编程语言不需要此实现技术。

  也就是说java中的类不复写Object中的hashCode方法的话,是调用本地系统的方法生成的一个整数值,hash值和内存地址有关系,但是它们并不相等。

 

  • 哈希表的性能

  常见的存储结构有顺序存储(数组)、链式存储(链表)、索引存储以及散列存储(哈希表),我们介绍一下几类常见存储结构对于新增、删除和查找的性能情况。

  1、数组

  数组Java中最高效的数据物理存储结构了,它采用一段连续的存储单元存储数据。对于指定的下标,查找元素的时间复杂度是O(1);对于指定的值,查找元素需要遍历整个数组,逐一比较数组元素和给定值,所以时间复杂度是O(n),对于有序数组,可以采取二分查等方式,可将时间复杂度提升为O(logn)。一般的新增或者删除操作,涉及到数组元素的挪动,时间复杂度是O(n)。

  2、链表

  一种链式存储方式,不保证顺序性,逻辑上相邻的元素之间用指针所指定,它不是用一块连续的内存存储,逻辑上相连的物理位置不一定相邻。对于新增和删除操作,只处理节点的引用即可,时间复杂度是O(1);查找指定的节点,则需要循环整个链表,逐一比较节点的值和给定的值,时间复杂度是O(n)。

  3、哈希表

  相比上述两种常见的数据结构,哈希表的性能优势比较明显,在不考虑哈希碰撞(实际场景中,哈希碰撞比较少见)的情况下,哈希表对于新增、删除和查找操作,时间复杂度都为O(1)。
  
  综上,我们可以总结得出三种数据结构的性能性能比较图
  
  新增 删除 查找
数组 O(1) O(n) O(n)
链表 O(1) O(1) O(n)
哈希表 O(1) O(1) O(1)

   哈希表为什么具备如此高效的性能呢?

  上面我们已经介绍了,数组是最高效的数据存储物理结构,根据下标查找元素的时间复杂度是O(1)。哈希表的基础就是数组,在不考虑哈希冲突的情况下,通过一个特定的函数计算出要存储的元素在数组中的下标,只需一步即可实现新增、删除和查找操作。这个特定的函数就是哈希函数,哈希函数的好坏直接影响创建的哈希表的性能。一个优秀的哈希函数需要具备如下几个特性:

  1. 正向快速:能根据算法逻辑,在有限时间内快速计算出哈希值。
  2. 逆向困难:给定哈希值,在有限时间内(极端困难)不可能推算出原始值。
  3. 输入敏感:即便是原始值做了一个非常小的修改,仅仅从哈希值来看,修改非常明显。
  4. 避免碰撞:理论上两个不同的原始值会根据同一个哈希函数计算出相同的哈希值,优秀的哈希算法要尽可能避免哈希碰撞的出现。

  HashMap采用链地址法解决哈希冲突,极端情况下hashMap的查找元素的时间复杂度是O(n),也即是采用一个返回固定值的哈希算法,这样不同的元素返回的哈希值是一样的,在某一个固定位置上,引入一个链表,存储所有的元素。

  • HashMap的内部结构

  HashMap内部结构是由数组和链表组成,如图:

  

  • HashMap的关键参数

  size        hashmap中的kv组合的总数量,拿上图举例,size = 4(数组元素)+4(链表节点) = 9。

  capacity  容量,hashmap中数组的长度,也称作桶的数量,默认值是DEFAULT_INITIAL_CAPACITY=16。拿上图举例,capacity=10。

  loadFactor 装载因子,默认是0.75,此数值可以衡量hashmap满的程度。

  threshold   扩容阀指,threshold = capacity * loadFactor ,当hashmap的size大于或者等于 threshold 时,hashmap将进行扩容。

  MAXIMUM_CAPACITY HashMap的最大容量,1 << 30 = 230

  • HashMap的构造函数

  HashMap有4个构造函数,下面这个函数是其他三个函数的基础。  

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    public HashMap(int initialCapacity, float loadFactor) {
        //容量小于0,抛出异常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //最大容量是2的30次幂
        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;
        //HashMap中这是一个空方法,LinkedHashMap则有逻辑
        init();
    }             

  从构造方法中可以看出,HashMap并未在new的时候就初始化数组,初始化数组是在put方法中进行的。

  • HashMap的put方法
    public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            //第一次存储数据时,进行数组的扩容
            inflateTable(threshold);
        }
        if (key == null)
            //k=null时,放置在数组的第一个位置
            return putForNullKey(value);
        //计算key的hashcode
        int hash = hash(key);
        //哈希表的秘密之所在,根据hashcode,计算此key在table中的存储位置
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //如果key是一样,则覆盖原的值
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                //钩子函数,hashmap并未实现
                e.recordAccess(this);
                return oldValue;
            }
        }
        //迭代hashmap时,fast-fail依据此值是否抛出ConcurrentModificationException异常
        modCount++;
        //新增元素
        addEntry(hash, key, value, i);
        return null;
    }    

  HashMap非线程安全,就是说的这个方法未加锁。当覆盖原值的时候,会把原值返回;当是新增一个元素时,则返回null。

  我们接下来看一些inflateTable函数

    private void inflateTable(int toSize) {
        // capacity大于等于toSize,并且是2的n次幂
        int capacity = roundUpToPowerOf2(toSize);
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        //初始化数组
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

  roundUpToPowerOf2函数的目的就是找到一个是2的次幂,并且是大于toSize,最接近toSize的正整数。它是怎么做到的呢?

    private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        //number非负数
        //并且最大是2的30次幂
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    }

  就是通过Integer.highestOneBit函数做到的,此方法的用意就是取正整数二进制的左边最高位的数字,然后再用这个数字的右边全部补0组成一个新的二进制数,这个二进制数的就是Integer.highestOneBit的结果。

  例如number=15,

  第一步:(15-1) << 1 = 14 * 2= 28 

  第二步:28的二进制表示是 00011100

  第三步:取 00011100,右边补0,组成新的二进制数 00010000

  则Integer.highestOneBit((15-1) << 1) = 16,也即是大于15,并且最接近15的2的n次幂的整数是16。

final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

  此版本的HashMap哈希函数应用了大量的位运算,目的就是使得hashcode非常分散。

  良好的哈希算法结合indexFor方法,使得存储的元素能均匀的分散在数组中。因为能直接索引到数组的下标值,所以HashMap的平均时间复杂度O(1)。

    /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }

   此方法相当于对h取模,但是通过位运算比取模运算效率更高。

    void addEntry(int hash, K key, V value, int bucketIndex) {
        //数量大于等于阀值,并且发生了哈希碰撞
        if ((size >= threshold) && (null != table[bucketIndex])) {
            //扩容hash表
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            //重新计算hash表的索引
            bucketIndex = indexFor(hash, table.length);
        }
        //创建Entry,如果此数组位置上已经有数据了,则在此位置上产生链表。最新的元素存储在数组中,最新生成的Entry的next指向原来的Entry。
        createEntry(hash, key, value, bucketIndex);    
    }
  • 为什么HashMap桶的数量一定是2的n次幂?

  上面介绍了HashMap第一次初始化数组的时候,通过roundUpToPowerOf2函数计算出数组的大小是2的n次幂,在addEntry的时候运行resize函数,将数组扩容到 2*table.length,这就决定了数组的大小一定是2的n次幂。

  这样的设计的目的是减少哈希碰撞,使得要存储的元素能均匀的分布在数组中。下面我们通过比较来证明这样设计的好处。

  我们假设要存储的元素的哈希值是[0,1,2...9]这10个数,当table.length = 16时,通过idnexFor函数计算出的索引值如下图所示:

  

  我们可以看到,要存储的元素的存储下标值非常均匀,并且没有产生任何哈希碰撞,此哈希表的时间复杂度是O(1)。下面我们把table.length=15,示意图如下:

  

  总共发生了5次碰撞,形成了5个链表,并且造成了table数组的空间浪费。

  • HashMap的get方法
    public V get(Object key) {
        if (key == null)
            //key是null的时候,直接在数组第一个位置取
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }
    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }
        //取key的hashcode
        int hash = (key == null) ? 0 : hash(key);
        //根据key的hashcode,定位索引下标
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            //首先哈希值必须相等
            //其次要不内存地址一样,要不就是equals
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }
  • 总结HashMap的知识点
  1. HashMap的初始话数组是16,最大容量capacity是230 ,默认装载因子是0.75。
  2. HashMap解决哈希冲突的方法是链地址法,物理存储结构是数组+链表。
  3. 此版本的HashMap是在put方法后才进行数组初始化的,数组的长度一直是2的n次幂。这样设计的好处是减少哈希冲突,使存储元素均匀的分布在HashMap的数组中。
  4. 扩容阀值threshold=capacity*loadFactor,当HashMap的size大于等于threshold,并且产生了哈希冲突时会进行扩容。扩容到原来的2倍容量,扩容非常耗费性能。
  5. HashMap是非线程安全的,并发可能会诱发产生死循环。要线程安全的话可以使用HashTable。

posted @ 2018-08-13 16:40  Go-Alone  阅读(495)  评论(0编辑  收藏  举报