Java中HashMap知识整理

一. HashMap(JDK-1.8)

0. 哈希表

在讨论哈希表之前,我们先大概了解下其他数据结构在新增,查找等基础操作执行性能

**数组:**采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)

**线性链表:**对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)

**二叉树:**对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。

**哈希表:**相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。

我们知道,数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,****哈希表的主干就是数组****。

比如我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。

存储位置 = f(关键字)

其中,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作:

查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。

0.1 哈希冲突

然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。

前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。

**那么哈希冲突如何解决呢?**哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式,

1. 简介

HashMap采用key/value存储结构,每个key对应唯一的value,查询和修改的速度都很快,能达到O(1)的平均时间复杂度。它是非线程安全的,且不保证元素存储的顺序;

2. 继承体系

HashMap实现了Cloneable,可以被克隆。

HashMap实现了Serializable,可以被序列化。

HashMap继承自AbstractMap,实现了Map接口,具有Map的所有功能。

3.HashMap存储结构
image-20200205095809050
3.1 结构说明

在Java中,HashMap的实现采用了(数组 + 链表 + 红黑树)的复杂结构,数组的一个元素又称作桶。

  1. 在添加元素时,会根据hash值算出元素在数组中的位置,如果该位置没有元素,则直接把元素放置在此处,如果该位置有元素了,则把元素以链表的形式放置在链表的尾部。

  2. 当一个链表的元素个数达到一定的数量(且数组的长度达到一定的长度)后,则把链表转化为红黑树,从而提高效率。

  3. 数组的查询效率为O(1),链表的查询效率是O(k),红黑树的查询效率是O(log k),k为桶中的元素个数,所以当元素数量非常多的时候,转化为红黑树能极大地提高效率。

3.2 为什么会转换成红黑树?
  1. 这个是在java8中做的优化,目的是当hash碰撞严重的时候,链表过长,通过树可以提升查找效率(链表是n ,树是logn),之所以用红黑树,是因为红黑树是平衡树,平衡树的特点是叶子节点的高度差不会大于1,这样可以避免树在极端情况下退化成链表,导致优化了等于白给。
3.3 为什么不全部链表转红黑树
  1. 第一个是链表的结构比红黑树简单,构造红黑树要比构造链表复杂,所以在链表的节点不多的情况下,从整体的性能看来,
    数组+链表+红黑树的结构不一定比数组+链表的结构性能高。
  2. 第二个是HashMap频繁的resize(扩容),扩容的时候需要重新计算节点的索引位置,也就是会将红黑树进行拆分和重组其实
    这是很复杂的,这里涉及到红黑树的着色和旋转,这又是一个比链表结构耗时的操作,所以为链表树化设置一个阀值是非常有必要的。
4. 源码解析
4.1 HashMap注释
  1. 允许NULL值,NULL键
  2. 不要轻易改变负载因子,负载因子过高会导致链表过长,查找键值对时间复杂度就会增高,负载因子过低会导致hash桶的 数量过多,空间复杂度会增高
  3. Hash表每次会扩容长度为以前的2倍
  4. HashMap是多线程不安全的,我在JDK1.7进行多线程put操作,之后遍历,直接死循环,CPU飙到100%,在JDK 1.8中进行多线程操作会出现节点和value值丢失,为什么JDK1.7与JDK1.8多线程操作会出现很大不同,是因为JDK 1.8的作者对resize方法进行了优化不会产生链表闭环。这也是本章的重点之一,具体的细节大家可以去查阅资料。这里我就不解释太多了
  5. 尽量设置HashMap的初始容量,尤其在数据量大的时候,防止多次resize
4.2 类常量
//默认hash桶初始长度16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

//hash表最大容量2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;

//默认负载因子 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//链表的数量大于等于8个并且桶的数量大于等于64时链表树化 
static final int TREEIFY_THRESHOLD = 8;

//hash表某个节点链表的数量小于等于6时树拆分
static final int UNTREEIFY_THRESHOLD = 6;

//树化时最小桶的数量
static final int MIN_TREEIFY_CAPACITY = 64;
4.3 实例变量
//hash桶
transient Node<K,V>[] table;                         

//键值对的数量
transient int size;

//HashMap结构修改的次数
transient int modCount;

//扩容的阀值,当键值对的数量超过这个阀值会产生扩容
int threshold;

//负载因子
final float loadFactor;
5. 构造方法
5.1 构造方法

