你必须要掌握的 HashMap

1. 概述

HashMap 是 Java 集合框架中的一个重要的数据结构,在日常开发过程中的使用频率是非常高的。

2. HashMap 详解

2.1 为什么需要 HashMap?

HashMap 是基于数组+链表(红黑树)实现的。为什么需要 HashMap 呢? 我们先从数组和链表的数据结构特性看起。由下表可见,数组和链表的表现各有优劣。那么有没有一种数据结构,使元素增、删、改、查的效率都很高呢?有的!它就是我们今天要介绍的 HashMap。

数据结构类型存储
数组空间大小固定,需要连续内存O(n)O(n)O(1)
链表空间动态扩展O(1)O(1)O(n)

2.2 什么是 HashMap?

见名知意,HashMap 即哈希表。HashMap 拆开来看,一部分是 Hash,一部分是 Map。

  • HashMap 实现了 Map 接口,我们知道 Map 是 key (键)和 value (值)的映射,所以 HashMap 也提供了所有可选的键值映射操作,并且允许空值和空键。
  • HashMap 根据 key 的 HashCode 值存储数据。

综上,HashMap 的特点如下:

  1. HashMap 有数组和链表的优点,它的查询效率比链表高,动态可扩展性比数组好。
    • 在理想情况下,即未发生任何的哈希碰撞,那么 get/put 方法的时间复杂度是 O(1)。
    • 如果发生了哈希碰撞,那么 get 方法的时间复杂度是:链表- O(n) 或 红黑树-O(logn)。
  2. HashMap 不是同步的,即是非线程安全的。
  3. HashMap 中的映射不是有序的。

2.3 HashMap 的重要参数

HashMap 有2个重要参数会影响它的性能,分别是:初始容量(initial capacity)和负载因子(load factor)。

  1. 初始容量(initial capacity):哈希表创建时的容量。默认值 16。
  2. 负载因子(load factor):负载因子用于表示 HashMap 中数据的填满程度。默认值 0.75。

HashMap 中元素数量超过[HashMap容量]和[负载因子]的乘积时,会触发扩容(rehash)将容量翻倍。扩容是一个非常耗时的操作,因为扩容后会将之前小数组中的元素转移到大数组中,这个操作非常耗时。

Q:负载因子默认值为什么是 0.75?

负载因子的默认值是在时间和空间成本上寻求的一种折中。

  • 负载因子越小,哈希表中能填满的数据就越少,哈希冲突的概率也就随之减少了。但这样会增大内存开销,而且还会使触发扩容的几率变大。
  • 负载因子越大,哈希表中能填满的数据就越多,可以降低内存开销。但这样哈希冲突的概率会随之增大了,查询效率也就降低了。

为什么负载因子的默认值是 0.75 ,而不是 0.7 、0.6 呢? jdk 1.8 源码介绍如下。

因为TreeNode的大小约为链表节点的两倍,所以我们只有在一个拉链已经拉了足够节点的时候才会转为tree(参考TREEIFY_THRESHOLD)。并且,当这个hash桶的节点因为移除或者扩容后resize数量变小的时候,我们会将树再转为拉链。如果一个用户的数据他的hashcode值分布十分好的情况下,就会很少使用到tree结构。在理想情况下,我们使用随机的hashcode值,loadfactor为0.75情况,尽管由于粒度调整会产生较大的方差,桶中的Node的分布频率服从参数为0.5的泊松分布。下面就是计算的结果:桶里出现1个的概率到为8的概率。桶里面大于8的概率已经小于一千万分之一了。

What is the significance of load factor in HashMap?

jdk1.8 源码

3. HashMap 常见问题解析

3.1 HashMap 为什么要用 Hash 计算?

