HashMap源码学习笔记

HashMap源码学习笔记

前言

从刚开始学习java,就觉得HashMap底层实现原理是一个非常高大上的问题,以至于从开始接触到现在2年时间过去了,都没有详细研究过。最近在不断写博客的过程中逐步培养起了源码阅读和官方文档阅读的习惯,所以也激起了研究HashMap原理学习的兴趣。
HashMap相关的问题特别多,这也是我们经常对其望而却步的原因。所以,本文不会对HashMap的各个细节问题都进行阐述,这一版会集中解决HashMap的几个关键问题和经常被问到的一些细节知识点。从而搭建起对HashMap一个初级地较为全面的认识,为后续进阶做好准

文章目录

      • HashMap源码学习笔记
        • 前言
        • HashMap核心问题
          • Put数据的原理
          • Put方法源码解析
          • resize方法源码分析
          • HashMap存在的并发安全问题
        • HashMap连环问
          • jdk1.7和1.8中Hashmap的区别?
          • HashMap的数据结构是什么?
          • HashMap的数组容量如何确定?
          • HashMap如何扩容?
          • HashMap如何计算key的hash值?
          • HashMap有哪些关键的概念
          • HashMap在什么情况下会进行扩容?
          • HashMap何时触发树化和退化
          • 小知识

HashMap核心问题

Put数据的原理
  1. 当map为空时,过程如下

    1. 根据初始容量大小,创建出一个对应的数组

    2. 计算出元素的hash值,根据hash值计算出角标,此时角标元素为空,把元素放入对应角标

      if ((p = tab[i = (n - 1) & hash]) == null)
                  tab[i] = newNode(hash, key, value, null);
      
  2. 当容量不超,但是两个元素hash冲突时,其过程就是往一个链表或者红黑树中添加元素

    1. 如果两个key相等,则直接用新的值覆盖老的值
    2. 如果此时的桶已经是一个树节点,则调用红黑树的putTreeVal的方法,向树中添加元素。
    3. 如果此时桶的结构还是链表,并且新增一个也不会触发树化,则往同一个桶中追加元素,采用尾插法向链表中添加元素,过程中如果遇到key相同的,说明这个key已经存在了,则直接覆盖老的值,否则就新建一个Node,添加到链表的尾部。
    4. 如果新增一个节点会触发桶的树化,则会
  3. 当添加完元素后,容量达到临界条件时,会触发扩容,扩容就是resize的过程,后续会有介绍。

Put方法源码解析

putVal方法,添加元素的过程

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
     
    Node<K,V>[] tab; Node<K,V> p; int n, i;
  	//如果数组为空,则初始化一个数组
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
  	//如果数组角标不存在元素,则直接放到数组中
    if ((p = tab[i = (n - 1) & hash]) == null)	//p就是后面往链表或者树的根节点
        tab[i] = newNode(hash, key, value, null);
    else {
     
        Node<K,V> e; K k;	//e就是一个临时节点,用于存放目标位置的指针
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))	//key已存在
            e = p;
        else if (p instanceof TreeNode)	//桶已经是红黑树的情况
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
     	//桶还是链表的情况
            for (int binCount = 0; ; ++binCount) {
      //遍历整个桶
                if ((e = p.next) == null) {
     	//桶内无重复的key,直接用尾插法添加
                  	//p.next指针变化不会影响e,注意,不是指针的内容变化,所以e此时还是null
                    p.next = newNode(hash, key, value, null);
                  	//桶内元素个数达到临街点,对桶进行树化
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
              	//尚未到最后一个节点,需要逐个元素判重,如果重复,直接e=p.next
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;	//尚未匹配到,则p=p.next,继续遍历下一个节点
            }
        }
        if (e != null) {
      // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
          	//空方法,从Map继承的,没有实现。LinkedHashMap会有实现。
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
  	//size增加1,并且判断此时的size是否大于阈值,如果大于,则进行扩容操作。
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}
resize方法源码分析
/**
 * Initializes or doubles table size.  If null, allocates in
 * accord with initial capacity target held in field threshold.
 * Otherwise, because we are using power-of-two expansion, the
 * elements from each bin must either stay at same index, or move
 * with a power of two offset in the new table.
 *
 * @return the table
 */