HashMap 的四种构造函数

5.1.1 HashMap()
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

可以看到,无参构造函数只是把 DEFAULT_LOAD_FACTOR 的值(默认 0.75)赋值给 loadFactor,loadFactor 是给 HashTable 用的,所以可以理解为啥正事都没干。

5.1.2 HashMap(int initialCapacity)
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

一般我们实际使用过程中都是用的这个,在新建一个 HashMap 的时候先预估存储的元素的个数,假设个数为 n ,然后用 n 除以 0.75 ,假设算出的值是 m ,initialCapacity 取不小于 m 的 2 的幂次方对应的值。

举个例子:我现假设要往 HashMap 中存入 17 个值,17 / 0.75 = 22.67 。取不小于 22.67 的最近的 2 的幂次方,最终 initialCapacity 取 32(2的5次方) 。即 new HashMap(32) 。

为什么 initialCapacity 要取 2 的幂次方呢?之后我们讲 put 方法的时候会讲到。

如果我脑残,非要 new HashMap(17) ,会发生什么呢?请看另外一个构造方法

5.1.3 HashMap(int initialCapacity, float loadFactor)
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;
    this.threshold = tableSizeFor(initialCapacity);
}

接上一个,此时,传入的 initialCapacity 是 17,loadFactor 是 0.75 。

第 2~9 行代码都是判断 initialCapacity 的合法性,是否小于 0 ,是否大于最大数组长度等等。

第 11 行代码才是重点,我们来看看 5.2 tableSizeFor 方法:

看注释,power of two 是二次方的意思。翻译一下就是,根据传进来的数返回一个合适的二次方的结果。 最终的结果就是传入 17,算出来的返回值是 32 ,最后 threshold 等于 32 。 此时 threshold 是临时保存初始化容量 initialCapacity 的值,在后面会有用到。

5.2 阈值tableSizeFor初始化

我们最常使用的是无参构造,在这个构造方法里面仅仅设置了加载因子为默认值,其他两个参数会在resize方法里面进行初始化,在这里知道这个结论就可以了,下面会在源码里面进行分析; 另外一个带有两个参数的构造方法,里面对初始容量和阈值进行了初始化,对阈值的初始化方法为 tableSizeFor(int cap),以10为例子进行分析

 /**
  * 找到大于或等于 cap 的最小2的幂
  * 看注释,power of two 是二次方的意思。翻译一下就是,根据传进来的数返回一个合适的二次方的结果。 最终的结果就是传入 17,算出来的返回值是 32 ,最后 threshold 等于 32 。 此时 threshold 是临时保存初始化容量 initialCapacity 的值,在后面会有用到。
  */