使用 Hash 算法可以提高存储空间的利用率,并提高数据的查询效率。HashMap 采用 Entry 数组来存储 key-value 对。如果哈希表中的元素按顺序存储,那么每次查询数据的时候都需要遍历表,时间复杂度是 O(n)。为了提升查询效率,HashMap 会根据 key 的 HashCode 来存储数据,这样通过 key 可以直接定位到其存储的数组下标 index,使得在没有哈希碰撞的情况下查询时间复杂度可达到 O(1)。

3.2 HashMap 扰动函数的作用?

  • 扰动函数 下图1是 jdk 1.8 中 HashMap 的扰动函数。你可能会有疑问,直接取 hashCode 不就可以了吗?为什么还要这样复杂计算呢?先上结论:使用扰动函数的目的是为了增加随机性,让数据元素更加均衡的散列,减少碰撞。 在 HashMap 的 get 、 put 方法中,主要有两段代码来计算 key 的位置,分别是(1)扰动函数—图1 hash()(2)与运算-图2 indexFor。如果不用扰动函数,直接 hashCode 后进行 & 运算, 那么碰撞会很严重。将 h >>> 16 后,h 右移16位刚好为 32 bit 的一半,相当于 h 自己的高半区和低半区做异或,这样混合了原始哈希码的高位和低位,加大了低位的随机性。而且混合后的低位掺杂了高位的部分特征,使高位的信息也被保留了下来。
  • 为什么 indexFor() 需要进行与&运算呢?理论上来说,字符串的 hashCode 是一个 int 类型的值,其取值范围在[-231, 231-1],前后加起来一共 40 多亿的映射空间,只要哈希值映射的比较松散,一般是不会发生哈希碰撞的。但 40 多亿长度的数组,内存是放不下的,因此这个 hashCode 值不能直接拿来用,而是需要和数组的长度做取模运算后才可以使用,用作访问数组的下标。读到这里你可能又要问,取模运算不应该用 % 吗,为什么是 & ? 这是因为 & 运算比 % 更加高效。当 b = 2n时,下方表达式成立。这也正好解释了为什么 HashMap 的数组长度是2的整次方。
    a % b = a & ( b − 1 ) a \% b = a \& (b-1) a%b=a&(b1)
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

3.3 为什么 HashMap 的数组长度是2的整次方?

因为当数组长度是 2n时,HashMap 的取模操作可以转化为与运算,与运算(&)比 取模运算(%) 更加高效。表达式如下。除此之外,2的整次幂刚好是偶数,偶数-1是奇数,奇数的二进制最后以为是1,保证了 h & (length-1) 的最后一位可能是 0 也可能是 1,这样便保证了哈希值的均匀性。
a % b = a & ( b − 1 ) a \% b = a \& (b-1) a%b=a&(b1)

操作符 & :如果相对应位都是 1,则结果为1,否则为 0 。

3.4 HashMap 的扩容机制

扩容的过程如下:
当我们 put 一个元素到哈希表时会调用 addEntry 方法,在 addEntry 方法中会判断是否需要扩容,如果当前size>=阈值就会创建一个是原数组两倍长度的新数组(即 resize方法),在 resize 方法中会通过 transfer 方法将原数组中的 Entry 重新哈希(rehash)到新数组中,最后将原数组引用指向新数组,同时计算下新的阈值。

  • 为什么要扩容?因为 HashMap 的底层使用的是数组,当数组无法承载更多元素时,就需要扩容,以便装入更多的元素。
  • 扩容的触发条件?当哈希表的大小超过了 capacity * loadFactor 时,会触发自动扩容。
/**jdk7 中的 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;
    }
/**jdk7 中的 addEntry 方法**/
void addEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        if (size++ >= threshold)
            resize(2 * table.length);
    }

/**jdk7 中的 resize 方法**/
    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);
    }

3.5 HashMap 为什么不是线程安全的?

主要有三方面原因:

  1. 多线程下扩容可能会死循环
  2. 多线程下 put 可能会导致元素丢失
  3. put 和 get 并发时可能导致 get 为 null
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值