//扩容之后数据如何移动,是本部分的学习重点
final Node<K,V>[] resize() {
     
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
     
      	//如果老数组已经是最大值了,则直接放大阈值,不进行扩容操作
        if (oldCap >= MAXIMUM_CAPACITY) {
     
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
      	//用<<操作对老容量扩大2倍,之后再对阈值扩大2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {
                    // 常规情况,zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
     
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({
     "rawtypes","unchecked"})
  	//创建一个新容量的数组,如果容量已经是最大值,可以发现,数组的元素不会往后挪了
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
     	//已经有数据的情况
        for (int j = 0; j < oldCap; ++j) {
     
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
     
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
              	//如果当前节点是树节点,则对一个树中的元素进行遍历判断是否需要移动
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else {
      // preserve order
                  	//这里的lo代表桶在迁移之前的原位,hi需要移动的元素的新位置,hi=lo+oldCap
                  	//在迁移的过程中,需要记录每个位置的head和tail,从一个桶分裂成2个桶
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
     
                        next = e.next;
                      	//这一行的作用是判断元素是否需要移动,详细参考说明【1】
                        if ((e.hash & oldCap) == 0) {
     	//此时不需要移动
                            if (loTail == null) //常规操作,链表采用尾插法添加元素,最开始tail和head都是null
                                loHead = e; //把第一个元素赋值给head
                            else
                                loTail.next = e; //如果链表中已经有元素了,则把当前元素赋值给loTail.next。详见【2】
                            loTail = e;	//把当前元素设置为新的链表尾部节点。
                        }
                        else {
     	//此时,当前元素需要移动
                            if (hiTail == null)	//和上文意思相同,就是往链表中追加元素的常规判断
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
     	//说明原来的位置还有部分元素保留
                        loTail.next = null;	//由于所有元素都遍历完了,最后一个元素的next需要置为空。这是必须的,否则有可能把分裂前的老元素带过来了。
                        newTab[j] = loHead;	//loHead就替换原来数组对应角标的元素,作为桶的根节点
                    }
                    if (hiTail != null) {
     
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead; //hiHead的位置就是lo+oldCap,hiHead作为移动后桶的根节点
                    }
                }
            }
        }
    }
    return newTab;
}

详细说明

  1. 为什么通过(e.hash & oldCap) == 0能够判断元素是否需要移动呢?
    1. 首先,元素最开始的角标是e.hash&(oldCap-1),一般是2n-1,各位都是1。而oldCap则是2n,除了高1位之外,其余各位都是0。
    2. 假设之前的数组长度是16,则原始角标是e.hash&1111,新的角标是e.hash&11111,变化就是最高的一位。如果e.hash对应最高的1位是0,则不需要移动,如果是1,则需要将角标+最高1位的值,这里是+16。
    3. 而e.hash&oldCap,刚好就是e.hash&10000,如果最高位是1,结果就是1,最高位是0,结果就是0。因此,用e.hash&oldCap,是判断扩容后是否需要迁移元素的标志。如果结果是0,则扩容前后index不变,如果是1,则需要将角标+oldCap
  2. 数据迁移过程的本质实际上是遍历一个旧桶,将数据分裂给2个新桶,刚好其中lo桶和旧桶位置相同。
    1. 因此这部分代码虽然看着复杂,但实际上就是一个往链表中增加元素的过程。如果感觉看不太懂,就回忆一下手写链表的过程即可。

小结

  1. resize一共做了2件最主要的事情,分别是数组扩容+数据移动。
  2. HashMap数据移动原理:数据移动本质上就是将一个链表分裂成2个链表。借助&运算的巧妙特性,可以快速判断某个元素是否需要移动(e.hash&oldCap==0?),移动后的桶的角标也可以非常简答地计算得出,hi=lo+oldCap。当你看这个移动过程感觉费劲的时候,只要联想链表添加元素的过程,就非常清晰了。
HashMap存在的并发安全问题

为什么我们会说HashMap是线程不安全的呢?接下来进行分析。首先我们需要明确一个理论基础,所有的线程不安全,都属于三个问题,分别是原子性问题,有序性问题,可见性问题。因此,分析HashMap的线程安全问题,也一定是从这三个角度出发。这也是分析任何线程安全问题一般的方法论。

首先我们来分析原子性问题。

  1. 原子性问题的条件是多个线程共享资源,并且对共享资源进行多步操作。HashMap的共享资源是table、bin、size。因此,可能发生以下安全问题

    1. 多个线程并发写入key不同的数据,最后size不对。已验证。
    2. 由于table是共用的,在table的角标上可能存在冲突。比如某个线程在resize,刚刚生成一个新的table,此时table[i]是null,另一个线程计算的index刚好等于i,从而table[i]=e。而刚好前一个线程的数据移动后的hi角标也是i。java不会判断table[i]是否为空,就会直接赋值。从而上一个e就被覆盖了。
      1. 总的来说,就是在一个线程resize数据移动的过程中,可能会存在数据覆盖的情况。
    3. 由于bin是共用的,假设某个bin的链表最后一个元素是tail,两个线程同时要添加一个元素,hash相同。都判断tail.next=null,准备执行tail.next=e,从而这两个数据会覆盖掉一个。或者两个线程都判断table[i]==null,都写到了table[i]中,此时就会出现数据覆盖。

    因此,hashMap的原子性问题会带来两个明显的问题,分别是1)size不对;2)数据覆盖从而导致丢失。

HashMap连环问

jdk1.7和1.8中Hashmap的区别?
  1. 1.7采用数组+单链表,1.8在单链表超过一定长度后改成红黑树存储
  2. 1.7扩容时需要重新计算哈希值和索引位置,1.8并不重新计算哈希值,巧妙地采用和扩容后容量进行&操作来计算新的索引位置。
  3. 1.7插入元素到单链表中采用头插入法,1.8采用的是尾插入法。