static final int tableSizeFor(int cap) {                                                                      
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
image-20200205095809050

需要注意一下的是,第一步 int n = cap - 1; 这个操作,执行这个操作的主要原因是为了防止在cap已经是2的n次幂的情况下,经过运算后得到的结果是cap的二倍的结果,例如如果n为l6,经过一系列运算之后,得到的结果是0001 1111,此时最后一步n+1 执行之后,就会返回32,有兴趣的可以自己进行尝试;

5.3 负载因子loadFactor取值

我们应该很容易理解负载因子应该在 0.5~1.0 之间。

  • 如果小于 0.5,假设取 0.3 。初始化数组长度 16,threshold = 16*0.3 = 4.8 ,也就是数组长度大于 5 的时候进行扩容,扩容到 32 ;以此类推,数组长度 10 的时候,扩容到 64 。越往后,未使用的空间会越来越大,纯属浪费。

  • 如果大于 1,假设取 1.5 。初始化数组长度 16,threshold = 16*1.5 = 24 。大家有没有发现一个问题,一个长度为 16 的数组要存储 24 条数据。那形成链表的几率就是百分之百了。会严重影响到查询的效率。

那为什么在 0.5~1.0 之间选择了 0.75 呢?0.6 行不行,0.8 行不行?

看看源码注释里面的一段话:

* <p>As a general rule, the default load factor (.75) offers a good
* tradeoff between time and space costs.  Higher values decrease the
* space overhead but increase the lookup cost (reflected in most of
* the operations of the <tt>HashMap</tt> class, including
* <tt>get</tt> and <tt>put</tt>).  The expected number of entries in
* the map and its load factor should be taken into account when
* setting its initial capacity, so as to minimize the number of
* rehash operations.  If the initial capacity is greater than the
* maximum number of entries divided by the load factor, no rehash
* operations will ever occur.

翻译一下,默认的负载因子 0.75 在时间和空间消耗上提供了一个平衡。较高的值会降低空间使用但是会增加查找消耗(会影响 put 和 get 方法)。map 期望的 entries 数量和负载因子在初始化的时候就要考虑到,这样可以降低 rehash 的次数。如果初始化的数组长度大于 map 存储数据的最大值除以负载因子,将不会有 rehash 发生。

其实官方的说法跟我上面实际使用中的取值方法一样,0.75 是官方认为最优的平衡时间和空间的值。至于 0.75 是怎么推算出来的,这是个数学问题。有兴趣的同学可以自行研究。

6.put方法
6.1 put()方法图示
image-20200205095809050
6.2 hash 扰动函数
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

上面代码里的key.hashCode()方法调用的是key键类型自带的哈希方法,返回整型散列值。理论上散列值是一个int类型,如果直接拿散列值作为下标访问HashMap主数组的话,考虑到2进制32位带符号的整型范围从-2147483648到2147483648,前后加起来大概40亿的映射空间。只要哈希方法映射地比较均匀松散,一般应用是很难出现碰撞的。

但问题是一个40亿长度的数组,内存是放不下的。各位想,HashMap的初始容量大小才16!所以这个散列值并不能直接拿来用,用之前还要先做对数组的长度取模运算,得到的余数再拿来访问数组下标。

jdk源码中的模运算是在indexFor()方法中完成的,indexFor()方法的代码很简单,就是把散列值和数组长度做一个“与”操作:

static int indexFor(int h, int length){
    return h & (length - 1);
}
....
bucketIndex = indexFor(hash, table.length);

这里顺便讲一下为什么HashMap的数组长度要取2的整次幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与操作”的结果就是散列值的高位全部归零,只保留低位值,用来做数组的下标。以初始长度16为例:16-1=15,2进制表示是 00000000 00000000 00001111。和某散列值做“与”操作的结果如下:

       10100101 11000100 00100101  //某散列值)
&      00000000 00000000 00001111
       00000000 00000000 00000101  //高位全部归零,只保留末四位

这时问题就来了,就算我们的散列值分布再松散,要是只取最后几位的话,碰撞会非常严重。更要命的是,如果散列本身做的不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,这会无比蛋疼。
这时候“扰动方法”的价值就体现出来了,请看下图:

image-20200205095809050

右移16位,刚好是32位的一半,自己的高半区和低半区做异或(如果a、b两个值不相同,则异或结果为1。如果a、b两个值相同,异或结果为0),就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。
而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留了下来

6.3 jdk 1.7和1.8的扰动函数
  • Java7中的扰动做了四次,而到了Java8,觉得做一次就够了,多了边际效用也不大,这就是所谓的为了效率考虑就改成了1次扰动,相比较而言减少了过多的位运算,是一种折中的设计。

  • 而且java8引入了红黑树,也可以增加散列的效率

6.4 梳理put方法逻辑
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

最核心的还是调用了 putVal 方法。其中 onlyIfAbsent 表示是否替换相同 key 的旧 value 值,默认都是替换。

public V put(K key, V value) {
    // 调用hash(key)计算出key的hash值
    return putVal(hash(key), key, value, false, true);
}

static final int hash(Object key) {
    int h;
    // 如果key为null,则hash值为0,否则调用key的hashCode()方法
    // 并让高16位与整个hash异或,这样做是为了使计算出的hash更分散
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K, V>[] tab;
    Node<K, V> p;
    int n, i;
    // 如果桶的数量为0,则初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        // 调用resize()初始化
        n = (tab = resize()).length;
    // (n - 1) & hash 计算元素在哪个桶中
    // 如果这个桶中还没有元素,则把这个元素放在桶中的第一个位置
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 新建一个节点放在桶中
        tab[i] = newNode(hash, key, value, null);
    else {
        // 如果桶中已经有元素存在了
        Node<K, V> e;
        K k;
        // 如果桶中第一个元素的key与待插入元素的key相同,保存到e中用于后续修改value值
        if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            // 如果第一个元素是树节点,则调用树节点的putTreeVal插入元素
            e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
        else {
            // 遍历这个桶对应的链表,binCount用于存储链表中元素的个数
            for (int binCount = 0; ; ++binCount) {
                // 如果链表遍历完了都没有找到相同key的元素,说明该key对应的元素不存在,则在链表最后插入一个新节点
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 如果插入新节点后链表长度大于8,则判断是否需要树化,因为第一个元素没有加到binCount中,所以这里-1
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果待插入的key在链表中找到了,则退出循环
                if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 如果找到了对应key的元素
        if (e != null) { // existing mapping for key
            // 记录下旧值
            V oldValue = e.value;
            // 判断是否需要替换旧值
            if (!onlyIfAbsent || oldValue == null)
                // 替换旧值为新值
                e.value = value;
            // 在节点被访问后做点什么事,在LinkedHashMap中用到
            afterNodeAccess(e);
            // 返回旧值
            return oldValue;
        }
    }
    // 到这里了说明没有找到元素
    // 修改次数加1
    ++modCount;
    // 元素数量加1,判断是否需要扩容
    if (++size > threshold)
        // 扩容
        resize();
    // 在节点插入后做点什么事,在LinkedHashMap中用到
    afterNodeInsertion(evict);
    // 没找到元素返回null
    return null;
}

1)计算key的hash值,hash值的计算方法为key的hash值高16位不变,低16位与高16位进行异或操作,作为keyhash值。至于为什么这么做,这里不做重点讲解。

2)如果数组table是否为null或长度是否等于0,条件为true时进行数据table扩容(实际执行resize方法),扩容操作以后单独讲解。

3)根据hash值计算Value将要存放的位置,即计算数组table索引。索引变量i = (n - 1) & hash;数组table的长度都是2的幂,因此这里直接对数组长度和hash值进行与运算,也就是说hash值的高位都被与运算置为0了,i仅与hash值的低n位有关。

4)判断table[i]是否是null,如果是null直接进行Value插入。

5)如果table[i]不是null,接着判断key是否重复,如果重复直接进行覆盖插入。

6)如果key不重复,判断table[i]是否是TreeNode类型,如果是红黑树,直接插入。

7)如果不是TreeNode就遍历链表,遍历时预判断插入新的Value后,链表长度是否大于等于8。条件为True时执行链表转红黑树,然后插入Value。

8)如果上面链表长度小于8执行链表插入。

9)最后检查数组是否需要扩容。

7. resize方法

我们来继续看上面提到的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, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity  loadFactor, MAXIMUM_CAPACITY + 1);
}

如果数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index,我们先来看看transfer这个方法

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
     //for循环中的代码,逐个遍历链表,重新计算索引位置,将老数组数据复制到新数组中去(数组不存储实际数据,所以仅仅是拷贝引用而已)
    for (Entry<K, V> e : table) {
        while (null != e) {
            Entry<K, V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
          //将当前entry的next链指向新的索引位置,newTable[i]有可能为空,有可能也是个entry链,如果是entry链,直接在链表头部插入。
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

这个方法将老数组中的数据逐个链表地遍历,扔到新的扩容后的数组中,我们的数组索引位置的计算是通过 对key值的hashcode进行hash扰乱运算后,再通过和 length-1进行位运算得到最终数组索引位置。

hashMap的数组长度一定保持2的次幂,比如16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。

从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h&(length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换),个人理解。

image-20200205095809050

还有,数组长度保持2的次幂,length-1的低位都为1,会使得获得的数组索引index更加均匀,比如:

image-20200205095809050

我们看到,上面的&运算,高位是不会对结果产生影响的(hash函数采用各种位运算可能也是为了使得低位更加散列),我们只关注低位bit,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。这也是数组长度设计为必须为2的次幂的原因。

image-20200205095809050

如果不是2的次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了。

7. 参考

https://zhaoxiaofa.com/2019/02/06/Java%E9%9B%86%E5%90%88%E2%80%94HashMap%E4%B9%8B%E6%A2%B3%E7%90%86put%E6%96%B9%E6%B3%95%E9%80%BB%E8%BE%91/

https://www.cnblogs.com/captainad/p/10905184.html

[https://gitee.com/alan-tang-tt/yuan/blob/master/%E6%AD%BB%E7%A3%95%20java%E9%9B%86%E5%90%88%E7%B3%BB%E5%88%97/code/HashMap.java](https://gitee.com/alan-tang-tt/yuan/blob/master/死磕 java集合系列/code/HashMap.java)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值