HashMap的数据结构是什么?
  1. 在1.8之前,采用的是数组+链表,1.8之后,采用的是数组+链表+红黑树。采用红黑树的目的在于提高数据查询的效率。当达到一定临界条件,会从链表树化为红黑树,如果数据量较少到一定程度,又会退化到链表。
HashMap的数组容量如何确定?

由于hashmap中使用了数组,因此,需要确定数组的长度。

  1. HashMap的数组初始容量一定是2的n次方,初始容量是16。主要有2个原因
    1. 首先在计算hash散列值确定数据的存放角标时,如果数组长度是2的n次方,就不需要进行取模运算,直接用hash值和容量-1的值进行与运算,得到的结果是相同的,效率极高。
    2. 如果数据容量是2的n次方,进行扩容的时候,方便进行数据移动。
HashMap如何扩容?

详见上一章的resize源码分析

HashMap如何计算key的hash值?
  1. jdk1.8计算hash值的算法如下:

    static final int hash(Object key) {
           
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    

    原因如下:

    1. hashmap的index=hash&(length-1),相当于只用了hash值的最后几位参与运算,这样一来,相当于hash值变小了,从而波动的范围也变小了,进而导致hash冲突的概率增加。比如数组长度是16,hash值原来长度是32位。那么相当于只要是最后4位相同的所有数,index都会相同。大概算一下冲突的个数就等于2(32-4)=228个。

    2. 如果增加了扰动函数,把进行h>>>16操作,那么,即使两个数最后4位相同,如果第17-20位不同,则不会冲突。这样一来,就实现了降低冲突元素个数的效果。

    3. 但是,我们一定有一个疑问,会不会原来2个数的低4位不同,进行亦或操作之后,反而相同了呢。以及为什么不用与运算或者或运算呢?在源码中是这样解释的:

      //采用异或运算从效率、效果、可用性上综合来看最合适的选择。
      There is a tradeoff between speed, utility, and quality of bit-spreading
      //其实hashcode的算法已经足够散列,但是由于hashmap中会用tree存大量的数据,为了避免系统性的bug,采用了XOR作为成本最低的一个扰动方式。
      Because many common sets of hashes are already reasonably distributed (so don't benefit from spreading), and because we use trees to handle large sets of collisions in bins, we just XOR some shifted bits in the cheapest possible way to reduce systematic lossage
      
    4. 结论:采用高位参与异或运算是一个综合效益最好的选择。核心目的在于避免hashcode可能存在的系统性缺失导致存储大量数据时的冲突问题。

HashMap有哪些关键的概念
  1. bin,桶
    1. hashmap的一个数组位置都能存多个元素,每个数组位置相当于一个存储区,这就是一个bin
    2. 在源码中介绍的是,This map usually acts as a binned (bucketed) hash table,hashmap类似一个分桶的hashtable。bin和bucket是一个意思,就是存储桶,或者存储区。
  2. loadFactor,加载因子
    1. 默认取值0.75
  3. TREEIFY_THRESHOLD/UNTREEIFY_THRESHOLD,bucket树化和退化的临界值
  4. MIN_TREEIFY_CAPACITY,树化的另一个条件,数组的最小容量(数组长度)
    1. 这是触发树化操作的最小数组容量,如果数组容量没有达到这个值,当数据量过大时,只会触发resize,不会触发树化操作。
    2. 这个参数最小不低于4 * TREEIFY_THRESHOLD,源码默认值是64。
HashMap在什么情况下会进行扩容?

在3种情况下,HashMap会进行扩容:

  1. 第一次添加元素时,此时还没有创建数组,会进行扩容,默认创建出一个长度为16的数组
  2. 当单个桶大小超过树化临界点,但是table大小没超过MIN_TREEIFY_CAPACITY时,不会进行树化,而是会进行扩容。MIN_TREEIFY_CAPACITY默认=64。
  3. 当集合元素的个数超过当前容量的阈值时,会进行扩容,默认阈值=0.75*oldCap
HashMap何时触发树化和退化
  1. 树化:当某个桶的元素个数达到树化临界点TREEIFY_THRESHOLD,并且Map的table大小超过临界值MIN_TREEIFY_CAPACITY,此时会触发树化
  2. 退化:进行退化的操作在split方法中,只有resize会调用split。
    1. 当执行resize操作时,如果发现当前树的size不大于退化临界值UNTREEIFY_THRESHOLD=6,则会触发树的退化。详细源码见上文中resize方法源码解读。
    2. 特别注意的是,在删除的过程中不会触发resize,因此也不会触发退化。这和我们传统理解是不一样的。
小知识
  1. 异或运算
    1. 运算符:a^b。在计算hashmap的key的hash值时,使用key.hashcode和自己右移16位的结果进行异或运算。h = key.hashCode()) ^ (h >>> 16)(无符号右移)
    2. 异或运算的特点:
      1. 0异或任何数都不变
      2. 1异或任何数都取反

你可能感兴趣的:(Java)